Compare commits
15 Commits
c13adaa0fc
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| dce66b9fb0 | |||
| 2bdc18c00a | |||
| 0525c8217e | |||
| b39377e487 | |||
| 7d5f4640b3 | |||
| 1c9b482df3 | |||
| fe07a83386 | |||
| d12902c292 | |||
| 10ef04d748 | |||
| 6fb4635c21 | |||
| 2ec7cd25cd | |||
| a470739190 | |||
| 098ff4e16d | |||
| 20c759150a | |||
| 580dc8be52 |
@@ -21,7 +21,9 @@
|
|||||||
"Bash(curl -s -X POST http://127.0.0.1:8000/api/novels -H \"Content-Type: application/json\" --data-raw \"{\"\"title\"\":\"\"Novel A\"\",\"\"description\"\":\"\"Desc A\"\"}\")",
|
"Bash(curl -s -X POST http://127.0.0.1:8000/api/novels -H \"Content-Type: application/json\" --data-raw \"{\"\"title\"\":\"\"Novel A\"\",\"\"description\"\":\"\"Desc A\"\"}\")",
|
||||||
"Bash(curl -s -X POST http://127.0.0.1:8000/api/novels -H \"Content-Type: application/json\" --data-raw \"{\"\"title\"\":\"\"Novel B\"\",\"\"description\"\":\"\"Desc B\"\"}\")",
|
"Bash(curl -s -X POST http://127.0.0.1:8000/api/novels -H \"Content-Type: application/json\" --data-raw \"{\"\"title\"\":\"\"Novel B\"\",\"\"description\"\":\"\"Desc B\"\"}\")",
|
||||||
"Bash(taskkill //F //IM uvicorn.exe)",
|
"Bash(taskkill //F //IM uvicorn.exe)",
|
||||||
"Bash(taskkill //F //IM python.exe)"
|
"Bash(taskkill //F //IM python.exe)",
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
17
.dockerignore
Normal file
17
.dockerignore
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
*.db
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.claude/
|
||||||
|
.git/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
start.bat
|
||||||
|
backend/data/
|
||||||
93
.gitea/workflows/deploy.yml
Normal file
93
.gitea/workflows/deploy.yml
Normal 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
|
||||||
34
Dockerfile
Normal file
34
Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# ── 阶段一:构建前端 ──
|
||||||
|
FROM node:20-alpine AS frontend-build
|
||||||
|
|
||||||
|
WORKDIR /app/frontend
|
||||||
|
COPY frontend/package.json frontend/package-lock.json* ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY frontend/ ./
|
||||||
|
RUN npm run build-only
|
||||||
|
|
||||||
|
|
||||||
|
# ── 阶段二:运行后端 + 服务前端静态文件 ──
|
||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 安装 Python 依赖
|
||||||
|
COPY backend/requirements.txt ./requirements.txt
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# 复制后端代码
|
||||||
|
COPY backend/app ./app
|
||||||
|
|
||||||
|
# 复制前端构建产物到后端静态目录
|
||||||
|
COPY --from=frontend-build /app/frontend/dist ./static
|
||||||
|
|
||||||
|
# 数据目录(SQLite 存储)
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
VOLUME /app/data
|
||||||
|
|
||||||
|
ENV NOVAL_DATA_DIR=/app/data
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from sqlmodel import SQLModel, Session, create_engine
|
from sqlalchemy import text
|
||||||
|
from sqlmodel import SQLModel, Session, create_engine, select
|
||||||
|
|
||||||
# 数据库文件存放在 backend/data/ 目录下
|
# 数据目录:优先使用环境变量(Docker 中设为 /app/data),否则用 backend/data/
|
||||||
DATA_DIR = Path(__file__).resolve().parent.parent / "data"
|
DATA_DIR = Path(os.environ.get("NOVAL_DATA_DIR", Path(__file__).resolve().parent.parent / "data"))
|
||||||
DATA_DIR.mkdir(exist_ok=True)
|
DATA_DIR.mkdir(exist_ok=True)
|
||||||
|
|
||||||
DATABASE_URL = f"sqlite:///{DATA_DIR / 'noval.db'}"
|
DATABASE_URL = f"sqlite:///{DATA_DIR / 'noval.db'}"
|
||||||
@@ -13,6 +16,154 @@ 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()
|
||||||
|
_seed_builtin_skills()
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate_outline_parent_id() -> None:
|
||||||
|
"""迁移:为 outline 表添加 parent_id 列(如果尚不存在)"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
# 检查 outline 表是否存在 parent_id 列
|
||||||
|
result = session.exec(text("PRAGMA table_info(outline)"))
|
||||||
|
columns = [row[1] for row in result]
|
||||||
|
if "parent_id" not in columns:
|
||||||
|
session.exec(text("ALTER TABLE outline ADD COLUMN parent_id INTEGER REFERENCES outline(id)"))
|
||||||
|
session.exec(text("CREATE INDEX IF NOT EXISTS ix_outline_parent_id ON outline(parent_id)"))
|
||||||
|
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():
|
||||||
|
|||||||
@@ -1,13 +1,24 @@
|
|||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from starlette.responses import FileResponse
|
||||||
|
|
||||||
from .database import init_db
|
from .database import init_db
|
||||||
from .routers.novels import router as novels_router
|
from .routers.novels import router as novels_router
|
||||||
from .routers.outlines import router as outlines_router
|
from .routers.outlines import router as outlines_router
|
||||||
|
from .routers.characters import router as characters_router
|
||||||
|
from .routers.chapters import router as chapters_router
|
||||||
|
from .routers.world_setting import router as world_setting_router
|
||||||
from .routers.ai_config import router as ai_config_router
|
from .routers.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,本地开发时不存在则跳过)
|
||||||
|
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
@@ -20,7 +31,7 @@ app = FastAPI(title="Noval", version="0.1.0", lifespan=lifespan)
|
|||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["http://localhost:5173"],
|
allow_origins=["*"],
|
||||||
allow_credentials=True,
|
allow_credentials=True,
|
||||||
allow_methods=["*"],
|
allow_methods=["*"],
|
||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
@@ -28,10 +39,29 @@ app.add_middleware(
|
|||||||
|
|
||||||
app.include_router(novels_router)
|
app.include_router(novels_router)
|
||||||
app.include_router(outlines_router)
|
app.include_router(outlines_router)
|
||||||
|
app.include_router(characters_router)
|
||||||
|
app.include_router(chapters_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")
|
||||||
def health():
|
def health():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# 生产环境:serve 前端静态文件(Vue SPA)
|
||||||
|
if STATIC_DIR.is_dir():
|
||||||
|
# 静态资源(js/css/img)
|
||||||
|
app.mount("/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="assets")
|
||||||
|
|
||||||
|
# 所有非 /api 路径返回 index.html(Vue Router history 模式)
|
||||||
|
@app.get("/{path:path}")
|
||||||
|
async def spa_fallback(path: str):
|
||||||
|
file_path = STATIC_DIR / path
|
||||||
|
if file_path.is_file():
|
||||||
|
return FileResponse(file_path)
|
||||||
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
|
|||||||
@@ -39,12 +39,73 @@ class ChatMessage(SQLModel, table=True):
|
|||||||
created_at: datetime = Field(default_factory=_now_utc)
|
created_at: datetime = Field(default_factory=_now_utc)
|
||||||
|
|
||||||
class Outline(SQLModel, table=True):
|
class Outline(SQLModel, table=True):
|
||||||
"""大纲表,属于小说的子资源"""
|
"""大纲表,属于小说的子资源,支持两级结构(父+子)"""
|
||||||
|
|
||||||
id: Optional[int] = Field(default=None, primary_key=True)
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
novel_id: int = Field(foreign_key="novel.id", index=True)
|
novel_id: int = Field(foreign_key="novel.id", index=True)
|
||||||
|
parent_id: int | None = Field(default=None, foreign_key="outline.id", index=True)
|
||||||
sort_order: int = Field(default=0)
|
sort_order: int = Field(default=0)
|
||||||
summary: str = Field(max_length=200)
|
summary: str = Field(max_length=200)
|
||||||
detail: str = Field(default="", max_length=1000)
|
detail: str = Field(default="", max_length=1000)
|
||||||
created_at: datetime = Field(default_factory=_now_utc)
|
created_at: datetime = Field(default_factory=_now_utc)
|
||||||
updated_at: datetime = Field(default_factory=_now_utc)
|
updated_at: datetime = Field(default_factory=_now_utc)
|
||||||
|
|
||||||
|
|
||||||
|
class Character(SQLModel, table=True):
|
||||||
|
"""角色表,属于小说的子资源"""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
novel_id: int = Field(foreign_key="novel.id", index=True)
|
||||||
|
name: str = Field(max_length=100)
|
||||||
|
description: str = Field(default="", max_length=1000)
|
||||||
|
created_at: datetime = Field(default_factory=_now_utc)
|
||||||
|
updated_at: datetime = Field(default_factory=_now_utc)
|
||||||
|
|
||||||
|
|
||||||
|
class Chapter(SQLModel, table=True):
|
||||||
|
"""章节表,属于小说的子资源"""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
novel_id: int = Field(foreign_key="novel.id", index=True)
|
||||||
|
sort_order: int = Field(default=0)
|
||||||
|
title: str = Field(max_length=200)
|
||||||
|
content: str = Field(default="", max_length=5000)
|
||||||
|
created_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):
|
||||||
|
"""世界观设定,每部小说一条记录"""
|
||||||
|
|
||||||
|
id: Optional[int] = Field(default=None, primary_key=True)
|
||||||
|
novel_id: int = Field(foreign_key="novel.id", index=True, unique=True)
|
||||||
|
content: str = Field(default="", max_length=5000)
|
||||||
|
created_at: datetime = Field(default_factory=_now_utc)
|
||||||
|
updated_at: datetime = Field(default_factory=_now_utc)
|
||||||
|
|||||||
136
backend/app/routers/chapters.py
Normal file
136
backend/app/routers/chapters.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlmodel import Session, select, func
|
||||||
|
|
||||||
|
from ..database import get_session
|
||||||
|
from ..models import Novel, Chapter
|
||||||
|
from ..schemas import (
|
||||||
|
ChapterCreate,
|
||||||
|
ChapterUpdate,
|
||||||
|
ChapterRead,
|
||||||
|
ChapterList,
|
||||||
|
ChapterReorder,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/novels/{novel_id}/chapters", tags=["chapters"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_novel_or_404(novel_id: int, session: Session) -> Novel:
|
||||||
|
novel = session.get(Novel, novel_id)
|
||||||
|
if not novel:
|
||||||
|
raise HTTPException(status_code=404, detail="小说不存在")
|
||||||
|
return novel
|
||||||
|
|
||||||
|
|
||||||
|
def _get_chapter_or_404(
|
||||||
|
chapter_id: int, novel_id: int, session: Session
|
||||||
|
) -> Chapter:
|
||||||
|
chapter = session.get(Chapter, chapter_id)
|
||||||
|
if not chapter or chapter.novel_id != novel_id:
|
||||||
|
raise HTTPException(status_code=404, detail="章节不存在")
|
||||||
|
return chapter
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ChapterList)
|
||||||
|
def list_chapters(
|
||||||
|
novel_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_get_novel_or_404(novel_id, session)
|
||||||
|
total = session.exec(
|
||||||
|
select(func.count(Chapter.id)).where(Chapter.novel_id == novel_id)
|
||||||
|
).one()
|
||||||
|
items = session.exec(
|
||||||
|
select(Chapter)
|
||||||
|
.where(Chapter.novel_id == novel_id)
|
||||||
|
.order_by(Chapter.sort_order)
|
||||||
|
).all()
|
||||||
|
return ChapterList(
|
||||||
|
items=[ChapterRead.model_validate(c, from_attributes=True) for c in items],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ChapterRead, status_code=201)
|
||||||
|
def create_chapter(
|
||||||
|
novel_id: int,
|
||||||
|
data: ChapterCreate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_get_novel_or_404(novel_id, session)
|
||||||
|
max_order = session.exec(
|
||||||
|
select(func.max(Chapter.sort_order)).where(Chapter.novel_id == novel_id)
|
||||||
|
).one()
|
||||||
|
next_order = (max_order or 0) + 1
|
||||||
|
|
||||||
|
chapter = Chapter(novel_id=novel_id, sort_order=next_order, **data.model_dump())
|
||||||
|
session.add(chapter)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(chapter)
|
||||||
|
return chapter
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/reorder", response_model=ChapterList)
|
||||||
|
def reorder_chapters(
|
||||||
|
novel_id: int,
|
||||||
|
data: ChapterReorder,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_get_novel_or_404(novel_id, session)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for idx, cid in enumerate(data.ordered_ids):
|
||||||
|
chapter = _get_chapter_or_404(cid, novel_id, session)
|
||||||
|
chapter.sort_order = idx + 1
|
||||||
|
chapter.updated_at = now
|
||||||
|
session.add(chapter)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
items = session.exec(
|
||||||
|
select(Chapter)
|
||||||
|
.where(Chapter.novel_id == novel_id)
|
||||||
|
.order_by(Chapter.sort_order)
|
||||||
|
).all()
|
||||||
|
total = len(items)
|
||||||
|
return ChapterList(
|
||||||
|
items=[ChapterRead.model_validate(c, from_attributes=True) for c in items],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{chapter_id}", response_model=ChapterRead)
|
||||||
|
def get_chapter(
|
||||||
|
novel_id: int,
|
||||||
|
chapter_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
return _get_chapter_or_404(chapter_id, novel_id, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{chapter_id}", response_model=ChapterRead)
|
||||||
|
def update_chapter(
|
||||||
|
novel_id: int,
|
||||||
|
chapter_id: int,
|
||||||
|
data: ChapterUpdate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
chapter = _get_chapter_or_404(chapter_id, novel_id, session)
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(chapter, key, value)
|
||||||
|
chapter.updated_at = datetime.now(timezone.utc)
|
||||||
|
session.add(chapter)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(chapter)
|
||||||
|
return chapter
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{chapter_id}", status_code=204)
|
||||||
|
def delete_chapter(
|
||||||
|
novel_id: int,
|
||||||
|
chapter_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
chapter = _get_chapter_or_404(chapter_id, novel_id, session)
|
||||||
|
session.delete(chapter)
|
||||||
|
session.commit()
|
||||||
103
backend/app/routers/characters.py
Normal file
103
backend/app/routers/characters.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlmodel import Session, select, func
|
||||||
|
|
||||||
|
from ..database import get_session
|
||||||
|
from ..models import Novel, Character
|
||||||
|
from ..schemas import (
|
||||||
|
CharacterCreate,
|
||||||
|
CharacterUpdate,
|
||||||
|
CharacterRead,
|
||||||
|
CharacterList,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/novels/{novel_id}/characters", tags=["characters"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_novel_or_404(novel_id: int, session: Session) -> Novel:
|
||||||
|
novel = session.get(Novel, novel_id)
|
||||||
|
if not novel:
|
||||||
|
raise HTTPException(status_code=404, detail="小说不存在")
|
||||||
|
return novel
|
||||||
|
|
||||||
|
|
||||||
|
def _get_character_or_404(
|
||||||
|
character_id: int, novel_id: int, session: Session
|
||||||
|
) -> Character:
|
||||||
|
character = session.get(Character, character_id)
|
||||||
|
if not character or character.novel_id != novel_id:
|
||||||
|
raise HTTPException(status_code=404, detail="角色不存在")
|
||||||
|
return character
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=CharacterList)
|
||||||
|
def list_characters(
|
||||||
|
novel_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_get_novel_or_404(novel_id, session)
|
||||||
|
total = session.exec(
|
||||||
|
select(func.count(Character.id)).where(Character.novel_id == novel_id)
|
||||||
|
).one()
|
||||||
|
items = session.exec(
|
||||||
|
select(Character)
|
||||||
|
.where(Character.novel_id == novel_id)
|
||||||
|
.order_by(Character.created_at)
|
||||||
|
).all()
|
||||||
|
return CharacterList(
|
||||||
|
items=[CharacterRead.model_validate(c, from_attributes=True) for c in items],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=CharacterRead, status_code=201)
|
||||||
|
def create_character(
|
||||||
|
novel_id: int,
|
||||||
|
data: CharacterCreate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_get_novel_or_404(novel_id, session)
|
||||||
|
character = Character(novel_id=novel_id, **data.model_dump())
|
||||||
|
session.add(character)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(character)
|
||||||
|
return character
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{character_id}", response_model=CharacterRead)
|
||||||
|
def get_character(
|
||||||
|
novel_id: int,
|
||||||
|
character_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
return _get_character_or_404(character_id, novel_id, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{character_id}", response_model=CharacterRead)
|
||||||
|
def update_character(
|
||||||
|
novel_id: int,
|
||||||
|
character_id: int,
|
||||||
|
data: CharacterUpdate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
character = _get_character_or_404(character_id, novel_id, session)
|
||||||
|
update_data = data.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
setattr(character, key, value)
|
||||||
|
character.updated_at = datetime.now(timezone.utc)
|
||||||
|
session.add(character)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(character)
|
||||||
|
return character
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{character_id}", status_code=204)
|
||||||
|
def delete_character(
|
||||||
|
novel_id: int,
|
||||||
|
character_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
character = _get_character_or_404(character_id, novel_id, session)
|
||||||
|
session.delete(character)
|
||||||
|
session.commit()
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
@@ -6,19 +7,478 @@ 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
|
from ..models import AIConfig, ChatMessage, Novel, Skill
|
||||||
from ..schemas import ChatRequest, ChatMessageRead, ChatMessageList
|
from ..schemas import ChatRequest, ChatMessageRead, ChatMessageList
|
||||||
from ..services.ai_provider import stream_chat
|
from ..services.ai_provider import (
|
||||||
|
stream_chat, simple_completion,
|
||||||
|
build_assistant_message, build_tool_results_messages, ToolCall,
|
||||||
|
)
|
||||||
|
from ..services.tools import get_openai_tools, get_anthropic_tools, execute_tool, get_tools_description, SPECIAL_TOOLS
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/chat", tags=["chat"])
|
router = APIRouter(prefix="/api/chat", tags=["chat"])
|
||||||
|
|
||||||
|
# 最大工具调用轮次
|
||||||
|
MAX_TOOL_ROUNDS = 8
|
||||||
|
|
||||||
|
# 工具名称到中文描述
|
||||||
|
TOOL_LABELS: dict[str, str] = {
|
||||||
|
"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": "读取文件",
|
||||||
|
"dispatch_subagent": "派遣子Agent",
|
||||||
|
}
|
||||||
|
|
||||||
|
# 子 Agent 最大轮次
|
||||||
|
MAX_SUBAGENT_ROUNDS = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _build_system_prompt(
|
||||||
|
novel_id: int | None,
|
||||||
|
session: Session,
|
||||||
|
page_context: str | None = None,
|
||||||
|
tools_enabled: bool = False,
|
||||||
|
skill: Skill | None = None,
|
||||||
|
allowed_tools: list[str] | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""根据小说上下文、Skill 和工具配置构建 system prompt"""
|
||||||
|
parts = [
|
||||||
|
"你是 Writing Red Dot 写作助手,专注于小说创作领域。",
|
||||||
|
"你擅长:构思情节、塑造角色、打磨文笔、设计世界观、规划大纲结构。",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 注入 Skill 提示词
|
||||||
|
if skill and skill.system_prompt:
|
||||||
|
parts.append(f"\n## 当前技能:{skill.name}\n{skill.system_prompt}")
|
||||||
|
|
||||||
|
if novel_id:
|
||||||
|
novel = session.get(Novel, novel_id)
|
||||||
|
if novel:
|
||||||
|
parts.append(f"\n当前小说:《{novel.title}》(ID: {novel.id})")
|
||||||
|
parts.append(f"小说简介:{novel.description or '暂无'}")
|
||||||
|
|
||||||
|
if page_context:
|
||||||
|
parts.append(f"\n用户当前正在查看:{page_context}。请结合当前页面场景提供针对性建议。")
|
||||||
|
|
||||||
|
if tools_enabled:
|
||||||
|
# 动态生成工具能力描述(根据 allowed_tools 过滤)
|
||||||
|
tools_desc = get_tools_description(allowed_tools)
|
||||||
|
if tools_desc:
|
||||||
|
parts.append(f"\n## 你的工具能力\n{tools_desc}")
|
||||||
|
if not novel_id:
|
||||||
|
parts.append("\n注意:工具操作需要关联到具体小说。如果用户需要使用工具,请提示他们先进入某本小说。")
|
||||||
|
|
||||||
|
# 注入可用 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## 可用子Agent(Skill)"]
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tools(provider: str, allowed: list[str] | None = None) -> list[dict]:
|
||||||
|
if provider == "anthropic":
|
||||||
|
return get_anthropic_tools(allowed)
|
||||||
|
return get_openai_tools(allowed)
|
||||||
|
|
||||||
|
|
||||||
|
def _sse(data: dict) -> str:
|
||||||
|
return f"data: {json.dumps(data, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _save_assistant_message(novel_id: int | None, full_response: str, tool_call_logs: list[str]):
|
||||||
|
"""保存 AI 回复到 DB。工具调用日志和回复文本分别存为独立记录。"""
|
||||||
|
with Session(engine) as s:
|
||||||
|
# 工具调用日志作为单独一条记录
|
||||||
|
if tool_call_logs:
|
||||||
|
tool_summary = "\n".join(tool_call_logs)
|
||||||
|
s.add(ChatMessage(
|
||||||
|
novel_id=novel_id,
|
||||||
|
role="assistant",
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
skill_name = sub_skill.name if sub_skill else "未知"
|
||||||
|
# 子 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")
|
||||||
async def chat_stream(
|
async def chat_stream(
|
||||||
data: ChatRequest,
|
data: ChatRequest,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""SSE 流式对话端点"""
|
"""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(
|
||||||
@@ -26,53 +486,59 @@ async def chat_stream(
|
|||||||
media_type="text/event-stream",
|
media_type="text/event-stream",
|
||||||
)
|
)
|
||||||
|
|
||||||
# 保存用户消息
|
# 保存用户消息(仅首次请求,续传审批时不保存)
|
||||||
user_msg = data.messages[-1] if data.messages else None
|
if not data.pending_tool_calls:
|
||||||
if user_msg and user_msg.role == "user":
|
user_msg = data.messages[-1] if data.messages else None
|
||||||
db_msg = ChatMessage(
|
if user_msg and user_msg.role == "user":
|
||||||
novel_id=data.novel_id,
|
db_msg = ChatMessage(
|
||||||
role="user",
|
novel_id=data.novel_id,
|
||||||
content=user_msg.content,
|
role="user",
|
||||||
)
|
content=user_msg.content,
|
||||||
session.add(db_msg)
|
)
|
||||||
session.commit()
|
session.add(db_msg)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
# 构建消息列表
|
# 构建消息列表
|
||||||
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 配置
|
||||||
context_preview = [{"role": m["role"], "content": m["content"][:200]} for m in messages]
|
skill: Skill | None = None
|
||||||
|
allowed_tools: list[str] | None = None
|
||||||
|
if data.skill_id:
|
||||||
|
skill = session.get(Skill, data.skill_id)
|
||||||
|
if skill and skill.allowed_tools:
|
||||||
|
try:
|
||||||
|
parsed = json.loads(skill.allowed_tools)
|
||||||
|
if isinstance(parsed, list) and len(parsed) > 0:
|
||||||
|
allowed_tools = parsed
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# 构建上下文
|
||||||
|
use_tools = bool(data.tools_enabled)
|
||||||
|
system_prompt = _build_system_prompt(
|
||||||
|
data.novel_id, session, data.page_context, use_tools,
|
||||||
|
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)
|
||||||
|
)
|
||||||
|
|
||||||
async def generate():
|
async def generate():
|
||||||
# 先发送上下文预览
|
"""SSE 生成器:从队列读取事件。客户端断开时后台任务继续运行。"""
|
||||||
yield f"data: {json.dumps({'context': context_preview})}\n\n"
|
|
||||||
|
|
||||||
full_response = ""
|
|
||||||
usage_info = {}
|
|
||||||
try:
|
try:
|
||||||
async for event in stream_chat(config, messages):
|
while True:
|
||||||
if event.text:
|
event = await queue.get()
|
||||||
full_response += event.text
|
if event is None:
|
||||||
yield f"data: {json.dumps({'content': event.text})}\n\n"
|
break
|
||||||
if event.usage:
|
yield _sse(event)
|
||||||
# 累加usage
|
except (asyncio.CancelledError, GeneratorExit):
|
||||||
for k, v in event.usage.items():
|
# 客户端断开连接 — 后台任务继续运行,最终会保存到 DB
|
||||||
if v:
|
pass
|
||||||
usage_info[k] = usage_info.get(k, 0) + v
|
|
||||||
except Exception as e:
|
|
||||||
yield f"data: {json.dumps({'error': str(e)})}\n\n"
|
|
||||||
finally:
|
|
||||||
# 保存AI回复
|
|
||||||
if full_response:
|
|
||||||
with Session(engine) as s:
|
|
||||||
ai_msg = ChatMessage(
|
|
||||||
novel_id=data.novel_id,
|
|
||||||
role="assistant",
|
|
||||||
content=full_response,
|
|
||||||
)
|
|
||||||
s.add(ai_msg)
|
|
||||||
s.commit()
|
|
||||||
yield f"data: {json.dumps({'done': True, 'usage': usage_info or None})}\n\n"
|
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
generate(),
|
generate(),
|
||||||
@@ -85,8 +551,8 @@ async def chat_stream(
|
|||||||
|
|
||||||
|
|
||||||
async def _error_stream(message: str):
|
async def _error_stream(message: str):
|
||||||
yield f"data: {json.dumps({'error': message})}\n\n"
|
yield _sse({"error": message})
|
||||||
yield f"data: {json.dumps({'done': True})}\n\n"
|
yield _sse({"done": True})
|
||||||
|
|
||||||
|
|
||||||
@router.get("/messages", response_model=ChatMessageList)
|
@router.get("/messages", response_model=ChatMessageList)
|
||||||
@@ -108,7 +574,6 @@ def list_messages(
|
|||||||
items = session.exec(
|
items = session.exec(
|
||||||
query.order_by(ChatMessage.created_at.desc()).limit(limit)
|
query.order_by(ChatMessage.created_at.desc()).limit(limit)
|
||||||
).all()
|
).all()
|
||||||
# 返回时按时间正序
|
|
||||||
items = list(reversed(items))
|
items = list(reversed(items))
|
||||||
return ChatMessageList(
|
return ChatMessageList(
|
||||||
items=[ChatMessageRead.model_validate(m, from_attributes=True) for m in items],
|
items=[ChatMessageRead.model_validate(m, from_attributes=True) for m in items],
|
||||||
@@ -130,3 +595,85 @@ def clear_messages(
|
|||||||
for msg in messages:
|
for msg in messages:
|
||||||
session.delete(msg)
|
session.delete(msg)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# 保留最近对话轮次数(每轮 = 1 user + 1 assistant)
|
||||||
|
KEEP_RECENT_ROUNDS = 2
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/compress")
|
||||||
|
async def compress_context(
|
||||||
|
novel_id: int | None = Query(default=None),
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""压缩对话上下文:用 LLM 总结旧消息,仅保留最近几轮对话。"""
|
||||||
|
config = session.exec(select(AIConfig)).first()
|
||||||
|
if not config or not config.api_key:
|
||||||
|
return {"error": "请先配置AI服务"}
|
||||||
|
|
||||||
|
# 查询所有消息(按时间正序)
|
||||||
|
query = select(ChatMessage)
|
||||||
|
if novel_id is not None:
|
||||||
|
query = query.where(ChatMessage.novel_id == novel_id)
|
||||||
|
else:
|
||||||
|
query = query.where(ChatMessage.novel_id.is_(None)) # type: ignore
|
||||||
|
all_msgs = list(session.exec(query.order_by(ChatMessage.created_at)).all())
|
||||||
|
|
||||||
|
if len(all_msgs) <= KEEP_RECENT_ROUNDS * 2:
|
||||||
|
return {"compressed": False, "reason": "消息太少,无需压缩"}
|
||||||
|
|
||||||
|
# 分割:旧消息(将被删除)+ 最近消息(保留原文)
|
||||||
|
keep_count = KEEP_RECENT_ROUNDS * 2
|
||||||
|
old_msgs = all_msgs[:-keep_count]
|
||||||
|
recent_msgs = all_msgs[-keep_count:]
|
||||||
|
|
||||||
|
# 将 **所有消息**(包括最近保留的)都给 LLM 看,确保摘要完整准确
|
||||||
|
conversation_text = ""
|
||||||
|
for msg in all_msgs:
|
||||||
|
role_label = "用户" if msg.role == "user" else "AI助手"
|
||||||
|
content = msg.content
|
||||||
|
if content.startswith("[对话摘要]"):
|
||||||
|
conversation_text += f"[之前的摘要]{content[5:]}\n\n"
|
||||||
|
else:
|
||||||
|
conversation_text += f"{role_label}: {content}\n\n"
|
||||||
|
|
||||||
|
# 调用 LLM 生成摘要
|
||||||
|
summary_prompt = (
|
||||||
|
"你是一个对话摘要助手。请将以下完整对话历史压缩为简洁的摘要,保留关键信息:\n"
|
||||||
|
"1. 用户提出的核心需求和决策\n"
|
||||||
|
"2. AI 执行的重要操作及结果(如创建/修改了哪些大纲、角色、章节等)\n"
|
||||||
|
"3. 达成的共识和待办事项\n"
|
||||||
|
"4. 当前工作进展和下一步计划\n\n"
|
||||||
|
"只输出摘要内容,不要加前缀或解释。用简洁的条目列表形式。"
|
||||||
|
)
|
||||||
|
summary_messages = [
|
||||||
|
{"role": "user", "content": f"请压缩以下对话历史:\n\n{conversation_text}"}
|
||||||
|
]
|
||||||
|
|
||||||
|
try:
|
||||||
|
summary = await simple_completion(config, summary_messages, summary_prompt)
|
||||||
|
except Exception as e:
|
||||||
|
return {"error": f"摘要生成失败: {str(e)}"}
|
||||||
|
|
||||||
|
# 删除旧消息
|
||||||
|
for msg in old_msgs:
|
||||||
|
session.delete(msg)
|
||||||
|
|
||||||
|
# 插入摘要消息,时间设为保留消息之前(确保排在最前面)
|
||||||
|
from datetime import timedelta
|
||||||
|
earliest_kept = recent_msgs[0].created_at
|
||||||
|
summary_msg = ChatMessage(
|
||||||
|
novel_id=novel_id,
|
||||||
|
role="assistant",
|
||||||
|
content=f"[对话摘要]\n{summary}",
|
||||||
|
created_at=earliest_kept - timedelta(seconds=1),
|
||||||
|
)
|
||||||
|
session.add(summary_msg)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"compressed": True,
|
||||||
|
"removed_count": len(old_msgs),
|
||||||
|
"kept_count": len(recent_msgs),
|
||||||
|
"summary_length": len(summary),
|
||||||
|
}
|
||||||
|
|||||||
130
backend/app/routers/files.py
Normal file
130
backend/app/routers/files.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""文件上传/管理路由"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, UploadFile, File
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from ..database import engine, DATA_DIR
|
||||||
|
from ..models import Novel, NovelFile
|
||||||
|
from ..schemas import FileRead, FileList
|
||||||
|
from ..services.file_parser import parse_file, ALLOWED_EXTENSIONS, MAX_FILE_SIZE
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/novels/{novel_id}/files", tags=["files"])
|
||||||
|
|
||||||
|
# 上传文件存储根目录
|
||||||
|
UPLOAD_DIR = DATA_DIR / "uploads"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_novel_upload_dir(novel_id: int) -> Path:
|
||||||
|
"""获取小说的上传目录,不存在则创建"""
|
||||||
|
d = UPLOAD_DIR / str(novel_id)
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
return d
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=FileRead, status_code=201)
|
||||||
|
async def upload_file(novel_id: int, file: UploadFile = File(...)):
|
||||||
|
"""上传文件并解析为纯文本"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
# 校验小说存在
|
||||||
|
novel = session.get(Novel, novel_id)
|
||||||
|
if not novel:
|
||||||
|
raise HTTPException(status_code=404, detail="小说不存在")
|
||||||
|
|
||||||
|
# 校验扩展名
|
||||||
|
if not file.filename:
|
||||||
|
raise HTTPException(status_code=400, detail="缺少文件名")
|
||||||
|
ext = Path(file.filename).suffix.lower()
|
||||||
|
if ext not in ALLOWED_EXTENSIONS:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"不支持的文件格式 {ext},仅支持: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 读取文件内容
|
||||||
|
content = await file.read()
|
||||||
|
file_size = len(content)
|
||||||
|
|
||||||
|
if file_size > MAX_FILE_SIZE:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"文件过大({file_size / 1024 / 1024:.1f}MB),最大允许 10MB",
|
||||||
|
)
|
||||||
|
|
||||||
|
# 生成存储名并保存原始文件
|
||||||
|
stored_name = f"{uuid.uuid4().hex}{ext}"
|
||||||
|
upload_dir = _get_novel_upload_dir(novel_id)
|
||||||
|
file_path = upload_dir / stored_name
|
||||||
|
file_path.write_bytes(content)
|
||||||
|
|
||||||
|
# 解析纯文本
|
||||||
|
try:
|
||||||
|
text_content = parse_file(file_path, file.content_type or "")
|
||||||
|
except Exception as e:
|
||||||
|
# 解析失败,清理已保存的文件
|
||||||
|
file_path.unlink(missing_ok=True)
|
||||||
|
raise HTTPException(status_code=400, detail=f"文件解析失败: {e}")
|
||||||
|
|
||||||
|
# 保存纯文本缓存
|
||||||
|
text_path = upload_dir / f"{stored_name}.txt"
|
||||||
|
text_path.write_text(text_content, encoding="utf-8")
|
||||||
|
|
||||||
|
# 写入数据库
|
||||||
|
with Session(engine) as session:
|
||||||
|
novel_file = NovelFile(
|
||||||
|
novel_id=novel_id,
|
||||||
|
filename=file.filename,
|
||||||
|
stored_name=stored_name,
|
||||||
|
file_size=file_size,
|
||||||
|
mime_type=file.content_type or "",
|
||||||
|
text_length=len(text_content),
|
||||||
|
)
|
||||||
|
session.add(novel_file)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(novel_file)
|
||||||
|
return novel_file
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=FileList)
|
||||||
|
def list_files(novel_id: int):
|
||||||
|
"""列出小说关联的所有文件"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
stmt = (
|
||||||
|
select(NovelFile)
|
||||||
|
.where(NovelFile.novel_id == novel_id)
|
||||||
|
.order_by(NovelFile.created_at.desc())
|
||||||
|
)
|
||||||
|
files = session.exec(stmt).all()
|
||||||
|
items = [
|
||||||
|
FileRead(
|
||||||
|
id=f.id, # type: ignore
|
||||||
|
novel_id=f.novel_id,
|
||||||
|
filename=f.filename,
|
||||||
|
file_size=f.file_size,
|
||||||
|
mime_type=f.mime_type,
|
||||||
|
text_length=f.text_length,
|
||||||
|
created_at=f.created_at,
|
||||||
|
)
|
||||||
|
for f in files
|
||||||
|
]
|
||||||
|
return FileList(items=items, total=len(items))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{file_id}", status_code=204)
|
||||||
|
def delete_file(novel_id: int, file_id: int):
|
||||||
|
"""删除文件(数据库记录 + 磁盘文件)"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
novel_file = session.get(NovelFile, file_id)
|
||||||
|
if not novel_file or novel_file.novel_id != novel_id:
|
||||||
|
raise HTTPException(status_code=404, detail="文件不存在")
|
||||||
|
|
||||||
|
# 删除磁盘文件
|
||||||
|
upload_dir = _get_novel_upload_dir(novel_id)
|
||||||
|
(upload_dir / novel_file.stored_name).unlink(missing_ok=True)
|
||||||
|
(upload_dir / f"{novel_file.stored_name}.txt").unlink(missing_ok=True)
|
||||||
|
|
||||||
|
# 删除数据库记录
|
||||||
|
session.delete(novel_file)
|
||||||
|
session.commit()
|
||||||
@@ -32,24 +32,62 @@ def _get_outline_or_404(
|
|||||||
return outline
|
return outline
|
||||||
|
|
||||||
|
|
||||||
|
def _build_outline_read(outline: Outline, children: list[Outline]) -> OutlineRead:
|
||||||
|
"""将 Outline ORM 对象转为 OutlineRead,附带子节点列表"""
|
||||||
|
return OutlineRead(
|
||||||
|
id=outline.id,
|
||||||
|
novel_id=outline.novel_id,
|
||||||
|
parent_id=outline.parent_id,
|
||||||
|
sort_order=outline.sort_order,
|
||||||
|
summary=outline.summary,
|
||||||
|
detail=outline.detail,
|
||||||
|
children=[
|
||||||
|
OutlineRead(
|
||||||
|
id=c.id,
|
||||||
|
novel_id=c.novel_id,
|
||||||
|
parent_id=c.parent_id,
|
||||||
|
sort_order=c.sort_order,
|
||||||
|
summary=c.summary,
|
||||||
|
detail=c.detail,
|
||||||
|
children=[],
|
||||||
|
created_at=c.created_at,
|
||||||
|
updated_at=c.updated_at,
|
||||||
|
)
|
||||||
|
for c in children
|
||||||
|
],
|
||||||
|
created_at=outline.created_at,
|
||||||
|
updated_at=outline.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=OutlineList)
|
@router.get("", response_model=OutlineList)
|
||||||
def list_outlines(
|
def list_outlines(
|
||||||
novel_id: int,
|
novel_id: int,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
_get_novel_or_404(novel_id, session)
|
_get_novel_or_404(novel_id, session)
|
||||||
total = session.exec(
|
# 只查顶级节点(parent_id IS NULL)
|
||||||
select(func.count(Outline.id)).where(Outline.novel_id == novel_id)
|
top_items = session.exec(
|
||||||
).one()
|
|
||||||
items = session.exec(
|
|
||||||
select(Outline)
|
select(Outline)
|
||||||
.where(Outline.novel_id == novel_id)
|
.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()
|
||||||
return OutlineList(
|
|
||||||
items=[OutlineRead.model_validate(o, from_attributes=True) for o in items],
|
# 查询所有子节点,按 parent_id 分组
|
||||||
total=total,
|
all_children = session.exec(
|
||||||
)
|
select(Outline)
|
||||||
|
.where(Outline.novel_id == novel_id, Outline.parent_id.isnot(None)) # type: ignore
|
||||||
|
.order_by(Outline.sort_order)
|
||||||
|
).all()
|
||||||
|
children_map: dict[int, list[Outline]] = {}
|
||||||
|
for child in all_children:
|
||||||
|
children_map.setdefault(child.parent_id, []).append(child)
|
||||||
|
|
||||||
|
items = [
|
||||||
|
_build_outline_read(o, children_map.get(o.id, []))
|
||||||
|
for o in top_items
|
||||||
|
]
|
||||||
|
return OutlineList(items=items, total=len(items))
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=OutlineRead, status_code=201)
|
@router.post("", response_model=OutlineRead, status_code=201)
|
||||||
@@ -59,17 +97,41 @@ def create_outline(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
_get_novel_or_404(novel_id, session)
|
_get_novel_or_404(novel_id, session)
|
||||||
# 自动设置 sort_order 为当前最大值 + 1
|
|
||||||
max_order = session.exec(
|
# 如果指定了 parent_id,校验父节点存在且是顶级节点
|
||||||
select(func.max(Outline.sort_order)).where(Outline.novel_id == novel_id)
|
if data.parent_id is not None:
|
||||||
).one()
|
parent = session.get(Outline, data.parent_id)
|
||||||
|
if not parent or parent.novel_id != novel_id:
|
||||||
|
raise HTTPException(status_code=404, detail="父节点不存在")
|
||||||
|
if parent.parent_id is not None:
|
||||||
|
raise HTTPException(status_code=400, detail="仅支持两级结构,不能在子节点下创建子节点")
|
||||||
|
|
||||||
|
# 同级内自增 sort_order(同 parent_id 下的 max + 1)
|
||||||
|
if data.parent_id is not None:
|
||||||
|
max_order = session.exec(
|
||||||
|
select(func.max(Outline.sort_order))
|
||||||
|
.where(Outline.novel_id == novel_id, Outline.parent_id == data.parent_id)
|
||||||
|
).one()
|
||||||
|
else:
|
||||||
|
max_order = session.exec(
|
||||||
|
select(func.max(Outline.sort_order))
|
||||||
|
.where(Outline.novel_id == novel_id, Outline.parent_id.is_(None)) # type: ignore
|
||||||
|
).one()
|
||||||
next_order = (max_order or 0) + 1
|
next_order = (max_order or 0) + 1
|
||||||
|
|
||||||
outline = Outline(novel_id=novel_id, sort_order=next_order, **data.model_dump())
|
outline = Outline(
|
||||||
|
novel_id=novel_id,
|
||||||
|
parent_id=data.parent_id,
|
||||||
|
sort_order=next_order,
|
||||||
|
summary=data.summary,
|
||||||
|
detail=data.detail,
|
||||||
|
)
|
||||||
session.add(outline)
|
session.add(outline)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(outline)
|
session.refresh(outline)
|
||||||
return outline
|
|
||||||
|
# 返回时附带空 children
|
||||||
|
return _build_outline_read(outline, [])
|
||||||
|
|
||||||
|
|
||||||
@router.put("/reorder", response_model=OutlineList)
|
@router.put("/reorder", response_model=OutlineList)
|
||||||
@@ -78,25 +140,53 @@ def reorder_outlines(
|
|||||||
data: OutlineReorder,
|
data: OutlineReorder,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
|
"""仅处理顶级节点排序"""
|
||||||
_get_novel_or_404(novel_id, session)
|
_get_novel_or_404(novel_id, session)
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
for idx, oid in enumerate(data.ordered_ids):
|
for idx, oid in enumerate(data.ordered_ids):
|
||||||
outline = _get_outline_or_404(oid, novel_id, session)
|
outline = _get_outline_or_404(oid, novel_id, session)
|
||||||
|
if outline.parent_id is not None:
|
||||||
|
raise HTTPException(status_code=400, detail=f"节点 {oid} 不是顶级节点,不能在此排序")
|
||||||
outline.sort_order = idx + 1
|
outline.sort_order = idx + 1
|
||||||
outline.updated_at = now
|
outline.updated_at = now
|
||||||
session.add(outline)
|
session.add(outline)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|
||||||
items = session.exec(
|
# 重新查询并返回嵌套结构
|
||||||
|
return list_outlines(novel_id, session)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{outline_id}/children/reorder", response_model=OutlineRead)
|
||||||
|
def reorder_children(
|
||||||
|
novel_id: int,
|
||||||
|
outline_id: int,
|
||||||
|
data: OutlineReorder,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
"""子节点排序"""
|
||||||
|
_get_novel_or_404(novel_id, session)
|
||||||
|
parent = _get_outline_or_404(outline_id, novel_id, session)
|
||||||
|
if parent.parent_id is not None:
|
||||||
|
raise HTTPException(status_code=400, detail="只有顶级节点可以排序子节点")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for idx, cid in enumerate(data.ordered_ids):
|
||||||
|
child = _get_outline_or_404(cid, novel_id, session)
|
||||||
|
if child.parent_id != outline_id:
|
||||||
|
raise HTTPException(status_code=400, detail=f"节点 {cid} 不是该父节点的子节点")
|
||||||
|
child.sort_order = idx + 1
|
||||||
|
child.updated_at = now
|
||||||
|
session.add(child)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
|
# 查询更新后的子节点
|
||||||
|
children = session.exec(
|
||||||
select(Outline)
|
select(Outline)
|
||||||
.where(Outline.novel_id == novel_id)
|
.where(Outline.novel_id == novel_id, Outline.parent_id == outline_id)
|
||||||
.order_by(Outline.sort_order)
|
.order_by(Outline.sort_order)
|
||||||
).all()
|
).all()
|
||||||
total = len(items)
|
session.refresh(parent)
|
||||||
return OutlineList(
|
return _build_outline_read(parent, list(children))
|
||||||
items=[OutlineRead.model_validate(o, from_attributes=True) for o in items],
|
|
||||||
total=total,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{outline_id}", response_model=OutlineRead)
|
@router.get("/{outline_id}", response_model=OutlineRead)
|
||||||
@@ -105,7 +195,14 @@ def get_outline(
|
|||||||
outline_id: int,
|
outline_id: int,
|
||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
return _get_outline_or_404(outline_id, novel_id, session)
|
outline = _get_outline_or_404(outline_id, novel_id, session)
|
||||||
|
# 查询子节点
|
||||||
|
children = session.exec(
|
||||||
|
select(Outline)
|
||||||
|
.where(Outline.novel_id == novel_id, Outline.parent_id == outline_id)
|
||||||
|
.order_by(Outline.sort_order)
|
||||||
|
).all()
|
||||||
|
return _build_outline_read(outline, list(children))
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{outline_id}", response_model=OutlineRead)
|
@router.patch("/{outline_id}", response_model=OutlineRead)
|
||||||
@@ -123,7 +220,14 @@ def update_outline(
|
|||||||
session.add(outline)
|
session.add(outline)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(outline)
|
session.refresh(outline)
|
||||||
return outline
|
|
||||||
|
# 查询子节点
|
||||||
|
children = session.exec(
|
||||||
|
select(Outline)
|
||||||
|
.where(Outline.novel_id == novel_id, Outline.parent_id == outline_id)
|
||||||
|
.order_by(Outline.sort_order)
|
||||||
|
).all()
|
||||||
|
return _build_outline_read(outline, list(children))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{outline_id}", status_code=204)
|
@router.delete("/{outline_id}", status_code=204)
|
||||||
@@ -133,5 +237,12 @@ def delete_outline(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
):
|
):
|
||||||
outline = _get_outline_or_404(outline_id, novel_id, session)
|
outline = _get_outline_or_404(outline_id, novel_id, session)
|
||||||
|
# 删除父节点时先删除所有子节点
|
||||||
|
children = session.exec(
|
||||||
|
select(Outline)
|
||||||
|
.where(Outline.novel_id == novel_id, Outline.parent_id == outline_id)
|
||||||
|
).all()
|
||||||
|
for child in children:
|
||||||
|
session.delete(child)
|
||||||
session.delete(outline)
|
session.delete(outline)
|
||||||
session.commit()
|
session.commit()
|
||||||
|
|||||||
111
backend/app/routers/skills.py
Normal file
111
backend/app/routers/skills.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
"""Skill 插件管理路由"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from sqlmodel import Session, select, or_
|
||||||
|
|
||||||
|
from ..database import engine
|
||||||
|
from ..models import Skill
|
||||||
|
from ..schemas import SkillCreate, SkillUpdate, SkillRead, SkillList, ToolInfo
|
||||||
|
from ..services.tools import TOOL_DEFINITIONS
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/skills", tags=["skills"])
|
||||||
|
|
||||||
|
|
||||||
|
def _skill_to_read(skill: Skill) -> SkillRead:
|
||||||
|
"""将 Skill ORM 对象转为 SkillRead,反序列化 allowed_tools"""
|
||||||
|
try:
|
||||||
|
tools = json.loads(skill.allowed_tools) if skill.allowed_tools else []
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
tools = []
|
||||||
|
return SkillRead(
|
||||||
|
id=skill.id, # type: ignore
|
||||||
|
name=skill.name,
|
||||||
|
description=skill.description,
|
||||||
|
system_prompt=skill.system_prompt,
|
||||||
|
allowed_tools=tools,
|
||||||
|
is_builtin=skill.is_builtin,
|
||||||
|
novel_id=skill.novel_id,
|
||||||
|
created_at=skill.created_at,
|
||||||
|
updated_at=skill.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=SkillList)
|
||||||
|
def list_skills(novel_id: int | None = None):
|
||||||
|
"""列出 Skill,返回全局 + 指定小说的"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
if novel_id is not None:
|
||||||
|
stmt = (
|
||||||
|
select(Skill)
|
||||||
|
.where(or_(Skill.novel_id.is_(None), Skill.novel_id == novel_id)) # type: ignore
|
||||||
|
.order_by(Skill.is_builtin.desc(), Skill.created_at) # type: ignore
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
stmt = select(Skill).order_by(Skill.is_builtin.desc(), Skill.created_at) # type: ignore
|
||||||
|
skills = session.exec(stmt).all()
|
||||||
|
items = [_skill_to_read(s) for s in skills]
|
||||||
|
return SkillList(items=items, total=len(items))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=SkillRead, status_code=201)
|
||||||
|
def create_skill(data: SkillCreate):
|
||||||
|
"""创建 Skill"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
skill = Skill(
|
||||||
|
name=data.name,
|
||||||
|
description=data.description,
|
||||||
|
system_prompt=data.system_prompt,
|
||||||
|
allowed_tools=json.dumps(data.allowed_tools, ensure_ascii=False),
|
||||||
|
is_builtin=False,
|
||||||
|
novel_id=data.novel_id,
|
||||||
|
)
|
||||||
|
session.add(skill)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(skill)
|
||||||
|
return _skill_to_read(skill)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tools", response_model=list[ToolInfo])
|
||||||
|
def get_available_tools():
|
||||||
|
"""返回所有可用工具名和描述(供前端工具 checkbox 使用)"""
|
||||||
|
return [
|
||||||
|
ToolInfo(name=t["name"], description=t["description"])
|
||||||
|
for t in TOOL_DEFINITIONS
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{skill_id}", response_model=SkillRead)
|
||||||
|
def update_skill(skill_id: int, data: SkillUpdate):
|
||||||
|
"""更新 Skill"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
skill = session.get(Skill, skill_id)
|
||||||
|
if not skill:
|
||||||
|
raise HTTPException(status_code=404, detail="Skill 不存在")
|
||||||
|
|
||||||
|
if data.name is not None:
|
||||||
|
skill.name = data.name
|
||||||
|
if data.description is not None:
|
||||||
|
skill.description = data.description
|
||||||
|
if data.system_prompt is not None:
|
||||||
|
skill.system_prompt = data.system_prompt
|
||||||
|
if data.allowed_tools is not None:
|
||||||
|
skill.allowed_tools = json.dumps(data.allowed_tools, ensure_ascii=False)
|
||||||
|
skill.updated_at = datetime.now(timezone.utc)
|
||||||
|
session.add(skill)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(skill)
|
||||||
|
return _skill_to_read(skill)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{skill_id}", status_code=204)
|
||||||
|
def delete_skill(skill_id: int):
|
||||||
|
"""删除 Skill"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
skill = session.get(Skill, skill_id)
|
||||||
|
if not skill:
|
||||||
|
raise HTTPException(status_code=404, detail="Skill 不存在")
|
||||||
|
session.delete(skill)
|
||||||
|
session.commit()
|
||||||
56
backend/app/routers/world_setting.py
Normal file
56
backend/app/routers/world_setting.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from ..database import get_session
|
||||||
|
from ..models import Novel, WorldSetting
|
||||||
|
from ..schemas import WorldSettingUpdate, WorldSettingRead
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/novels/{novel_id}/world-setting", tags=["world-setting"])
|
||||||
|
|
||||||
|
|
||||||
|
def _get_novel_or_404(novel_id: int, session: Session) -> Novel:
|
||||||
|
novel = session.get(Novel, novel_id)
|
||||||
|
if not novel:
|
||||||
|
raise HTTPException(status_code=404, detail="小说不存在")
|
||||||
|
return novel
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=WorldSettingRead)
|
||||||
|
def get_world_setting(
|
||||||
|
novel_id: int,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_get_novel_or_404(novel_id, session)
|
||||||
|
ws = session.exec(
|
||||||
|
select(WorldSetting).where(WorldSetting.novel_id == novel_id)
|
||||||
|
).first()
|
||||||
|
if not ws:
|
||||||
|
# 不存在则自动创建空记录
|
||||||
|
ws = WorldSetting(novel_id=novel_id, content="")
|
||||||
|
session.add(ws)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(ws)
|
||||||
|
return ws
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=WorldSettingRead)
|
||||||
|
def save_world_setting(
|
||||||
|
novel_id: int,
|
||||||
|
data: WorldSettingUpdate,
|
||||||
|
session: Session = Depends(get_session),
|
||||||
|
):
|
||||||
|
_get_novel_or_404(novel_id, session)
|
||||||
|
ws = session.exec(
|
||||||
|
select(WorldSetting).where(WorldSetting.novel_id == novel_id)
|
||||||
|
).first()
|
||||||
|
if ws:
|
||||||
|
ws.content = data.content
|
||||||
|
ws.updated_at = datetime.now(timezone.utc)
|
||||||
|
else:
|
||||||
|
ws = WorldSetting(novel_id=novel_id, content=data.content)
|
||||||
|
session.add(ws)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(ws)
|
||||||
|
return ws
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -32,6 +34,7 @@ class NovelList(BaseModel):
|
|||||||
class OutlineCreate(BaseModel):
|
class OutlineCreate(BaseModel):
|
||||||
summary: str = Field(min_length=1, max_length=200)
|
summary: str = Field(min_length=1, max_length=200)
|
||||||
detail: str = Field(default="", max_length=1000)
|
detail: str = Field(default="", max_length=1000)
|
||||||
|
parent_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class OutlineUpdate(BaseModel):
|
class OutlineUpdate(BaseModel):
|
||||||
@@ -43,9 +46,11 @@ class OutlineUpdate(BaseModel):
|
|||||||
class OutlineRead(BaseModel):
|
class OutlineRead(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
novel_id: int
|
novel_id: int
|
||||||
|
parent_id: int | None = None
|
||||||
sort_order: int
|
sort_order: int
|
||||||
summary: str
|
summary: str
|
||||||
detail: str
|
detail: str
|
||||||
|
children: list[OutlineRead] = []
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
@@ -60,6 +65,140 @@ class OutlineReorder(BaseModel):
|
|||||||
ordered_ids: list[int]
|
ordered_ids: list[int]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 角色 ──
|
||||||
|
|
||||||
|
|
||||||
|
class CharacterCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=100)
|
||||||
|
description: str = Field(default="", max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class CharacterUpdate(BaseModel):
|
||||||
|
name: str | None = Field(default=None, min_length=1, max_length=100)
|
||||||
|
description: str | None = Field(default=None, max_length=1000)
|
||||||
|
|
||||||
|
|
||||||
|
class CharacterRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
novel_id: int
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CharacterList(BaseModel):
|
||||||
|
items: list[CharacterRead]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
# ── 章节 ──
|
||||||
|
|
||||||
|
|
||||||
|
class ChapterCreate(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=200)
|
||||||
|
content: str = Field(default="", max_length=5000)
|
||||||
|
|
||||||
|
|
||||||
|
class ChapterUpdate(BaseModel):
|
||||||
|
title: str | None = Field(default=None, min_length=1, max_length=200)
|
||||||
|
content: str | None = Field(default=None, max_length=5000)
|
||||||
|
sort_order: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ChapterRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
novel_id: int
|
||||||
|
sort_order: int
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ChapterList(BaseModel):
|
||||||
|
items: list[ChapterRead]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class ChapterReorder(BaseModel):
|
||||||
|
"""排序请求:按顺序传入章节id列表"""
|
||||||
|
ordered_ids: list[int]
|
||||||
|
|
||||||
|
|
||||||
|
# ── 世界观 ──
|
||||||
|
|
||||||
|
|
||||||
|
class WorldSettingUpdate(BaseModel):
|
||||||
|
content: str = Field(default="", max_length=5000)
|
||||||
|
|
||||||
|
|
||||||
|
class WorldSettingRead(BaseModel):
|
||||||
|
id: int
|
||||||
|
novel_id: int
|
||||||
|
content: str
|
||||||
|
created_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配置 ──
|
||||||
|
|
||||||
|
|
||||||
@@ -87,9 +226,23 @@ class ChatMessageInput(BaseModel):
|
|||||||
content: str
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class PendingToolCall(BaseModel):
|
||||||
|
"""待审批的工具调用"""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
arguments: dict
|
||||||
|
|
||||||
|
|
||||||
class ChatRequest(BaseModel):
|
class ChatRequest(BaseModel):
|
||||||
messages: list[ChatMessageInput]
|
messages: list[ChatMessageInput]
|
||||||
novel_id: int | None = None
|
novel_id: int | None = None
|
||||||
|
page_context: str | None = None # 用户当前所在页面,如 "大纲页" "世界观页"
|
||||||
|
tools_enabled: bool = True # 是否启用工具调用
|
||||||
|
auto_approve_tools: bool = False # 是否自动执行工具(无需人工审批)
|
||||||
|
skill_id: int | None = None # 激活的 Skill ID
|
||||||
|
# 工具审批续传
|
||||||
|
pending_tool_calls: list[PendingToolCall] | None = None
|
||||||
|
assistant_text: str | None = None # LLM 在工具调用前输出的文本
|
||||||
|
|
||||||
|
|
||||||
class ChatMessageRead(BaseModel):
|
class ChatMessageRead(BaseModel):
|
||||||
@@ -103,3 +256,7 @@ class ChatMessageRead(BaseModel):
|
|||||||
class ChatMessageList(BaseModel):
|
class ChatMessageList(BaseModel):
|
||||||
items: list[ChatMessageRead]
|
items: list[ChatMessageRead]
|
||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
# 解决 OutlineRead 自引用
|
||||||
|
OutlineRead.model_rebuild()
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
"""AI Provider 抽象层,支持 OpenAI 兼容 API 和 Anthropic 原生 API。"""
|
"""AI Provider 抽象层,支持 OpenAI 兼容 API 和 Anthropic 原生 API。
|
||||||
|
支持文本流式输出和 tool calling。"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from typing import AsyncGenerator
|
from typing import AsyncGenerator
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
@@ -8,33 +10,168 @@ import httpx
|
|||||||
from ..models import AIConfig
|
from ..models import AIConfig
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ToolCall:
|
||||||
|
"""一次工具调用"""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
arguments: dict
|
||||||
|
|
||||||
|
|
||||||
class StreamEvent:
|
class StreamEvent:
|
||||||
"""流式事件:文本chunk或usage信息"""
|
"""流式事件:文本chunk、usage信息、或工具调用"""
|
||||||
def __init__(self, text: str = "", usage: dict | None = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
text: str = "",
|
||||||
|
usage: dict | None = None,
|
||||||
|
tool_calls: list[ToolCall] | None = None,
|
||||||
|
):
|
||||||
self.text = text
|
self.text = text
|
||||||
self.usage = usage
|
self.usage = usage
|
||||||
|
self.tool_calls = tool_calls
|
||||||
|
|
||||||
|
|
||||||
async def stream_chat(
|
async def stream_chat(
|
||||||
config: AIConfig,
|
config: AIConfig,
|
||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
system_prompt: str = "",
|
system_prompt: str = "",
|
||||||
|
tools: list[dict] | None = None,
|
||||||
) -> AsyncGenerator[StreamEvent, None]:
|
) -> AsyncGenerator[StreamEvent, None]:
|
||||||
"""根据 provider 类型调用对应的流式 API,返回 StreamEvent。"""
|
"""根据 provider 类型调用对应的流式 API,返回 StreamEvent。"""
|
||||||
if config.provider == "anthropic":
|
if config.provider == "anthropic":
|
||||||
async for event in _stream_anthropic(config, messages, system_prompt):
|
async for event in _stream_anthropic(config, messages, system_prompt, tools):
|
||||||
yield event
|
yield event
|
||||||
else:
|
else:
|
||||||
async for event in _stream_openai(config, messages, system_prompt):
|
async for event in _stream_openai(config, messages, system_prompt, tools):
|
||||||
yield event
|
yield event
|
||||||
|
|
||||||
|
|
||||||
|
# ── 消息格式构建(用于 tool call 循环中追加消息)──
|
||||||
|
|
||||||
|
|
||||||
|
def build_assistant_message(provider: str, text: str, tool_calls: list[ToolCall]) -> dict:
|
||||||
|
"""构建包含工具调用的 assistant 消息"""
|
||||||
|
if provider == "anthropic":
|
||||||
|
content = []
|
||||||
|
if text:
|
||||||
|
content.append({"type": "text", "text": text})
|
||||||
|
for tc in tool_calls:
|
||||||
|
content.append({
|
||||||
|
"type": "tool_use",
|
||||||
|
"id": tc.id,
|
||||||
|
"name": tc.name,
|
||||||
|
"input": tc.arguments,
|
||||||
|
})
|
||||||
|
return {"role": "assistant", "content": content}
|
||||||
|
else:
|
||||||
|
# OpenAI 格式
|
||||||
|
msg: dict = {
|
||||||
|
"role": "assistant",
|
||||||
|
"content": text or None,
|
||||||
|
"tool_calls": [
|
||||||
|
{
|
||||||
|
"id": tc.id,
|
||||||
|
"type": "function",
|
||||||
|
"function": {
|
||||||
|
"name": tc.name,
|
||||||
|
"arguments": json.dumps(tc.arguments, ensure_ascii=False),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for tc in tool_calls
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def build_tool_results_messages(
|
||||||
|
provider: str, results: list[dict],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""构建工具结果消息。results: [{"id": str, "result": str}]
|
||||||
|
OpenAI: 返回多条 tool message
|
||||||
|
Anthropic: 返回一条 user message,content 是 tool_result 数组
|
||||||
|
"""
|
||||||
|
if provider == "anthropic":
|
||||||
|
content = [
|
||||||
|
{"type": "tool_result", "tool_use_id": r["id"], "content": r["result"]}
|
||||||
|
for r in results
|
||||||
|
]
|
||||||
|
return [{"role": "user", "content": content}]
|
||||||
|
else:
|
||||||
|
return [
|
||||||
|
{"role": "tool", "tool_call_id": r["id"], "content": r["result"]}
|
||||||
|
for r in results
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def simple_completion(
|
||||||
|
config: AIConfig,
|
||||||
|
messages: list[dict],
|
||||||
|
system_prompt: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""非流式调用,返回完整文本。用于摘要压缩等场景。"""
|
||||||
|
if config.provider == "anthropic":
|
||||||
|
return await _simple_anthropic(config, messages, system_prompt)
|
||||||
|
return await _simple_openai(config, messages, system_prompt)
|
||||||
|
|
||||||
|
|
||||||
|
async def _simple_openai(config: AIConfig, messages: list[dict], system_prompt: str) -> str:
|
||||||
|
url = f"{config.base_url.rstrip('/')}/chat/completions"
|
||||||
|
payload_messages = []
|
||||||
|
if system_prompt:
|
||||||
|
payload_messages.append({"role": "system", "content": system_prompt})
|
||||||
|
payload_messages.extend(messages)
|
||||||
|
|
||||||
|
body = {"model": config.model, "messages": payload_messages, "stream": False}
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"Bearer {config.api_key}", "Content-Type": "application/json"},
|
||||||
|
json=body,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return data["choices"][0]["message"]["content"] or ""
|
||||||
|
|
||||||
|
|
||||||
|
async def _simple_anthropic(config: AIConfig, messages: list[dict], system_prompt: str) -> str:
|
||||||
|
url = f"{config.base_url.rstrip('/')}/messages"
|
||||||
|
body: dict = {
|
||||||
|
"model": config.model,
|
||||||
|
"max_tokens": 4096,
|
||||||
|
"messages": messages,
|
||||||
|
"stream": False,
|
||||||
|
}
|
||||||
|
if system_prompt:
|
||||||
|
body["system"] = system_prompt
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
url,
|
||||||
|
headers={
|
||||||
|
"x-api-key": config.api_key,
|
||||||
|
"anthropic-version": "2023-06-01",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json=body,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
for block in data.get("content", []):
|
||||||
|
if block.get("type") == "text":
|
||||||
|
return block["text"]
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── OpenAI 兼容 ──
|
||||||
|
|
||||||
|
|
||||||
async def _stream_openai(
|
async def _stream_openai(
|
||||||
config: AIConfig,
|
config: AIConfig,
|
||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
system_prompt: str,
|
system_prompt: str,
|
||||||
|
tools: list[dict] | None = None,
|
||||||
) -> AsyncGenerator[StreamEvent, None]:
|
) -> AsyncGenerator[StreamEvent, None]:
|
||||||
"""调用 OpenAI 兼容 API(stream=true)。"""
|
"""调用 OpenAI 兼容 API(stream=true),支持 tool calling。"""
|
||||||
url = f"{config.base_url.rstrip('/')}/chat/completions"
|
url = f"{config.base_url.rstrip('/')}/chat/completions"
|
||||||
|
|
||||||
payload_messages = []
|
payload_messages = []
|
||||||
@@ -42,12 +179,14 @@ async def _stream_openai(
|
|||||||
payload_messages.append({"role": "system", "content": system_prompt})
|
payload_messages.append({"role": "system", "content": system_prompt})
|
||||||
payload_messages.extend(messages)
|
payload_messages.extend(messages)
|
||||||
|
|
||||||
body = {
|
body: dict = {
|
||||||
"model": config.model,
|
"model": config.model,
|
||||||
"messages": payload_messages,
|
"messages": payload_messages,
|
||||||
"stream": True,
|
"stream": True,
|
||||||
"stream_options": {"include_usage": True},
|
"stream_options": {"include_usage": True},
|
||||||
}
|
}
|
||||||
|
if tools:
|
||||||
|
body["tools"] = tools
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=120) as client:
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
async with client.stream(
|
async with client.stream(
|
||||||
@@ -72,6 +211,9 @@ async def _stream_openai(
|
|||||||
if "text/html" in ct:
|
if "text/html" in ct:
|
||||||
raise RuntimeError(f"API地址返回了HTML页面,请检查API地址是否正确(当前: {url})")
|
raise RuntimeError(f"API地址返回了HTML页面,请检查API地址是否正确(当前: {url})")
|
||||||
|
|
||||||
|
# 累积工具调用
|
||||||
|
pending_tool_calls: dict[int, dict] = {} # index -> {id, name, arguments}
|
||||||
|
|
||||||
async for line in resp.aiter_lines():
|
async for line in resp.aiter_lines():
|
||||||
if not line.startswith("data: "):
|
if not line.startswith("data: "):
|
||||||
continue
|
continue
|
||||||
@@ -80,7 +222,8 @@ async def _stream_openai(
|
|||||||
break
|
break
|
||||||
try:
|
try:
|
||||||
chunk = json.loads(data)
|
chunk = json.loads(data)
|
||||||
# 提取usage(OpenAI在最后一个chunk返回)
|
|
||||||
|
# 提取 usage
|
||||||
usage = chunk.get("usage")
|
usage = chunk.get("usage")
|
||||||
if usage:
|
if usage:
|
||||||
yield StreamEvent(usage={
|
yield StreamEvent(usage={
|
||||||
@@ -88,22 +231,63 @@ async def _stream_openai(
|
|||||||
"completion_tokens": usage.get("completion_tokens", 0),
|
"completion_tokens": usage.get("completion_tokens", 0),
|
||||||
"total_tokens": usage.get("total_tokens", 0),
|
"total_tokens": usage.get("total_tokens", 0),
|
||||||
})
|
})
|
||||||
|
|
||||||
choices = chunk.get("choices", [])
|
choices = chunk.get("choices", [])
|
||||||
if choices:
|
if not choices:
|
||||||
delta = choices[0].get("delta", {})
|
continue
|
||||||
content = delta.get("content")
|
|
||||||
if content:
|
choice = choices[0]
|
||||||
yield StreamEvent(text=content)
|
delta = choice.get("delta", {})
|
||||||
|
finish_reason = choice.get("finish_reason")
|
||||||
|
|
||||||
|
# 文本内容
|
||||||
|
content = delta.get("content")
|
||||||
|
if content:
|
||||||
|
yield StreamEvent(text=content)
|
||||||
|
|
||||||
|
# 工具调用 chunk
|
||||||
|
for tc_delta in delta.get("tool_calls", []):
|
||||||
|
idx = tc_delta["index"]
|
||||||
|
if idx not in pending_tool_calls:
|
||||||
|
pending_tool_calls[idx] = {"id": "", "name": "", "arguments": ""}
|
||||||
|
entry = pending_tool_calls[idx]
|
||||||
|
if "id" in tc_delta:
|
||||||
|
entry["id"] = tc_delta["id"]
|
||||||
|
func = tc_delta.get("function", {})
|
||||||
|
if "name" in func:
|
||||||
|
entry["name"] = func["name"]
|
||||||
|
if "arguments" in func:
|
||||||
|
entry["arguments"] += func["arguments"]
|
||||||
|
|
||||||
|
# 结束:如果有工具调用,发出事件
|
||||||
|
if finish_reason == "tool_calls" and pending_tool_calls:
|
||||||
|
tool_calls = []
|
||||||
|
for tc_data in pending_tool_calls.values():
|
||||||
|
try:
|
||||||
|
args = json.loads(tc_data["arguments"]) if tc_data["arguments"] else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
args = {}
|
||||||
|
tool_calls.append(ToolCall(
|
||||||
|
id=tc_data["id"],
|
||||||
|
name=tc_data["name"],
|
||||||
|
arguments=args,
|
||||||
|
))
|
||||||
|
yield StreamEvent(tool_calls=tool_calls)
|
||||||
|
|
||||||
except (json.JSONDecodeError, KeyError, IndexError):
|
except (json.JSONDecodeError, KeyError, IndexError):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
||||||
|
# ── Anthropic 原生 ──
|
||||||
|
|
||||||
|
|
||||||
async def _stream_anthropic(
|
async def _stream_anthropic(
|
||||||
config: AIConfig,
|
config: AIConfig,
|
||||||
messages: list[dict],
|
messages: list[dict],
|
||||||
system_prompt: str,
|
system_prompt: str,
|
||||||
|
tools: list[dict] | None = None,
|
||||||
) -> AsyncGenerator[StreamEvent, None]:
|
) -> AsyncGenerator[StreamEvent, None]:
|
||||||
"""调用 Anthropic Messages API(stream=true)。"""
|
"""调用 Anthropic Messages API(stream=true),支持 tool calling。"""
|
||||||
url = f"{config.base_url.rstrip('/')}/messages"
|
url = f"{config.base_url.rstrip('/')}/messages"
|
||||||
|
|
||||||
body: dict = {
|
body: dict = {
|
||||||
@@ -114,6 +298,8 @@ async def _stream_anthropic(
|
|||||||
}
|
}
|
||||||
if system_prompt:
|
if system_prompt:
|
||||||
body["system"] = system_prompt
|
body["system"] = system_prompt
|
||||||
|
if tools:
|
||||||
|
body["tools"] = tools
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=120) as client:
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
async with client.stream(
|
async with client.stream(
|
||||||
@@ -139,16 +325,39 @@ async def _stream_anthropic(
|
|||||||
if "text/html" in ct:
|
if "text/html" in ct:
|
||||||
raise RuntimeError(f"API地址返回了HTML页面,请检查API地址是否正确(当前: {url})")
|
raise RuntimeError(f"API地址返回了HTML页面,请检查API地址是否正确(当前: {url})")
|
||||||
|
|
||||||
|
# 累积工具调用
|
||||||
|
pending_tool_uses: dict[int, dict] = {} # block_index -> {id, name, input_json}
|
||||||
|
stop_reason = None
|
||||||
|
|
||||||
async for line in resp.aiter_lines():
|
async for line in resp.aiter_lines():
|
||||||
if not line.startswith("data: "):
|
if not line.startswith("data: "):
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
event = json.loads(line[6:])
|
event = json.loads(line[6:])
|
||||||
if event.get("type") == "content_block_delta":
|
event_type = event.get("type")
|
||||||
text = event.get("delta", {}).get("text", "")
|
|
||||||
if text:
|
if event_type == "content_block_start":
|
||||||
yield StreamEvent(text=text)
|
block = event.get("content_block", {})
|
||||||
elif event.get("type") == "message_delta":
|
idx = event.get("index", 0)
|
||||||
|
if block.get("type") == "tool_use":
|
||||||
|
pending_tool_uses[idx] = {
|
||||||
|
"id": block["id"],
|
||||||
|
"name": block["name"],
|
||||||
|
"input_json": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
elif event_type == "content_block_delta":
|
||||||
|
idx = event.get("index", 0)
|
||||||
|
delta = event.get("delta", {})
|
||||||
|
if delta.get("type") == "text_delta":
|
||||||
|
text = delta.get("text", "")
|
||||||
|
if text:
|
||||||
|
yield StreamEvent(text=text)
|
||||||
|
elif delta.get("type") == "input_json_delta":
|
||||||
|
if idx in pending_tool_uses:
|
||||||
|
pending_tool_uses[idx]["input_json"] += delta.get("partial_json", "")
|
||||||
|
|
||||||
|
elif event_type == "message_delta":
|
||||||
usage = event.get("usage", {})
|
usage = event.get("usage", {})
|
||||||
if usage:
|
if usage:
|
||||||
yield StreamEvent(usage={
|
yield StreamEvent(usage={
|
||||||
@@ -156,7 +365,9 @@ async def _stream_anthropic(
|
|||||||
"completion_tokens": usage.get("output_tokens", 0),
|
"completion_tokens": usage.get("output_tokens", 0),
|
||||||
"total_tokens": 0,
|
"total_tokens": 0,
|
||||||
})
|
})
|
||||||
elif event.get("type") == "message_start":
|
stop_reason = event.get("delta", {}).get("stop_reason")
|
||||||
|
|
||||||
|
elif event_type == "message_start":
|
||||||
msg = event.get("message", {})
|
msg = event.get("message", {})
|
||||||
usage = msg.get("usage", {})
|
usage = msg.get("usage", {})
|
||||||
if usage:
|
if usage:
|
||||||
@@ -165,7 +376,24 @@ async def _stream_anthropic(
|
|||||||
"completion_tokens": 0,
|
"completion_tokens": 0,
|
||||||
"total_tokens": 0,
|
"total_tokens": 0,
|
||||||
})
|
})
|
||||||
elif event.get("type") == "message_stop":
|
|
||||||
|
elif event_type == "message_stop":
|
||||||
break
|
break
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# 如果有工具调用,发出事件
|
||||||
|
if stop_reason == "tool_use" and pending_tool_uses:
|
||||||
|
tool_calls = []
|
||||||
|
for tu_data in pending_tool_uses.values():
|
||||||
|
try:
|
||||||
|
args = json.loads(tu_data["input_json"]) if tu_data["input_json"] else {}
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
args = {}
|
||||||
|
tool_calls.append(ToolCall(
|
||||||
|
id=tu_data["id"],
|
||||||
|
name=tu_data["name"],
|
||||||
|
arguments=args,
|
||||||
|
))
|
||||||
|
yield StreamEvent(tool_calls=tool_calls)
|
||||||
|
|||||||
64
backend/app/services/file_parser.py
Normal file
64
backend/app/services/file_parser.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""文件解析服务:将上传的文件转换为纯文本"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# 支持的文件扩展名
|
||||||
|
ALLOWED_EXTENSIONS = {".txt", ".md", ".docx", ".pdf"}
|
||||||
|
|
||||||
|
# 最大文件大小 10MB
|
||||||
|
MAX_FILE_SIZE = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
def parse_file(file_path: Path, mime_type: str) -> str:
|
||||||
|
"""解析文件为纯文本,返回文本内容"""
|
||||||
|
ext = file_path.suffix.lower()
|
||||||
|
|
||||||
|
if ext in (".txt", ".md"):
|
||||||
|
return _parse_text(file_path)
|
||||||
|
elif ext == ".docx":
|
||||||
|
return _parse_docx(file_path)
|
||||||
|
elif ext == ".pdf":
|
||||||
|
return _parse_pdf(file_path)
|
||||||
|
else:
|
||||||
|
raise ValueError(f"不支持的文件格式: {ext}")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_text(file_path: Path) -> str:
|
||||||
|
"""解析纯文本文件,自动检测编码"""
|
||||||
|
for encoding in ("utf-8", "gbk", "gb18030", "utf-16", "latin-1"):
|
||||||
|
try:
|
||||||
|
return file_path.read_text(encoding=encoding)
|
||||||
|
except (UnicodeDecodeError, UnicodeError):
|
||||||
|
continue
|
||||||
|
# 最后用 errors='replace' 兜底
|
||||||
|
return file_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_docx(file_path: Path) -> str:
|
||||||
|
"""解析 Word 文档"""
|
||||||
|
try:
|
||||||
|
from docx import Document
|
||||||
|
except ImportError:
|
||||||
|
raise RuntimeError("需要安装 python-docx: pip install python-docx")
|
||||||
|
|
||||||
|
doc = Document(str(file_path))
|
||||||
|
paragraphs = [p.text for p in doc.paragraphs if p.text.strip()]
|
||||||
|
return "\n\n".join(paragraphs)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_pdf(file_path: Path) -> str:
|
||||||
|
"""解析 PDF 文件"""
|
||||||
|
try:
|
||||||
|
import fitz # pymupdf
|
||||||
|
except ImportError:
|
||||||
|
raise RuntimeError("需要安装 pymupdf: pip install pymupdf")
|
||||||
|
|
||||||
|
doc = fitz.open(str(file_path))
|
||||||
|
pages = []
|
||||||
|
for page in doc:
|
||||||
|
text = page.get_text().strip()
|
||||||
|
if text:
|
||||||
|
pages.append(text)
|
||||||
|
doc.close()
|
||||||
|
return "\n\n".join(pages)
|
||||||
760
backend/app/services/tools.py
Normal file
760
backend/app/services/tools.py
Normal file
@@ -0,0 +1,760 @@
|
|||||||
|
"""工具注册表和执行器 —— 小说信息、大纲 & 世界观 CRUD"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from sqlmodel import Session, select, func
|
||||||
|
|
||||||
|
from ..database import engine
|
||||||
|
from ..models import Novel, Outline, WorldSetting, Character, Chapter, NovelFile
|
||||||
|
from ..database import DATA_DIR
|
||||||
|
|
||||||
|
|
||||||
|
# ── 工具定义(provider 无关格式)──
|
||||||
|
|
||||||
|
TOOL_DEFINITIONS: list[dict] = [
|
||||||
|
{
|
||||||
|
"name": "get_novel_info",
|
||||||
|
"description": "获取当前小说的基础信息,包括标题、简介、创建时间等",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "update_novel_info",
|
||||||
|
"description": "更新当前小说的基础信息(标题或简介)",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string", "description": "新的小说标题(可选,1-100字)"},
|
||||||
|
"description": {"type": "string", "description": "新的小说简介(可选)"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "list_outlines",
|
||||||
|
"description": "列出当前小说的所有大纲节点(仅返回 id、排序和摘要标题,不含详情)。如需查看某节点的详情内容,请使用 get_outline_detail 工具。",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_outline_detail",
|
||||||
|
"description": "查看指定大纲节点的完整详情内容",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"outline_id": {"type": "integer", "description": "大纲节点 ID"},
|
||||||
|
},
|
||||||
|
"required": ["outline_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create_outline",
|
||||||
|
"description": "为当前小说创建一个新的大纲节点",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"summary": {"type": "string", "description": "大纲摘要(简短标题,200字以内)"},
|
||||||
|
"detail": {"type": "string", "description": "大纲详情(可选,1000字以内)"},
|
||||||
|
"parent_id": {"type": "integer", "description": "父节点 ID,创建子节点时需提供"},
|
||||||
|
},
|
||||||
|
"required": ["summary"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "update_outline",
|
||||||
|
"description": "更新指定大纲节点的摘要或详情",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"outline_id": {"type": "integer", "description": "要更新的大纲节点 ID"},
|
||||||
|
"summary": {"type": "string", "description": "新的摘要(可选)"},
|
||||||
|
"detail": {"type": "string", "description": "新的详情(可选)"},
|
||||||
|
},
|
||||||
|
"required": ["outline_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "delete_outline",
|
||||||
|
"description": "删除指定的大纲节点",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"outline_id": {"type": "integer", "description": "要删除的大纲节点 ID"},
|
||||||
|
},
|
||||||
|
"required": ["outline_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "reorder_outlines",
|
||||||
|
"description": "调整大纲节点的排列顺序。传入按期望顺序排列的节点 ID 数组即可。可以排序顶级节点,也可以排序某个父节点下的子节点。",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"ordered_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"type": "integer"},
|
||||||
|
"description": "按期望顺序排列的大纲节点 ID 数组",
|
||||||
|
},
|
||||||
|
"parent_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "父节点 ID。如果排序的是某个父节点下的子节点,需提供此参数;排序顶级节点时不传",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": ["ordered_ids"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_world_setting",
|
||||||
|
"description": "获取当前小说的世界观设定内容",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "save_world_setting",
|
||||||
|
"description": "保存/更新当前小说的世界观设定",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"content": {"type": "string", "description": "世界观设定内容(5000字以内)"},
|
||||||
|
},
|
||||||
|
"required": ["content"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
# ── 角色工具 ──
|
||||||
|
{
|
||||||
|
"name": "list_characters",
|
||||||
|
"description": "列出当前小说的所有角色(仅返回 id 和名称,不含描述详情)。如需查看某角色的完整描述,请使用 get_character_detail 工具。",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_character_detail",
|
||||||
|
"description": "查看指定角色的完整描述信息",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"character_id": {"type": "integer", "description": "角色 ID"},
|
||||||
|
},
|
||||||
|
"required": ["character_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create_character",
|
||||||
|
"description": "为当前小说创建一个新角色",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string", "description": "角色名称(100字以内)"},
|
||||||
|
"description": {"type": "string", "description": "角色描述:外貌、性格、背景等(可选,1000字以内)"},
|
||||||
|
},
|
||||||
|
"required": ["name"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "update_character",
|
||||||
|
"description": "更新指定角色的名称或描述",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"character_id": {"type": "integer", "description": "要更新的角色 ID"},
|
||||||
|
"name": {"type": "string", "description": "新的角色名称(可选)"},
|
||||||
|
"description": {"type": "string", "description": "新的角色描述(可选)"},
|
||||||
|
},
|
||||||
|
"required": ["character_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "delete_character",
|
||||||
|
"description": "删除指定的角色",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"character_id": {"type": "integer", "description": "要删除的角色 ID"},
|
||||||
|
},
|
||||||
|
"required": ["character_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
# ── 章节工具 ──
|
||||||
|
{
|
||||||
|
"name": "list_chapters",
|
||||||
|
"description": "列出当前小说的所有章节(仅返回 id、排序和标题,不含正文)。如需查看某章节的完整正文,请使用 get_chapter_detail 工具。",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "get_chapter_detail",
|
||||||
|
"description": "查看指定章节的完整正文内容",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"chapter_id": {"type": "integer", "description": "章节 ID"},
|
||||||
|
},
|
||||||
|
"required": ["chapter_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "create_chapter",
|
||||||
|
"description": "为当前小说创建一个新章节",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"title": {"type": "string", "description": "章节标题(200字以内)"},
|
||||||
|
"content": {"type": "string", "description": "章节正文(可选,5000字以内)"},
|
||||||
|
},
|
||||||
|
"required": ["title"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "update_chapter",
|
||||||
|
"description": "更新指定章节的标题或正文内容",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"chapter_id": {"type": "integer", "description": "要更新的章节 ID"},
|
||||||
|
"title": {"type": "string", "description": "新的章节标题(可选)"},
|
||||||
|
"content": {"type": "string", "description": "新的章节正文(可选)"},
|
||||||
|
},
|
||||||
|
"required": ["chapter_id"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "delete_chapter",
|
||||||
|
"description": "删除指定的章节",
|
||||||
|
"parameters": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"chapter_id": {"type": "integer", "description": "要删除的章节 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 _filter_defs(allowed: list[str] | None) -> list[dict]:
|
||||||
|
"""根据白名单过滤工具定义"""
|
||||||
|
if not allowed:
|
||||||
|
return TOOL_DEFINITIONS
|
||||||
|
return [t for t in TOOL_DEFINITIONS if t["name"] in allowed]
|
||||||
|
|
||||||
|
|
||||||
|
def get_openai_tools(allowed: list[str] | None = None) -> list[dict]:
|
||||||
|
"""转换为 OpenAI function calling 格式"""
|
||||||
|
return [
|
||||||
|
{"type": "function", "function": t}
|
||||||
|
for t in _filter_defs(allowed)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_anthropic_tools(allowed: list[str] | None = None) -> list[dict]:
|
||||||
|
"""转换为 Anthropic tool_use 格式"""
|
||||||
|
return [
|
||||||
|
{"name": t["name"], "description": t["description"], "input_schema": t["parameters"]}
|
||||||
|
for t in _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)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 工具执行 ──
|
||||||
|
|
||||||
|
|
||||||
|
def execute_tool(name: str, arguments: dict, novel_id: int) -> str:
|
||||||
|
"""执行工具,返回结果字符串(JSON)"""
|
||||||
|
with Session(engine) as session:
|
||||||
|
handlers = {
|
||||||
|
"get_novel_info": _get_novel_info,
|
||||||
|
"update_novel_info": _update_novel_info,
|
||||||
|
"list_outlines": _list_outlines,
|
||||||
|
"get_outline_detail": _get_outline_detail,
|
||||||
|
"create_outline": _create_outline,
|
||||||
|
"update_outline": _update_outline,
|
||||||
|
"delete_outline": _delete_outline,
|
||||||
|
"reorder_outlines": _reorder_outlines,
|
||||||
|
"get_world_setting": _get_world_setting,
|
||||||
|
"save_world_setting": _save_world_setting,
|
||||||
|
"list_characters": _list_characters,
|
||||||
|
"get_character_detail": _get_character_detail,
|
||||||
|
"create_character": _create_character,
|
||||||
|
"update_character": _update_character,
|
||||||
|
"delete_character": _delete_character,
|
||||||
|
"list_chapters": _list_chapters,
|
||||||
|
"get_chapter_detail": _get_chapter_detail,
|
||||||
|
"create_chapter": _create_chapter,
|
||||||
|
"update_chapter": _update_chapter,
|
||||||
|
"delete_chapter": _delete_chapter,
|
||||||
|
"list_files": _list_files,
|
||||||
|
"read_file": _read_file,
|
||||||
|
}
|
||||||
|
handler = handlers.get(name)
|
||||||
|
if not handler:
|
||||||
|
return json.dumps({"error": f"未知工具: {name}"}, ensure_ascii=False)
|
||||||
|
try:
|
||||||
|
return handler(session, novel_id, arguments)
|
||||||
|
except Exception as e:
|
||||||
|
return json.dumps({"error": str(e)}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_novel_info(session: Session, novel_id: int, _args: dict) -> str:
|
||||||
|
novel = session.get(Novel, novel_id)
|
||||||
|
if not novel:
|
||||||
|
return json.dumps({"error": "小说不存在"}, ensure_ascii=False)
|
||||||
|
return json.dumps({
|
||||||
|
"id": novel.id,
|
||||||
|
"title": novel.title,
|
||||||
|
"description": novel.description or "",
|
||||||
|
"created_at": novel.created_at.isoformat() if novel.created_at else None,
|
||||||
|
"updated_at": novel.updated_at.isoformat() if novel.updated_at else None,
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_novel_info(session: Session, novel_id: int, args: dict) -> str:
|
||||||
|
novel = session.get(Novel, novel_id)
|
||||||
|
if not novel:
|
||||||
|
return json.dumps({"error": "小说不存在"}, ensure_ascii=False)
|
||||||
|
|
||||||
|
if "title" in args:
|
||||||
|
title = args["title"].strip()
|
||||||
|
if not title or len(title) > 100:
|
||||||
|
return json.dumps({"error": "标题长度需在1-100字之间"}, ensure_ascii=False)
|
||||||
|
novel.title = title
|
||||||
|
if "description" in args:
|
||||||
|
novel.description = args["description"]
|
||||||
|
novel.updated_at = datetime.now(timezone.utc)
|
||||||
|
session.add(novel)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(novel)
|
||||||
|
return json.dumps({
|
||||||
|
"id": novel.id,
|
||||||
|
"title": novel.title,
|
||||||
|
"description": novel.description or "",
|
||||||
|
"updated": True,
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _list_outlines(session: Session, novel_id: int, _args: dict) -> str:
|
||||||
|
"""列表仅返回 id + 排序 + 摘要标题(不含 detail),节省上下文。"""
|
||||||
|
top_items = session.exec(
|
||||||
|
select(Outline)
|
||||||
|
.where(Outline.novel_id == novel_id, Outline.parent_id.is_(None)) # type: ignore
|
||||||
|
.order_by(Outline.sort_order)
|
||||||
|
).all()
|
||||||
|
all_children = session.exec(
|
||||||
|
select(Outline)
|
||||||
|
.where(Outline.novel_id == novel_id, Outline.parent_id.isnot(None)) # type: ignore
|
||||||
|
.order_by(Outline.sort_order)
|
||||||
|
).all()
|
||||||
|
children_map: dict[int, list[Outline]] = {}
|
||||||
|
for child in all_children:
|
||||||
|
children_map.setdefault(child.parent_id, []).append(child)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for o in top_items:
|
||||||
|
node: dict = {"id": o.id, "sort_order": o.sort_order, "summary": o.summary}
|
||||||
|
kids = children_map.get(o.id, [])
|
||||||
|
if kids:
|
||||||
|
node["children"] = [
|
||||||
|
{"id": c.id, "sort_order": c.sort_order, "summary": c.summary, "parent_id": c.parent_id}
|
||||||
|
for c in kids
|
||||||
|
]
|
||||||
|
result.append(node)
|
||||||
|
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:
|
||||||
|
parent_id = args.get("parent_id")
|
||||||
|
|
||||||
|
# 如果指定了 parent_id,校验父节点存在且是顶级节点
|
||||||
|
if parent_id is not None:
|
||||||
|
parent = session.get(Outline, parent_id)
|
||||||
|
if not parent or parent.novel_id != novel_id:
|
||||||
|
return json.dumps({"error": "父节点不存在"}, ensure_ascii=False)
|
||||||
|
if parent.parent_id is not None:
|
||||||
|
return json.dumps({"error": "仅支持两级结构,不能在子节点下创建子节点"}, ensure_ascii=False)
|
||||||
|
|
||||||
|
# 同级内自增 sort_order
|
||||||
|
if parent_id is not None:
|
||||||
|
max_order = session.exec(
|
||||||
|
select(func.max(Outline.sort_order))
|
||||||
|
.where(Outline.novel_id == novel_id, Outline.parent_id == parent_id)
|
||||||
|
).one()
|
||||||
|
else:
|
||||||
|
max_order = session.exec(
|
||||||
|
select(func.max(Outline.sort_order))
|
||||||
|
.where(Outline.novel_id == novel_id, Outline.parent_id.is_(None)) # type: ignore
|
||||||
|
).one()
|
||||||
|
next_order = (max_order or 0) + 1
|
||||||
|
|
||||||
|
outline = Outline(
|
||||||
|
novel_id=novel_id,
|
||||||
|
parent_id=parent_id,
|
||||||
|
sort_order=next_order,
|
||||||
|
summary=args["summary"],
|
||||||
|
detail=args.get("detail", ""),
|
||||||
|
)
|
||||||
|
session.add(outline)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(outline)
|
||||||
|
result = {"id": outline.id, "sort_order": outline.sort_order, "summary": outline.summary, "detail": outline.detail}
|
||||||
|
if parent_id is not None:
|
||||||
|
result["parent_id"] = parent_id
|
||||||
|
return json.dumps(result, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_outline(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)
|
||||||
|
|
||||||
|
if "summary" in args:
|
||||||
|
outline.summary = args["summary"]
|
||||||
|
if "detail" in args:
|
||||||
|
outline.detail = args["detail"]
|
||||||
|
outline.updated_at = datetime.now(timezone.utc)
|
||||||
|
session.add(outline)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(outline)
|
||||||
|
return json.dumps(
|
||||||
|
{"id": outline.id, "summary": outline.summary, "detail": outline.detail},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_outline(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)
|
||||||
|
|
||||||
|
# 删除父节点时先删除所有子节点
|
||||||
|
children = session.exec(
|
||||||
|
select(Outline)
|
||||||
|
.where(Outline.novel_id == novel_id, Outline.parent_id == outline.id)
|
||||||
|
).all()
|
||||||
|
for child in children:
|
||||||
|
session.delete(child)
|
||||||
|
session.delete(outline)
|
||||||
|
session.commit()
|
||||||
|
return json.dumps({"deleted": True, "id": args["outline_id"]}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _reorder_outlines(session: Session, novel_id: int, args: dict) -> str:
|
||||||
|
ordered_ids = args["ordered_ids"]
|
||||||
|
parent_id = args.get("parent_id")
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for idx, oid in enumerate(ordered_ids):
|
||||||
|
outline = session.get(Outline, oid)
|
||||||
|
if not outline or outline.novel_id != novel_id:
|
||||||
|
return json.dumps({"error": f"大纲节点 {oid} 不存在"}, ensure_ascii=False)
|
||||||
|
# 校验层级一致性
|
||||||
|
if parent_id is None and outline.parent_id is not None:
|
||||||
|
return json.dumps({"error": f"节点 {oid} 不是顶级节点"}, ensure_ascii=False)
|
||||||
|
if parent_id is not None and outline.parent_id != parent_id:
|
||||||
|
return json.dumps({"error": f"节点 {oid} 不属于父节点 {parent_id}"}, ensure_ascii=False)
|
||||||
|
outline.sort_order = idx + 1
|
||||||
|
outline.updated_at = now
|
||||||
|
session.add(outline)
|
||||||
|
session.commit()
|
||||||
|
return json.dumps({
|
||||||
|
"reordered": True,
|
||||||
|
"count": len(ordered_ids),
|
||||||
|
"parent_id": parent_id,
|
||||||
|
}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_world_setting(session: Session, novel_id: int, _args: dict) -> str:
|
||||||
|
ws = session.exec(
|
||||||
|
select(WorldSetting).where(WorldSetting.novel_id == novel_id)
|
||||||
|
).first()
|
||||||
|
if not ws:
|
||||||
|
return json.dumps({"content": "", "exists": False}, ensure_ascii=False)
|
||||||
|
return json.dumps({"content": ws.content, "exists": True}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _save_world_setting(session: Session, novel_id: int, args: dict) -> str:
|
||||||
|
ws = session.exec(
|
||||||
|
select(WorldSetting).where(WorldSetting.novel_id == novel_id)
|
||||||
|
).first()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if ws:
|
||||||
|
ws.content = args["content"]
|
||||||
|
ws.updated_at = now
|
||||||
|
else:
|
||||||
|
ws = WorldSetting(novel_id=novel_id, content=args["content"])
|
||||||
|
session.add(ws)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(ws)
|
||||||
|
return json.dumps({"saved": True, "content_length": len(ws.content)}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 角色处理函数 ──
|
||||||
|
|
||||||
|
|
||||||
|
def _list_characters(session: Session, novel_id: int, _args: dict) -> str:
|
||||||
|
"""列表仅返回 id + 名称(不含 description),节省上下文。"""
|
||||||
|
items = session.exec(
|
||||||
|
select(Character)
|
||||||
|
.where(Character.novel_id == novel_id)
|
||||||
|
.order_by(Character.created_at)
|
||||||
|
).all()
|
||||||
|
result = [{"id": c.id, "name": c.name} for c in items]
|
||||||
|
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:
|
||||||
|
name = args["name"].strip()
|
||||||
|
if not name or len(name) > 100:
|
||||||
|
return json.dumps({"error": "角色名称长度需在1-100字之间"}, ensure_ascii=False)
|
||||||
|
|
||||||
|
char = Character(
|
||||||
|
novel_id=novel_id,
|
||||||
|
name=name,
|
||||||
|
description=args.get("description", ""),
|
||||||
|
)
|
||||||
|
session.add(char)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(char)
|
||||||
|
return json.dumps(
|
||||||
|
{"id": char.id, "name": char.name, "description": char.description},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_character(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)
|
||||||
|
|
||||||
|
if "name" in args:
|
||||||
|
name = args["name"].strip()
|
||||||
|
if not name or len(name) > 100:
|
||||||
|
return json.dumps({"error": "角色名称长度需在1-100字之间"}, ensure_ascii=False)
|
||||||
|
char.name = name
|
||||||
|
if "description" in args:
|
||||||
|
char.description = args["description"]
|
||||||
|
char.updated_at = datetime.now(timezone.utc)
|
||||||
|
session.add(char)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(char)
|
||||||
|
return json.dumps(
|
||||||
|
{"id": char.id, "name": char.name, "description": char.description},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_character(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)
|
||||||
|
session.delete(char)
|
||||||
|
session.commit()
|
||||||
|
return json.dumps({"deleted": True, "id": args["character_id"]}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 章节处理函数 ──
|
||||||
|
|
||||||
|
|
||||||
|
def _list_chapters(session: Session, novel_id: int, _args: dict) -> str:
|
||||||
|
"""列表仅返回 id + 排序 + 标题(不含正文),节省上下文。"""
|
||||||
|
items = session.exec(
|
||||||
|
select(Chapter)
|
||||||
|
.where(Chapter.novel_id == novel_id)
|
||||||
|
.order_by(Chapter.sort_order)
|
||||||
|
).all()
|
||||||
|
result = [{"id": c.id, "sort_order": c.sort_order, "title": c.title} for c in items]
|
||||||
|
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:
|
||||||
|
title = args["title"].strip()
|
||||||
|
if not title or len(title) > 200:
|
||||||
|
return json.dumps({"error": "章节标题长度需在1-200字之间"}, ensure_ascii=False)
|
||||||
|
|
||||||
|
# 自增 sort_order
|
||||||
|
max_order = session.exec(
|
||||||
|
select(func.max(Chapter.sort_order))
|
||||||
|
.where(Chapter.novel_id == novel_id)
|
||||||
|
).one()
|
||||||
|
next_order = (max_order or 0) + 1
|
||||||
|
|
||||||
|
chapter = Chapter(
|
||||||
|
novel_id=novel_id,
|
||||||
|
sort_order=next_order,
|
||||||
|
title=title,
|
||||||
|
content=args.get("content", ""),
|
||||||
|
)
|
||||||
|
session.add(chapter)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(chapter)
|
||||||
|
return json.dumps(
|
||||||
|
{"id": chapter.id, "sort_order": chapter.sort_order, "title": chapter.title},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_chapter(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)
|
||||||
|
|
||||||
|
if "title" in args:
|
||||||
|
title = args["title"].strip()
|
||||||
|
if not title or len(title) > 200:
|
||||||
|
return json.dumps({"error": "章节标题长度需在1-200字之间"}, ensure_ascii=False)
|
||||||
|
chapter.title = title
|
||||||
|
if "content" in args:
|
||||||
|
chapter.content = args["content"]
|
||||||
|
chapter.updated_at = datetime.now(timezone.utc)
|
||||||
|
session.add(chapter)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(chapter)
|
||||||
|
return json.dumps(
|
||||||
|
{"id": chapter.id, "title": chapter.title, "content_length": len(chapter.content)},
|
||||||
|
ensure_ascii=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_chapter(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)
|
||||||
|
session.delete(chapter)
|
||||||
|
session.commit()
|
||||||
|
return json.dumps({"deleted": True, "id": args["chapter_id"]}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── 文件处理函数 ──
|
||||||
|
|
||||||
|
|
||||||
|
def _list_files(session: Session, novel_id: int, _args: dict) -> str:
|
||||||
|
items = session.exec(
|
||||||
|
select(NovelFile)
|
||||||
|
.where(NovelFile.novel_id == novel_id)
|
||||||
|
.order_by(NovelFile.created_at.desc())
|
||||||
|
).all()
|
||||||
|
result = [
|
||||||
|
{
|
||||||
|
"id": f.id,
|
||||||
|
"filename": f.filename,
|
||||||
|
"file_size": f.file_size,
|
||||||
|
"text_length": f.text_length,
|
||||||
|
"created_at": f.created_at.isoformat() if f.created_at else None,
|
||||||
|
}
|
||||||
|
for f in items
|
||||||
|
]
|
||||||
|
return json.dumps({"files": result, "total": len(result)}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _read_file(session: Session, novel_id: int, args: dict) -> str:
|
||||||
|
file_id = args["file_id"]
|
||||||
|
offset = max(0, args.get("offset", 0))
|
||||||
|
limit = min(10000, max(1, args.get("limit", 5000)))
|
||||||
|
|
||||||
|
novel_file = session.get(NovelFile, file_id)
|
||||||
|
if not novel_file or novel_file.novel_id != novel_id:
|
||||||
|
return json.dumps({"error": "文件不存在"}, ensure_ascii=False)
|
||||||
|
|
||||||
|
# 读取预解析的纯文本缓存
|
||||||
|
text_path = DATA_DIR / "uploads" / str(novel_id) / f"{novel_file.stored_name}.txt"
|
||||||
|
if not text_path.exists():
|
||||||
|
return json.dumps({"error": "文件纯文本缓存不存在,可能需要重新上传"}, ensure_ascii=False)
|
||||||
|
|
||||||
|
full_text = text_path.read_text(encoding="utf-8")
|
||||||
|
total_length = len(full_text)
|
||||||
|
segment = full_text[offset:offset + limit]
|
||||||
|
|
||||||
|
return json.dumps({
|
||||||
|
"filename": novel_file.filename,
|
||||||
|
"text": segment,
|
||||||
|
"offset": offset,
|
||||||
|
"limit": limit,
|
||||||
|
"total_length": total_length,
|
||||||
|
"has_more": offset + limit < total_length,
|
||||||
|
}, ensure_ascii=False)
|
||||||
@@ -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
|
||||||
|
|||||||
18
docker-compose.yml
Normal file
18
docker-compose.yml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
services:
|
||||||
|
noval:
|
||||||
|
build: .
|
||||||
|
image: crpi-om2xd9y8cmaizszf.cn-beijing.personal.cr.aliyuncs.com/test-namespace-gu/writing-reddot:latest
|
||||||
|
container_name: noval
|
||||||
|
ports:
|
||||||
|
- "8111:8000"
|
||||||
|
expose:
|
||||||
|
- "8000"
|
||||||
|
volumes:
|
||||||
|
# SQLite 数据持久化
|
||||||
|
- noval-data:/app/data
|
||||||
|
environment:
|
||||||
|
- TZ=Asia/Shanghai
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
noval-data:
|
||||||
33
frontend/src/api/chapters.ts
Normal file
33
frontend/src/api/chapters.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { http } from './client'
|
||||||
|
import type { Chapter, ChapterCreate, ChapterUpdate, ChapterList } from '@/types/chapter'
|
||||||
|
|
||||||
|
const base = (novelId: number) => `/novels/${novelId}/chapters`
|
||||||
|
|
||||||
|
export async function fetchChapters(novelId: number): Promise<ChapterList> {
|
||||||
|
const { data } = await http.get<ChapterList>(base(novelId))
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchChapter(novelId: number, chapterId: number): Promise<Chapter> {
|
||||||
|
const { data } = await http.get<Chapter>(`${base(novelId)}/${chapterId}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createChapter(novelId: number, payload: ChapterCreate): Promise<Chapter> {
|
||||||
|
const { data } = await http.post<Chapter>(base(novelId), payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateChapter(novelId: number, chapterId: number, payload: ChapterUpdate): Promise<Chapter> {
|
||||||
|
const { data } = await http.patch<Chapter>(`${base(novelId)}/${chapterId}`, payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteChapter(novelId: number, chapterId: number): Promise<void> {
|
||||||
|
await http.delete(`${base(novelId)}/${chapterId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reorderChapters(novelId: number, orderedIds: number[]): Promise<ChapterList> {
|
||||||
|
const { data } = await http.put<ChapterList>(`${base(novelId)}/reorder`, { ordered_ids: orderedIds })
|
||||||
|
return data
|
||||||
|
}
|
||||||
23
frontend/src/api/characters.ts
Normal file
23
frontend/src/api/characters.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { http } from './client'
|
||||||
|
import type { Character, CharacterCreate, CharacterUpdate, CharacterList } from '@/types/character'
|
||||||
|
|
||||||
|
const base = (novelId: number) => `/novels/${novelId}/characters`
|
||||||
|
|
||||||
|
export async function fetchCharacters(novelId: number): Promise<CharacterList> {
|
||||||
|
const { data } = await http.get<CharacterList>(base(novelId))
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCharacter(novelId: number, payload: CharacterCreate): Promise<Character> {
|
||||||
|
const { data } = await http.post<Character>(base(novelId), payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCharacter(novelId: number, characterId: number, payload: CharacterUpdate): Promise<Character> {
|
||||||
|
const { data } = await http.patch<Character>(`${base(novelId)}/${characterId}`, payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCharacter(novelId: number, characterId: number): Promise<void> {
|
||||||
|
await http.delete(`${base(novelId)}/${characterId}`)
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { http } from './client'
|
import { http } from './client'
|
||||||
import type { AIConfig, AIConfigSave, ChatMessageList } from '@/types/chat'
|
import type { AIConfig, AIConfigSave, ChatMessageList, ToolCallEntry } from '@/types/chat'
|
||||||
|
|
||||||
// ── AI配置 ──
|
// ── AI配置 ──
|
||||||
|
|
||||||
@@ -33,6 +33,22 @@ export async function clearMessages(novelId?: number): Promise<void> {
|
|||||||
await http.delete('/chat/messages', { params })
|
await http.delete('/chat/messages', { params })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CompressResult {
|
||||||
|
compressed: boolean
|
||||||
|
reason?: string
|
||||||
|
error?: string
|
||||||
|
removed_count?: number
|
||||||
|
kept_count?: number
|
||||||
|
summary_length?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function compressContext(novelId?: number): Promise<CompressResult> {
|
||||||
|
const params: Record<string, unknown> = {}
|
||||||
|
if (novelId !== undefined) params.novel_id = novelId
|
||||||
|
const { data } = await http.post<CompressResult>('/chat/compress', null, { params })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
// ── 流式聊天 ──
|
// ── 流式聊天 ──
|
||||||
|
|
||||||
export interface TokenUsage {
|
export interface TokenUsage {
|
||||||
@@ -41,27 +57,82 @@ export interface TokenUsage {
|
|||||||
total_tokens: number
|
total_tokens: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContextMessage {
|
export interface ToolResultInfo {
|
||||||
role: string
|
name: string
|
||||||
content: string
|
label: string
|
||||||
|
result: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PendingToolCallData {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
label: string
|
||||||
|
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
|
||||||
|
toolsEnabled?: boolean
|
||||||
|
autoApproveTools?: boolean
|
||||||
|
skillId?: number | null
|
||||||
|
// 工具审批续传
|
||||||
|
pendingToolCalls?: PendingToolCallData[]
|
||||||
|
assistantText?: string
|
||||||
|
// 回调
|
||||||
onChunk: (text: string) => void
|
onChunk: (text: string) => void
|
||||||
onError: (error: string) => void
|
onError: (error: string) => void
|
||||||
onDone: (usage?: TokenUsage) => void
|
onDone: (usage?: TokenUsage, pending?: boolean) => void
|
||||||
onContext?: (context: ContextMessage[]) => void
|
onToolCallsPending?: (calls: PendingToolCallData[], assistantText: string) => void
|
||||||
|
onToolCallsAuto?: (calls: PendingToolCallData[]) => 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 { messages, novelId, onChunk, onError, onDone, onContext, signal } = options
|
const {
|
||||||
|
messages, novelId, pageContext, toolsEnabled, autoApproveTools, skillId,
|
||||||
|
pendingToolCalls, assistantText,
|
||||||
|
onChunk, onError, onDone, onToolCallsPending, onToolCallsAuto, onToolResult,
|
||||||
|
onSubAgentStart, onSubAgentChunk, onSubAgentToolCall, onSubAgentToolResult, onSubAgentDone,
|
||||||
|
signal,
|
||||||
|
} = options
|
||||||
|
|
||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
messages,
|
messages,
|
||||||
novel_id: novelId ?? null,
|
novel_id: novelId ?? null,
|
||||||
|
page_context: pageContext ?? null,
|
||||||
|
tools_enabled: toolsEnabled ?? true,
|
||||||
|
auto_approve_tools: autoApproveTools ?? false,
|
||||||
|
skill_id: skillId ?? null,
|
||||||
|
pending_tool_calls: pendingToolCalls ?? null,
|
||||||
|
assistant_text: assistantText ?? null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const resp = await fetch('/api/chat/stream', {
|
const resp = await fetch('/api/chat/stream', {
|
||||||
@@ -95,7 +166,7 @@ export async function streamChat(options: StreamChatOptions): Promise<void> {
|
|||||||
try {
|
try {
|
||||||
const event = JSON.parse(line.slice(6))
|
const event = JSON.parse(line.slice(6))
|
||||||
if (event.done) {
|
if (event.done) {
|
||||||
onDone(event.usage ?? undefined)
|
onDone(event.usage ?? undefined, event.pending ?? false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.error) {
|
if (event.error) {
|
||||||
@@ -103,12 +174,34 @@ export async function streamChat(options: StreamChatOptions): Promise<void> {
|
|||||||
onDone()
|
onDone()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (event.context && onContext) {
|
|
||||||
onContext(event.context)
|
|
||||||
}
|
|
||||||
if (event.content) {
|
if (event.content) {
|
||||||
onChunk(event.content)
|
onChunk(event.content)
|
||||||
}
|
}
|
||||||
|
if (event.tool_calls_pending && onToolCallsPending) {
|
||||||
|
onToolCallsPending(event.tool_calls_pending, event.assistant_text ?? '')
|
||||||
|
}
|
||||||
|
if (event.tool_calls_auto && onToolCallsAuto) {
|
||||||
|
onToolCallsAuto(event.tool_calls_auto)
|
||||||
|
}
|
||||||
|
if (event.tool_result && onToolResult) {
|
||||||
|
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
39
frontend/src/api/files.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { http } from './client'
|
||||||
|
|
||||||
|
export interface NovelFile {
|
||||||
|
id: number
|
||||||
|
novel_id: number
|
||||||
|
filename: string
|
||||||
|
file_size: number
|
||||||
|
mime_type: string
|
||||||
|
text_length: number
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileList {
|
||||||
|
items: NovelFile[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传文件 */
|
||||||
|
export async function uploadFile(novelId: number, file: File): Promise<NovelFile> {
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('file', file)
|
||||||
|
const { data } = await http.post<NovelFile>(
|
||||||
|
`/novels/${novelId}/files`,
|
||||||
|
form,
|
||||||
|
{ headers: { 'Content-Type': 'multipart/form-data' }, timeout: 60000 },
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列出文件 */
|
||||||
|
export async function listFiles(novelId: number): Promise<FileList> {
|
||||||
|
const { data } = await http.get<FileList>(`/novels/${novelId}/files`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除文件 */
|
||||||
|
export async function deleteFile(novelId: number, fileId: number): Promise<void> {
|
||||||
|
await http.delete(`/novels/${novelId}/files/${fileId}`)
|
||||||
|
}
|
||||||
@@ -31,3 +31,8 @@ export async function reorderOutlines(novelId: number, orderedIds: number[]): Pr
|
|||||||
const { data } = await http.put<OutlineList>(`${base(novelId)}/reorder`, { ordered_ids: orderedIds })
|
const { data } = await http.put<OutlineList>(`${base(novelId)}/reorder`, { ordered_ids: orderedIds })
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function reorderChildren(novelId: number, outlineId: number, orderedIds: number[]): Promise<Outline> {
|
||||||
|
const { data } = await http.put<Outline>(`${base(novelId)}/${outlineId}/children/reorder`, { ordered_ids: orderedIds })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|||||||
68
frontend/src/api/skills.ts
Normal file
68
frontend/src/api/skills.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { http } from './client'
|
||||||
|
|
||||||
|
export interface Skill {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
system_prompt: string
|
||||||
|
allowed_tools: string[]
|
||||||
|
is_builtin: boolean
|
||||||
|
novel_id: number | null
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillList {
|
||||||
|
items: Skill[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolInfo {
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillCreate {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
system_prompt?: string
|
||||||
|
allowed_tools?: string[]
|
||||||
|
novel_id?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkillUpdate {
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
system_prompt?: string
|
||||||
|
allowed_tools?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列出 Skill(全局 + 指定小说的) */
|
||||||
|
export async function listSkills(novelId?: number): Promise<SkillList> {
|
||||||
|
const params = novelId ? { novel_id: novelId } : {}
|
||||||
|
const { data } = await http.get<SkillList>('/skills', { params })
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建 Skill */
|
||||||
|
export async function createSkill(skill: SkillCreate): Promise<Skill> {
|
||||||
|
const { data } = await http.post<Skill>('/skills', skill)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新 Skill */
|
||||||
|
export async function updateSkill(id: number, skill: SkillUpdate): Promise<Skill> {
|
||||||
|
const { data } = await http.patch<Skill>(`/skills/${id}`, skill)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 删除 Skill */
|
||||||
|
export async function deleteSkill(id: number): Promise<void> {
|
||||||
|
await http.delete(`/skills/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取所有可用工具列表 */
|
||||||
|
export async function getAvailableTools(): Promise<ToolInfo[]> {
|
||||||
|
const { data } = await http.get<ToolInfo[]>('/skills/tools')
|
||||||
|
return data
|
||||||
|
}
|
||||||
14
frontend/src/api/worldSetting.ts
Normal file
14
frontend/src/api/worldSetting.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { http } from './client'
|
||||||
|
import type { WorldSetting, WorldSettingUpdate } from '@/types/worldSetting'
|
||||||
|
|
||||||
|
const base = (novelId: number) => `/novels/${novelId}/world-setting`
|
||||||
|
|
||||||
|
export async function fetchWorldSetting(novelId: number): Promise<WorldSetting> {
|
||||||
|
const { data } = await http.get<WorldSetting>(base(novelId))
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveWorldSetting(novelId: number, payload: WorldSettingUpdate): Promise<WorldSetting> {
|
||||||
|
const { data } = await http.put<WorldSetting>(base(novelId), payload)
|
||||||
|
return data
|
||||||
|
}
|
||||||
193
frontend/src/components/chapter/ChapterEditor.vue
Normal file
193
frontend/src/components/chapter/ChapterEditor.vue
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
import type { Chapter } from '@/types/chapter'
|
||||||
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
|
import BaseTextarea from '@/components/ui/BaseTextarea.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
chapter: Chapter
|
||||||
|
saving: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
save: [data: { title: string; content: string }]
|
||||||
|
back: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const title = ref('')
|
||||||
|
const content = ref('')
|
||||||
|
const hasChanges = ref(false)
|
||||||
|
|
||||||
|
const contentLength = computed(() => content.value.length)
|
||||||
|
const contentHint = computed(() => {
|
||||||
|
if (contentLength.value === 0) return '上限 5000 字'
|
||||||
|
return `${contentLength.value} / 5000 字`
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.chapter, (ch) => {
|
||||||
|
title.value = ch.title
|
||||||
|
content.value = ch.content
|
||||||
|
hasChanges.value = false
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
function onInput() {
|
||||||
|
hasChanges.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
emit('save', {
|
||||||
|
title: title.value.trim(),
|
||||||
|
content: content.value,
|
||||||
|
})
|
||||||
|
hasChanges.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="chapter-editor">
|
||||||
|
<div class="editor-header">
|
||||||
|
<button class="back-btn" @click="emit('back')">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
返回列表
|
||||||
|
</button>
|
||||||
|
<div class="editor-title-wrap">
|
||||||
|
<input
|
||||||
|
v-model="title"
|
||||||
|
class="editor-title-input"
|
||||||
|
placeholder="章节标题"
|
||||||
|
maxlength="200"
|
||||||
|
@input="onInput"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="editor-actions">
|
||||||
|
<span v-if="hasChanges" class="unsaved-hint">未保存</span>
|
||||||
|
<BaseButton
|
||||||
|
variant="primary"
|
||||||
|
size="small"
|
||||||
|
:loading="saving"
|
||||||
|
:disabled="!hasChanges || !title.trim()"
|
||||||
|
@click="handleSave"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="editor-body">
|
||||||
|
<BaseTextarea
|
||||||
|
v-model="content"
|
||||||
|
placeholder="在此书写章节内容..."
|
||||||
|
:rows="20"
|
||||||
|
:maxlength="5000"
|
||||||
|
:max-height="800"
|
||||||
|
@input="onInput"
|
||||||
|
/>
|
||||||
|
<div class="editor-footer">
|
||||||
|
<span class="word-count" :class="{ 'word-count--near': contentLength > 4500 }">
|
||||||
|
{{ contentHint }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chapter-editor {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding-bottom: var(--space-lg);
|
||||||
|
border-bottom: 1px solid var(--paper-200);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--ink-400);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
transition: all var(--duration-fast) ease;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
color: var(--ink-700);
|
||||||
|
background: var(--paper-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-title-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-title-input {
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-900);
|
||||||
|
outline: none;
|
||||||
|
padding: var(--space-xs) 0;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: border-color var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-title-input:focus {
|
||||||
|
border-bottom-color: var(--vermilion);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-title-input::placeholder {
|
||||||
|
color: var(--ink-200);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsaved-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--vermilion);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ink-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-count--near {
|
||||||
|
color: var(--vermilion);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
93
frontend/src/components/chapter/ChapterEmptyState.vue
Normal file
93
frontend/src/components/chapter/ChapterEmptyState.vue
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
create: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="empty-state">
|
||||||
|
<!-- 书页SVG -->
|
||||||
|
<svg class="book-svg" viewBox="0 0 200 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- 打开的书 -->
|
||||||
|
<path class="book-left" d="M30 40 Q100 35 100 45 L100 150 Q100 140 30 145 Z" fill="#faf8f5" stroke="#e8e0d4" stroke-width="1" />
|
||||||
|
<path class="book-right" d="M170 40 Q100 35 100 45 L100 150 Q100 140 170 145 Z" fill="#faf8f5" stroke="#e8e0d4" stroke-width="1" />
|
||||||
|
|
||||||
|
<!-- 书脊 -->
|
||||||
|
<line x1="100" y1="38" x2="100" y2="150" stroke="#d9cebf" stroke-width="1.5" />
|
||||||
|
|
||||||
|
<!-- 左页文字线 -->
|
||||||
|
<line class="text-line tl-1" x1="45" y1="65" x2="90" y2="65" stroke="#e8e0d4" stroke-width="0.8" stroke-dasharray="3 2" />
|
||||||
|
<line class="text-line tl-2" x1="45" y1="78" x2="85" y2="78" stroke="#e8e0d4" stroke-width="0.8" stroke-dasharray="3 2" />
|
||||||
|
<line class="text-line tl-3" x1="45" y1="91" x2="88" y2="91" stroke="#e8e0d4" stroke-width="0.8" stroke-dasharray="3 2" />
|
||||||
|
<line class="text-line tl-4" x1="45" y1="104" x2="80" y2="104" stroke="#e8e0d4" stroke-width="0.8" stroke-dasharray="3 2" />
|
||||||
|
|
||||||
|
<!-- 右页空白(等待书写) -->
|
||||||
|
<text class="page-hint" x="135" y="95" text-anchor="middle" font-size="11" fill="#d9cebf">?</text>
|
||||||
|
|
||||||
|
<!-- 翻页效果 -->
|
||||||
|
<path class="page-flip" d="M100 45 Q115 42 130 50 L125 140 Q110 135 100 150 Z" fill="#f5f0ea" stroke="#e8e0d4" stroke-width="0.8" opacity="0.5" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<h3 class="empty-title">开始书写你的故事</h3>
|
||||||
|
<p class="empty-desc">还没有章节,创建第一章来展开你的叙事</p>
|
||||||
|
<BaseButton variant="primary" @click="$emit('create')">
|
||||||
|
创建第一章
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3xl) var(--space-xl);
|
||||||
|
animation: fadeInUp var(--duration-slow) var(--ease-out-smooth) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-svg {
|
||||||
|
width: 200px;
|
||||||
|
height: 180px;
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.book-left {
|
||||||
|
animation: fadeIn 0.6s ease 0.2s both;
|
||||||
|
}
|
||||||
|
.book-right {
|
||||||
|
animation: fadeIn 0.6s ease 0.4s both;
|
||||||
|
}
|
||||||
|
.page-flip {
|
||||||
|
animation: fadeIn 0.6s ease 0.6s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-line {
|
||||||
|
opacity: 0;
|
||||||
|
animation: fadeIn 0.4s ease both;
|
||||||
|
}
|
||||||
|
.tl-1 { animation-delay: 0.6s; }
|
||||||
|
.tl-2 { animation-delay: 0.8s; }
|
||||||
|
.tl-3 { animation-delay: 1.0s; }
|
||||||
|
.tl-4 { animation-delay: 1.2s; }
|
||||||
|
|
||||||
|
.page-hint {
|
||||||
|
animation: fadeIn 0.4s ease 1.4s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-900);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ink-400);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
79
frontend/src/components/chapter/ChapterFormModal.vue
Normal file
79
frontend/src/components/chapter/ChapterFormModal.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
import type { Chapter } from '@/types/chapter'
|
||||||
|
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||||
|
import BaseInput from '@/components/ui/BaseInput.vue'
|
||||||
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
chapter: Chapter | null
|
||||||
|
saving: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [data: { title: string }]
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const title = ref('')
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.chapter)
|
||||||
|
const modalTitle = computed(() => isEdit.value ? '重命名章节' : '新建章节')
|
||||||
|
const canSubmit = computed(() => title.value.trim().length > 0 && !props.saving)
|
||||||
|
|
||||||
|
watch(() => props.show, (val) => {
|
||||||
|
if (val) {
|
||||||
|
title.value = props.chapter?.title ?? ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
emit('submit', { title: title.value.trim() })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseModal :show="show" @close="emit('close')">
|
||||||
|
<form class="chapter-form" @submit.prevent="handleSubmit">
|
||||||
|
<h2 class="form-title">{{ modalTitle }}</h2>
|
||||||
|
|
||||||
|
<BaseInput
|
||||||
|
v-model="title"
|
||||||
|
label="章节标题"
|
||||||
|
:maxlength="200"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<BaseButton variant="secondary" type="button" @click="emit('close')">取消</BaseButton>
|
||||||
|
<BaseButton variant="primary" type="submit" :loading="saving" :disabled="!canSubmit">
|
||||||
|
{{ isEdit ? '保存' : '创建' }}
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</BaseModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chapter-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
172
frontend/src/components/chapter/ChapterItem.vue
Normal file
172
frontend/src/components/chapter/ChapterItem.vue
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Chapter } from '@/types/chapter'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
chapter: Chapter
|
||||||
|
index: number
|
||||||
|
active: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
select: [chapter: Chapter]
|
||||||
|
edit: [chapter: Chapter]
|
||||||
|
delete: [chapter: Chapter]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const wordCount = computed(() => props.chapter.content.length)
|
||||||
|
|
||||||
|
import { computed } from 'vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="chapter-item"
|
||||||
|
:class="{ 'chapter-item--active': active }"
|
||||||
|
draggable="true"
|
||||||
|
@click="$emit('select', chapter)"
|
||||||
|
>
|
||||||
|
<div class="item-drag-handle" title="拖拽排序">
|
||||||
|
<svg width="10" height="14" viewBox="0 0 10 14" fill="none">
|
||||||
|
<circle cx="3" cy="2" r="1.2" fill="currentColor" />
|
||||||
|
<circle cx="7" cy="2" r="1.2" fill="currentColor" />
|
||||||
|
<circle cx="3" cy="7" r="1.2" fill="currentColor" />
|
||||||
|
<circle cx="7" cy="7" r="1.2" fill="currentColor" />
|
||||||
|
<circle cx="3" cy="12" r="1.2" fill="currentColor" />
|
||||||
|
<circle cx="7" cy="12" r="1.2" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="item-order">{{ index + 1 }}</span>
|
||||||
|
|
||||||
|
<div class="item-info">
|
||||||
|
<span class="item-title">{{ chapter.title }}</span>
|
||||||
|
<span class="item-meta">{{ wordCount }} 字</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="item-actions">
|
||||||
|
<button class="action-btn" title="重命名" @click.stop="$emit('edit', chapter)">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path d="M10.5 1.5l2 2-8 8H2.5v-2z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn action-btn--danger" title="删除" @click.stop="$emit('delete', chapter)">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path d="M2 4h10M5 4V2.5h4V4M3.5 4v8h7V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chapter-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
background: var(--paper-50);
|
||||||
|
border: 1px solid var(--paper-200);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--duration-fast) var(--ease-out-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-item:hover {
|
||||||
|
border-color: var(--paper-300);
|
||||||
|
box-shadow: 0 2px 8px rgba(26, 26, 46, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-item--active {
|
||||||
|
border-color: var(--vermilion);
|
||||||
|
background: #fef8f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-drag-handle {
|
||||||
|
color: var(--ink-200);
|
||||||
|
cursor: grab;
|
||||||
|
padding: var(--space-xs);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-item:hover .item-drag-handle {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-order {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--paper-100);
|
||||||
|
color: var(--ink-400);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-item--active .item-order {
|
||||||
|
background: var(--vermilion);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-title {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--ink-800);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ink-300);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-item:hover .item-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--paper-100);
|
||||||
|
color: var(--ink-400);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background: var(--paper-200);
|
||||||
|
color: var(--ink-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--danger:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: var(--vermilion);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
91
frontend/src/components/chapter/ChapterList.vue
Normal file
91
frontend/src/components/chapter/ChapterList.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Chapter } from '@/types/chapter'
|
||||||
|
import ChapterItem from './ChapterItem.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
chapters: Chapter[]
|
||||||
|
activeId: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [chapter: Chapter]
|
||||||
|
edit: [chapter: Chapter]
|
||||||
|
delete: [chapter: Chapter]
|
||||||
|
reorder: [chapters: Chapter[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const dragIndex = ref(-1)
|
||||||
|
const dragOverIndex = ref(-1)
|
||||||
|
|
||||||
|
function onDragStart(e: DragEvent, index: number) {
|
||||||
|
dragIndex.value = index
|
||||||
|
if (e.dataTransfer) {
|
||||||
|
e.dataTransfer.effectAllowed = 'move'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(e: DragEvent, index: number) {
|
||||||
|
e.preventDefault()
|
||||||
|
dragOverIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
if (dragIndex.value !== -1 && dragOverIndex.value !== -1 && dragIndex.value !== dragOverIndex.value) {
|
||||||
|
const reordered = [...props.chapters]
|
||||||
|
const [moved] = reordered.splice(dragIndex.value, 1)
|
||||||
|
reordered.splice(dragOverIndex.value, 0, moved)
|
||||||
|
emit('reorder', reordered)
|
||||||
|
}
|
||||||
|
dragIndex.value = -1
|
||||||
|
dragOverIndex.value = -1
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="chapter-list">
|
||||||
|
<div
|
||||||
|
v-for="(chapter, index) in chapters"
|
||||||
|
:key="chapter.id"
|
||||||
|
class="chapter-list-item"
|
||||||
|
:class="{
|
||||||
|
'chapter-list-item--drag-over': dragOverIndex === index && dragIndex !== index,
|
||||||
|
}"
|
||||||
|
@dragstart="onDragStart($event, index)"
|
||||||
|
@dragover="onDragOver($event, index)"
|
||||||
|
@dragend="onDragEnd"
|
||||||
|
>
|
||||||
|
<ChapterItem
|
||||||
|
:chapter="chapter"
|
||||||
|
:index="index"
|
||||||
|
:active="chapter.id === activeId"
|
||||||
|
@select="emit('select', $event)"
|
||||||
|
@edit="emit('edit', $event)"
|
||||||
|
@delete="emit('delete', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chapter-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-list-item {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-list-item--drag-over::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--vermilion);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
106
frontend/src/components/character/CharacterCard.vue
Normal file
106
frontend/src/components/character/CharacterCard.vue
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Character } from '@/types/character'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
character: Character
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
edit: [character: Character]
|
||||||
|
delete: [character: Character]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="character-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-name">{{ character.name }}</h3>
|
||||||
|
<div class="card-actions">
|
||||||
|
<button class="action-btn" title="编辑" @click="$emit('edit', character)">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path d="M10.5 1.5l2 2-8 8H2.5v-2z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="action-btn action-btn--danger" title="删除" @click="$emit('delete', character)">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path d="M2 4h10M5 4V2.5h4V4M3.5 4v8h7V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="card-desc">{{ character.description || '暂无描述' }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.character-card {
|
||||||
|
background: var(--paper-50);
|
||||||
|
border: 1px solid var(--paper-200);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
transition: transform var(--duration-fast) var(--ease-out-smooth),
|
||||||
|
box-shadow var(--duration-fast) var(--ease-out-smooth);
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-card:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
box-shadow: 0 8px 24px rgba(26, 26, 46, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-card:hover .card-actions {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--paper-100);
|
||||||
|
color: var(--ink-400);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn:hover {
|
||||||
|
background: var(--paper-200);
|
||||||
|
color: var(--ink-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--danger:hover {
|
||||||
|
background: #fef2f2;
|
||||||
|
color: var(--vermilion);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-desc {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ink-500);
|
||||||
|
line-height: 1.7;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
83
frontend/src/components/character/CharacterEmptyState.vue
Normal file
83
frontend/src/components/character/CharacterEmptyState.vue
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
create: []
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="empty-state">
|
||||||
|
<!-- 人物剪影SVG -->
|
||||||
|
<svg class="person-svg" viewBox="0 0 200 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!-- 中间人物 -->
|
||||||
|
<circle class="head head-center" cx="100" cy="50" r="18" fill="#e8e0d4" stroke="#9090a8" stroke-width="1" />
|
||||||
|
<path class="body body-center" d="M75 90 Q100 75 125 90 L120 140 H80 Z" fill="#e8e0d4" stroke="#9090a8" stroke-width="1" />
|
||||||
|
|
||||||
|
<!-- 左侧人物 -->
|
||||||
|
<circle class="head head-left" cx="45" cy="65" r="14" fill="#f0ebe4" stroke="#b0b0c0" stroke-width="0.8" opacity="0.6" />
|
||||||
|
<path class="body body-left" d="M28 95 Q45 83 62 95 L59 135 H31 Z" fill="#f0ebe4" stroke="#b0b0c0" stroke-width="0.8" opacity="0.6" />
|
||||||
|
|
||||||
|
<!-- 右侧人物 -->
|
||||||
|
<circle class="head head-right" cx="155" cy="65" r="14" fill="#f0ebe4" stroke="#b0b0c0" stroke-width="0.8" opacity="0.6" />
|
||||||
|
<path class="body body-right" d="M138 95 Q155 83 172 95 L169 135 H141 Z" fill="#f0ebe4" stroke="#b0b0c0" stroke-width="0.8" opacity="0.6" />
|
||||||
|
|
||||||
|
<!-- 连接线 -->
|
||||||
|
<line class="connect-line" x1="65" y1="80" x2="82" y2="70" stroke="#d9cebf" stroke-width="1" stroke-dasharray="3 3" />
|
||||||
|
<line class="connect-line" x1="135" y1="80" x2="118" y2="70" stroke="#d9cebf" stroke-width="1" stroke-dasharray="3 3" />
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<h3 class="empty-title">塑造你的角色</h3>
|
||||||
|
<p class="empty-desc">还没有角色,创建第一个来丰富你的故事世界</p>
|
||||||
|
<BaseButton variant="primary" @click="$emit('create')">
|
||||||
|
创建第一个角色
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--space-3xl) var(--space-xl);
|
||||||
|
animation: fadeInUp var(--duration-slow) var(--ease-out-smooth) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.person-svg {
|
||||||
|
width: 200px;
|
||||||
|
height: 180px;
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.head-center {
|
||||||
|
animation: fadeIn 0.6s ease 0.2s both;
|
||||||
|
}
|
||||||
|
.body-center {
|
||||||
|
animation: fadeIn 0.6s ease 0.4s both;
|
||||||
|
}
|
||||||
|
.head-left, .body-left {
|
||||||
|
animation: fadeIn 0.6s ease 0.6s both;
|
||||||
|
}
|
||||||
|
.head-right, .body-right {
|
||||||
|
animation: fadeIn 0.6s ease 0.8s both;
|
||||||
|
}
|
||||||
|
.connect-line {
|
||||||
|
animation: fadeIn 0.4s ease 1.0s both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-900);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: var(--ink-400);
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
123
frontend/src/components/character/CharacterFormModal.vue
Normal file
123
frontend/src/components/character/CharacterFormModal.vue
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
import type { Character, CharacterCreate, CharacterUpdate } from '@/types/character'
|
||||||
|
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||||
|
import BaseInput from '@/components/ui/BaseInput.vue'
|
||||||
|
import BaseTextarea from '@/components/ui/BaseTextarea.vue'
|
||||||
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
character: Character | null
|
||||||
|
saving: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [data: CharacterCreate | CharacterUpdate]
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const name = ref('')
|
||||||
|
const description = ref('')
|
||||||
|
|
||||||
|
const isEdit = computed(() => !!props.character)
|
||||||
|
const title = computed(() => isEdit.value ? '编辑角色' : '新建角色')
|
||||||
|
|
||||||
|
const descLength = computed(() => description.value.length)
|
||||||
|
const descHint = computed(() => {
|
||||||
|
if (descLength.value === 0) return '建议约 200 字'
|
||||||
|
if (descLength.value < 100) return `${descLength.value} 字,建议至少 100 字`
|
||||||
|
if (descLength.value <= 300) return `${descLength.value} 字`
|
||||||
|
return `${descLength.value} 字,建议不超过 300 字`
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSubmit = computed(() => name.value.trim().length > 0 && !props.saving)
|
||||||
|
|
||||||
|
watch(() => props.show, (val) => {
|
||||||
|
if (val) {
|
||||||
|
name.value = props.character?.name ?? ''
|
||||||
|
description.value = props.character?.description ?? ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!canSubmit.value) return
|
||||||
|
emit('submit', {
|
||||||
|
name: name.value.trim(),
|
||||||
|
description: description.value.trim(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseModal :show="show" @close="emit('close')">
|
||||||
|
<form class="character-form" @submit.prevent="handleSubmit">
|
||||||
|
<h2 class="form-title">{{ title }}</h2>
|
||||||
|
|
||||||
|
<BaseInput
|
||||||
|
v-model="name"
|
||||||
|
label="角色名称"
|
||||||
|
:maxlength="100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="desc-field">
|
||||||
|
<BaseTextarea
|
||||||
|
v-model="description"
|
||||||
|
label="角色描述"
|
||||||
|
:rows="6"
|
||||||
|
:maxlength="1000"
|
||||||
|
:max-height="300"
|
||||||
|
/>
|
||||||
|
<span class="desc-counter" :class="{ 'desc-counter--warn': descLength > 300 }">
|
||||||
|
{{ descHint }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-actions">
|
||||||
|
<BaseButton variant="secondary" type="button" @click="emit('close')">取消</BaseButton>
|
||||||
|
<BaseButton variant="primary" type="submit" :loading="saving" :disabled="!canSubmit">
|
||||||
|
{{ isEdit ? '保存' : '创建' }}
|
||||||
|
</BaseButton>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</BaseModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.character-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.4rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc-field {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc-counter {
|
||||||
|
display: block;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ink-300);
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc-counter--warn {
|
||||||
|
color: var(--vermilion);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
33
frontend/src/components/character/CharacterGrid.vue
Normal file
33
frontend/src/components/character/CharacterGrid.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Character } from '@/types/character'
|
||||||
|
import CharacterCard from './CharacterCard.vue'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
characters: Character[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
edit: [character: Character]
|
||||||
|
delete: [character: Character]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="character-grid">
|
||||||
|
<CharacterCard
|
||||||
|
v-for="c in characters"
|
||||||
|
:key="c.id"
|
||||||
|
:character="c"
|
||||||
|
@edit="$emit('edit', $event)"
|
||||||
|
@delete="$emit('delete', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.character-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
321
frontend/src/components/chat/ChatFiles.vue
Normal file
321
frontend/src/components/chat/ChatFiles.vue
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from 'vue'
|
||||||
|
import { listFiles, deleteFile, uploadFile } from '@/api/files'
|
||||||
|
import type { NovelFile } from '@/api/files'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||||
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
novelId: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const files = ref<NovelFile[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const isUploading = ref(false)
|
||||||
|
const deletingId = ref<number | null>(null)
|
||||||
|
const fileInputRef = ref<HTMLInputElement>()
|
||||||
|
|
||||||
|
const ALLOWED_EXTS = ['.txt', '.md', '.docx', '.pdf']
|
||||||
|
const MAX_SIZE = 10 * 1024 * 1024
|
||||||
|
|
||||||
|
// 打开时加载文件列表
|
||||||
|
watch(() => props.show, async (val) => {
|
||||||
|
if (val) {
|
||||||
|
await loadFiles()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const result = await listFiles(props.novelId)
|
||||||
|
files.value = result.items
|
||||||
|
} catch {
|
||||||
|
toast.error('加载文件列表失败')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerUpload() {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileSelect(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
const file = target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
target.value = ''
|
||||||
|
|
||||||
|
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
|
||||||
|
if (!ALLOWED_EXTS.includes(ext)) {
|
||||||
|
toast.error(`不支持的格式 ${ext},仅支持 ${ALLOWED_EXTS.join('、')}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (file.size > MAX_SIZE) {
|
||||||
|
toast.error(`文件过大(${(file.size / 1024 / 1024).toFixed(1)}MB),最大 10MB`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isUploading.value = true
|
||||||
|
try {
|
||||||
|
await uploadFile(props.novelId, file)
|
||||||
|
toast.success(`已上传: ${file.name}`)
|
||||||
|
await loadFiles()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '上传失败'
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
isUploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(file: NovelFile) {
|
||||||
|
deletingId.value = file.id
|
||||||
|
try {
|
||||||
|
await deleteFile(props.novelId, file.id)
|
||||||
|
files.value = files.value.filter(f => f.id !== file.id)
|
||||||
|
toast.success(`已删除: ${file.filename}`)
|
||||||
|
} catch {
|
||||||
|
toast.error('删除失败')
|
||||||
|
} finally {
|
||||||
|
deletingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化文件大小 */
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化日期 */
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
const d = new Date(iso)
|
||||||
|
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文件类型图标颜色 */
|
||||||
|
function getExtColor(filename: string): string {
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase()
|
||||||
|
switch (ext) {
|
||||||
|
case 'txt': case 'md': return 'var(--bamboo)'
|
||||||
|
case 'docx': return '#4a90d9'
|
||||||
|
case 'pdf': return '#c73e1d'
|
||||||
|
default: return 'var(--ink-400)'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BaseModal :show="show" title="文件工作区" @close="emit('close')">
|
||||||
|
<div class="files-container">
|
||||||
|
<!-- 上传区 -->
|
||||||
|
<div class="upload-area" @click="triggerUpload">
|
||||||
|
<input
|
||||||
|
ref="fileInputRef"
|
||||||
|
type="file"
|
||||||
|
accept=".txt,.md,.docx,.pdf"
|
||||||
|
style="display: none"
|
||||||
|
@change="handleFileSelect"
|
||||||
|
/>
|
||||||
|
<div v-if="isUploading" class="upload-hint uploading">
|
||||||
|
<svg class="spin" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<circle cx="10" cy="10" r="8" stroke="currentColor" stroke-width="2" stroke-dasharray="40" stroke-dashoffset="10" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span>上传中...</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="upload-hint">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
|
||||||
|
<path d="M10 4v12M4 10h12" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span>点击上传文件</span>
|
||||||
|
<span class="upload-sub">支持 txt、md、docx、pdf,最大 10MB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 文件列表 -->
|
||||||
|
<div v-if="loading" class="files-loading">加载中...</div>
|
||||||
|
<div v-else-if="files.length === 0" class="files-empty">
|
||||||
|
<p>还没有上传文件</p>
|
||||||
|
<p class="files-empty-sub">上传小说或剧本后,AI 可以帮你分析内容</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="file-list">
|
||||||
|
<div v-for="file in files" :key="file.id" class="file-item">
|
||||||
|
<div class="file-icon" :style="{ color: getExtColor(file.filename) }">
|
||||||
|
<svg width="18" height="18" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M4 2h5l3 3v9H4V2z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M9 2v3h3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="file-info">
|
||||||
|
<div class="file-name">{{ file.filename }}</div>
|
||||||
|
<div class="file-meta">
|
||||||
|
{{ formatSize(file.file_size) }} · {{ file.text_length.toLocaleString() }} 字 · {{ formatDate(file.created_at) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="file-delete"
|
||||||
|
title="删除文件"
|
||||||
|
:disabled="deletingId === file.id"
|
||||||
|
@click.stop="handleDelete(file)"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M12 4L4 12M4 4l8 8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<BaseButton variant="ghost" @click="emit('close')">关闭</BaseButton>
|
||||||
|
</template>
|
||||||
|
</BaseModal>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.files-container {
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 上传区 */
|
||||||
|
.upload-area {
|
||||||
|
border: 2px dashed var(--paper-300);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-lg);
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--duration-fast) ease;
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area:hover {
|
||||||
|
border-color: var(--vermilion);
|
||||||
|
background: rgba(199, 62, 29, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
color: var(--ink-400);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-hint.uploading {
|
||||||
|
color: var(--vermilion);
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-sub {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ink-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spin {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from { transform: rotate(0deg); }
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 加载和空状态 */
|
||||||
|
.files-loading,
|
||||||
|
.files-empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: var(--space-xl) 0;
|
||||||
|
color: var(--ink-400);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-empty-sub {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--ink-300);
|
||||||
|
margin-top: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 文件列表 */
|
||||||
|
.file-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--paper-100);
|
||||||
|
border: 1px solid var(--paper-200);
|
||||||
|
transition: background var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item:hover {
|
||||||
|
background: var(--paper-50);
|
||||||
|
border-color: var(--paper-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-name {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-800);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-meta {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--ink-400);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-delete {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--ink-300);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-delete:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
background: rgba(199, 62, 29, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-delete:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,10 +1,22 @@
|
|||||||
<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 { useChatAgent } from '@/composables/useChatAgent'
|
import { useChatAgent } from '@/composables/useChatAgent'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
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 ChatMessageComp from './ChatMessage.vue'
|
import ChatMessageComp from './ChatMessage.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'
|
||||||
|
|
||||||
defineProps<{
|
const props = defineProps<{
|
||||||
open: boolean
|
open: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -12,28 +24,137 @@ const emit = defineEmits<{
|
|||||||
close: []
|
close: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { messages, isStreaming, streamingContent, lastUsage, loadHistory, sendMessage, stopStreaming, clearChat } = useChatAgent()
|
const {
|
||||||
|
entries, isStreaming, isCompressing, streamingContent, lastUsage,
|
||||||
|
toolsEnabled, autoApproveTools, activeSkillId, currentNovelId,
|
||||||
|
loadHistory, sendMessage, approveToolCalls, skipToolCalls,
|
||||||
|
stopStreaming, clearChat, compressChat,
|
||||||
|
} = useChatAgent()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const input = ref('')
|
const input = ref('')
|
||||||
|
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 MODEL_CONTEXT: Record<string, number> = {
|
const showSlashMenu = ref(false)
|
||||||
'gpt-4o': 128000,
|
const slashFilter = ref('')
|
||||||
'gpt-4o-mini': 128000,
|
const slashSelectedIndex = ref(0)
|
||||||
'gpt-4-turbo': 128000,
|
const cachedSkills = ref<Skill[]>([])
|
||||||
'gpt-4': 8192,
|
|
||||||
'gpt-3.5-turbo': 16385,
|
interface SlashCommand {
|
||||||
'claude-sonnet-4-20250514': 200000,
|
id: string
|
||||||
'claude-opus-4-20250514': 200000,
|
label: string
|
||||||
'claude-haiku-4-5-20251001': 200000,
|
desc: string
|
||||||
'claude-3-5-sonnet-20241022': 200000,
|
icon: string
|
||||||
'claude-3-opus-20240229': 200000,
|
type: 'action' | 'skill'
|
||||||
|
skillId?: number
|
||||||
}
|
}
|
||||||
const DEFAULT_CONTEXT = 128000
|
|
||||||
|
// 内置命令
|
||||||
|
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 totalUsed = computed(() => {
|
const totalUsed = computed(() => {
|
||||||
if (!lastUsage.value) return 0
|
if (!lastUsage.value) return 0
|
||||||
const u = lastUsage.value
|
const u = lastUsage.value
|
||||||
@@ -54,40 +175,121 @@ const usageLabel = computed(() => {
|
|||||||
return `${total.toLocaleString()} / ${contextLimit.value.toLocaleString()} tokens(输入 ${u.prompt_tokens.toLocaleString()} · 输出 ${u.completion_tokens.toLocaleString()})`
|
return `${total.toLocaleString()} / ${contextLimit.value.toLocaleString()} tokens(输入 ${u.prompt_tokens.toLocaleString()} · 输出 ${u.completion_tokens.toLocaleString()})`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 是否有待审批的工具调用
|
||||||
|
const hasPendingTools = computed(() =>
|
||||||
|
entries.value.some(e => isToolCallGroup(e) && e.status === 'pending')
|
||||||
|
)
|
||||||
|
|
||||||
|
async function handleCompress() {
|
||||||
|
const result = await compressChat()
|
||||||
|
if (result.success) {
|
||||||
|
toast.success(result.message)
|
||||||
|
} else {
|
||||||
|
toast.info(result.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMarkdown(text: string): string {
|
||||||
|
return marked.parse(text) as string
|
||||||
|
}
|
||||||
|
|
||||||
function scrollToBottom() {
|
function scrollToBottom() {
|
||||||
|
// 双重 nextTick:第一次等 v-if 渲染面板 DOM,第二次等内容渲染完成
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
if (messagesRef.value) {
|
nextTick(() => {
|
||||||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
if (messagesRef.value) {
|
||||||
}
|
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
const text = input.value.trim()
|
const text = input.value.trim()
|
||||||
if (!text || isStreaming.value) return
|
if (!text || isStreaming.value || hasPendingTools.value) return
|
||||||
input.value = ''
|
input.value = ''
|
||||||
await sendMessage(text)
|
await sendMessage(text)
|
||||||
}
|
}
|
||||||
|
|
||||||
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自动滚动
|
async function handleApprove(group: ToolCallGroup) {
|
||||||
watch([messages, streamingContent], scrollToBottom)
|
await approveToolCalls(group)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSkip(group: ToolCallGroup) {
|
||||||
|
skipToolCalls(group)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件上传
|
||||||
|
function triggerFileInput() {
|
||||||
|
fileInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFileSelect(e: Event) {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
const file = target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
target.value = '' // 重置,允许重复上传同名文件
|
||||||
|
|
||||||
|
// 前端校验
|
||||||
|
const ext = '.' + file.name.split('.').pop()?.toLowerCase()
|
||||||
|
if (!ALLOWED_EXTS.includes(ext)) {
|
||||||
|
toast.error(`不支持的格式 ${ext},仅支持 ${ALLOWED_EXTS.join('、')}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (file.size > MAX_SIZE) {
|
||||||
|
toast.error(`文件过大(${(file.size / 1024 / 1024).toFixed(1)}MB),最大 10MB`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!currentNovelId.value) {
|
||||||
|
toast.error('请先进入一部小说')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isUploading.value = true
|
||||||
|
try {
|
||||||
|
const result = await uploadFile(currentNovelId.value, file)
|
||||||
|
toast.success(`文件已上传:${result.filename}(${result.text_length} 字)`)
|
||||||
|
// 自动填入提示
|
||||||
|
input.value = `我上传了文件「${result.filename}」,请帮我分析其中的内容。`
|
||||||
|
} catch (err: unknown) {
|
||||||
|
const msg = (err as { response?: { data?: { detail?: string } } })?.response?.data?.detail || '上传失败'
|
||||||
|
toast.error(msg)
|
||||||
|
} finally {
|
||||||
|
isUploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动滚动:消息变化时、面板打开时
|
||||||
|
watch([entries, streamingContent], scrollToBottom)
|
||||||
|
watch(() => props.open, (isOpen) => {
|
||||||
|
if (isOpen) scrollToBottom()
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadHistory()
|
loadHistory()
|
||||||
|
loadCachedSkills()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 小说切换时刷新技能缓存
|
||||||
|
watch(currentNovelId, () => loadCachedSkills())
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Teleport to="body">
|
<Teleport to="body">
|
||||||
<Transition name="panel">
|
<Transition name="panel">
|
||||||
<div v-if="open" class="chat-overlay" @click.self="emit('close')">
|
<div v-if="open" class="chat-overlay">
|
||||||
<div class="chat-panel">
|
<div class="chat-panel">
|
||||||
<!-- 头部 -->
|
<!-- 头部 -->
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
@@ -98,6 +300,61 @@ onMounted(() => {
|
|||||||
<path d="M2 4h12M5 4V2.5h6V4M3.5 4v9.5h9V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M2 4h12M5 4V2.5h6V4M3.5 4v9.5h9V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="icon-btn"
|
||||||
|
:class="{ 'icon-btn--compressing': isCompressing }"
|
||||||
|
:title="isCompressing ? '正在压缩上下文...' : '压缩上下文(总结旧消息,释放空间)'"
|
||||||
|
:disabled="isCompressing || isStreaming"
|
||||||
|
@click="handleCompress"
|
||||||
|
>
|
||||||
|
<svg 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>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="currentNovelId"
|
||||||
|
class="icon-btn"
|
||||||
|
title="文件工作区"
|
||||||
|
@click="showFiles = true"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M13.5 7.5l-5.8 5.8a3.2 3.2 0 01-4.5-4.5l5.8-5.8a2.1 2.1 0 013 3L6.2 11.8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="currentNovelId"
|
||||||
|
class="icon-btn"
|
||||||
|
:class="{ 'icon-btn--active': toolsEnabled }"
|
||||||
|
:title="toolsEnabled ? '工具已启用(点击关闭)' : '工具已关闭(点击开启)'"
|
||||||
|
@click="toolsEnabled = !toolsEnabled"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M9.5 1.5L13.5 5.5L6.5 12.5H2.5V8.5L9.5 1.5Z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="currentNovelId && toolsEnabled"
|
||||||
|
class="icon-btn"
|
||||||
|
:class="{ 'icon-btn--active': autoApproveTools }"
|
||||||
|
:title="autoApproveTools ? '工具自动执行(点击切换为需审批)' : '工具需审批(点击切换为自动执行)'"
|
||||||
|
@click="autoApproveTools = !autoApproveTools"
|
||||||
|
>
|
||||||
|
<!-- 自动执行:闪电图标;需审批:盾牌图标 -->
|
||||||
|
<svg v-if="autoApproveTools" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M8.5 1L3.5 9H7.5L7 15L12.5 7H8.5L8.5 1Z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M8 1.5L2.5 4V7.5C2.5 11 5 13.5 8 14.5C11 13.5 13.5 11 13.5 7.5V4L8 1.5Z" 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>
|
||||||
|
</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" />
|
||||||
@@ -126,17 +383,44 @@ onMounted(() => {
|
|||||||
|
|
||||||
<!-- 消息区 -->
|
<!-- 消息区 -->
|
||||||
<div ref="messagesRef" class="panel-messages">
|
<div ref="messagesRef" class="panel-messages">
|
||||||
<div v-if="messages.length === 0 && !isStreaming" class="empty-chat">
|
<div v-if="entries.length === 0 && !isStreaming" class="empty-chat">
|
||||||
<p class="empty-hint">有什么可以帮你的?</p>
|
<p class="empty-hint">有什么可以帮你的?</p>
|
||||||
<p class="empty-sub">关于小说创作的任何问题,都可以问我</p>
|
<p class="empty-sub">关于小说创作的任何问题,都可以问我</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ChatMessageComp
|
<template v-for="entry in entries" :key="entry.id">
|
||||||
v-for="msg in messages"
|
<!-- 工具调用组 -->
|
||||||
:key="msg.id"
|
<ChatToolCalls
|
||||||
:role="msg.role"
|
v-if="isToolCallGroup(entry)"
|
||||||
:content="msg.content"
|
:group="entry"
|
||||||
/>
|
@approve="handleApprove(entry as ToolCallGroup)"
|
||||||
|
@skip="handleSkip(entry as ToolCallGroup)"
|
||||||
|
/>
|
||||||
|
<!-- 子 Agent -->
|
||||||
|
<ChatSubAgent
|
||||||
|
v-else-if="isSubAgentEntry(entry)"
|
||||||
|
:entry="entry"
|
||||||
|
/>
|
||||||
|
<!-- 对话摘要(压缩后的历史总结) -->
|
||||||
|
<div
|
||||||
|
v-else-if="entry.role === 'assistant' && entry.content.startsWith('[对话摘要]')"
|
||||||
|
class="context-summary"
|
||||||
|
>
|
||||||
|
<div class="summary-header">
|
||||||
|
<svg width="14" height="14" 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 class="summary-content" v-html="renderMarkdown(entry.content.replace('[对话摘要]\n', ''))" />
|
||||||
|
</div>
|
||||||
|
<!-- 普通消息 -->
|
||||||
|
<ChatMessageComp
|
||||||
|
v-else
|
||||||
|
:role="entry.role"
|
||||||
|
:content="entry.content"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- 流式消息 -->
|
<!-- 流式消息 -->
|
||||||
<ChatMessageComp
|
<ChatMessageComp
|
||||||
@@ -150,22 +434,79 @@ 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"
|
: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"
|
||||||
:disabled="!input.trim()"
|
:disabled="!input.trim() || hasPendingTools"
|
||||||
@click="handleSend"
|
@click="handleSend"
|
||||||
>
|
>
|
||||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
|
||||||
@@ -184,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>
|
||||||
@@ -194,11 +536,12 @@ onMounted(() => {
|
|||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 面板 */
|
/* 面板 */
|
||||||
.chat-panel {
|
.chat-panel {
|
||||||
width: 380px;
|
width: 480px;
|
||||||
max-width: 100vw;
|
max-width: 100vw;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: var(--paper-50);
|
background: var(--paper-50);
|
||||||
@@ -206,6 +549,7 @@ onMounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
box-shadow: -8px 0 30px rgba(26, 26, 46, 0.1);
|
box-shadow: -8px 0 30px rgba(26, 26, 46, 0.1);
|
||||||
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 头部 */
|
/* 头部 */
|
||||||
@@ -249,6 +593,31 @@ onMounted(() => {
|
|||||||
color: var(--ink-900);
|
color: var(--ink-900);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-btn--compressing {
|
||||||
|
animation: pulse 1.2s ease-in-out infinite;
|
||||||
|
color: #d4a017;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:disabled {
|
||||||
|
opacity: 0.3;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn--active {
|
||||||
|
color: var(--bamboo);
|
||||||
|
background: rgba(91, 140, 90, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn--active:hover {
|
||||||
|
color: var(--bamboo);
|
||||||
|
background: rgba(91, 140, 90, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
/* Token用量进度条 */
|
/* Token用量进度条 */
|
||||||
.usage-bar {
|
.usage-bar {
|
||||||
padding: var(--space-sm) var(--space-lg);
|
padding: var(--space-sm) var(--space-lg);
|
||||||
@@ -287,6 +656,34 @@ onMounted(() => {
|
|||||||
font-family: var(--font-mono, monospace);
|
font-family: var(--font-mono, monospace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 上下文摘要卡片 */
|
||||||
|
.context-summary {
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
background: linear-gradient(135deg, rgba(212, 160, 23, 0.06), rgba(212, 160, 23, 0.02));
|
||||||
|
border: 1px dashed rgba(212, 160, 23, 0.3);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--ink-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
font-weight: 600;
|
||||||
|
color: #d4a017;
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-content :deep(p) { margin: 0 0 0.3em; }
|
||||||
|
.summary-content :deep(p:last-child) { margin-bottom: 0; }
|
||||||
|
.summary-content :deep(ul) { margin: 0.2em 0; padding-left: 1.3em; }
|
||||||
|
.summary-content :deep(li) { margin-bottom: 0.1em; }
|
||||||
|
|
||||||
/* 消息区 */
|
/* 消息区 */
|
||||||
.panel-messages {
|
.panel-messages {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -342,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;
|
||||||
@@ -353,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);
|
||||||
|
|||||||
279
frontend/src/components/chat/ChatSubAgent.vue
Normal file
279
frontend/src/components/chat/ChatSubAgent.vue
Normal 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>
|
||||||
440
frontend/src/components/chat/ChatToolCalls.vue
Normal file
440
frontend/src/components/chat/ChatToolCalls.vue
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { ToolCallGroup, ToolCallEntry } from '@/types/chat'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
group: ToolCallGroup
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
approve: []
|
||||||
|
skip: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isPending = computed(() => props.group.status === 'pending')
|
||||||
|
const isExecuting = computed(() => props.group.status === 'executing')
|
||||||
|
const isDone = computed(() => props.group.status === 'done')
|
||||||
|
const isSkipped = computed(() => props.group.status === 'skipped')
|
||||||
|
|
||||||
|
// 工具图标(SVG path)
|
||||||
|
const TOOL_ICONS: Record<string, string> = {
|
||||||
|
// 大纲
|
||||||
|
list_outlines: 'M3 4h10M3 8h10M3 12h6',
|
||||||
|
get_outline_detail: 'M4 2h8v12H4zM6 5h4M6 8h4',
|
||||||
|
create_outline: 'M8 3v10M3 8h10',
|
||||||
|
update_outline: 'M3 12l2-2 4 4-2 2H3v-4zM9 6l2-2 4 4-2 2-4-4z',
|
||||||
|
delete_outline: 'M3 5h10M5 5V3h6v2M4 5v8h8V5',
|
||||||
|
reorder_outlines: 'M3 4h7M3 8h7M3 12h7M12 3l2 2-2 2M12 9l2 2-2 2',
|
||||||
|
// 世界观
|
||||||
|
get_world_setting: 'M8 1a7 7 0 100 14A7 7 0 008 1zM1 8h14M8 1c2 2 3 4.5 3 7s-1 5-3 7M8 1c-2 2-3 4.5-3 7s1 5 3 7',
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
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',
|
||||||
|
// 章节
|
||||||
|
list_chapters: 'M4 2h8v12H4zM6 5h4M6 8h4M6 11h2',
|
||||||
|
get_chapter_detail: 'M4 2h8v12H4zM6 5h4M6 8h4M6 11h4',
|
||||||
|
create_chapter: 'M3 2h7v12H3zM12 5v5M9.5 7.5h5',
|
||||||
|
update_chapter: 'M4 2h8v12H4zM7 7l2-2 2 2-2 2z',
|
||||||
|
delete_chapter: 'M4 2h8v12H4zM6 6l4 4M10 6l-4 4',
|
||||||
|
// 文件
|
||||||
|
list_files: 'M13.5 7.5l-5.8 5.8a3.2 3.2 0 01-4.5-4.5l5.8-5.8a2.1 2.1 0 013 3L6.2 11.8',
|
||||||
|
read_file: 'M4 2h5l3 3v9H4V2zM9 2v3h3M6 8h4M6 10h4M6 12h2',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具颜色主题
|
||||||
|
const TOOL_COLORS: Record<string, string> = {
|
||||||
|
// 大纲
|
||||||
|
list_outlines: 'var(--bamboo)',
|
||||||
|
get_outline_detail: 'var(--bamboo)',
|
||||||
|
create_outline: 'var(--bamboo)',
|
||||||
|
update_outline: '#d4a017',
|
||||||
|
delete_outline: 'var(--danger)',
|
||||||
|
reorder_outlines: '#d4a017',
|
||||||
|
// 世界观
|
||||||
|
get_world_setting: 'var(--ink-500)',
|
||||||
|
save_world_setting: 'var(--bamboo)',
|
||||||
|
// 角色
|
||||||
|
list_characters: '#7c6bc4',
|
||||||
|
get_character_detail: '#7c6bc4',
|
||||||
|
create_character: '#7c6bc4',
|
||||||
|
update_character: '#d4a017',
|
||||||
|
delete_character: 'var(--danger)',
|
||||||
|
// 章节
|
||||||
|
list_chapters: '#4a90d9',
|
||||||
|
get_chapter_detail: '#4a90d9',
|
||||||
|
create_chapter: '#4a90d9',
|
||||||
|
update_chapter: '#d4a017',
|
||||||
|
delete_chapter: 'var(--danger)',
|
||||||
|
// 文件
|
||||||
|
list_files: '#e8833a',
|
||||||
|
read_file: '#e8833a',
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIcon(name: string): string {
|
||||||
|
return TOOL_ICONS[name] ?? 'M8 3v10M3 8h10'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColor(name: string): string {
|
||||||
|
return TOOL_COLORS[name] ?? 'var(--ink-400)'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化参数为可读文本 */
|
||||||
|
function formatArgs(call: ToolCallEntry): string {
|
||||||
|
const args = call.arguments
|
||||||
|
if (!args || Object.keys(args).length === 0) return ''
|
||||||
|
|
||||||
|
switch (call.name) {
|
||||||
|
case 'get_outline_detail':
|
||||||
|
return `#${args.outline_id}`
|
||||||
|
case 'create_outline':
|
||||||
|
return args.summary as string ?? ''
|
||||||
|
case 'update_outline':
|
||||||
|
return `#${args.outline_id}` + (args.summary ? ` → ${args.summary}` : '')
|
||||||
|
case 'delete_outline':
|
||||||
|
return `#${args.outline_id}`
|
||||||
|
case 'reorder_outlines': {
|
||||||
|
const ids = args.ordered_ids as number[]
|
||||||
|
return ids ? `${ids.length} 个节点` + (args.parent_id ? ` (父节点 #${args.parent_id})` : '') : ''
|
||||||
|
}
|
||||||
|
case 'save_world_setting': {
|
||||||
|
const content = (args.content as string) ?? ''
|
||||||
|
return content.length > 80 ? content.slice(0, 80) + '…' : content
|
||||||
|
}
|
||||||
|
// 角色
|
||||||
|
case 'get_character_detail':
|
||||||
|
return `#${args.character_id}`
|
||||||
|
case 'create_character':
|
||||||
|
return args.name as string ?? ''
|
||||||
|
case 'update_character':
|
||||||
|
return `#${args.character_id}` + (args.name ? ` → ${args.name}` : '')
|
||||||
|
case 'delete_character':
|
||||||
|
return `#${args.character_id}`
|
||||||
|
// 章节
|
||||||
|
case 'get_chapter_detail':
|
||||||
|
return `#${args.chapter_id}`
|
||||||
|
case 'create_chapter':
|
||||||
|
return args.title as string ?? ''
|
||||||
|
case 'update_chapter':
|
||||||
|
return `#${args.chapter_id}` + (args.title ? ` → ${args.title}` : '')
|
||||||
|
case 'delete_chapter':
|
||||||
|
return `#${args.chapter_id}`
|
||||||
|
// 文件
|
||||||
|
case 'read_file':
|
||||||
|
return `#${args.file_id}` + (args.offset ? ` (偏移 ${args.offset})` : '')
|
||||||
|
default:
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化工具结果 */
|
||||||
|
function formatResult(call: ToolCallEntry): string {
|
||||||
|
if (!call.result) return ''
|
||||||
|
try {
|
||||||
|
const data = call.parsedResult ?? JSON.parse(call.result)
|
||||||
|
// 通用错误检查
|
||||||
|
if ((data as { error?: string }).error) return `失败: ${(data as { error: string }).error}`
|
||||||
|
|
||||||
|
switch (call.name) {
|
||||||
|
// 大纲
|
||||||
|
case 'list_outlines': {
|
||||||
|
const items = (data as { outlines: { id: number; summary: string }[] }).outlines
|
||||||
|
if (!items?.length) return '暂无大纲'
|
||||||
|
return items.map((o: { id: number; summary: string }) => `• ${o.summary}`).join('\n')
|
||||||
|
}
|
||||||
|
case 'create_outline':
|
||||||
|
return `已创建: ${(data as { summary: string }).summary}`
|
||||||
|
case 'update_outline':
|
||||||
|
return `已更新: ${(data as { summary: string }).summary}`
|
||||||
|
case 'delete_outline':
|
||||||
|
return '已删除'
|
||||||
|
case 'reorder_outlines':
|
||||||
|
return `已调整 ${(data as { count: number }).count} 个节点的排序`
|
||||||
|
// 世界观
|
||||||
|
case 'get_world_setting': {
|
||||||
|
const content = (data as { content: string }).content
|
||||||
|
if (!content) return '暂无世界观设定'
|
||||||
|
return content.length > 120 ? content.slice(0, 120) + '…' : content
|
||||||
|
}
|
||||||
|
case 'save_world_setting':
|
||||||
|
return `已保存 (${(data as { content_length: number }).content_length} 字)`
|
||||||
|
// 角色
|
||||||
|
case 'list_characters': {
|
||||||
|
const chars = (data as { characters: { id: number; name: string }[] }).characters
|
||||||
|
if (!chars?.length) return '暂无角色'
|
||||||
|
return chars.map((c: { id: number; name: string }) => `• ${c.name}`).join('\n')
|
||||||
|
}
|
||||||
|
case 'create_character':
|
||||||
|
return `已创建: ${(data as { name: string }).name}`
|
||||||
|
case 'update_character':
|
||||||
|
return `已更新: ${(data as { name: string }).name}`
|
||||||
|
case 'delete_character':
|
||||||
|
return '已删除'
|
||||||
|
// 章节
|
||||||
|
case 'list_chapters': {
|
||||||
|
const chs = (data as { chapters: { id: number; title: string }[] }).chapters
|
||||||
|
if (!chs?.length) return '暂无章节'
|
||||||
|
return chs.map((c: { id: number; title: string }) => `• ${c.title}`).join('\n')
|
||||||
|
}
|
||||||
|
case 'create_chapter':
|
||||||
|
return `已创建: ${(data as { title: string }).title}`
|
||||||
|
case 'update_chapter':
|
||||||
|
return `已更新: ${(data as { title: string }).title}`
|
||||||
|
case 'delete_chapter':
|
||||||
|
return '已删除'
|
||||||
|
// 文件
|
||||||
|
case 'list_files': {
|
||||||
|
const files = (data as { files: { id: number; filename: string; text_length: number }[] }).files
|
||||||
|
if (!files?.length) return '暂无文件'
|
||||||
|
return files.map((f: { id: number; filename: string; text_length: number }) => `• ${f.filename} (${f.text_length} 字)`).join('\n')
|
||||||
|
}
|
||||||
|
case 'read_file': {
|
||||||
|
const d = data as { filename: string; total_length: number; offset: number; limit: number; has_more: boolean }
|
||||||
|
return `${d.filename} [${d.offset}–${d.offset + d.limit}] / ${d.total_length} 字${d.has_more ? '(还有更多)' : ''}`
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return call.result.slice(0, 100)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return call.result.slice(0, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="tool-calls-card">
|
||||||
|
<div class="tool-calls-header">
|
||||||
|
<span class="tool-calls-title">工具调用</span>
|
||||||
|
<span v-if="isPending" class="tool-calls-badge badge--pending">待确认</span>
|
||||||
|
<span v-else-if="isExecuting" class="tool-calls-badge badge--executing">执行中</span>
|
||||||
|
<span v-else-if="isDone" class="tool-calls-badge badge--done">已完成</span>
|
||||||
|
<span v-else-if="isSkipped" class="tool-calls-badge badge--skipped">已跳过</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 每个工具调用 -->
|
||||||
|
<div
|
||||||
|
v-for="call in group.calls"
|
||||||
|
:key="call.id"
|
||||||
|
class="tool-item"
|
||||||
|
:class="{ 'tool-item--danger': call.name.includes('delete') }"
|
||||||
|
>
|
||||||
|
<div class="tool-item-header">
|
||||||
|
<svg class="tool-item-icon" width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path :d="getIcon(call.name)" :stroke="getColor(call.name)" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" fill="none" />
|
||||||
|
</svg>
|
||||||
|
<span class="tool-item-label" :style="{ color: getColor(call.name) }">{{ call.label }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 参数 -->
|
||||||
|
<div v-if="formatArgs(call)" class="tool-item-args">{{ formatArgs(call) }}</div>
|
||||||
|
|
||||||
|
<!-- 结果 -->
|
||||||
|
<div v-if="call.result" class="tool-item-result">
|
||||||
|
<pre class="result-text">{{ formatResult(call) }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 审批按钮 -->
|
||||||
|
<div v-if="isPending" class="tool-actions">
|
||||||
|
<button class="tool-btn tool-btn--approve" @click="emit('approve')">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path d="M3 7l3 3 5-5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
执行
|
||||||
|
</button>
|
||||||
|
<button class="tool-btn tool-btn--skip" @click="emit('skip')">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path d="M10 4L4 10M4 4l6 6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
跳过
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 执行中指示 -->
|
||||||
|
<div v-if="isExecuting" class="tool-executing">
|
||||||
|
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tool-calls-card {
|
||||||
|
background: var(--paper-50);
|
||||||
|
border: 1px solid var(--paper-200);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-calls-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-calls-title {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-400);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-calls-badge {
|
||||||
|
font-size: 0.68rem;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--pending {
|
||||||
|
background: rgba(212, 160, 23, 0.12);
|
||||||
|
color: #b8860b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--executing {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge--skipped {
|
||||||
|
background: var(--paper-100);
|
||||||
|
color: var(--ink-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-item {
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--paper-100);
|
||||||
|
margin-bottom: var(--space-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-item--danger {
|
||||||
|
background: rgba(220, 53, 69, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-item-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-item-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-item-label {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-item-args {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--ink-500);
|
||||||
|
margin-top: 2px;
|
||||||
|
padding-left: 22px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-item-result {
|
||||||
|
margin-top: 4px;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ink-600);
|
||||||
|
background: var(--paper-50);
|
||||||
|
border: 1px solid var(--paper-200);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
max-height: 120px;
|
||||||
|
overflow-y: auto;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 审批按钮 */
|
||||||
|
.tool-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
border: 1px solid;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn--approve {
|
||||||
|
background: var(--bamboo);
|
||||||
|
border-color: var(--bamboo);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn--approve:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn--skip {
|
||||||
|
background: var(--paper-50);
|
||||||
|
border-color: var(--paper-300);
|
||||||
|
color: var(--ink-500);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn--skip:hover {
|
||||||
|
background: var(--paper-100);
|
||||||
|
color: var(--ink-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 执行中 */
|
||||||
|
.tool-executing {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: var(--space-xs) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-executing .dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bamboo);
|
||||||
|
animation: typingBounce 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-executing .dot:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
.tool-executing .dot:nth-child(3) { animation-delay: 0.4s; }
|
||||||
|
|
||||||
|
@keyframes toolPulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.5; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes typingBounce {
|
||||||
|
0%, 60%, 100% { transform: translateY(0); }
|
||||||
|
30% { transform: translateY(-4px); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
506
frontend/src/components/chat/SkillManageModal.vue
Normal file
506
frontend/src/components/chat/SkillManageModal.vue
Normal file
@@ -0,0 +1,506 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, computed } from 'vue'
|
||||||
|
import { listSkills, createSkill, updateSkill, deleteSkill, getAvailableTools } from '@/api/skills'
|
||||||
|
import type { Skill, ToolInfo } from '@/api/skills'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||||
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
|
import BaseInput from '@/components/ui/BaseInput.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
show: boolean
|
||||||
|
novelId?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
updated: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const toast = useToast()
|
||||||
|
const skills = ref<Skill[]>([])
|
||||||
|
const tools = ref<ToolInfo[]>([])
|
||||||
|
const selectedId = ref<number | null>(null)
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
// 编辑表单
|
||||||
|
const formName = ref('')
|
||||||
|
const formDesc = ref('')
|
||||||
|
const formPrompt = ref('')
|
||||||
|
const formTools = ref<string[]>([])
|
||||||
|
const isNew = ref(false)
|
||||||
|
|
||||||
|
const selectedSkill = computed(() =>
|
||||||
|
skills.value.find(s => s.id === selectedId.value) ?? null
|
||||||
|
)
|
||||||
|
|
||||||
|
// 工具分组
|
||||||
|
const TOOL_GROUPS: { label: string; tools: string[] }[] = [
|
||||||
|
{ label: '小说信息', tools: ['get_novel_info', 'update_novel_info'] },
|
||||||
|
{ label: '大纲管理', tools: ['list_outlines', '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>
|
||||||
254
frontend/src/components/chat/SkillSelector.vue
Normal file
254
frontend/src/components/chat/SkillSelector.vue
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { listSkills } from '@/api/skills'
|
||||||
|
import type { Skill } from '@/api/skills'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: number | null
|
||||||
|
novelId: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: number | null]
|
||||||
|
manage: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const skills = ref<Skill[]>([])
|
||||||
|
const open = ref(false)
|
||||||
|
const dropdownRef = ref<HTMLElement>()
|
||||||
|
|
||||||
|
const activeSkill = ref<Skill | null>(null)
|
||||||
|
|
||||||
|
async function loadSkills() {
|
||||||
|
try {
|
||||||
|
const result = await listSkills(props.novelId)
|
||||||
|
skills.value = result.items
|
||||||
|
// 同步当前激活 Skill
|
||||||
|
if (props.modelValue) {
|
||||||
|
activeSkill.value = skills.value.find(s => s.id === props.modelValue) ?? null
|
||||||
|
}
|
||||||
|
} catch { /* 静默 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSkill(skill: Skill | null) {
|
||||||
|
activeSkill.value = skill
|
||||||
|
emit('update:modelValue', skill?.id ?? null)
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClickOutside(e: MouseEvent) {
|
||||||
|
if (dropdownRef.value && !dropdownRef.value.contains(e.target as Node)) {
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.novelId, loadSkills)
|
||||||
|
watch(() => props.modelValue, (id) => {
|
||||||
|
activeSkill.value = id ? skills.value.find(s => s.id === id) ?? null : null
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadSkills()
|
||||||
|
document.addEventListener('click', handleClickOutside, true)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', handleClickOutside, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 外部调用:重新加载技能列表 */
|
||||||
|
defineExpose({ loadSkills })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div ref="dropdownRef" class="skill-selector">
|
||||||
|
<button
|
||||||
|
class="skill-trigger"
|
||||||
|
:class="{ 'skill-trigger--active': activeSkill }"
|
||||||
|
:title="activeSkill ? `当前技能:${activeSkill.name}` : '选择技能'"
|
||||||
|
@click.stop="open = !open"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M8 1l2.5 5H14l-4 3.5 1.5 5.5L8 12l-3.5 3 1.5-5.5L2 6h3.5z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span v-if="activeSkill" class="skill-name">{{ activeSkill.name }}</span>
|
||||||
|
<span v-else class="skill-name">技能</span>
|
||||||
|
<svg class="chevron" :class="{ 'chevron--open': open }" width="10" height="10" viewBox="0 0 10 10" fill="none">
|
||||||
|
<path d="M2.5 4L5 6.5L7.5 4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Transition name="dropdown">
|
||||||
|
<div v-if="open" class="skill-dropdown">
|
||||||
|
<!-- 无技能选项 -->
|
||||||
|
<div
|
||||||
|
class="skill-option"
|
||||||
|
:class="{ 'skill-option--active': !activeSkill }"
|
||||||
|
@click="selectSkill(null)"
|
||||||
|
>
|
||||||
|
<span class="option-name">无技能</span>
|
||||||
|
<span class="option-desc">使用默认写作助手</span>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-divider" />
|
||||||
|
<!-- 技能列表 -->
|
||||||
|
<div
|
||||||
|
v-for="skill in skills"
|
||||||
|
:key="skill.id"
|
||||||
|
class="skill-option"
|
||||||
|
:class="{ 'skill-option--active': activeSkill?.id === skill.id }"
|
||||||
|
@click="selectSkill(skill)"
|
||||||
|
>
|
||||||
|
<div class="option-header">
|
||||||
|
<span class="option-name">{{ skill.name }}</span>
|
||||||
|
<span v-if="skill.is_builtin" class="builtin-badge">预置</span>
|
||||||
|
</div>
|
||||||
|
<span class="option-desc">{{ skill.description }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="dropdown-divider" />
|
||||||
|
<!-- 管理入口 -->
|
||||||
|
<div class="skill-option skill-option--manage" @click="emit('manage'); open = false">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none">
|
||||||
|
<circle cx="8" cy="8" r="2.5" stroke="currentColor" stroke-width="1.2" />
|
||||||
|
<path d="M8 1v2M8 13v2M1 8h2M13 8h2" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span>管理技能...</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.skill-selector {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--paper-200);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--ink-500);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
transition: all var(--duration-fast) ease;
|
||||||
|
height: 28px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-trigger:hover {
|
||||||
|
border-color: var(--ink-300);
|
||||||
|
color: var(--ink-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-trigger--active {
|
||||||
|
border-color: var(--bamboo);
|
||||||
|
color: var(--bamboo);
|
||||||
|
background: rgba(91, 140, 90, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-name {
|
||||||
|
max-width: 80px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron {
|
||||||
|
transition: transform var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chevron--open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 下拉菜单 */
|
||||||
|
.skill-dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
right: 0;
|
||||||
|
width: 240px;
|
||||||
|
background: var(--paper-50);
|
||||||
|
border: 1px solid var(--paper-200);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
z-index: 1100;
|
||||||
|
max-height: 320px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--paper-200);
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-option {
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-option:hover {
|
||||||
|
background: var(--paper-100);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-option--active {
|
||||||
|
background: rgba(91, 140, 90, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-option--manage {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--ink-400);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skill-option--manage:hover {
|
||||||
|
color: var(--ink-700);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-name {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.option-desc {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--ink-400);
|
||||||
|
margin-top: 2px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builtin-badge {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(91, 140, 90, 0.12);
|
||||||
|
color: var(--bamboo);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 动画 */
|
||||||
|
.dropdown-enter-active,
|
||||||
|
.dropdown-leave-active {
|
||||||
|
transition: all var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dropdown-enter-from,
|
||||||
|
.dropdown-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
183
frontend/src/components/novel/NovelSubNav.vue
Normal file
183
frontend/src/components/novel/NovelSubNav.vue
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
novelId: number
|
||||||
|
novelTitle: string
|
||||||
|
/** 当前页面的辅助信息,如 "共 5 个节点" */
|
||||||
|
subtitle?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const NAV_ITEMS = [
|
||||||
|
{ name: 'novel-outlines', label: '大纲', icon: 'M3 4h10M3 8h10M3 12h6' },
|
||||||
|
{ name: 'novel-chapters', label: '章节', icon: 'M4 2h8v12H4zM6 5h4M6 8h4' },
|
||||||
|
{ name: 'novel-characters', 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' },
|
||||||
|
{ name: 'novel-world-setting', label: '世界观', icon: 'M8 1a7 7 0 100 14A7 7 0 008 1zM1 8h14M8 1c2 2 3 4.5 3 7s-1 5-3 7M8 1c-2 2-3 4.5-3 7s1 5 3 7' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const currentRoute = computed(() => route.name as string)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="sub-nav">
|
||||||
|
<!-- 第一行:返回 + 标题 + 右侧操作 -->
|
||||||
|
<div class="sub-nav-top">
|
||||||
|
<RouterLink
|
||||||
|
:to="{ name: 'novel-detail', params: { id: novelId } }"
|
||||||
|
class="back-link"
|
||||||
|
>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path d="M10 3L5 8l5 5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span class="back-title">{{ novelTitle }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<div class="sub-nav-actions">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 第二行:导航标签 + 辅助信息 -->
|
||||||
|
<div class="sub-nav-bar">
|
||||||
|
<nav class="nav-tabs">
|
||||||
|
<RouterLink
|
||||||
|
v-for="item in NAV_ITEMS"
|
||||||
|
:key="item.name"
|
||||||
|
:to="{ name: item.name, params: { id: novelId } }"
|
||||||
|
class="nav-tab"
|
||||||
|
:class="{ 'nav-tab--active': currentRoute === item.name }"
|
||||||
|
>
|
||||||
|
<svg class="nav-tab-icon" width="14" height="14" viewBox="0 0 16 16" fill="none">
|
||||||
|
<path :d="item.icon" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round" fill="none" />
|
||||||
|
</svg>
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<span v-if="subtitle" class="nav-subtitle">{{ subtitle }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sub-nav {
|
||||||
|
margin-bottom: var(--space-xl);
|
||||||
|
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 顶部行:返回 + 操作按钮 ── */
|
||||||
|
.sub-nav-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: var(--ink-400);
|
||||||
|
transition: color var(--duration-fast) ease;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover {
|
||||||
|
color: var(--vermilion);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: transform var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover svg {
|
||||||
|
transform: translateX(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-title {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.35rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--ink-900);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
transition: color var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-link:hover .back-title {
|
||||||
|
color: var(--vermilion);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sub-nav-actions {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 导航标签栏 ── */
|
||||||
|
.sub-nav-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-md);
|
||||||
|
border-bottom: 1px solid var(--paper-200);
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ink-400);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -1px; /* 与底部边线重叠 */
|
||||||
|
transition: all var(--duration-fast) ease;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab:hover {
|
||||||
|
color: var(--ink-700);
|
||||||
|
background: var(--paper-100);
|
||||||
|
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab--active {
|
||||||
|
color: var(--vermilion);
|
||||||
|
border-bottom-color: var(--vermilion);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab--active:hover {
|
||||||
|
color: var(--vermilion);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tab--active .nav-tab-icon {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-subtitle {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--ink-300);
|
||||||
|
white-space: nowrap;
|
||||||
|
padding-bottom: var(--space-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
187
frontend/src/components/outline/OutlineChildList.vue
Normal file
187
frontend/src/components/outline/OutlineChildList.vue
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Outline } from '@/types/outline'
|
||||||
|
import OutlineChildNode from './OutlineChildNode.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
children: Outline[]
|
||||||
|
expanded: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
addChild: []
|
||||||
|
editChild: [child: Outline]
|
||||||
|
deleteChild: [child: Outline]
|
||||||
|
reorderChildren: [children: Outline[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 子节点拖拽排序
|
||||||
|
const dragIndex = ref<number | null>(null)
|
||||||
|
const dragOverIndex = ref<number | null>(null)
|
||||||
|
|
||||||
|
function onDragStart(index: number) {
|
||||||
|
dragIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragOver(index: number) {
|
||||||
|
if (dragIndex.value === null || dragIndex.value === index) return
|
||||||
|
dragOverIndex.value = index
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDragEnd() {
|
||||||
|
if (dragIndex.value !== null && dragOverIndex.value !== null && dragIndex.value !== dragOverIndex.value) {
|
||||||
|
const items = [...props.children]
|
||||||
|
const [moved] = items.splice(dragIndex.value, 1)
|
||||||
|
items.splice(dragOverIndex.value, 0, moved)
|
||||||
|
emit('reorderChildren', items)
|
||||||
|
}
|
||||||
|
dragIndex.value = null
|
||||||
|
dragOverIndex.value = null
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="child-list-wrapper" :class="{ 'child-list-wrapper--open': expanded }">
|
||||||
|
<div class="child-list-inner">
|
||||||
|
<div class="child-list">
|
||||||
|
<!-- 竹青竖线 -->
|
||||||
|
<div class="child-line" />
|
||||||
|
|
||||||
|
<!-- 子节点卡片 -->
|
||||||
|
<div
|
||||||
|
v-for="(child, index) in children"
|
||||||
|
:key="child.id"
|
||||||
|
class="child-item"
|
||||||
|
:class="{
|
||||||
|
'child-item--dragging': dragIndex === index,
|
||||||
|
'child-item--drag-over': dragOverIndex === index,
|
||||||
|
}"
|
||||||
|
draggable="true"
|
||||||
|
@dragstart.stop="onDragStart(index)"
|
||||||
|
@dragover.prevent.stop="onDragOver(index)"
|
||||||
|
@dragend.stop="onDragEnd"
|
||||||
|
>
|
||||||
|
<OutlineChildNode
|
||||||
|
:child="child"
|
||||||
|
:index="index"
|
||||||
|
@edit="emit('editChild', child)"
|
||||||
|
@delete="emit('deleteChild', child)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 添加子节点按钮 -->
|
||||||
|
<div class="child-add-wrapper">
|
||||||
|
<button class="child-add-btn" @click.stop="emit('addChild')">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path d="M7 3v8M3 7h8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span>添加子节点</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* 展开容器:grid-template-rows 过渡 */
|
||||||
|
.child-list-wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows var(--duration-normal) var(--ease-out-smooth);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-list-wrapper--open {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-list-inner {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-list {
|
||||||
|
position: relative;
|
||||||
|
padding: var(--space-md) 0 var(--space-sm) var(--space-xl);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 竹青竖线 */
|
||||||
|
.child-line {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
top: 0;
|
||||||
|
bottom: var(--space-md);
|
||||||
|
width: 2px;
|
||||||
|
background: linear-gradient(
|
||||||
|
to bottom,
|
||||||
|
var(--bamboo),
|
||||||
|
rgba(91, 140, 90, 0.3)
|
||||||
|
);
|
||||||
|
border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 子节点项 */
|
||||||
|
.child-item {
|
||||||
|
position: relative;
|
||||||
|
transition: opacity var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-item--dragging {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-item--drag-over::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--bamboo);
|
||||||
|
border-radius: 1px;
|
||||||
|
z-index: 5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 添加子节点按钮 */
|
||||||
|
.child-add-wrapper {
|
||||||
|
padding-left: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-add-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 4px 12px;
|
||||||
|
background: none;
|
||||||
|
border: 1px dashed rgba(91, 140, 90, 0.4);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--bamboo);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--duration-fast) ease;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-add-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
border-color: var(--bamboo);
|
||||||
|
background: rgba(91, 140, 90, 0.06);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 响应式 */
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
.child-list {
|
||||||
|
padding-left: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-line {
|
||||||
|
left: 28px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
237
frontend/src/components/outline/OutlineChildNode.vue
Normal file
237
frontend/src/components/outline/OutlineChildNode.vue
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import type { Outline } from '@/types/outline'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
child: Outline
|
||||||
|
index: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
edit: []
|
||||||
|
delete: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const expanded = ref(false)
|
||||||
|
|
||||||
|
function toggle() {
|
||||||
|
expanded.value = !expanded.value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="child-node"
|
||||||
|
:class="{ 'child-node--expanded': expanded }"
|
||||||
|
:style="{ '--child-delay': `${index * 80}ms` }"
|
||||||
|
@click="toggle"
|
||||||
|
>
|
||||||
|
<!-- 左侧竹青圆点 -->
|
||||||
|
<div class="child-dot" />
|
||||||
|
|
||||||
|
<div class="child-content">
|
||||||
|
<!-- 摘要 -->
|
||||||
|
<h4 class="child-summary">{{ child.summary }}</h4>
|
||||||
|
|
||||||
|
<!-- 详情(可展开) -->
|
||||||
|
<div class="child-detail-wrapper" :class="{ 'child-detail-wrapper--open': expanded }">
|
||||||
|
<div class="child-detail-inner">
|
||||||
|
<p v-if="child.detail" class="child-detail">{{ child.detail }}</p>
|
||||||
|
<p v-else class="child-detail child-detail--empty">暂无详情</p>
|
||||||
|
|
||||||
|
<!-- 操作按钮 -->
|
||||||
|
<div class="child-actions" @click.stop>
|
||||||
|
<button class="child-action-btn child-action-btn--edit" @click="emit('edit')">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path d="M10.5 1.5l2 2-8 8H2.5v-2l8-8z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
编辑
|
||||||
|
</button>
|
||||||
|
<button class="child-action-btn child-action-btn--delete" @click="emit('delete')">
|
||||||
|
<svg width="12" height="12" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path d="M2 4h10M5 4V2.5h4V4M3.5 4v8h7V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 展开箭头 -->
|
||||||
|
<div class="child-expand-hint">
|
||||||
|
<svg
|
||||||
|
class="child-chevron"
|
||||||
|
:class="{ 'child-chevron--open': expanded }"
|
||||||
|
width="10"
|
||||||
|
height="10"
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.child-node {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-md);
|
||||||
|
padding: var(--space-md) var(--space-lg);
|
||||||
|
background: var(--paper-100);
|
||||||
|
border: 1px solid var(--paper-200);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
transform var(--duration-fast) ease,
|
||||||
|
box-shadow var(--duration-fast) ease,
|
||||||
|
border-color var(--duration-fast) ease;
|
||||||
|
animation: childFadeInUp var(--duration-normal) var(--ease-out-smooth) both;
|
||||||
|
animation-delay: var(--child-delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-node:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 2px 12px rgba(91, 140, 90, 0.1);
|
||||||
|
border-color: var(--bamboo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-node--expanded {
|
||||||
|
border-color: var(--bamboo);
|
||||||
|
border-left-width: 3px;
|
||||||
|
background: var(--paper-50);
|
||||||
|
box-shadow: 0 2px 12px rgba(91, 140, 90, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧竹青圆点 */
|
||||||
|
.child-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--bamboo);
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 6px;
|
||||||
|
opacity: 0.7;
|
||||||
|
transition: all var(--duration-fast) ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-node:hover .child-dot,
|
||||||
|
.child-node--expanded .child-dot {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 内容区 */
|
||||||
|
.child-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-summary {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-800);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 详情展开区 */
|
||||||
|
.child-detail-wrapper {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows var(--duration-normal) var(--ease-out-smooth);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-detail-wrapper--open {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-detail-inner {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-detail {
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
padding-top: var(--space-sm);
|
||||||
|
border-top: 1px dashed rgba(91, 140, 90, 0.25);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--ink-500);
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-detail--empty {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--ink-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 操作按钮 */
|
||||||
|
.child-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xs);
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--paper-200);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--duration-fast) ease;
|
||||||
|
font-family: var(--font-body);
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-action-btn--edit {
|
||||||
|
color: var(--bamboo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-action-btn--edit:hover {
|
||||||
|
background: rgba(91, 140, 90, 0.08);
|
||||||
|
border-color: var(--bamboo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-action-btn--delete {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-action-btn--delete:hover {
|
||||||
|
background: var(--danger-ghost);
|
||||||
|
border-color: var(--danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 展开箭头 */
|
||||||
|
.child-expand-hint {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: var(--ink-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-chevron {
|
||||||
|
transition: transform var(--duration-normal) var(--ease-out-back);
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-chevron--open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes childFadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(8px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -10,6 +10,7 @@ const props = defineProps<{
|
|||||||
show: boolean
|
show: boolean
|
||||||
outline: Outline | null
|
outline: Outline | null
|
||||||
saving: boolean
|
saving: boolean
|
||||||
|
parentId?: number | null
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -21,7 +22,11 @@ const summary = ref('')
|
|||||||
const detail = ref('')
|
const detail = ref('')
|
||||||
|
|
||||||
const isEdit = computed(() => !!props.outline)
|
const isEdit = computed(() => !!props.outline)
|
||||||
const title = computed(() => isEdit.value ? '编辑大纲' : '新建大纲')
|
const isChild = computed(() => !!props.parentId)
|
||||||
|
const title = computed(() => {
|
||||||
|
if (isEdit.value) return isChild.value ? '编辑子节点' : '编辑大纲'
|
||||||
|
return isChild.value ? '新建子节点' : '新建大纲'
|
||||||
|
})
|
||||||
|
|
||||||
const detailLength = computed(() => detail.value.length)
|
const detailLength = computed(() => detail.value.length)
|
||||||
const detailHint = computed(() => {
|
const detailHint = computed(() => {
|
||||||
@@ -42,17 +47,24 @@ watch(() => props.show, (val) => {
|
|||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
if (!canSubmit.value) return
|
if (!canSubmit.value) return
|
||||||
emit('submit', {
|
const data: OutlineCreate | OutlineUpdate = {
|
||||||
summary: summary.value.trim(),
|
summary: summary.value.trim(),
|
||||||
detail: detail.value.trim(),
|
detail: detail.value.trim(),
|
||||||
})
|
}
|
||||||
|
// 新建子节点时附带 parent_id
|
||||||
|
if (!isEdit.value && props.parentId) {
|
||||||
|
(data as OutlineCreate).parent_id = props.parentId
|
||||||
|
}
|
||||||
|
emit('submit', data)
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BaseModal :show="show" @close="emit('close')">
|
<BaseModal :show="show" @close="emit('close')">
|
||||||
<form class="outline-form" @submit.prevent="handleSubmit">
|
<form class="outline-form" @submit.prevent="handleSubmit">
|
||||||
<h2 class="form-title">{{ title }}</h2>
|
<h2 class="form-title" :class="{ 'form-title--child': isChild }">
|
||||||
|
{{ title }}
|
||||||
|
</h2>
|
||||||
|
|
||||||
<BaseInput
|
<BaseInput
|
||||||
v-model="summary"
|
v-model="summary"
|
||||||
@@ -98,6 +110,10 @@ function handleSubmit() {
|
|||||||
color: var(--ink-900);
|
color: var(--ink-900);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-title--child {
|
||||||
|
color: var(--bamboo);
|
||||||
|
}
|
||||||
|
|
||||||
.detail-field {
|
.detail-field {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import type { Outline } from '@/types/outline'
|
import type { Outline } from '@/types/outline'
|
||||||
|
import OutlineChildList from './OutlineChildList.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
outline: Outline
|
outline: Outline
|
||||||
@@ -10,77 +11,146 @@ const props = defineProps<{
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
edit: []
|
edit: []
|
||||||
delete: []
|
delete: []
|
||||||
|
addChild: []
|
||||||
|
editChild: [child: Outline]
|
||||||
|
deleteChild: [child: Outline]
|
||||||
|
reorderChildren: [children: Outline[]]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const expanded = ref(false)
|
const expanded = ref(false)
|
||||||
|
const childrenExpanded = ref(false)
|
||||||
|
|
||||||
|
const childCount = computed(() => props.outline.children?.length ?? 0)
|
||||||
|
const hasChildren = computed(() => childCount.value > 0)
|
||||||
|
|
||||||
|
// 子节点从无变有时自动展开(如 AI 创建了子节点)
|
||||||
|
watch(hasChildren, (val, oldVal) => {
|
||||||
|
if (val && !oldVal) childrenExpanded.value = true
|
||||||
|
})
|
||||||
|
|
||||||
function toggle() {
|
function toggle() {
|
||||||
expanded.value = !expanded.value
|
expanded.value = !expanded.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleChildren(e: Event) {
|
||||||
|
e.stopPropagation()
|
||||||
|
childrenExpanded.value = !childrenExpanded.value
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="outline-node-wrapper">
|
||||||
class="outline-node"
|
<div
|
||||||
:class="{ 'outline-node--expanded': expanded }"
|
class="outline-node"
|
||||||
@click="toggle"
|
:class="{
|
||||||
>
|
'outline-node--expanded': expanded,
|
||||||
<!-- 拖拽手柄 -->
|
'outline-node--has-children': hasChildren,
|
||||||
<div class="node-drag-handle" @click.stop>
|
'outline-node--children-open': childrenExpanded,
|
||||||
<svg width="12" height="16" viewBox="0 0 12 16" fill="none">
|
}"
|
||||||
<circle cx="3" cy="2" r="1.5" fill="currentColor" />
|
@click="toggle"
|
||||||
<circle cx="9" cy="2" r="1.5" fill="currentColor" />
|
>
|
||||||
<circle cx="3" cy="8" r="1.5" fill="currentColor" />
|
<!-- 拖拽手柄 -->
|
||||||
<circle cx="9" cy="8" r="1.5" fill="currentColor" />
|
<div class="node-drag-handle" @click.stop>
|
||||||
<circle cx="3" cy="14" r="1.5" fill="currentColor" />
|
<svg width="12" height="16" viewBox="0 0 12 16" fill="none">
|
||||||
<circle cx="9" cy="14" r="1.5" fill="currentColor" />
|
<circle cx="3" cy="2" r="1.5" fill="currentColor" />
|
||||||
</svg>
|
<circle cx="9" cy="2" r="1.5" fill="currentColor" />
|
||||||
</div>
|
<circle cx="3" cy="8" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="9" cy="8" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="3" cy="14" r="1.5" fill="currentColor" />
|
||||||
|
<circle cx="9" cy="14" r="1.5" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 概要 -->
|
<!-- 子节点数量角标 -->
|
||||||
<h3 class="node-summary">{{ outline.summary }}</h3>
|
<div v-if="hasChildren" class="child-badge" @click="toggleChildren">
|
||||||
|
<span class="child-badge-count">{{ childCount }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 详情(可展开) -->
|
<!-- 概要 -->
|
||||||
<div class="node-detail-wrapper" :class="{ 'node-detail-wrapper--open': expanded }">
|
<div class="node-summary-row">
|
||||||
<div class="node-detail-inner">
|
<h3 class="node-summary">{{ outline.summary }}</h3>
|
||||||
<p v-if="outline.detail" class="node-detail">{{ outline.detail }}</p>
|
<!-- 子节点展开/折叠切换 -->
|
||||||
<p v-else class="node-detail node-detail--empty">暂无详情,点击编辑添加</p>
|
<button
|
||||||
|
v-if="hasChildren"
|
||||||
|
class="children-toggle"
|
||||||
|
:class="{ 'children-toggle--open': childrenExpanded }"
|
||||||
|
:title="childrenExpanded ? '折叠子节点' : '展开子节点'"
|
||||||
|
@click="toggleChildren"
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path d="M4.5 3l5 4-5 4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span class="children-toggle-label">{{ childCount }} 个子节点</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 操作按钮 -->
|
<!-- 详情(可展开) -->
|
||||||
<div class="node-actions" @click.stop>
|
<div class="node-detail-wrapper" :class="{ 'node-detail-wrapper--open': expanded }">
|
||||||
<button class="action-btn action-btn--edit" @click="emit('edit')">
|
<div class="node-detail-inner">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
<p v-if="outline.detail" class="node-detail">{{ outline.detail }}</p>
|
||||||
<path d="M10.5 1.5l2 2-8 8H2.5v-2l8-8z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
<p v-else class="node-detail node-detail--empty">暂无详情,点击编辑添加</p>
|
||||||
</svg>
|
|
||||||
编辑
|
<!-- 操作按钮 -->
|
||||||
</button>
|
<div class="node-actions" @click.stop>
|
||||||
<button class="action-btn action-btn--delete" @click="emit('delete')">
|
<button class="action-btn action-btn--edit" @click="emit('edit')">
|
||||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
<path d="M2 4h10M5 4V2.5h4V4M3.5 4v8h7V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
<path d="M10.5 1.5l2 2-8 8H2.5v-2l8-8z" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
</svg>
|
</svg>
|
||||||
删除
|
编辑
|
||||||
</button>
|
</button>
|
||||||
|
<button class="action-btn action-btn--child" @click="emit('addChild')">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path d="M7 3v8M3 7h8" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
子节点
|
||||||
|
</button>
|
||||||
|
<button class="action-btn action-btn--delete" @click="emit('delete')">
|
||||||
|
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||||
|
<path d="M2 4h10M5 4V2.5h4V4M3.5 4v8h7V4" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- 展开提示 -->
|
||||||
|
<div class="node-expand-hint">
|
||||||
|
<svg
|
||||||
|
class="expand-chevron"
|
||||||
|
:class="{ 'expand-chevron--open': expanded }"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
viewBox="0 0 12 12"
|
||||||
|
fill="none"
|
||||||
|
>
|
||||||
|
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 展开提示 -->
|
<!-- 子节点展开引导三角 -->
|
||||||
<div class="node-expand-hint">
|
<div v-if="childrenExpanded" class="children-arrow" />
|
||||||
<svg
|
|
||||||
class="expand-chevron"
|
<!-- 子节点区域 -->
|
||||||
:class="{ 'expand-chevron--open': expanded }"
|
<OutlineChildList
|
||||||
width="12"
|
v-if="hasChildren || childrenExpanded"
|
||||||
height="12"
|
:children="outline.children || []"
|
||||||
viewBox="0 0 12 12"
|
:expanded="childrenExpanded"
|
||||||
fill="none"
|
@add-child="emit('addChild')"
|
||||||
>
|
@edit-child="(child) => emit('editChild', child)"
|
||||||
<path d="M3 4.5l3 3 3-3" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" stroke-linejoin="round" />
|
@delete-child="(child) => emit('deleteChild', child)"
|
||||||
</svg>
|
@reorder-children="(children) => emit('reorderChildren', children)"
|
||||||
</div>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.outline-node-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.outline-node {
|
.outline-node {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--paper-50);
|
background: var(--paper-50);
|
||||||
@@ -107,6 +177,33 @@ function toggle() {
|
|||||||
border-left-width: 3px;
|
border-left-width: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.outline-node--children-open {
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 子节点展开引导三角 */
|
||||||
|
.children-arrow {
|
||||||
|
width: 12px;
|
||||||
|
height: 8px;
|
||||||
|
margin: 0 auto;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children-arrow::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 6px solid transparent;
|
||||||
|
border-right: 6px solid transparent;
|
||||||
|
border-top: 8px solid var(--bamboo);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
/* 拖拽手柄 */
|
/* 拖拽手柄 */
|
||||||
.node-drag-handle {
|
.node-drag-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -127,6 +224,44 @@ function toggle() {
|
|||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 子节点数量角标 */
|
||||||
|
.child-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: -6px;
|
||||||
|
right: var(--space-xl);
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bamboo);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform var(--duration-fast) var(--ease-out-back);
|
||||||
|
z-index: 4;
|
||||||
|
box-shadow: 0 1px 4px rgba(91, 140, 90, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-badge:hover {
|
||||||
|
transform: scale(1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.child-badge-count {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 概要行 */
|
||||||
|
.node-summary-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 概要标题 */
|
/* 概要标题 */
|
||||||
.node-summary {
|
.node-summary {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-display);
|
||||||
@@ -137,6 +272,41 @@ function toggle() {
|
|||||||
padding-right: var(--space-lg);
|
padding-right: var(--space-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 子节点展开/折叠切换 */
|
||||||
|
.children-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
background: rgba(91, 140, 90, 0.06);
|
||||||
|
border: 1px solid rgba(91, 140, 90, 0.2);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: var(--bamboo);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--duration-fast) ease;
|
||||||
|
width: fit-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children-toggle:hover {
|
||||||
|
background: rgba(91, 140, 90, 0.12);
|
||||||
|
border-color: var(--bamboo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.children-toggle svg {
|
||||||
|
transition: transform var(--duration-normal) var(--ease-out-back);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.children-toggle--open svg {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.children-toggle-label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
/* 详情展开区 */
|
/* 详情展开区 */
|
||||||
.node-detail-wrapper {
|
.node-detail-wrapper {
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -199,6 +369,15 @@ function toggle() {
|
|||||||
border-color: var(--bamboo);
|
border-color: var(--bamboo);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-btn--child {
|
||||||
|
color: var(--bamboo);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--child:hover {
|
||||||
|
background: rgba(91, 140, 90, 0.08);
|
||||||
|
border-color: var(--bamboo);
|
||||||
|
}
|
||||||
|
|
||||||
.action-btn--delete {
|
.action-btn--delete {
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ const emit = defineEmits<{
|
|||||||
delete: [outline: Outline]
|
delete: [outline: Outline]
|
||||||
reorder: [outlines: Outline[]]
|
reorder: [outlines: Outline[]]
|
||||||
add: []
|
add: []
|
||||||
|
addChild: [parent: Outline]
|
||||||
|
editChild: [child: Outline]
|
||||||
|
deleteChild: [child: Outline]
|
||||||
|
reorderChildren: [parentId: number, children: Outline[]]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// 拖拽排序
|
// 拖拽排序
|
||||||
@@ -69,13 +73,17 @@ function onDragEnd() {
|
|||||||
<!-- 连接横线 -->
|
<!-- 连接横线 -->
|
||||||
<div class="timeline-bridge" />
|
<div class="timeline-bridge" />
|
||||||
|
|
||||||
<!-- 节点卡片 -->
|
<!-- 节点卡片(含子节点) -->
|
||||||
<div class="timeline-card">
|
<div class="timeline-card">
|
||||||
<OutlineNode
|
<OutlineNode
|
||||||
:outline="outline"
|
:outline="outline"
|
||||||
:index="index"
|
:index="index"
|
||||||
@edit="emit('edit', outline)"
|
@edit="emit('edit', outline)"
|
||||||
@delete="emit('delete', outline)"
|
@delete="emit('delete', outline)"
|
||||||
|
@add-child="emit('addChild', outline)"
|
||||||
|
@edit-child="(child) => emit('editChild', child)"
|
||||||
|
@delete-child="(child) => emit('deleteChild', child)"
|
||||||
|
@reorder-children="(children) => emit('reorderChildren', outline.id, children)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,30 +1,215 @@
|
|||||||
import { ref } from 'vue'
|
import { ref, computed, watch } from 'vue'
|
||||||
import { streamChat, fetchMessages, clearMessages as apiClearMessages } from '@/api/chat'
|
import { useRoute } from 'vue-router'
|
||||||
import type { ChatMessage } from '@/types/chat'
|
import { streamChat, fetchMessages, clearMessages as apiClearMessages, compressContext } from '@/api/chat'
|
||||||
import type { TokenUsage } from '@/api/chat'
|
import type { ChatMessage, ChatEntry, ToolCallGroup, SubAgentEntry } from '@/types/chat'
|
||||||
|
import type { TokenUsage, PendingToolCallData, SubAgentStartInfo, SubAgentToolCallInfo, SubAgentToolResultInfo } from '@/api/chat'
|
||||||
|
|
||||||
// 全局状态(跨组件共享)
|
// 中文标签 → 英文工具名(用于解析历史消息中的工具调用日志)
|
||||||
const messages = ref<ChatMessage[]>([])
|
const LABEL_TO_TOOL_NAME: Record<string, string> = {
|
||||||
const isStreaming = ref(false)
|
'查看小说信息': 'get_novel_info',
|
||||||
const streamingContent = ref('')
|
'更新小说信息': 'update_novel_info',
|
||||||
const lastUsage = ref<TokenUsage | null>(null)
|
'查询大纲': 'list_outlines',
|
||||||
let abortController: AbortController | null = null
|
'查看大纲详情': 'get_outline_detail',
|
||||||
let idCounter = Date.now()
|
'创建大纲': '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',
|
||||||
|
'派遣子Agent': 'dispatch_subagent',
|
||||||
|
}
|
||||||
|
|
||||||
export function useChatAgent() {
|
/**
|
||||||
async function loadHistory(novelId?: number) {
|
* 解析历史 assistant 消息中的工具调用日志,拆分为 ToolCallGroup / SubAgentEntry + 纯文本消息
|
||||||
try {
|
* 工具格式:[工具调用: 标签] 参数: {...} → 结果: {...}
|
||||||
const list = await fetchMessages(novelId)
|
* 子Agent格式:[子Agent: 技能名] 任务: ... → 结果: ...
|
||||||
messages.value = list.items
|
*/
|
||||||
} catch {
|
function parseHistoryMessage(msg: ChatMessage): ChatEntry[] {
|
||||||
// 静默失败
|
const content = msg.content
|
||||||
|
if (!content.startsWith('[以下操作已执行]')) {
|
||||||
|
return [msg]
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultEntries: ChatEntry[] = []
|
||||||
|
// 用双换行分隔工具调用块和后续回复
|
||||||
|
const firstBlankLine = content.indexOf('\n\n')
|
||||||
|
const toolSection = firstBlankLine >= 0 ? content.substring(0, firstBlankLine) : content
|
||||||
|
const replyText = firstBlankLine >= 0 ? content.substring(firstBlankLine + 2).trim() : ''
|
||||||
|
|
||||||
|
// 解析每一行
|
||||||
|
const toolLines = toolSection.split('\n').slice(1) // 跳过 [以下操作已执行]
|
||||||
|
const toolCalls: ToolCallGroup['calls'] = []
|
||||||
|
|
||||||
|
for (const line of toolLines) {
|
||||||
|
// 尝试匹配子 Agent 格式
|
||||||
|
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
|
||||||
|
let args: Record<string, unknown> = {}
|
||||||
|
try { args = JSON.parse(toolMatch[2]) } catch { /* ok */ }
|
||||||
|
const resultStr = toolMatch[3]
|
||||||
|
let parsedResult: unknown = undefined
|
||||||
|
try { parsedResult = JSON.parse(resultStr) } catch { /* ok */ }
|
||||||
|
toolCalls.push({
|
||||||
|
id: `hist-${idCounter++}`,
|
||||||
|
name,
|
||||||
|
label,
|
||||||
|
arguments: args,
|
||||||
|
result: resultStr,
|
||||||
|
parsedResult,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendMessage(content: string, novelId?: number) {
|
// 推入剩余的普通工具调用
|
||||||
if (isStreaming.value || !content.trim()) return
|
if (toolCalls.length > 0) {
|
||||||
|
resultEntries.push({
|
||||||
|
id: idCounter++,
|
||||||
|
type: 'tool_calls',
|
||||||
|
calls: toolCalls,
|
||||||
|
status: 'done',
|
||||||
|
assistantText: '',
|
||||||
|
} as ToolCallGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replyText) {
|
||||||
|
resultEntries.push({
|
||||||
|
...msg,
|
||||||
|
content: replyText,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return resultEntries
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局状态(跨组件共享)
|
||||||
|
const entries = ref<ChatEntry[]>([])
|
||||||
|
const isStreaming = ref(false)
|
||||||
|
const streamingContent = ref('')
|
||||||
|
const lastUsage = ref<TokenUsage | null>(null)
|
||||||
|
const toolsEnabled = ref(true)
|
||||||
|
const autoApproveTools = ref(false)
|
||||||
|
const activeSkillId = ref<number | null>(null)
|
||||||
|
const isCompressing = ref(false)
|
||||||
|
let abortController: AbortController | null = null
|
||||||
|
let idCounter = Date.now()
|
||||||
|
|
||||||
|
// 上下文窗口大小(默认 128k,后续可从配置读取)
|
||||||
|
const DEFAULT_CONTEXT_WINDOW = 128000
|
||||||
|
// 自动压缩阈值
|
||||||
|
const AUTO_COMPRESS_THRESHOLD = 0.85
|
||||||
|
// 当前已加载历史的小说 ID(用于检测切换)
|
||||||
|
let loadedNovelId: number | undefined | null = null
|
||||||
|
|
||||||
|
// 路由名称 → 页面上下文描述
|
||||||
|
const PAGE_LABELS: Record<string, string> = {
|
||||||
|
'novel-detail': '小说详情页',
|
||||||
|
'novel-outlines': '大纲管理页',
|
||||||
|
'novel-chapters': '章节管理页',
|
||||||
|
'novel-characters': '角色管理页',
|
||||||
|
'novel-world-setting': '世界观设定页',
|
||||||
|
'novel-edit': '小说编辑页',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useChatAgent() {
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const currentNovelId = computed(() => {
|
||||||
|
const id = route.params.id
|
||||||
|
return id ? Number(id) : undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
const pageContext = computed(() => {
|
||||||
|
const name = route.name as string
|
||||||
|
return PAGE_LABELS[name] ?? undefined
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 仅普通消息(用于发送给 API) */
|
||||||
|
const chatMessages = computed(() =>
|
||||||
|
entries.value.filter((e): e is ChatMessage => !('type' in e))
|
||||||
|
)
|
||||||
|
|
||||||
|
async function loadHistory(novelId?: number) {
|
||||||
|
const targetId = novelId ?? currentNovelId.value
|
||||||
|
try {
|
||||||
|
const list = await fetchMessages(targetId)
|
||||||
|
// 解析 assistant 消息中的工具调用日志,还原为 ToolCallGroup 卡片
|
||||||
|
const parsed: ChatEntry[] = []
|
||||||
|
for (const msg of list.items) {
|
||||||
|
if (msg.role === 'assistant') {
|
||||||
|
parsed.push(...parseHistoryMessage(msg))
|
||||||
|
} else {
|
||||||
|
parsed.push(msg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries.value = parsed
|
||||||
|
loadedNovelId = targetId
|
||||||
|
lastUsage.value = null
|
||||||
|
} catch {
|
||||||
|
// 静默
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听小说切换,自动重新加载对应小说的聊天历史
|
||||||
|
watch(currentNovelId, (newId) => {
|
||||||
|
if (newId !== loadedNovelId) {
|
||||||
|
// 如果正在流式输出,先中断
|
||||||
|
if (isStreaming.value && abortController) {
|
||||||
|
abortController.abort()
|
||||||
|
abortController = null
|
||||||
|
isStreaming.value = false
|
||||||
|
streamingContent.value = ''
|
||||||
|
}
|
||||||
|
loadHistory(newId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function sendMessage(content: string) {
|
||||||
|
if (isStreaming.value || !content.trim()) return
|
||||||
|
const novelId = currentNovelId.value
|
||||||
|
|
||||||
// 添加用户消息到列表
|
|
||||||
const userMsg: ChatMessage = {
|
const userMsg: ChatMessage = {
|
||||||
id: idCounter++,
|
id: idCounter++,
|
||||||
novel_id: novelId ?? null,
|
novel_id: novelId ?? null,
|
||||||
@@ -32,53 +217,252 @@ export function useChatAgent() {
|
|||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
messages.value.push(userMsg)
|
entries.value.push(userMsg)
|
||||||
|
|
||||||
|
await _doStreamRequest(novelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户批准工具调用 */
|
||||||
|
async function approveToolCalls(group: ToolCallGroup) {
|
||||||
|
group.status = 'executing'
|
||||||
|
const novelId = currentNovelId.value
|
||||||
|
await _doStreamRequest(novelId, group)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 用户跳过工具调用 */
|
||||||
|
function skipToolCalls(group: ToolCallGroup) {
|
||||||
|
group.status = 'skipped'
|
||||||
|
// 把 LLM 在工具调用前的文本作为 assistant 消息保存
|
||||||
|
if (group.assistantText) {
|
||||||
|
entries.value.push({
|
||||||
|
id: idCounter++,
|
||||||
|
novel_id: currentNovelId.value ?? null,
|
||||||
|
role: 'assistant',
|
||||||
|
content: group.assistantText,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
isStreaming.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 核心:发起流式请求 */
|
||||||
|
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()
|
||||||
|
|
||||||
// 构建发送给API的消息列表(取最近20条)
|
// 构建消息列表(取最近 20 条)
|
||||||
const recentMessages = messages.value
|
const recentMessages = chatMessages.value
|
||||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
|
||||||
.slice(-20)
|
.slice(-20)
|
||||||
.map(m => ({ role: m.role, content: m.content }))
|
.map(m => ({ role: m.role, content: m.content }))
|
||||||
|
|
||||||
|
// 审批续传参数
|
||||||
|
const pendingToolCalls = approvedGroup
|
||||||
|
? approvedGroup.calls.map(c => ({ id: c.id, name: c.name, label: c.label, arguments: c.arguments }))
|
||||||
|
: undefined
|
||||||
|
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,
|
||||||
|
toolsEnabled: toolsEnabled.value,
|
||||||
|
skillId: activeSkillId.value,
|
||||||
|
autoApproveTools: autoApproveTools.value,
|
||||||
|
pendingToolCalls,
|
||||||
|
assistantText,
|
||||||
signal: abortController.signal,
|
signal: abortController.signal,
|
||||||
|
|
||||||
onChunk(text) {
|
onChunk(text) {
|
||||||
streamingContent.value += text
|
streamingContent.value += text
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onToolCallsPending(calls: PendingToolCallData[], aText: string) {
|
||||||
|
// 先把当前 streaming 文本保存(如果有)
|
||||||
|
// LLM 在调用工具前可能输出了一些文本
|
||||||
|
if (streamingContent.value) {
|
||||||
|
// 不作为独立消息,因为它会在续传后被合并
|
||||||
|
// 这段文本已包含在 aText 中
|
||||||
|
}
|
||||||
|
streamingContent.value = ''
|
||||||
|
|
||||||
|
// 创建工具调用组
|
||||||
|
const group: ToolCallGroup = {
|
||||||
|
id: idCounter++,
|
||||||
|
type: 'tool_calls',
|
||||||
|
calls: calls.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
label: c.label,
|
||||||
|
arguments: c.arguments,
|
||||||
|
})),
|
||||||
|
status: 'pending',
|
||||||
|
assistantText: aText,
|
||||||
|
}
|
||||||
|
entries.value.push(group)
|
||||||
|
},
|
||||||
|
|
||||||
|
onToolCallsAuto(calls: PendingToolCallData[]) {
|
||||||
|
// 自动执行模式:直接创建工具调用组,状态为 executing
|
||||||
|
streamingContent.value = ''
|
||||||
|
const group: ToolCallGroup = {
|
||||||
|
id: idCounter++,
|
||||||
|
type: 'tool_calls',
|
||||||
|
calls: calls.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
label: c.label,
|
||||||
|
arguments: c.arguments,
|
||||||
|
})),
|
||||||
|
status: 'executing',
|
||||||
|
assistantText: '',
|
||||||
|
}
|
||||||
|
entries.value.push(group)
|
||||||
|
},
|
||||||
|
|
||||||
|
onToolResult(info) {
|
||||||
|
// 找到正在执行的工具组,更新对应工具的结果
|
||||||
|
const group = [...entries.value].reverse().find(
|
||||||
|
(e): e is ToolCallGroup => 'type' in e && e.type === 'tool_calls' && (e.status === 'executing' || e.status === 'done')
|
||||||
|
)
|
||||||
|
if (group) {
|
||||||
|
// 匹配第一个同名且还没有 result 的 call(处理多个相同工具调用的情况)
|
||||||
|
const call = group.calls.find(c => c.name === info.name && !c.result)
|
||||||
|
if (call) {
|
||||||
|
call.result = info.result
|
||||||
|
try { call.parsedResult = JSON.parse(info.result) } catch { /* ok */ }
|
||||||
|
}
|
||||||
|
// 所有工具都有结果 → 标记完成
|
||||||
|
if (group.calls.every(c => c.result)) {
|
||||||
|
group.status = 'done'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 通知页面组件刷新数据
|
||||||
|
window.dispatchEvent(new CustomEvent('tool-executed', {
|
||||||
|
detail: { name: info.name },
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
|
||||||
|
// ── 子 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 errMsg: ChatMessage = {
|
// 检测上下文溢出错误 — 自动压缩并续接
|
||||||
|
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({
|
||||||
id: idCounter++,
|
id: idCounter++,
|
||||||
novel_id: novelId ?? null,
|
novel_id: novelId ?? null,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: `⚠️ ${error}`,
|
content: `⚠️ ${error}`,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
}
|
})
|
||||||
messages.value.push(errMsg)
|
|
||||||
},
|
},
|
||||||
onDone(usage) {
|
|
||||||
|
onDone(usage, pending) {
|
||||||
if (usage) {
|
if (usage) {
|
||||||
lastUsage.value = usage
|
lastUsage.value = usage
|
||||||
}
|
}
|
||||||
|
if (pending) {
|
||||||
|
isStreaming.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 正常完成
|
||||||
if (streamingContent.value) {
|
if (streamingContent.value) {
|
||||||
const aiMsg: ChatMessage = {
|
entries.value.push({
|
||||||
id: idCounter++,
|
id: idCounter++,
|
||||||
novel_id: novelId ?? null,
|
novel_id: novelId ?? null,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: streamingContent.value,
|
content: streamingContent.value,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
}
|
})
|
||||||
messages.value.push(aiMsg)
|
|
||||||
}
|
}
|
||||||
streamingContent.value = ''
|
streamingContent.value = ''
|
||||||
isStreaming.value = false
|
isStreaming.value = false
|
||||||
abortController = null
|
abortController = null
|
||||||
|
// 注:自动压缩已移至 _doStreamRequest 预检阶段(发送前检查)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -90,25 +474,61 @@ export function useChatAgent() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function clearChat(novelId?: number) {
|
async function clearChat() {
|
||||||
try {
|
try {
|
||||||
await apiClearMessages(novelId)
|
await apiClearMessages(currentNovelId.value)
|
||||||
} catch {
|
} catch {
|
||||||
// 静默
|
// 静默
|
||||||
}
|
}
|
||||||
messages.value = []
|
entries.value = []
|
||||||
streamingContent.value = ''
|
streamingContent.value = ''
|
||||||
lastUsage.value = null
|
lastUsage.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 压缩上下文:总结旧消息,保留最近两轮对话 */
|
||||||
|
async function compressChat(): Promise<{ success: boolean; message: string }> {
|
||||||
|
if (isCompressing.value || isStreaming.value) {
|
||||||
|
return { success: false, message: '请等待当前操作完成' }
|
||||||
|
}
|
||||||
|
isCompressing.value = true
|
||||||
|
try {
|
||||||
|
const result = await compressContext(currentNovelId.value)
|
||||||
|
if (result.error) {
|
||||||
|
return { success: false, message: result.error }
|
||||||
|
}
|
||||||
|
if (!result.compressed) {
|
||||||
|
return { success: false, message: result.reason || '无需压缩' }
|
||||||
|
}
|
||||||
|
// 压缩成功,重新加载历史
|
||||||
|
await loadHistory()
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: `已压缩 ${result.removed_count} 条消息为摘要`,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return { success: false, message: '压缩请求失败' }
|
||||||
|
} finally {
|
||||||
|
isCompressing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messages,
|
entries,
|
||||||
isStreaming,
|
isStreaming,
|
||||||
|
isCompressing,
|
||||||
streamingContent,
|
streamingContent,
|
||||||
lastUsage,
|
lastUsage,
|
||||||
|
toolsEnabled,
|
||||||
|
autoApproveTools,
|
||||||
|
activeSkillId,
|
||||||
|
currentNovelId,
|
||||||
|
pageContext,
|
||||||
loadHistory,
|
loadHistory,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
|
approveToolCalls,
|
||||||
|
skipToolCalls,
|
||||||
stopStreaming,
|
stopStreaming,
|
||||||
clearChat,
|
clearChat,
|
||||||
|
compressChat,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
23
frontend/src/composables/useToolRefresh.ts
Normal file
23
frontend/src/composables/useToolRefresh.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 监听 AI 工具执行事件,当匹配的工具完成时自动刷新数据
|
||||||
|
* @param toolNames 需要监听的工具名列表
|
||||||
|
* @param refresh 刷新回调
|
||||||
|
*/
|
||||||
|
export function useToolRefresh(toolNames: string[], refresh: () => void) {
|
||||||
|
function handler(e: Event) {
|
||||||
|
const name = (e as CustomEvent).detail?.name
|
||||||
|
if (toolNames.includes(name)) {
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('tool-executed', handler)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('tool-executed', handler)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -24,9 +24,36 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/NovelFormView.vue'),
|
component: () => import('@/views/NovelFormView.vue'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/novels/:id/outlines',
|
path: '/skills',
|
||||||
name: 'novel-outlines',
|
name: 'skills',
|
||||||
component: () => import('@/views/OutlineView.vue'),
|
component: () => import('@/views/SkillEditorView.vue'),
|
||||||
|
},
|
||||||
|
// 工作区:大纲/章节/角色/世界观共享父布局(导航栏持久化)
|
||||||
|
{
|
||||||
|
path: '/novels/:id',
|
||||||
|
component: () => import('@/views/NovelWorkspace.vue'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'outlines',
|
||||||
|
name: 'novel-outlines',
|
||||||
|
component: () => import('@/views/OutlineView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'chapters',
|
||||||
|
name: 'novel-chapters',
|
||||||
|
component: () => import('@/views/ChapterView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'characters',
|
||||||
|
name: 'novel-characters',
|
||||||
|
component: () => import('@/views/CharacterView.vue'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'world-setting',
|
||||||
|
name: 'novel-world-setting',
|
||||||
|
component: () => import('@/views/WorldSettingView.vue'),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
29
frontend/src/types/chapter.ts
Normal file
29
frontend/src/types/chapter.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
export interface Chapter {
|
||||||
|
id: number
|
||||||
|
novel_id: number
|
||||||
|
sort_order: number
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChapterCreate {
|
||||||
|
title: string
|
||||||
|
content?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChapterUpdate {
|
||||||
|
title?: string
|
||||||
|
content?: string
|
||||||
|
sort_order?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChapterList {
|
||||||
|
items: Chapter[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChapterReorder {
|
||||||
|
ordered_ids: number[]
|
||||||
|
}
|
||||||
23
frontend/src/types/character.ts
Normal file
23
frontend/src/types/character.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export interface Character {
|
||||||
|
id: number
|
||||||
|
novel_id: number
|
||||||
|
name: string
|
||||||
|
description: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterCreate {
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterUpdate {
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CharacterList {
|
||||||
|
items: Character[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
@@ -6,6 +6,48 @@ export interface ChatMessage {
|
|||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 工具调用组(在对话中显示) */
|
||||||
|
export interface ToolCallGroup {
|
||||||
|
id: number
|
||||||
|
type: 'tool_calls'
|
||||||
|
calls: ToolCallEntry[]
|
||||||
|
status: 'pending' | 'executing' | 'done' | 'skipped'
|
||||||
|
assistantText: string // LLM 在调用工具前输出的文本
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToolCallEntry {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
label: string
|
||||||
|
arguments: Record<string, unknown>
|
||||||
|
result?: string
|
||||||
|
parsedResult?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 子 Agent 执行组(在对话中显示) */
|
||||||
|
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 {
|
||||||
|
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
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
export interface Outline {
|
export interface Outline {
|
||||||
id: number
|
id: number
|
||||||
novel_id: number
|
novel_id: number
|
||||||
|
parent_id: number | null
|
||||||
sort_order: number
|
sort_order: number
|
||||||
summary: string
|
summary: string
|
||||||
detail: string
|
detail: string
|
||||||
|
children: Outline[]
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
@@ -11,6 +13,7 @@ export interface Outline {
|
|||||||
export interface OutlineCreate {
|
export interface OutlineCreate {
|
||||||
summary: string
|
summary: string
|
||||||
detail?: string
|
detail?: string
|
||||||
|
parent_id?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OutlineUpdate {
|
export interface OutlineUpdate {
|
||||||
|
|||||||
11
frontend/src/types/worldSetting.ts
Normal file
11
frontend/src/types/worldSetting.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export interface WorldSetting {
|
||||||
|
id: number
|
||||||
|
novel_id: number
|
||||||
|
content: string
|
||||||
|
created_at: string
|
||||||
|
updated_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WorldSettingUpdate {
|
||||||
|
content: string
|
||||||
|
}
|
||||||
312
frontend/src/views/ChapterView.vue
Normal file
312
frontend/src/views/ChapterView.vue
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, inject, onMounted, computed, watchEffect } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { fetchChapters, createChapter, updateChapter, deleteChapter, reorderChapters } from '@/api/chapters'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { useToolRefresh } from '@/composables/useToolRefresh'
|
||||||
|
import type { Novel } from '@/types/novel'
|
||||||
|
import type { Chapter } from '@/types/chapter'
|
||||||
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
|
import ChapterList from '@/components/chapter/ChapterList.vue'
|
||||||
|
import ChapterEditor from '@/components/chapter/ChapterEditor.vue'
|
||||||
|
import ChapterEmptyState from '@/components/chapter/ChapterEmptyState.vue'
|
||||||
|
import ChapterFormModal from '@/components/chapter/ChapterFormModal.vue'
|
||||||
|
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const novelId = inject<number>('workspace-novel-id', Number(route.params.id))
|
||||||
|
const novel = inject<import('vue').Ref<Novel | null>>('workspace-novel', ref(null))
|
||||||
|
const subtitle = inject<import('vue').Ref<string>>('workspace-subtitle', ref(''))
|
||||||
|
const chapters = ref<Chapter[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
// 当前选中的章节(用于编辑)
|
||||||
|
const activeChapter = ref<Chapter | null>(null)
|
||||||
|
|
||||||
|
// 表单弹窗(新建/重命名)
|
||||||
|
const showForm = ref(false)
|
||||||
|
const editingChapter = ref<Chapter | null>(null)
|
||||||
|
const formSaving = ref(false)
|
||||||
|
|
||||||
|
// 编辑器保存状态
|
||||||
|
const editorSaving = ref(false)
|
||||||
|
|
||||||
|
// 删除确认
|
||||||
|
const showDeleteConfirm = ref(false)
|
||||||
|
const deletingChapter = ref<Chapter | null>(null)
|
||||||
|
const deleting = ref(false)
|
||||||
|
|
||||||
|
const totalChapters = computed(() => chapters.value.length)
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
subtitle.value = `共 ${totalChapters.value} 章`
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const list = await fetchChapters(novelId)
|
||||||
|
chapters.value = list.items
|
||||||
|
// 默认展开最后一章
|
||||||
|
if (chapters.value.length > 0 && !activeChapter.value) {
|
||||||
|
activeChapter.value = chapters.value[chapters.value.length - 1]
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('加载失败')
|
||||||
|
router.push({ name: 'home' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 工具执行后自动刷新章节数据
|
||||||
|
useToolRefresh(['list_chapters', 'create_chapter', 'update_chapter', 'delete_chapter'], load)
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingChapter.value = null
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openRename(chapter: Chapter) {
|
||||||
|
editingChapter.value = chapter
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectChapter(chapter: Chapter) {
|
||||||
|
activeChapter.value = chapter
|
||||||
|
}
|
||||||
|
|
||||||
|
function backToList() {
|
||||||
|
activeChapter.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFormSubmit(data: { title: string }) {
|
||||||
|
formSaving.value = true
|
||||||
|
try {
|
||||||
|
if (editingChapter.value) {
|
||||||
|
const updated = await updateChapter(novelId, editingChapter.value.id, { title: data.title })
|
||||||
|
const idx = chapters.value.findIndex(c => c.id === updated.id)
|
||||||
|
if (idx !== -1) chapters.value[idx] = updated
|
||||||
|
if (activeChapter.value?.id === updated.id) activeChapter.value = updated
|
||||||
|
toast.success('章节已重命名')
|
||||||
|
} else {
|
||||||
|
const created = await createChapter(novelId, { title: data.title })
|
||||||
|
chapters.value.push(created)
|
||||||
|
activeChapter.value = created
|
||||||
|
toast.success('章节已创建')
|
||||||
|
}
|
||||||
|
showForm.value = false
|
||||||
|
} catch {
|
||||||
|
toast.error('保存失败')
|
||||||
|
} finally {
|
||||||
|
formSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEditorSave(data: { title: string; content: string }) {
|
||||||
|
if (!activeChapter.value) return
|
||||||
|
editorSaving.value = true
|
||||||
|
try {
|
||||||
|
const updated = await updateChapter(novelId, activeChapter.value.id, {
|
||||||
|
title: data.title,
|
||||||
|
content: data.content,
|
||||||
|
})
|
||||||
|
const idx = chapters.value.findIndex(c => c.id === updated.id)
|
||||||
|
if (idx !== -1) chapters.value[idx] = updated
|
||||||
|
activeChapter.value = updated
|
||||||
|
toast.success('章节已保存')
|
||||||
|
} catch {
|
||||||
|
toast.error('保存失败')
|
||||||
|
} finally {
|
||||||
|
editorSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(chapter: Chapter) {
|
||||||
|
deletingChapter.value = chapter
|
||||||
|
showDeleteConfirm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deletingChapter.value) return
|
||||||
|
deleting.value = true
|
||||||
|
try {
|
||||||
|
await deleteChapter(novelId, deletingChapter.value.id)
|
||||||
|
if (activeChapter.value?.id === deletingChapter.value.id) {
|
||||||
|
activeChapter.value = null
|
||||||
|
}
|
||||||
|
chapters.value = chapters.value.filter(c => c.id !== deletingChapter.value!.id)
|
||||||
|
toast.success('章节已删除')
|
||||||
|
showDeleteConfirm.value = false
|
||||||
|
} catch {
|
||||||
|
toast.error('删除失败')
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReorder(reordered: Chapter[]) {
|
||||||
|
chapters.value = reordered
|
||||||
|
try {
|
||||||
|
const result = await reorderChapters(novelId, reordered.map(c => c.id))
|
||||||
|
chapters.value = result.items
|
||||||
|
} catch {
|
||||||
|
toast.error('排序保存失败')
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="!loading" class="chapter-view">
|
||||||
|
<Teleport to="#workspace-actions">
|
||||||
|
<BaseButton v-if="totalChapters > 0" variant="primary" @click="openCreate">
|
||||||
|
新建章节
|
||||||
|
</BaseButton>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<template v-if="totalChapters > 0">
|
||||||
|
<!-- 双栏布局 -->
|
||||||
|
<div class="chapter-layout">
|
||||||
|
<aside class="chapter-sidebar" :class="{ 'chapter-sidebar--hidden': activeChapter }">
|
||||||
|
<ChapterList
|
||||||
|
:chapters="chapters"
|
||||||
|
:active-id="activeChapter?.id ?? null"
|
||||||
|
@select="selectChapter"
|
||||||
|
@edit="openRename"
|
||||||
|
@delete="confirmDelete"
|
||||||
|
@reorder="handleReorder"
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main v-if="activeChapter" class="chapter-main">
|
||||||
|
<ChapterEditor
|
||||||
|
:chapter="activeChapter"
|
||||||
|
:saving="editorSaving"
|
||||||
|
@save="handleEditorSave"
|
||||||
|
@back="backToList"
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div v-else class="chapter-placeholder">
|
||||||
|
<p>选择一个章节开始编辑</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<ChapterEmptyState v-else @create="openCreate" />
|
||||||
|
|
||||||
|
<ChapterFormModal
|
||||||
|
:show="showForm"
|
||||||
|
:chapter="editingChapter"
|
||||||
|
:saving="formSaving"
|
||||||
|
@submit="handleFormSubmit"
|
||||||
|
@close="showForm = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:show="showDeleteConfirm"
|
||||||
|
title="确认删除"
|
||||||
|
:message="`确定要删除「${deletingChapter?.title}」吗?章节内容将无法恢复。`"
|
||||||
|
confirm-text="删除"
|
||||||
|
:loading="deleting"
|
||||||
|
@confirm="handleDelete"
|
||||||
|
@cancel="showDeleteConfirm = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="loading" class="chapter-loading">
|
||||||
|
<div class="loading-pulse" style="height: 2rem; width: 40%; margin: 0 auto var(--space-lg)" />
|
||||||
|
<div class="loading-pulse" style="height: 1rem; width: 25%; margin: 0 auto var(--space-2xl)" />
|
||||||
|
<div class="loading-layout">
|
||||||
|
<div class="loading-pulse" style="height: 300px; width: 280px" />
|
||||||
|
<div class="loading-pulse" style="height: 300px; flex: 1" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chapter-view {
|
||||||
|
/* max-width 由父 NovelWorkspace 控制 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 双栏布局 */
|
||||||
|
.chapter-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
min-height: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-sidebar {
|
||||||
|
width: 300px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-main {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
background: var(--paper-50);
|
||||||
|
border: 1px solid var(--paper-200);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-placeholder {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--paper-50);
|
||||||
|
border: 1px dashed var(--paper-300);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
color: var(--ink-300);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.chapter-loading {
|
||||||
|
max-width: 1100px;
|
||||||
|
margin: var(--space-3xl) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-layout {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-pulse {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--paper-100) 25%,
|
||||||
|
var(--paper-200) 50%,
|
||||||
|
var(--paper-100) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
animation: shimmer 1.5s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 小屏适配:隐藏侧边栏,编辑器全屏 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.chapter-layout {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-sidebar {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-sidebar--hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chapter-placeholder {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
183
frontend/src/views/CharacterView.vue
Normal file
183
frontend/src/views/CharacterView.vue
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, inject, onMounted, computed, watchEffect } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { fetchCharacters, createCharacter, updateCharacter, deleteCharacter } from '@/api/characters'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { useToolRefresh } from '@/composables/useToolRefresh'
|
||||||
|
import type { Novel } from '@/types/novel'
|
||||||
|
import type { Character, CharacterCreate, CharacterUpdate } from '@/types/character'
|
||||||
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
|
import CharacterGrid from '@/components/character/CharacterGrid.vue'
|
||||||
|
import CharacterEmptyState from '@/components/character/CharacterEmptyState.vue'
|
||||||
|
import CharacterFormModal from '@/components/character/CharacterFormModal.vue'
|
||||||
|
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const novelId = inject<number>('workspace-novel-id', Number(route.params.id))
|
||||||
|
const novel = inject<import('vue').Ref<Novel | null>>('workspace-novel', ref(null))
|
||||||
|
const subtitle = inject<import('vue').Ref<string>>('workspace-subtitle', ref(''))
|
||||||
|
const characters = ref<Character[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
// 表单弹窗
|
||||||
|
const showForm = ref(false)
|
||||||
|
const editingCharacter = ref<Character | null>(null)
|
||||||
|
const formSaving = ref(false)
|
||||||
|
|
||||||
|
// 删除确认
|
||||||
|
const showDeleteConfirm = ref(false)
|
||||||
|
const deletingCharacter = ref<Character | null>(null)
|
||||||
|
const deleting = ref(false)
|
||||||
|
|
||||||
|
const totalCharacters = computed(() => characters.value.length)
|
||||||
|
|
||||||
|
watchEffect(() => {
|
||||||
|
subtitle.value = `共 ${totalCharacters.value} 个角色`
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const list = await fetchCharacters(novelId)
|
||||||
|
characters.value = list.items
|
||||||
|
} catch {
|
||||||
|
toast.error('加载失败')
|
||||||
|
router.push({ name: 'home' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AI 工具执行后自动刷新角色数据
|
||||||
|
useToolRefresh(['list_characters', 'create_character', 'update_character', 'delete_character'], load)
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editingCharacter.value = null
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(character: Character) {
|
||||||
|
editingCharacter.value = character
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFormSubmit(data: CharacterCreate | CharacterUpdate) {
|
||||||
|
formSaving.value = true
|
||||||
|
try {
|
||||||
|
if (editingCharacter.value) {
|
||||||
|
const updated = await updateCharacter(novelId, editingCharacter.value.id, data as CharacterUpdate)
|
||||||
|
const idx = characters.value.findIndex(c => c.id === updated.id)
|
||||||
|
if (idx !== -1) characters.value[idx] = updated
|
||||||
|
toast.success('角色已更新')
|
||||||
|
} else {
|
||||||
|
const created = await createCharacter(novelId, data as CharacterCreate)
|
||||||
|
characters.value.push(created)
|
||||||
|
toast.success('角色已创建')
|
||||||
|
}
|
||||||
|
showForm.value = false
|
||||||
|
} catch {
|
||||||
|
toast.error('保存失败')
|
||||||
|
} finally {
|
||||||
|
formSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDelete(character: Character) {
|
||||||
|
deletingCharacter.value = character
|
||||||
|
showDeleteConfirm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!deletingCharacter.value) return
|
||||||
|
deleting.value = true
|
||||||
|
try {
|
||||||
|
await deleteCharacter(novelId, deletingCharacter.value.id)
|
||||||
|
characters.value = characters.value.filter(c => c.id !== deletingCharacter.value!.id)
|
||||||
|
toast.success('角色已删除')
|
||||||
|
showDeleteConfirm.value = false
|
||||||
|
} catch {
|
||||||
|
toast.error('删除失败')
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="!loading" class="character-view">
|
||||||
|
<Teleport to="#workspace-actions">
|
||||||
|
<BaseButton v-if="totalCharacters > 0" variant="primary" @click="openCreate">
|
||||||
|
添加角色
|
||||||
|
</BaseButton>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<CharacterGrid
|
||||||
|
v-if="totalCharacters > 0"
|
||||||
|
:characters="characters"
|
||||||
|
@edit="openEdit"
|
||||||
|
@delete="confirmDelete"
|
||||||
|
/>
|
||||||
|
<CharacterEmptyState v-else @create="openCreate" />
|
||||||
|
|
||||||
|
<CharacterFormModal
|
||||||
|
:show="showForm"
|
||||||
|
:character="editingCharacter"
|
||||||
|
:saving="formSaving"
|
||||||
|
@submit="handleFormSubmit"
|
||||||
|
@close="showForm = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
:show="showDeleteConfirm"
|
||||||
|
title="确认删除"
|
||||||
|
:message="`确定要删除角色「${deletingCharacter?.name}」吗?`"
|
||||||
|
confirm-text="删除"
|
||||||
|
:loading="deleting"
|
||||||
|
@confirm="handleDelete"
|
||||||
|
@cancel="showDeleteConfirm = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="loading" class="character-loading">
|
||||||
|
<div class="loading-pulse" style="height: 2rem; width: 40%; margin: 0 auto var(--space-lg)" />
|
||||||
|
<div class="loading-pulse" style="height: 1rem; width: 25%; margin: 0 auto var(--space-2xl)" />
|
||||||
|
<div class="loading-grid">
|
||||||
|
<div v-for="i in 4" :key="i" class="loading-pulse" style="height: 120px" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.character-view {
|
||||||
|
/* max-width 由父 NovelWorkspace 控制 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.character-loading {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: var(--space-3xl) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: var(--space-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-pulse {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--paper-100) 25%,
|
||||||
|
var(--paper-200) 50%,
|
||||||
|
var(--paper-100) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
animation: shimmer 1.5s ease infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,6 +3,7 @@ import { ref, onMounted } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { fetchNovel, deleteNovel } from '@/api/novels'
|
import { fetchNovel, deleteNovel } from '@/api/novels'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { useToolRefresh } from '@/composables/useToolRefresh'
|
||||||
import type { Novel } from '@/types/novel'
|
import type { Novel } from '@/types/novel'
|
||||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
|
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
|
||||||
@@ -18,6 +19,9 @@ const deleting = ref(false)
|
|||||||
|
|
||||||
const novelId = Number(route.params.id)
|
const novelId = Number(route.params.id)
|
||||||
|
|
||||||
|
// AI 工具执行后自动刷新小说详情
|
||||||
|
useToolRefresh(['get_novel_info', 'update_novel_info'], load)
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -87,6 +91,15 @@ onMounted(load)
|
|||||||
<RouterLink :to="{ name: 'novel-outlines', params: { id: novel.id } }">
|
<RouterLink :to="{ name: 'novel-outlines', params: { id: novel.id } }">
|
||||||
<BaseButton variant="primary">大纲</BaseButton>
|
<BaseButton variant="primary">大纲</BaseButton>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'novel-chapters', params: { id: novel.id } }">
|
||||||
|
<BaseButton variant="primary">章节</BaseButton>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'novel-characters', params: { id: novel.id } }">
|
||||||
|
<BaseButton variant="primary">角色</BaseButton>
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink :to="{ name: 'novel-world-setting', params: { id: novel.id } }">
|
||||||
|
<BaseButton variant="primary">世界观</BaseButton>
|
||||||
|
</RouterLink>
|
||||||
<RouterLink :to="{ name: 'novel-edit', params: { id: novel.id } }">
|
<RouterLink :to="{ name: 'novel-edit', params: { id: novel.id } }">
|
||||||
<BaseButton variant="secondary">编辑</BaseButton>
|
<BaseButton variant="secondary">编辑</BaseButton>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|||||||
91
frontend/src/views/NovelWorkspace.vue
Normal file
91
frontend/src/views/NovelWorkspace.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, provide } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { fetchNovel } from '@/api/novels'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import type { Novel } from '@/types/novel'
|
||||||
|
import NovelSubNav from '@/components/novel/NovelSubNav.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const novelId = Number(route.params.id)
|
||||||
|
const novel = ref<Novel | null>(null)
|
||||||
|
const loading = ref(true)
|
||||||
|
|
||||||
|
/** 子页面可设置的副标题(如 "共 5 个节点") */
|
||||||
|
const subtitle = ref('')
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
novel.value = await fetchNovel(novelId)
|
||||||
|
} catch {
|
||||||
|
toast.error('作品不存在')
|
||||||
|
router.push({ name: 'home' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提供给子路由
|
||||||
|
provide('workspace-novel', novel)
|
||||||
|
provide('workspace-novel-id', novelId)
|
||||||
|
provide('workspace-subtitle', subtitle)
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="!loading && novel" class="workspace">
|
||||||
|
<NovelSubNav
|
||||||
|
:novel-id="novelId"
|
||||||
|
:novel-title="novel.title"
|
||||||
|
:subtitle="subtitle"
|
||||||
|
>
|
||||||
|
<template #actions>
|
||||||
|
<!-- 子页面通过 Teleport 注入操作按钮 -->
|
||||||
|
<div id="workspace-actions" />
|
||||||
|
</template>
|
||||||
|
</NovelSubNav>
|
||||||
|
|
||||||
|
<div class="workspace-content">
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="loading" class="workspace-loading">
|
||||||
|
<div class="loading-pulse" style="height: 1.5rem; width: 30%; margin-bottom: var(--space-md)" />
|
||||||
|
<div class="loading-pulse" style="height: 1rem; width: 60%; margin-bottom: var(--space-2xl)" />
|
||||||
|
<div class="loading-pulse" style="height: 20rem; width: 100%" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.workspace {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-content {
|
||||||
|
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspace-loading {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: var(--space-3xl) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-pulse {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--paper-100) 25%,
|
||||||
|
var(--paper-200) 50%,
|
||||||
|
var(--paper-100) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
animation: shimmer 1.5s ease infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, inject, onMounted, computed, watchEffect } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { fetchNovel } from '@/api/novels'
|
import { fetchOutlines, createOutline, updateOutline, deleteOutline, reorderOutlines, reorderChildren as apiReorderChildren } from '@/api/outlines'
|
||||||
import { fetchOutlines, createOutline, updateOutline, deleteOutline, reorderOutlines } from '@/api/outlines'
|
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { useToolRefresh } from '@/composables/useToolRefresh'
|
||||||
import type { Novel } from '@/types/novel'
|
import type { Novel } from '@/types/novel'
|
||||||
import type { Outline, OutlineCreate, OutlineUpdate } from '@/types/outline'
|
import type { Outline, OutlineCreate, OutlineUpdate } from '@/types/outline'
|
||||||
import BaseButton from '@/components/ui/BaseButton.vue'
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
@@ -16,31 +16,35 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const novelId = Number(route.params.id)
|
const novelId = inject<number>('workspace-novel-id', Number(route.params.id))
|
||||||
const novel = ref<Novel | null>(null)
|
const novel = inject<import('vue').Ref<Novel | null>>('workspace-novel', ref(null))
|
||||||
|
const subtitle = inject<import('vue').Ref<string>>('workspace-subtitle', ref(''))
|
||||||
const outlines = ref<Outline[]>([])
|
const outlines = ref<Outline[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
|
|
||||||
// 表单弹窗
|
// 表单弹窗
|
||||||
const showForm = ref(false)
|
const showForm = ref(false)
|
||||||
const editingOutline = ref<Outline | null>(null)
|
const editingOutline = ref<Outline | null>(null)
|
||||||
|
const formParentId = ref<number | null>(null)
|
||||||
const formSaving = ref(false)
|
const formSaving = ref(false)
|
||||||
|
|
||||||
// 删除确认
|
// 删除确认
|
||||||
const showDeleteConfirm = ref(false)
|
const showDeleteConfirm = ref(false)
|
||||||
const deletingOutline = ref<Outline | null>(null)
|
const deletingOutline = ref<Outline | null>(null)
|
||||||
|
const deletingIsChild = ref(false)
|
||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
|
|
||||||
const totalOutlines = computed(() => outlines.value.length)
|
const totalOutlines = computed(() => outlines.value.length)
|
||||||
|
|
||||||
|
// 同步副标题到父布局
|
||||||
|
watchEffect(() => {
|
||||||
|
subtitle.value = `共 ${totalOutlines.value} 个节点`
|
||||||
|
})
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [n, list] = await Promise.all([
|
const list = await fetchOutlines(novelId)
|
||||||
fetchNovel(novelId),
|
|
||||||
fetchOutlines(novelId),
|
|
||||||
])
|
|
||||||
novel.value = n
|
|
||||||
outlines.value = list.items
|
outlines.value = list.items
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('加载失败')
|
toast.error('加载失败')
|
||||||
@@ -50,28 +54,54 @@ async function load() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AI 工具执行后自动刷新大纲数据
|
||||||
|
useToolRefresh(['list_outlines', 'create_outline', 'update_outline', 'delete_outline', 'reorder_outlines'], load)
|
||||||
|
|
||||||
|
// ── 顶级节点操作 ──
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
editingOutline.value = null
|
editingOutline.value = null
|
||||||
|
formParentId.value = null
|
||||||
showForm.value = true
|
showForm.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEdit(outline: Outline) {
|
function openEdit(outline: Outline) {
|
||||||
editingOutline.value = outline
|
editingOutline.value = outline
|
||||||
|
formParentId.value = null
|
||||||
showForm.value = true
|
showForm.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 子节点操作 ──
|
||||||
|
|
||||||
|
function openCreateChild(parent: Outline) {
|
||||||
|
editingOutline.value = null
|
||||||
|
formParentId.value = parent.id
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditChild(child: Outline) {
|
||||||
|
editingOutline.value = child
|
||||||
|
formParentId.value = child.parent_id
|
||||||
|
showForm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 统一表单提交 ──
|
||||||
|
|
||||||
async function handleFormSubmit(data: OutlineCreate | OutlineUpdate) {
|
async function handleFormSubmit(data: OutlineCreate | OutlineUpdate) {
|
||||||
formSaving.value = true
|
formSaving.value = true
|
||||||
try {
|
try {
|
||||||
if (editingOutline.value) {
|
if (editingOutline.value) {
|
||||||
|
// 编辑(顶级 or 子节点都一样)
|
||||||
const updated = await updateOutline(novelId, editingOutline.value.id, data as OutlineUpdate)
|
const updated = await updateOutline(novelId, editingOutline.value.id, data as OutlineUpdate)
|
||||||
const idx = outlines.value.findIndex(o => o.id === updated.id)
|
_replaceNode(updated)
|
||||||
if (idx !== -1) outlines.value[idx] = updated
|
toast.success('已更新')
|
||||||
toast.success('大纲已更新')
|
|
||||||
} else {
|
} else {
|
||||||
const created = await createOutline(novelId, data as OutlineCreate)
|
// 新建
|
||||||
outlines.value.push(created)
|
await createOutline(novelId, data as OutlineCreate)
|
||||||
toast.success('大纲已创建')
|
// 重新加载以获取完整嵌套结构
|
||||||
|
const list = await fetchOutlines(novelId)
|
||||||
|
outlines.value = list.items
|
||||||
|
toast.success(formParentId.value ? '子节点已创建' : '大纲已创建')
|
||||||
}
|
}
|
||||||
showForm.value = false
|
showForm.value = false
|
||||||
} catch {
|
} catch {
|
||||||
@@ -81,8 +111,38 @@ async function handleFormSubmit(data: OutlineCreate | OutlineUpdate) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 在 outlines 树中替换更新后的节点 */
|
||||||
|
function _replaceNode(updated: Outline) {
|
||||||
|
// 尝试在顶级查找
|
||||||
|
const topIdx = outlines.value.findIndex(o => o.id === updated.id)
|
||||||
|
if (topIdx !== -1) {
|
||||||
|
// 保留 children
|
||||||
|
updated.children = outlines.value[topIdx].children || []
|
||||||
|
outlines.value[topIdx] = updated
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 在子节点中查找
|
||||||
|
for (const parent of outlines.value) {
|
||||||
|
if (!parent.children) continue
|
||||||
|
const childIdx = parent.children.findIndex(c => c.id === updated.id)
|
||||||
|
if (childIdx !== -1) {
|
||||||
|
parent.children[childIdx] = updated
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 删除 ──
|
||||||
|
|
||||||
function confirmDelete(outline: Outline) {
|
function confirmDelete(outline: Outline) {
|
||||||
deletingOutline.value = outline
|
deletingOutline.value = outline
|
||||||
|
deletingIsChild.value = false
|
||||||
|
showDeleteConfirm.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteChild(child: Outline) {
|
||||||
|
deletingOutline.value = child
|
||||||
|
deletingIsChild.value = true
|
||||||
showDeleteConfirm.value = true
|
showDeleteConfirm.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,8 +151,20 @@ async function handleDelete() {
|
|||||||
deleting.value = true
|
deleting.value = true
|
||||||
try {
|
try {
|
||||||
await deleteOutline(novelId, deletingOutline.value.id)
|
await deleteOutline(novelId, deletingOutline.value.id)
|
||||||
outlines.value = outlines.value.filter(o => o.id !== deletingOutline.value!.id)
|
if (deletingIsChild.value) {
|
||||||
toast.success('大纲已删除')
|
// 从父节点的 children 中移除
|
||||||
|
for (const parent of outlines.value) {
|
||||||
|
if (!parent.children) continue
|
||||||
|
const idx = parent.children.findIndex(c => c.id === deletingOutline.value!.id)
|
||||||
|
if (idx !== -1) {
|
||||||
|
parent.children.splice(idx, 1)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
outlines.value = outlines.value.filter(o => o.id !== deletingOutline.value!.id)
|
||||||
|
}
|
||||||
|
toast.success('已删除')
|
||||||
showDeleteConfirm.value = false
|
showDeleteConfirm.value = false
|
||||||
} catch {
|
} catch {
|
||||||
toast.error('删除失败')
|
toast.error('删除失败')
|
||||||
@@ -101,6 +173,8 @@ async function handleDelete() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 排序 ──
|
||||||
|
|
||||||
async function handleReorder(reordered: Outline[]) {
|
async function handleReorder(reordered: Outline[]) {
|
||||||
outlines.value = reordered
|
outlines.value = reordered
|
||||||
try {
|
try {
|
||||||
@@ -112,23 +186,33 @@ async function handleReorder(reordered: Outline[]) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleChildReorder(parentId: number, reordered: Outline[]) {
|
||||||
|
// 乐观更新
|
||||||
|
const parent = outlines.value.find(o => o.id === parentId)
|
||||||
|
if (parent) {
|
||||||
|
parent.children = reordered
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiReorderChildren(novelId, parentId, reordered.map(c => c.id))
|
||||||
|
// 重新加载获取最新数据
|
||||||
|
const list = await fetchOutlines(novelId)
|
||||||
|
outlines.value = list.items
|
||||||
|
} catch {
|
||||||
|
toast.error('子节点排序保存失败')
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(load)
|
onMounted(load)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="!loading && novel" class="outline-view">
|
<div v-if="!loading" class="outline-view">
|
||||||
<div class="outline-header">
|
<Teleport to="#workspace-actions">
|
||||||
<BaseButton variant="ghost" @click="router.push({ name: 'novel-detail', params: { id: novelId } })">
|
|
||||||
← 返回
|
|
||||||
</BaseButton>
|
|
||||||
<div class="header-center">
|
|
||||||
<h1 class="outline-title">{{ novel.title }}</h1>
|
|
||||||
<p class="outline-subtitle">故事大纲 · 共 {{ totalOutlines }} 个节点</p>
|
|
||||||
</div>
|
|
||||||
<BaseButton v-if="totalOutlines > 0" variant="primary" @click="openCreate">
|
<BaseButton v-if="totalOutlines > 0" variant="primary" @click="openCreate">
|
||||||
添加节点
|
添加节点
|
||||||
</BaseButton>
|
</BaseButton>
|
||||||
</div>
|
</Teleport>
|
||||||
|
|
||||||
<OutlineTimeline
|
<OutlineTimeline
|
||||||
v-if="totalOutlines > 0"
|
v-if="totalOutlines > 0"
|
||||||
@@ -137,6 +221,10 @@ onMounted(load)
|
|||||||
@delete="confirmDelete"
|
@delete="confirmDelete"
|
||||||
@reorder="handleReorder"
|
@reorder="handleReorder"
|
||||||
@add="openCreate"
|
@add="openCreate"
|
||||||
|
@add-child="openCreateChild"
|
||||||
|
@edit-child="openEditChild"
|
||||||
|
@delete-child="confirmDeleteChild"
|
||||||
|
@reorder-children="handleChildReorder"
|
||||||
/>
|
/>
|
||||||
<OutlineEmptyState v-else @create="openCreate" />
|
<OutlineEmptyState v-else @create="openCreate" />
|
||||||
|
|
||||||
@@ -144,6 +232,7 @@ onMounted(load)
|
|||||||
:show="showForm"
|
:show="showForm"
|
||||||
:outline="editingOutline"
|
:outline="editingOutline"
|
||||||
:saving="formSaving"
|
:saving="formSaving"
|
||||||
|
:parent-id="formParentId"
|
||||||
@submit="handleFormSubmit"
|
@submit="handleFormSubmit"
|
||||||
@close="showForm = false"
|
@close="showForm = false"
|
||||||
/>
|
/>
|
||||||
@@ -151,7 +240,9 @@ onMounted(load)
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
:show="showDeleteConfirm"
|
:show="showDeleteConfirm"
|
||||||
title="确认删除"
|
title="确认删除"
|
||||||
:message="`确定要删除「${deletingOutline?.summary}」吗?`"
|
:message="deletingIsChild
|
||||||
|
? `确定要删除子节点「${deletingOutline?.summary}」吗?`
|
||||||
|
: `确定要删除「${deletingOutline?.summary}」吗?${(deletingOutline?.children?.length ?? 0) > 0 ? '其下所有子节点也将被删除。' : ''}`"
|
||||||
confirm-text="删除"
|
confirm-text="删除"
|
||||||
:loading="deleting"
|
:loading="deleting"
|
||||||
@confirm="handleDelete"
|
@confirm="handleDelete"
|
||||||
@@ -171,37 +262,10 @@ onMounted(load)
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.outline-view {
|
.outline-view {
|
||||||
max-width: 900px;
|
/* max-width 由父 NovelWorkspace 控制 */
|
||||||
margin: 0 auto;
|
|
||||||
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.outline-header {
|
/* header 已由 NovelSubNav 处理 */
|
||||||
display: flex;
|
|
||||||
align-items: flex-start;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: var(--space-md);
|
|
||||||
margin-bottom: var(--space-2xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-center {
|
|
||||||
flex: 1;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outline-title {
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 1.8rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--ink-900);
|
|
||||||
line-height: 1.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.outline-subtitle {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--ink-400);
|
|
||||||
margin-top: var(--space-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading */
|
/* Loading */
|
||||||
.outline-loading {
|
.outline-loading {
|
||||||
|
|||||||
662
frontend/src/views/SkillEditorView.vue
Normal file
662
frontend/src/views/SkillEditorView.vue
Normal 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>
|
||||||
181
frontend/src/views/WorldSettingView.vue
Normal file
181
frontend/src/views/WorldSettingView.vue
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, inject, onMounted, computed } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { fetchWorldSetting, saveWorldSetting } from '@/api/worldSetting'
|
||||||
|
import { useToast } from '@/composables/useToast'
|
||||||
|
import { useToolRefresh } from '@/composables/useToolRefresh'
|
||||||
|
import type { Novel } from '@/types/novel'
|
||||||
|
import BaseButton from '@/components/ui/BaseButton.vue'
|
||||||
|
import BaseTextarea from '@/components/ui/BaseTextarea.vue'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const toast = useToast()
|
||||||
|
|
||||||
|
const novelId = inject<number>('workspace-novel-id', Number(route.params.id))
|
||||||
|
const novel = inject<import('vue').Ref<Novel | null>>('workspace-novel', ref(null))
|
||||||
|
const subtitle = inject<import('vue').Ref<string>>('workspace-subtitle', ref(''))
|
||||||
|
const content = ref('')
|
||||||
|
const loading = ref(true)
|
||||||
|
const saving = ref(false)
|
||||||
|
const hasChanges = ref(false)
|
||||||
|
|
||||||
|
const contentLength = computed(() => content.value.length)
|
||||||
|
const contentHint = computed(() => {
|
||||||
|
if (contentLength.value === 0) return '建议至少 500 字'
|
||||||
|
if (contentLength.value < 500) return `${contentLength.value} 字,建议至少 500 字`
|
||||||
|
return `${contentLength.value} 字`
|
||||||
|
})
|
||||||
|
|
||||||
|
// 世界观页不需要副标题
|
||||||
|
subtitle.value = ''
|
||||||
|
|
||||||
|
// AI 工具执行后自动刷新世界观数据
|
||||||
|
useToolRefresh(['get_world_setting', 'save_world_setting'], load)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const ws = await fetchWorldSetting(novelId)
|
||||||
|
content.value = ws.content
|
||||||
|
} catch {
|
||||||
|
toast.error('加载失败')
|
||||||
|
router.push({ name: 'home' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onInput() {
|
||||||
|
hasChanges.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await saveWorldSetting(novelId, { content: content.value })
|
||||||
|
hasChanges.value = false
|
||||||
|
toast.success('世界观已保存')
|
||||||
|
} catch {
|
||||||
|
toast.error('保存失败')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="!loading" class="world-view">
|
||||||
|
<Teleport to="#workspace-actions">
|
||||||
|
<BaseButton
|
||||||
|
variant="primary"
|
||||||
|
:loading="saving"
|
||||||
|
:disabled="!hasChanges"
|
||||||
|
@click="handleSave"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</BaseButton>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<div class="editor-container">
|
||||||
|
<div class="editor-hint">
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
||||||
|
<circle cx="8" cy="8" r="7" stroke="currentColor" stroke-width="1.2" />
|
||||||
|
<path d="M8 4v5M8 11v1" stroke="currentColor" stroke-width="1.2" stroke-linecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span>描述故事发生的世界背景、历史、规则、地理环境等设定</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<BaseTextarea
|
||||||
|
v-model="content"
|
||||||
|
placeholder="在此描述你的故事世界..."
|
||||||
|
:rows="16"
|
||||||
|
:maxlength="5000"
|
||||||
|
:max-height="600"
|
||||||
|
@input="onInput"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="editor-footer">
|
||||||
|
<span class="word-count" :class="{ 'word-count--low': contentLength < 500 && contentLength > 0 }">
|
||||||
|
{{ contentHint }}
|
||||||
|
</span>
|
||||||
|
<span v-if="hasChanges" class="unsaved-hint">未保存的更改</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="loading" class="world-loading">
|
||||||
|
<div class="loading-pulse" style="height: 2rem; width: 40%; margin: 0 auto var(--space-lg)" />
|
||||||
|
<div class="loading-pulse" style="height: 1rem; width: 20%; margin: 0 auto var(--space-2xl)" />
|
||||||
|
<div class="loading-pulse" style="height: 300px; width: 100%" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.world-view {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
animation: fadeInUp var(--duration-normal) var(--ease-out-smooth) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-container {
|
||||||
|
background: var(--paper-50);
|
||||||
|
border: 1px solid var(--paper-200);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-xl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--ink-400);
|
||||||
|
margin-bottom: var(--space-lg);
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
|
background: var(--paper-100);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ink-300);
|
||||||
|
}
|
||||||
|
|
||||||
|
.word-count--low {
|
||||||
|
color: var(--vermilion);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unsaved-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--vermilion);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading */
|
||||||
|
.world-loading {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: var(--space-3xl) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-pulse {
|
||||||
|
background: linear-gradient(
|
||||||
|
90deg,
|
||||||
|
var(--paper-100) 25%,
|
||||||
|
var(--paper-200) 50%,
|
||||||
|
var(--paper-100) 75%
|
||||||
|
);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
animation: shimmer 1.5s ease infinite;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
44
k8s/README.md
Normal file
44
k8s/README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Noval K3s 部署说明
|
||||||
|
|
||||||
|
## 文件列表
|
||||||
|
|
||||||
|
- `namespace.yaml`:命名空间
|
||||||
|
- `pvc.yaml`:SQLite 数据持久化存储
|
||||||
|
- `deployment.yaml`:应用部署(2 副本,滚动更新)
|
||||||
|
- `service.yaml`:集群内服务
|
||||||
|
- `cluster-issuer.yaml`:cert-manager ClusterIssuer(Let'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
14
k8s/cluster-issuer.yaml
Normal 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
65
k8s/deployment.yaml
Normal 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
28
k8s/ingress.yaml
Normal 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
6
k8s/namespace.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
apiVersion: v1
|
||||||
|
kind: Namespace
|
||||||
|
metadata:
|
||||||
|
name: noval-roog-code
|
||||||
|
labels:
|
||||||
|
app: noval
|
||||||
14
k8s/pvc.yaml
Normal file
14
k8s/pvc.yaml
Normal 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
16
k8s/service.yaml
Normal 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
|
||||||
65
start.bat
Normal file
65
start.bat
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
@echo off
|
||||||
|
chcp 65001 >nul
|
||||||
|
title Noval - 小说创作应用
|
||||||
|
|
||||||
|
:: 先关闭已有的 Noval 进程
|
||||||
|
tasklist /FI "WINDOWTITLE eq Noval-Backend*" 2>nul | find /I "cmd.exe" >nul
|
||||||
|
if not errorlevel 1 (
|
||||||
|
echo [*] 检测到已运行的后端,正在关闭...
|
||||||
|
taskkill /FI "WINDOWTITLE eq Noval-Backend*" /F >nul 2>&1
|
||||||
|
)
|
||||||
|
tasklist /FI "WINDOWTITLE eq Noval-Frontend*" 2>nul | find /I "cmd.exe" >nul
|
||||||
|
if not errorlevel 1 (
|
||||||
|
echo [*] 检测到已运行的前端,正在关闭...
|
||||||
|
taskkill /FI "WINDOWTITLE eq Noval-Frontend*" /F >nul 2>&1
|
||||||
|
)
|
||||||
|
:: 清理可能残留的占用 8000 端口的进程
|
||||||
|
for /f "tokens=5" %%a in ('netstat -ano ^| findstr ":8000.*LISTENING"') do (
|
||||||
|
taskkill /PID %%a /F >nul 2>&1
|
||||||
|
)
|
||||||
|
timeout /t 1 /nobreak >nul
|
||||||
|
|
||||||
|
echo ========================================
|
||||||
|
echo Noval 启动中...
|
||||||
|
echo ========================================
|
||||||
|
echo.
|
||||||
|
|
||||||
|
:: 检查后端虚拟环境
|
||||||
|
if not exist "backend\.venv\Scripts\uvicorn.exe" (
|
||||||
|
echo [!] 未检测到后端虚拟环境,正在初始化...
|
||||||
|
cd backend
|
||||||
|
py -3 -m venv .venv
|
||||||
|
.venv\Scripts\pip install -r requirements.txt
|
||||||
|
cd ..
|
||||||
|
echo.
|
||||||
|
)
|
||||||
|
|
||||||
|
:: 检查前端依赖
|
||||||
|
if not exist "frontend\node_modules" (
|
||||||
|
echo [!] 未检测到前端依赖,正在安装...
|
||||||
|
cd frontend
|
||||||
|
call npm install
|
||||||
|
cd ..
|
||||||
|
echo.
|
||||||
|
)
|
||||||
|
|
||||||
|
echo [1/2] 启动后端 (port 8000)...
|
||||||
|
start "Noval-Backend" cmd /c "cd backend && .venv\Scripts\uvicorn app.main:app --port 8000 --reload"
|
||||||
|
|
||||||
|
echo [2/2] 启动前端 (port 5173)...
|
||||||
|
start "Noval-Frontend" cmd /c "cd frontend && npm run dev"
|
||||||
|
|
||||||
|
:: 等待服务就绪后打开浏览器
|
||||||
|
timeout /t 3 /nobreak >nul
|
||||||
|
start http://localhost:5173
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ========================================
|
||||||
|
echo Noval 已启动!
|
||||||
|
echo 前端: http://localhost:5173
|
||||||
|
echo 后端: http://localhost:8000/docs
|
||||||
|
echo ========================================
|
||||||
|
echo 再次运行此脚本会自动重启所有服务
|
||||||
|
echo 后端已启用 --reload,修改代码自动生效
|
||||||
|
echo ========================================
|
||||||
|
pause
|
||||||
Reference in New Issue
Block a user