diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..7dad3b1 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,73 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +env/ +venv/ +ENV/ +*.egg-info/ +dist/ +build/ +.pytest_cache/ + +# Node +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.npm +.eslintcache + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# 环境变量文件 +.env +.env.local +.env.*.local + +# 数据库文件(不包含在镜像中) +data/*.db +backend/data/*.db + +# 日志文件 +logs/ +*.log + +# Git +.git/ +.gitignore +.gitattributes + +# 文档 +docs/ +*.md +!README.md + +# 测试 +tests/ +test/ +*.test.js +*.spec.js + +# 前端构建文件(会在Docker内部构建) +frontend/dist/ +frontend/build/ + +# 后端静态文件(会从前端构建阶段复制) +backend/static/ + +# Docker相关 +Dockerfile +.dockerignore +docker-compose*.yml \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d36db68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,105 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual Environment +venv/ +ENV/ +env/ +.venv + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ +.DS_Store + +# Environment variables +.env +.env.local +.env.*.local + +# Database files +*.db +*.db-shm +*.db-wal +*.sqlite +*.sqlite3 + +# Logs +*.log +logs/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# Node.js +node_modules/ +.npm +.eslintcache + +# Build outputs +dist/ +build/ +backend/static/ + +# OS +Thumbs.db +.DS_Store +*.tmp + +# User data +backend/data/*.db +backend/data/*.db-shm +backend/data/*.db-wal +backend/data/users.json +backend/data/admins.json + +# Temporary files +*.bak +*.swp +*.tmp +.temp/ +temp/ +tmp/ + +# Coverage +.coverage +htmlcov/ +*.cover +.hypothesis/ +.pytest_cache/ + +# Type checking +.mypy_cache/ +.dmypy.json +dmypy.json + +# Jupyter Notebook +.ipynb_checkpoints + +data/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..60a4447 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,71 @@ +# 多阶段构建 Dockerfile for AI Story Creator +# 阶段1: 构建前端 +FROM node:22-alpine AS frontend-builder + +WORKDIR /frontend + +# 复制前端依赖文件 +COPY frontend/package*.json ./ + +# 使用国内npm镜像加速 +RUN npm config set registry https://registry.npmmirror.com + +# 安装依赖 +RUN npm install + +# 复制前端源代码 +COPY frontend/ ./ + +# 临时修改vite配置,使其输出到dist目录(而不是../backend/static) +RUN sed -i "s|outDir: '../backend/static'|outDir: 'dist'|g" vite.config.ts + +# 构建前端 +RUN npm run build + +# 阶段2: 构建最终镜像 +FROM python:3.11-slim + +# 设置工作目录 +WORKDIR /app + +# 使用国内镜像源加速 +RUN sed -i 's/deb.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources \ + && sed -i 's/security.debian.org/mirrors.aliyun.com/g' /etc/apt/sources.list.d/debian.sources + +# 安装系统依赖 +RUN apt-get update && apt-get install -y \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +# 复制后端依赖文件 +COPY backend/requirements.txt ./ + +# 安装Python依赖 +RUN pip install --no-cache-dir -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/ + +# 复制后端代码 +COPY backend/ ./ + +# 从前端构建阶段复制构建好的静态文件 +COPY --from=frontend-builder /frontend/dist ./static + +# 创建必要的目录 +RUN mkdir -p /app/data /app/logs + +# 复制环境变量示例文件 +COPY backend/.env.example ./.env.example + +# 暴露端口 +EXPOSE 8000 + +# 设置环境变量 +ENV PYTHONUNBUFFERED=1 +ENV APP_HOST=0.0.0.0 +ENV APP_PORT=8000 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 + +# 启动命令 +CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..32ec7dc --- /dev/null +++ b/README.md @@ -0,0 +1,600 @@ +# MuMuAINovel 📚✨ + +
+ +![Version](https://img.shields.io/badge/version-1.0.0-blue.svg) +![Python](https://img.shields.io/badge/python-3.11-blue.svg) +![FastAPI](https://img.shields.io/badge/FastAPI-0.109.0-green.svg) +![React](https://img.shields.io/badge/react-18.3.1-blue.svg) +![TypeScript](https://img.shields.io/badge/typescript-5.9.3-blue.svg) +![License](https://img.shields.io/badge/license-GPL%20v3-blue.svg) + +**一款基于 AI 的智能小说创作助手,帮助你轻松创作精彩故事** + +[特性](#特性) • [快速开始](#快速开始) • [部署方式](#部署方式) • [配置说明](#配置说明) • [项目结构](#项目结构) + +
+ +--- + +## ✨ 特性 + +- 🤖 **多 AI 模型支持** - 支持 OpenAI、Google Gemini、Anthropic Claude 等主流 AI 模型 +- 📝 **智能向导** - 通过向导式引导快速创建小说项目,AI 自动生成大纲、角色和世界观 +- 👥 **角色管理** - 创建和管理小说角色,包括人物关系、组织架构等 +- 📖 **章节编辑** - 支持章节的创建、编辑、重新生成和润色功能 +- 🌐 **世界观设定** - 构建完整的故事世界观和背景设定 +- 🔐 **多种登录方式** - 支持 LinuxDO OAuth 登录和本地账户登录 +- 🐳 **Docker 部署** - 一键部署,开箱即用 +- 💾 **数据持久化** - 基于 SQLite 的本地数据存储,支持多用户隔离 +- 🎨 **现代化 UI** - 基于 Ant Design 的美观界面,响应式设计 + +## 🚀 快速开始 + +### 前置要求 + +- **Docker 部署**:Docker 和 Docker Compose +- **本地开发**:Python 3.11+ 和 Node.js 18+ +- **必需**:至少一个 AI 服务的 API Key(OpenAI/Gemini/Anthropic) + +### 方式一:从源码构建 Docker 镜像 + +```bash +# 1. 克隆项目 +git clone https://github.com/xiamuceer-j/MuMuAINovel.git +cd MuMuAINovel + +# 2. 配置环境变量 +cp backend/.env.example .env +# 编辑 .env 文件,填入你的 API Keys + +# 3. 启动服务(会自动构建镜像) +docker-compose up -d + +# 4. 访问应用 +# 打开浏览器访问 http://localhost:8000 +``` + +### 方式二:本地开发 + +#### 后端设置 + +```bash +# 进入后端目录 +cd backend + +# 创建虚拟环境 +python -m venv .venv + +# 激活虚拟环境 +# Windows: +.venv\Scripts\activate +# Linux/Mac: +source .venv/bin/activate + +# 安装依赖 +pip install -r requirements.txt + +# 配置环境变量 +cp .env.example .env +# 编辑 .env 文件,填入你的配置 + +# 启动后端服务 +python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload +``` + +#### 前端设置 + +```bash +# 进入前端目录 +cd frontend + +# 安装依赖 +npm install + +# 开发模式(需要后端已启动) +npm run dev + +# 或构建生产版本 +npm run build +``` + +## 🐳 部署方式 + +### Docker Compose 部署 + +#### 使用 Docker Hub 镜像(推荐) + +项目已发布到 Docker Hub,可直接拉取使用: + +```bash +# 查看可用版本 +docker pull mumujie/mumuainovel:latest + +# 启动服务 +docker-compose up -d + +# 查看日志 +docker-compose logs -f + +# 停止服务 +docker-compose down + +# 重启服务 +docker-compose restart + +# 更新到最新版本 +docker-compose pull +docker-compose up -d +``` + +#### Docker Compose 配置文件示例 + +使用 Docker Hub 镜像的完整配置: + +```yaml +services: + ai-story: + image: mumujie/mumuainovel:latest + container_name: mumuainovel + ports: + - "8800:8000" # 宿主机端口:容器端口 + volumes: + # 持久化数据库和日志 + - ./data:/app/data + - ./logs:/app/logs + # 挂载环境变量文件 + - ./.env:/app/.env:ro + environment: + - APP_NAME=mumuainovel + - APP_VERSION=1.0.0 + - APP_HOST=0.0.0.0 + - APP_PORT=8000 + - DEBUG=false + # 其他环境变量会从 .env 文件自动加载 + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + networks: + - ai-story-network + +networks: + ai-story-network: + driver: bridge +``` + +### 生产环境部署建议 + +#### 1. 环境变量配置 + +**必需配置**: +- `OPENAI_API_KEY` 或 `GEMINI_API_KEY`:至少配置一个 AI 服务 +- `LOCAL_AUTH_PASSWORD`:修改为强密码 + +**推荐配置**: +- `OPENAI_BASE_URL`:如果使用中转 API,修改为中转服务地址 +- `DEFAULT_AI_PROVIDER`:根据你的 API Key 选择 `openai`、`gemini` 或 `anthropic` +- `DEFAULT_MODEL`:选择合适的模型(如 `gpt-4o-mini`、`gemini-2.0-flash-exp`) + +#### 2. 数据持久化 + +数据目录已通过 volume 挂载,数据不会丢失: +- `./data`:SQLite 数据库文件 +- `./logs`:应用日志文件 + +#### 3. 端口配置 + +默认端口映射:`8800:8000` +- 宿主机端口:`8800`(可自定义修改) +- 容器内端口:`8000`(固定,不要修改) + +访问地址:`http://your-server-ip:8800` + +#### 4. 反向代理配置(Nginx) + +推荐使用 Nginx 配置 HTTPS: + +```nginx +server { + listen 80; + server_name your-domain.com; + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl http2; + server_name your-domain.com; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://localhost:8800; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 支持 SSE(服务器推送事件) + proxy_buffering off; + proxy_cache off; + proxy_set_header Connection ''; + proxy_http_version 1.1; + chunked_transfer_encoding off; + } +} +``` + +配置后记得更新 `.env` 中的 `LINUXDO_REDIRECT_URI` 和 `FRONTEND_URL`。 + +#### 5. 资源限制(可选) + +在 `docker-compose.yml` 中添加资源限制: + +```yaml +services: + ai-story: + # ... 其他配置 + deploy: + resources: + limits: + cpus: '2.0' + memory: 2G + reservations: + cpus: '0.5' + memory: 512M +``` + +### 端口说明 + +- **默认端口**:`8800`(宿主机)→ `8000`(容器) +- **可自定义**:修改 docker-compose.yml 中的 `ports` 配置 +- **健康检查**:容器内部使用 `8000` 端口进行健康检查 + +## ⚙️ 配置说明 + +### 环境变量 + +创建 `.env` 文件并配置以下变量: + +```bash +# ===== AI 服务配置(必填)===== +# OpenAI 配置(支持官方API和中转API) +OPENAI_API_KEY=your_openai_key_here +OPENAI_BASE_URL=https://api.openai.com/v1 + +# Google Gemini 配置(推荐,免费额度大) +# GEMINI_API_KEY=your_gemini_key_here + +# Anthropic 配置 +# ANTHROPIC_API_KEY=your_anthropic_key_here +# ANTHROPIC_BASE_URL=https://api.anthropic.com + +# 中转API配置示例(使用OpenAI格式) +# New API 中转服务 +# OPENAI_API_KEY=your_newapi_key_here +# OPENAI_BASE_URL=https://api.new-api.com/v1 + +# API2D 中转服务 +# OPENAI_API_KEY=your_api2d_key_here +# OPENAI_BASE_URL=https://api.api2d.com/v1 + +# OpenAI-SB 中转服务 +# OPENAI_API_KEY=your_openai_sb_key_here +# OPENAI_BASE_URL=https://api.openai-sb.com/v1 + +# 其他支持 OpenAI 格式的中转服务 +# OPENAI_API_KEY=your_api_key_here +# OPENAI_BASE_URL=https://your-api-proxy.com/v1 + +# 默认 AI 提供商和模型 +DEFAULT_AI_PROVIDER=openai +DEFAULT_MODEL=gpt-4o-mini +DEFAULT_TEMPERATURE=0.8 +DEFAULT_MAX_TOKENS=32000 + +# ===== 应用配置 ===== +APP_NAME=MuMuAINovel +APP_VERSION=1.0.0 +APP_HOST=0.0.0.0 +APP_PORT=8000 +DEBUG=false + +# ===== LinuxDO OAuth 配置(可选)===== +LINUXDO_CLIENT_ID=your_client_id_here +LINUXDO_CLIENT_SECRET=your_client_secret_here +LINUXDO_REDIRECT_URI=http://localhost:8000/api/auth/callback +FRONTEND_URL=http://localhost:8000 + +# ===== 本地账户登录配置 ===== +LOCAL_AUTH_ENABLED=true +LOCAL_AUTH_USERNAME=admin +LOCAL_AUTH_PASSWORD=your_secure_password_here +LOCAL_AUTH_DISPLAY_NAME=管理员 + +# ===== CORS 配置(生产环境)===== +# CORS_ORIGINS=https://your-domain.com,https://www.your-domain.com +``` + +### AI 模型配置 + +项目支持多个 AI 提供商,你可以根据需要配置: + +| 提供商 | 推荐模型 | 用途 | +|--------|---------|------| +| OpenAI | gpt-4, gpt-3.5-turbo | 高质量文本生成 | +| Anthropic | claude-3-opus, claude-3-sonnet | 长文本创作 | + +#### 使用中转API服务 + +如果你无法直接访问 OpenAI 官方 API,或者想使用更经济实惠的中转服务,本项目完全支持各种 OpenAI 兼容格式的中转 API: + +##### 配置方法 + +只需修改 `.env` 文件中的两个参数: + +```bash +# 1. 填入中转服务提供的 API Key +OPENAI_API_KEY=your_api_key_from_proxy_service + +# 2. 修改 Base URL 为中转服务的地址 +OPENAI_BASE_URL=https://your-proxy-service.com/v1 +``` + +##### 常见中转服务配置示例 + +**New API** +```bash +OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxx +OPENAI_BASE_URL=https://api.new-api.com/v1 +``` + +**API2D** +```bash +OPENAI_API_KEY=fk-xxxxxxxxxxxxxxxx +OPENAI_BASE_URL=https://api.api2d.com/v1 +``` + +**OpenAI-SB** +```bash +OPENAI_API_KEY=sb-xxxxxxxxxxxxxxxx +OPENAI_BASE_URL=https://api.openai-sb.com/v1 +``` + +**自建 One API / New API** +```bash +OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxx +OPENAI_BASE_URL=https://your-domain.com/v1 +``` + +##### 注意事项 + +- ✅ 所有支持 OpenAI 接口格式的服务都可以使用 +- ✅ 确保中转服务的 Base URL 以 `/v1` 结尾 +- ✅ 根据中转服务支持的模型,修改 `DEFAULT_MODEL` 参数 +- ⚠️ 不同中转服务的模型名称可能不同,请参考服务商文档 +- ⚠️ 部分中转服务可能对请求频率或并发有限制 + +##### 推荐的中转服务 + +如果你需要中转服务,以下是一些常见选择: + +1. **New API** - 开源的 API 分发系统,支持多种模型 +2. **API2D** - 国内稳定的 API 中转服务 +3. **OpenAI-SB** - 提供多种 AI 模型的中转 +4. **自建服务** - 使用 One API 或 New API 自行搭建 + +> 💡 提示:使用中转服务时,请确保服务提供商的可靠性和数据安全性 + +### 登录方式配置 + +#### 本地账户登录(默认启用) + +适合个人使用或小型团队: + +```bash +LOCAL_AUTH_ENABLED=true +LOCAL_AUTH_USERNAME=admin +LOCAL_AUTH_PASSWORD=your_password +``` + +#### LinuxDO OAuth 登录 + +适合需要社区集成的场景,需要在 [LinuxDO](https://linux.do) 注册 OAuth 应用: + +```bash +LINUXDO_CLIENT_ID=your_client_id +LINUXDO_CLIENT_SECRET=your_client_secret +LINUXDO_REDIRECT_URI=http://your-domain:8000/api/auth/callback +``` + +## 📁 项目结构 + +``` +MuMuAINovel/ +├── backend/ # 后端服务 +│ ├── app/ +│ │ ├── api/ # API 路由 +│ │ │ ├── auth.py # 认证接口 +│ │ │ ├── projects.py # 项目管理 +│ │ │ ├── chapters.py # 章节管理 +│ │ │ ├── characters.py # 角色管理 +│ │ │ ├── wizard_stream.py # 向导流式生成 +│ │ │ └── ... +│ │ ├── models/ # 数据模型 +│ │ ├── schemas/ # Pydantic 模型 +│ │ ├── services/ # 业务逻辑 +│ │ │ ├── ai_service.py # AI 服务封装 +│ │ │ └── oauth_service.py # OAuth 服务 +│ │ ├── middleware/ # 中间件 +│ │ ├── utils/ # 工具函数 +│ │ ├── config.py # 配置管理 +│ │ ├── database.py # 数据库连接 +│ │ └── main.py # 应用入口 +│ ├── data/ # 数据存储目录 +│ ├── static/ # 前端静态文件(构建后) +│ ├── requirements.txt # Python 依赖 +│ └── .env.example # 环境变量示例 +├── frontend/ # 前端应用 +│ ├── src/ +│ │ ├── pages/ # 页面组件 +│ │ │ ├── ProjectList.tsx # 项目列表 +│ │ │ ├── ProjectWizardNew.tsx # 创建向导 +│ │ │ ├── Chapters.tsx # 章节管理 +│ │ │ ├── Characters.tsx # 角色管理 +│ │ │ └── ... +│ │ ├── components/ # 通用组件 +│ │ ├── services/ # API 服务 +│ │ ├── store/ # 状态管理(Zustand) +│ │ ├── types/ # TypeScript 类型 +│ │ └── utils/ # 工具函数 +│ ├── package.json +│ └── vite.config.ts +├── docker-compose.yml # Docker Compose 配置 +├── Dockerfile # Docker 镜像构建 +└── README.md # 项目说明文档 +``` + +## 🛠️ 技术栈 + +### 后端 + +- **框架**:FastAPI 0.109.0 +- **数据库**:SQLite + SQLAlchemy(异步) +- **AI 集成**:OpenAI、Anthropic、Google Gemini SDK +- **认证**:LinuxDO OAuth2、本地账户 +- **日志**:Python logging + 文件轮转 + +### 前端 + +- **框架**:React 18.3 + TypeScript +- **UI 库**:Ant Design 5.27 +- **路由**:React Router 6.28 +- **状态管理**:Zustand 5.0 +- **HTTP 客户端**:Axios +- **构建工具**:Vite 7.1 + +## 📖 使用指南 + +### 创建第一个小说项目 + +1. **登录系统** + - 使用本地账户或 LinuxDO 账户登录 + +2. **创建项目** + - 点击"创建项目"按钮 + - 选择"使用向导创建"或"手动创建" + +3. **使用向导(推荐)** + - 输入小说基本信息(标题、类型、背景等) + - AI 自动生成大纲、角色和世界观 + - 实时查看生成进度 + +4. **编辑和完善** + - 在项目详情页查看和编辑大纲 + - 管理角色和人物关系 + - 生成和编辑章节内容 + + +### API 文档 + +应用启动后,可访问自动生成的 API 文档: + +- Swagger UI:`http://localhost:8000/docs` +- ReDoc:`http://localhost:8000/redoc` + +## 🔧 开发指南 + +### 后端开发 + +```bash +cd backend + +# 激活虚拟环境 +source .venv/bin/activate # Linux/Mac +# 或 +.venv\Scripts\activate # Windows + +# 启动开发服务器(热重载) +python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +### 前端开发 + +```bash +cd frontend + +# 启动开发服务器 +npm run dev + +# 构建生产版本 +npm run build + +# 预览生产版本 +npm run preview +``` + +### 代码规范 + +- **后端**:遵循 PEP 8 规范 +- **前端**:使用 ESLint + TypeScript 严格模式 +- **提交**:建议使用语义化提交信息 + +## 🤝 贡献 + +欢迎提交 Issue 和 Pull Request! + +1. Fork 本项目 +2. 创建特性分支 (`git checkout -b feature/AmazingFeature`) +3. 提交更改 (`git commit -m 'Add some AmazingFeature'`) +4. 推送到分支 (`git push origin feature/AmazingFeature`) +5. 提交 Pull Request + +## 📝 许可证 + +本项目采用 [GNU General Public License v3.0](https://www.gnu.org/licenses/gpl-3.0.html) 开源协议 + +**这意味着:** + +- ✅ **可以** - 自由使用、复制、修改和分发本项目 +- ✅ **可以** - 用于商业目的 +- ✅ **可以** - 用于个人学习和研究 +- 📝 **必须** - 开源你的修改版本 +- 📝 **必须** - 保留原作者版权声明 +- 📝 **必须** - 以相同的 GPL v3 协议发布衍生作品 + +详见 [LICENSE](LICENSE) 文件 + +## 🙏 致谢 + +- [FastAPI](https://fastapi.tiangolo.com/) - 现代化的 Python Web 框架 +- [React](https://react.dev/) - 用户界面构建库 +- [Ant Design](https://ant.design/) - 企业级 UI 设计语言 +- [OpenAI](https://openai.com/) / [Anthropic](https://www.anthropic.com/) - AI 模型提供商 + +## 📧 联系方式 + +如有问题或建议,欢迎通过以下方式联系: + +- 提交 [Issue](https://github.com/yourusername/MuMuAINovel/issues) +- Linux DO [LD](https://linux.do/t/topic/1100112) + +--- + +
+ +**如果这个项目对你有帮助,请给个 ⭐️ Star 支持一下!** + +Made with ❤️ + +
+ +## Star History + +[![Star History Chart](https://api.star-history.com/svg?repos=xiamuceer-j/MuMuAINovel&type=date&legend=top-left)](https://www.star-history.com/#xiamuceer-j/MuMuAINovel&type=date&legend=top-left) + +![Alt](https://repobeats.axiom.co/api/embed/ee7141a5f269c64759302e067abe23b46796bafe.svg "Repobeats analytics image") \ No newline at end of file diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..f66079f --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,48 @@ +# AI服务配置 +# OpenAI配置 +OPENAI_API_KEY=your_openai_key_here +OPENAI_BASE_URL=https://api.openai.com/v1 + +# Anthropic配置 +ANTHROPIC_API_KEY=your_anthropic_key_here +ANTHROPIC_BASE_URL=https://api.anthropic.com + +# 默认AI提供商:openai, gemini, anthropic +DEFAULT_AI_PROVIDER=openai +DEFAULT_MODEL=gpt-4.1 +DEFAULT_TEMPERATURE=0.8 +DEFAULT_MAX_TOKENS=32000 + +# 应用配置 +APP_NAME=MuMuAINovel +APP_VERSION=1.0.0 +APP_HOST=0.0.0.0 +APP_PORT=8000 +DEBUG=true + +# LinuxDO OAuth2 配置(可选) +# 注意:Docker部署时,LINUXDO_REDIRECT_URI 应该使用实际的域名或服务器IP +# 本地开发: http://localhost:8000/api/auth/callback +# 生产环境: https://your-domain.com/api/auth/callback 或 http://your-server-ip:8000/api/auth/callback +LINUXDO_CLIENT_ID=your_client_id_here +LINUXDO_CLIENT_SECRET=your_client_secret_here +LINUXDO_REDIRECT_URI=http://localhost:8000/api/auth/callback + +# 前端URL配置(用于OAuth回调后重定向到前端) +# 本地开发: http://localhost:8000 +# 生产环境: https://your-domain.com 或 http://your-server-ip:8000 +FRONTEND_URL=http://localhost:8000 + +# 本地账户登录配置 +# 启用本地账户登录(true/false) +LOCAL_AUTH_ENABLED=true +# 本地登录用户名 +LOCAL_AUTH_USERNAME=admin +# 本地登录密码 +LOCAL_AUTH_PASSWORD=your_secure_password_here +# 本地用户显示名称 +LOCAL_AUTH_DISPLAY_NAME=管理员 + +# CORS配置(生产环境) +# 允许的跨域来源,多个用逗号分隔 +# CORS_ORIGINS=https://your-domain.com,https://www.your-domain.com \ No newline at end of file diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..53b080a --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,2 @@ +"""AI Story Creator - 后端应用包""" +__version__ = "1.0.0" \ No newline at end of file diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..5fef9eb --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +"""API路由模块""" \ No newline at end of file diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py new file mode 100644 index 0000000..767f936 --- /dev/null +++ b/backend/app/api/auth.py @@ -0,0 +1,230 @@ +""" +认证 API - LinuxDO OAuth2 登录 + 本地账户登录 +""" +from fastapi import APIRouter, HTTPException, Response, Request +from fastapi.responses import RedirectResponse +from pydantic import BaseModel +from typing import Optional +import hashlib +from app.services.oauth_service import LinuxDOOAuthService +from app.user_manager import user_manager +from app.database import init_db +from app.logger import get_logger +from app.config import settings + +logger = get_logger(__name__) + +router = APIRouter(prefix="/auth", tags=["认证"]) + +# OAuth2 服务实例 +oauth_service = LinuxDOOAuthService() + +# State 临时存储(生产环境应使用 Redis) +_state_storage = {} + + +class AuthUrlResponse(BaseModel): + auth_url: str + state: str + + +class LocalLoginRequest(BaseModel): + """本地登录请求""" + username: str + password: str + + +class LocalLoginResponse(BaseModel): + """本地登录响应""" + success: bool + message: str + user: Optional[dict] = None + + +@router.get("/config") +async def get_auth_config(): + """获取认证配置信息""" + return { + "local_auth_enabled": settings.LOCAL_AUTH_ENABLED, + "linuxdo_enabled": bool(settings.LINUXDO_CLIENT_ID and settings.LINUXDO_CLIENT_SECRET) + } + + +@router.post("/local/login", response_model=LocalLoginResponse) +async def local_login(request: LocalLoginRequest, response: Response): + """本地账户登录""" + # 检查是否启用本地登录 + if not settings.LOCAL_AUTH_ENABLED: + raise HTTPException(status_code=403, detail="本地账户登录未启用") + + # 检查是否配置了本地账户 + if not settings.LOCAL_AUTH_USERNAME or not settings.LOCAL_AUTH_PASSWORD: + raise HTTPException(status_code=500, detail="本地账户未配置") + + # 验证用户名和密码 + if request.username != settings.LOCAL_AUTH_USERNAME or request.password != settings.LOCAL_AUTH_PASSWORD: + raise HTTPException(status_code=401, detail="用户名或密码错误") + + # 生成本地用户ID(使用用户名的hash) + user_id = f"local_{hashlib.md5(request.username.encode()).hexdigest()[:16]}" + + # 创建或更新本地用户 + user = await user_manager.create_or_update_from_linuxdo( + linuxdo_id=user_id, + username=request.username, + display_name=settings.LOCAL_AUTH_DISPLAY_NAME, + avatar_url=None, + trust_level=9 # 本地用户给予高信任级别 + ) + + # 初始化用户数据库 + try: + await init_db(user.user_id) + logger.info(f"本地用户 {user.user_id} 数据库初始化成功") + except Exception as e: + logger.error(f"本地用户 {user.user_id} 数据库初始化失败: {e}") + + # 设置 Cookie(7天有效) + response.set_cookie( + key="user_id", + value=user.user_id, + max_age=7 * 24 * 60 * 60, # 7天 + httponly=True, + samesite="lax" + ) + + return LocalLoginResponse( + success=True, + message="登录成功", + user=user.dict() + ) + + +@router.get("/linuxdo/url", response_model=AuthUrlResponse) +async def get_linuxdo_auth_url(): + """获取 LinuxDO 授权 URL""" + state = oauth_service.generate_state() + auth_url = oauth_service.get_authorization_url(state) + + # 临时存储 state(5分钟有效) + _state_storage[state] = True + + return AuthUrlResponse(auth_url=auth_url, state=state) + + +async def _handle_callback( + code: Optional[str] = None, + state: Optional[str] = None, + error: Optional[str] = None, + response: Response = None +): + """ + LinuxDO OAuth2 回调处理 + + 成功后重定向到前端首页,并设置 user_id Cookie + """ + # 检查是否有错误 + if error: + raise HTTPException(status_code=400, detail=f"授权失败: {error}") + + # 检查必需参数 + if not code or not state: + raise HTTPException(status_code=400, detail="缺少 code 或 state 参数") + + # 验证 state(防止 CSRF) + if state not in _state_storage: + raise HTTPException(status_code=400, detail="无效的 state 参数") + + # 删除已使用的 state + del _state_storage[state] + + # 1. 使用 code 获取 access_token + token_data = await oauth_service.get_access_token(code) + if not token_data or "access_token" not in token_data: + raise HTTPException(status_code=400, detail="获取访问令牌失败") + + access_token = token_data["access_token"] + + # 2. 使用 access_token 获取用户信息 + user_info = await oauth_service.get_user_info(access_token) + if not user_info: + raise HTTPException(status_code=400, detail="获取用户信息失败") + + # 3. 创建或更新用户 + linuxdo_id = str(user_info.get("id")) + username = user_info.get("username", "") + display_name = user_info.get("name", username) + avatar_url = user_info.get("avatar_url") + trust_level = user_info.get("trust_level", 0) + + user = await user_manager.create_or_update_from_linuxdo( + linuxdo_id=linuxdo_id, + username=username, + display_name=display_name, + avatar_url=avatar_url, + trust_level=trust_level + ) + + # 3.5. 初始化用户数据库(如果是新用户) + try: + await init_db(user.user_id) + logger.info(f"用户 {user.user_id} 数据库初始化成功") + except Exception as e: + logger.error(f"用户 {user.user_id} 数据库初始化失败: {e}") + # 继续执行,不影响登录流程(可能是已存在的用户) + + # 4. 设置 Cookie 并重定向到前端回调页面 + # 使用配置的前端URL,支持不同的部署环境 + frontend_url = settings.FRONTEND_URL.rstrip('/') + redirect_url = f"{frontend_url}/auth/callback" + logger.info(f"OAuth回调成功,重定向到前端: {redirect_url}") + redirect_response = RedirectResponse(url=redirect_url) + + # 设置 httponly Cookie(7天有效) + redirect_response.set_cookie( + key="user_id", + value=user.user_id, + max_age=7 * 24 * 60 * 60, # 7天 + httponly=True, + samesite="lax" + ) + + return redirect_response + + +@router.get("/linuxdo/callback") +async def linuxdo_callback( + code: Optional[str] = None, + state: Optional[str] = None, + error: Optional[str] = None, + response: Response = None +): + """LinuxDO OAuth2 回调处理(标准路径)""" + return await _handle_callback(code, state, error, response) + + +@router.get("/callback") +async def callback_alias( + code: Optional[str] = None, + state: Optional[str] = None, + error: Optional[str] = None, + response: Response = None +): + """LinuxDO OAuth2 回调处理(兼容路径)""" + return await _handle_callback(code, state, error, response) + + +@router.post("/logout") +async def logout(response: Response): + """退出登录""" + response.delete_cookie("user_id") + return {"message": "退出登录成功"} + + +@router.get("/user") +async def get_current_user(request: Request): + """获取当前登录用户信息""" + if not hasattr(request.state, "user") or not request.state.user: + raise HTTPException(status_code=401, detail="未登录") + + return request.state.user.dict() \ No newline at end of file diff --git a/backend/app/api/chapters.py b/backend/app/api/chapters.py new file mode 100644 index 0000000..b4ae9b4 --- /dev/null +++ b/backend/app/api/chapters.py @@ -0,0 +1,655 @@ +"""章节管理API""" +from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +import json +import asyncio + +from app.database import get_db +from app.models.chapter import Chapter +from app.models.project import Project +from app.models.outline import Outline +from app.models.character import Character +from app.models.generation_history import GenerationHistory +from app.schemas.chapter import ( + ChapterCreate, + ChapterUpdate, + ChapterResponse, + ChapterListResponse +) +from app.services.ai_service import ai_service +from app.services.prompt_service import prompt_service +from app.logger import get_logger + +router = APIRouter(prefix="/chapters", tags=["章节管理"]) +logger = get_logger(__name__) + + +@router.post("", response_model=ChapterResponse, summary="创建章节") +async def create_chapter( + chapter: ChapterCreate, + db: AsyncSession = Depends(get_db) +): + """创建新的章节""" + # 验证项目是否存在 + result = await db.execute( + select(Project).where(Project.id == chapter.project_id) + ) + project = result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="项目不存在") + + # 计算字数 + word_count = len(chapter.content) + + db_chapter = Chapter( + **chapter.model_dump(), + word_count=word_count + ) + db.add(db_chapter) + + # 更新项目的当前字数 + project.current_words = project.current_words + word_count + + await db.commit() + await db.refresh(db_chapter) + return db_chapter + + +@router.get("/project/{project_id}", response_model=ChapterListResponse, summary="获取项目的所有章节") +async def get_project_chapters( + project_id: str, + db: AsyncSession = Depends(get_db) +): + """获取指定项目的所有章节(路径参数版本)""" + # 获取总数 + count_result = await db.execute( + select(func.count(Chapter.id)).where(Chapter.project_id == project_id) + ) + total = count_result.scalar_one() + + # 获取章节列表 + result = await db.execute( + select(Chapter) + .where(Chapter.project_id == project_id) + .order_by(Chapter.chapter_number) + ) + chapters = result.scalars().all() + + return ChapterListResponse(total=total, items=chapters) + + +@router.get("/{chapter_id}", response_model=ChapterResponse, summary="获取章节详情") +async def get_chapter( + chapter_id: str, + db: AsyncSession = Depends(get_db) +): + """根据ID获取章节详情""" + result = await db.execute( + select(Chapter).where(Chapter.id == chapter_id) + ) + chapter = result.scalar_one_or_none() + + if not chapter: + raise HTTPException(status_code=404, detail="章节不存在") + + return chapter + + +@router.put("/{chapter_id}", response_model=ChapterResponse, summary="更新章节") +async def update_chapter( + chapter_id: str, + chapter_update: ChapterUpdate, + db: AsyncSession = Depends(get_db) +): + """更新章节信息""" + result = await db.execute( + select(Chapter).where(Chapter.id == chapter_id) + ) + chapter = result.scalar_one_or_none() + + if not chapter: + raise HTTPException(status_code=404, detail="章节不存在") + + # 记录旧字数 + old_word_count = chapter.word_count or 0 + + # 更新字段 + update_data = chapter_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(chapter, field, value) + + # 如果内容更新了,重新计算字数 + if "content" in update_data and chapter.content: + new_word_count = len(chapter.content) + chapter.word_count = new_word_count + + # 更新项目字数 + result = await db.execute( + select(Project).where(Project.id == chapter.project_id) + ) + project = result.scalar_one_or_none() + if project: + project.current_words = project.current_words - old_word_count + new_word_count + + await db.commit() + await db.refresh(chapter) + return chapter + + +@router.delete("/{chapter_id}", summary="删除章节") +async def delete_chapter( + chapter_id: str, + db: AsyncSession = Depends(get_db) +): + """删除章节""" + result = await db.execute( + select(Chapter).where(Chapter.id == chapter_id) + ) + chapter = result.scalar_one_or_none() + + if not chapter: + raise HTTPException(status_code=404, detail="章节不存在") + + # 更新项目字数 + result = await db.execute( + select(Project).where(Project.id == chapter.project_id) + ) + project = result.scalar_one_or_none() + if project: + project.current_words = max(0, project.current_words - chapter.word_count) + + await db.delete(chapter) + await db.commit() + + return {"message": "章节删除成功"} + + +async def check_prerequisites(db: AsyncSession, chapter: Chapter) -> tuple[bool, str, list[Chapter]]: + """ + 检查章节前置条件 + + Args: + db: 数据库会话 + chapter: 当前章节 + + Returns: + (可否生成, 错误信息, 前置章节列表) + """ + # 如果是第一章,无需检查前置 + if chapter.chapter_number == 1: + return True, "", [] + + # 查询所有前置章节(序号小于当前章节的) + result = await db.execute( + select(Chapter) + .where(Chapter.project_id == chapter.project_id) + .where(Chapter.chapter_number < chapter.chapter_number) + .order_by(Chapter.chapter_number) + ) + previous_chapters = result.scalars().all() + + # 检查是否所有前置章节都有内容 + incomplete_chapters = [ + ch for ch in previous_chapters + if not ch.content or ch.content.strip() == "" + ] + + if incomplete_chapters: + missing_numbers = [str(ch.chapter_number) for ch in incomplete_chapters] + error_msg = f"需要先完成前置章节:第 {', '.join(missing_numbers)} 章" + return False, error_msg, previous_chapters + + return True, "", previous_chapters + + +@router.get("/{chapter_id}/can-generate", summary="检查章节是否可以生成") +async def check_can_generate( + chapter_id: str, + db: AsyncSession = Depends(get_db) +): + """ + 检查章节是否满足生成条件 + 返回可生成状态和前置章节信息 + """ + # 获取章节 + result = await db.execute( + select(Chapter).where(Chapter.id == chapter_id) + ) + chapter = result.scalar_one_or_none() + if not chapter: + raise HTTPException(status_code=404, detail="章节不存在") + + # 检查前置条件 + can_generate, error_msg, previous_chapters = await check_prerequisites(db, chapter) + + # 构建前置章节信息 + previous_info = [ + { + "id": ch.id, + "chapter_number": ch.chapter_number, + "title": ch.title, + "has_content": bool(ch.content and ch.content.strip()), + "word_count": ch.word_count or 0 + } + for ch in previous_chapters + ] + + return { + "can_generate": can_generate, + "reason": error_msg if not can_generate else "", + "previous_chapters": previous_info, + "chapter_number": chapter.chapter_number + } + + +@router.post("/{chapter_id}/generate", summary="AI创作章节内容") +async def generate_chapter_content( + chapter_id: str, + db: AsyncSession = Depends(get_db) +): + """ + 根据大纲、前置章节内容和项目信息AI创作章节完整内容 + 要求:必须按顺序生成,确保前置章节都已完成 + """ + # 获取章节 + result = await db.execute( + select(Chapter).where(Chapter.id == chapter_id) + ) + chapter = result.scalar_one_or_none() + if not chapter: + raise HTTPException(status_code=404, detail="章节不存在") + + # 检查前置条件 + can_generate, error_msg, previous_chapters = await check_prerequisites(db, chapter) + if not can_generate: + raise HTTPException(status_code=400, detail=error_msg) + + try: + # 获取项目信息 + project_result = await db.execute( + select(Project).where(Project.id == chapter.project_id) + ) + project = project_result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="项目不存在") + + # 获取对应的大纲(使用新的查询确保获取最新数据) + outline_result = await db.execute( + select(Outline) + .where(Outline.project_id == chapter.project_id) + .where(Outline.order_index == chapter.chapter_number) + .execution_options(populate_existing=True) + ) + outline = outline_result.scalar_one_or_none() + + # 获取所有大纲用于上下文(使用新的查询确保获取最新数据) + all_outlines_result = await db.execute( + select(Outline) + .where(Outline.project_id == chapter.project_id) + .order_by(Outline.order_index) + .execution_options(populate_existing=True) + ) + all_outlines = all_outlines_result.scalars().all() + outlines_context = "\n".join([ + f"第{o.order_index}章 {o.title}: {o.content[:100]}..." + for o in all_outlines + ]) + + # 获取角色信息 + characters_result = await db.execute( + select(Character).where(Character.project_id == chapter.project_id) + ) + characters = characters_result.scalars().all() + characters_info = "\n".join([ + f"- {c.name}({'组织' if c.is_organization else '角色'}, {c.role_type}): {c.personality[:100] if c.personality else ''}" + for c in characters + ]) + + # 构建前置章节内容上下文(如果有前置章节) + previous_content = "" + if previous_chapters: + # Token控制:保留最近3章的完整内容,早期章节使用摘要 + recent_chapters = previous_chapters[-3:] if len(previous_chapters) > 3 else previous_chapters + early_chapters = previous_chapters[:-3] if len(previous_chapters) > 3 else [] + + # 早期章节摘要 + if early_chapters: + early_summary = "【前期剧情概要】\n" + "\n".join([ + f"第{ch.chapter_number}章《{ch.title}》:{ch.content[:200] if ch.content else ''}..." + for ch in early_chapters + ]) + previous_content += early_summary + "\n\n" + + # 最近章节完整内容 + if recent_chapters: + recent_content = "【最近章节完整内容】\n" + "\n\n".join([ + f"=== 第{ch.chapter_number}章:{ch.title} ===\n{ch.content}" + for ch in recent_chapters + ]) + previous_content += recent_content + + logger.info(f"构建前置上下文:{len(early_chapters)}章摘要 + {len(recent_chapters)}章完整内容") + + # 根据是否有前置内容选择不同的提示词 + if previous_content: + # 使用带上下文的提示词 + prompt = prompt_service.get_chapter_generation_with_context_prompt( + title=project.title, + theme=project.theme or '', + genre=project.genre or '', + narrative_perspective=project.narrative_perspective or '第三人称', + time_period=project.world_time_period or '未设定', + location=project.world_location or '未设定', + atmosphere=project.world_atmosphere or '未设定', + rules=project.world_rules or '未设定', + characters_info=characters_info or '暂无角色信息', + outlines_context=outlines_context, + previous_content=previous_content, + chapter_number=chapter.chapter_number, + chapter_title=chapter.title, + chapter_outline=outline.content if outline else chapter.summary or '暂无大纲' + ) + else: + # 第一章,使用原有提示词 + prompt = prompt_service.get_chapter_generation_prompt( + title=project.title, + theme=project.theme or '', + genre=project.genre or '', + narrative_perspective=project.narrative_perspective or '第三人称', + time_period=project.world_time_period or '未设定', + location=project.world_location or '未设定', + atmosphere=project.world_atmosphere or '未设定', + rules=project.world_rules or '未设定', + characters_info=characters_info or '暂无角色信息', + outlines_context=outlines_context, + chapter_number=chapter.chapter_number, + chapter_title=chapter.title, + chapter_outline=outline.content if outline else chapter.summary or '暂无大纲' + ) + + logger.info(f"开始AI创作章节 {chapter_id}") + + # 调用AI生成 + ai_content = await ai_service.generate_text( + prompt=prompt + ) + + # 更新章节内容 + old_word_count = chapter.word_count or 0 + chapter.content = ai_content + new_word_count = len(ai_content) + chapter.word_count = new_word_count + chapter.status = "completed" + + # 更新项目字数 + project.current_words = project.current_words - old_word_count + new_word_count + + # 记录生成历史 + history = GenerationHistory( + project_id=chapter.project_id, + chapter_id=chapter.id, + prompt=f"创作章节: 第{chapter.chapter_number}章 {chapter.title}", + generated_content=ai_content[:500] if len(ai_content) > 500 else ai_content, + model="default" + ) + db.add(history) + + await db.commit() + await db.refresh(chapter) + + logger.info(f"成功创作章节 {chapter_id},共 {new_word_count} 字") + + return {"content": ai_content} + + except Exception as e: + logger.error(f"创作章节失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"创作章节失败: {str(e)}") + +@router.post("/{chapter_id}/generate-stream", summary="AI创作章节内容(流式)") +async def generate_chapter_content_stream( + chapter_id: str, + request: Request +): + """ + 根据大纲、前置章节内容和项目信息AI创作章节完整内容(流式返回) + 要求:必须按顺序生成,确保前置章节都已完成 + + 注意:此函数不使用依赖注入的db,而是在生成器内部创建独立的数据库会话 + 以避免流式响应期间的连接泄漏问题 + """ + # 预先验证章节存在性(使用临时会话) + async for temp_db in get_db(request): + try: + result = await temp_db.execute( + select(Chapter).where(Chapter.id == chapter_id) + ) + chapter = result.scalar_one_or_none() + if not chapter: + raise HTTPException(status_code=404, detail="章节不存在") + + # 检查前置条件 + can_generate, error_msg, previous_chapters = await check_prerequisites(temp_db, chapter) + if not can_generate: + raise HTTPException(status_code=400, detail=error_msg) + + # 保存前置章节数据供生成器使用 + previous_chapters_data = [ + { + 'id': ch.id, + 'chapter_number': ch.chapter_number, + 'title': ch.title, + 'content': ch.content + } + for ch in previous_chapters + ] + finally: + await temp_db.close() + break + + async def event_generator(): + # 在生成器内部创建独立的数据库会话 + db_session = None + db_committed = False + try: + # 创建新的数据库会话 + async for db_session in get_db(request): + # 重新获取章节信息 + chapter_result = await db_session.execute( + select(Chapter).where(Chapter.id == chapter_id) + ) + current_chapter = chapter_result.scalar_one_or_none() + if not current_chapter: + yield f"data: {json.dumps({'type': 'error', 'error': '章节不存在'}, ensure_ascii=False)}\n\n" + return + + # 获取项目信息 + project_result = await db_session.execute( + select(Project).where(Project.id == current_chapter.project_id) + ) + project = project_result.scalar_one_or_none() + if not project: + yield f"data: {json.dumps({'type': 'error', 'error': '项目不存在'}, ensure_ascii=False)}\n\n" + return + + # 获取对应的大纲 + outline_result = await db_session.execute( + select(Outline) + .where(Outline.project_id == current_chapter.project_id) + .where(Outline.order_index == current_chapter.chapter_number) + .execution_options(populate_existing=True) + ) + outline = outline_result.scalar_one_or_none() + + # 获取所有大纲用于上下文 + all_outlines_result = await db_session.execute( + select(Outline) + .where(Outline.project_id == current_chapter.project_id) + .order_by(Outline.order_index) + .execution_options(populate_existing=True) + ) + all_outlines = all_outlines_result.scalars().all() + outlines_context = "\n".join([ + f"第{o.order_index}章 {o.title}: {o.content[:100]}..." + for o in all_outlines + ]) + + # 获取角色信息 + characters_result = await db_session.execute( + select(Character).where(Character.project_id == current_chapter.project_id) + ) + characters = characters_result.scalars().all() + characters_info = "\n".join([ + f"- {c.name}({'组织' if c.is_organization else '角色'}, {c.role_type}): {c.personality[:100] if c.personality else ''}" + for c in characters + ]) + + # 构建前置章节内容上下文(使用之前保存的数据) + previous_content = "" + if previous_chapters_data: + recent_chapters = previous_chapters_data[-3:] if len(previous_chapters_data) > 3 else previous_chapters_data + early_chapters = previous_chapters_data[:-3] if len(previous_chapters_data) > 3 else [] + + if early_chapters: + early_summary = "【前期剧情概要】\n" + "\n".join([ + f"第{ch['chapter_number']}章《{ch['title']}》:{ch['content'][:200] if ch['content'] else ''}..." + for ch in early_chapters + ]) + previous_content += early_summary + "\n\n" + + if recent_chapters: + recent_content = "【最近章节完整内容】\n" + "\n\n".join([ + f"=== 第{ch['chapter_number']}章:{ch['title']} ===\n{ch['content']}" + for ch in recent_chapters + ]) + previous_content += recent_content + + logger.info(f"构建前置上下文:{len(early_chapters)}章摘要 + {len(recent_chapters)}章完整内容") + + # 发送开始事件 + yield f"data: {json.dumps({'type': 'start', 'message': '开始AI创作...'}, ensure_ascii=False)}\n\n" + + # 根据是否有前置内容选择不同的提示词 + if previous_content: + prompt = prompt_service.get_chapter_generation_with_context_prompt( + title=project.title, + theme=project.theme or '', + genre=project.genre or '', + narrative_perspective=project.narrative_perspective or '第三人称', + time_period=project.world_time_period or '未设定', + location=project.world_location or '未设定', + atmosphere=project.world_atmosphere or '未设定', + rules=project.world_rules or '未设定', + characters_info=characters_info or '暂无角色信息', + outlines_context=outlines_context, + previous_content=previous_content, + chapter_number=current_chapter.chapter_number, + chapter_title=current_chapter.title, + chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲' + ) + else: + prompt = prompt_service.get_chapter_generation_prompt( + title=project.title, + theme=project.theme or '', + genre=project.genre or '', + narrative_perspective=project.narrative_perspective or '第三人称', + time_period=project.world_time_period or '未设定', + location=project.world_location or '未设定', + atmosphere=project.world_atmosphere or '未设定', + rules=project.world_rules or '未设定', + characters_info=characters_info or '暂无角色信息', + outlines_context=outlines_context, + chapter_number=current_chapter.chapter_number, + chapter_title=current_chapter.title, + chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲' + ) + + logger.info(f"开始AI流式创作章节 {chapter_id}") + + # 流式生成内容 + full_content = "" + async for chunk in ai_service.generate_text_stream(prompt=prompt): + full_content += chunk + yield f"data: {json.dumps({'type': 'content', 'content': chunk}, ensure_ascii=False)}\n\n" + await asyncio.sleep(0) # 让出控制权 + + # 更新章节内容到数据库 + old_word_count = current_chapter.word_count or 0 + current_chapter.content = full_content + new_word_count = len(full_content) + current_chapter.word_count = new_word_count + current_chapter.status = "completed" + + # 更新项目字数 + project.current_words = project.current_words - old_word_count + new_word_count + + # 记录生成历史 + history = GenerationHistory( + project_id=current_chapter.project_id, + chapter_id=current_chapter.id, + prompt=f"创作章节: 第{current_chapter.chapter_number}章 {current_chapter.title}", + generated_content=full_content[:500] if len(full_content) > 500 else full_content, + model="default" + ) + db_session.add(history) + + await db_session.commit() + db_committed = True + await db_session.refresh(current_chapter) + + logger.info(f"成功创作章节 {chapter_id},共 {new_word_count} 字") + + # 发送完成事件 + yield f"data: {json.dumps({'type': 'done', 'message': '创作完成', 'word_count': new_word_count}, ensure_ascii=False)}\n\n" + + break # 退出async for db_session循环 + + except GeneratorExit: + # SSE连接断开 + logger.warning("章节生成器被提前关闭(SSE断开)") + if db_session and not db_committed: + try: + if db_session.in_transaction(): + await db_session.rollback() + logger.info("章节生成事务已回滚(GeneratorExit)") + except Exception as e: + logger.error(f"GeneratorExit回滚失败: {str(e)}") + except Exception as e: + logger.error(f"流式创作章节失败: {str(e)}") + if db_session and not db_committed: + try: + if db_session.in_transaction(): + await db_session.rollback() + logger.info("章节生成事务已回滚(异常)") + except Exception as rollback_error: + logger.error(f"回滚失败: {str(rollback_error)}") + yield f"data: {json.dumps({'type': 'error', 'error': str(e)}, ensure_ascii=False)}\n\n" + finally: + # 确保数据库会话被正确关闭 + if db_session: + try: + # 最后检查:确保没有未提交的事务 + if not db_committed and db_session.in_transaction(): + await db_session.rollback() + logger.warning("在finally中发现未提交事务,已回滚") + + await db_session.close() + logger.info("数据库会话已关闭") + except Exception as close_error: + logger.error(f"关闭数据库会话失败: {str(close_error)}") + # 强制关闭 + try: + await db_session.close() + except: + pass + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" + } + ) diff --git a/backend/app/api/characters.py b/backend/app/api/characters.py new file mode 100644 index 0000000..b689ce7 --- /dev/null +++ b/backend/app/api/characters.py @@ -0,0 +1,491 @@ +"""角色管理API""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +import json + +from app.database import get_db +from app.models.character import Character +from app.models.project import Project +from app.models.generation_history import GenerationHistory +from app.models.relationship import CharacterRelationship, Organization, OrganizationMember, RelationshipType +from app.schemas.character import ( + CharacterUpdate, + CharacterResponse, + CharacterListResponse, + CharacterGenerateRequest +) +from app.services.ai_service import ai_service +from app.services.prompt_service import prompt_service +from app.logger import get_logger + +router = APIRouter(prefix="/characters", tags=["角色管理"]) +logger = get_logger(__name__) + + +@router.get("", response_model=CharacterListResponse, summary="获取角色列表") +async def get_characters( + project_id: str, + db: AsyncSession = Depends(get_db) +): + """获取指定项目的所有角色(query参数版本)""" + # 获取总数 + count_result = await db.execute( + select(func.count(Character.id)).where(Character.project_id == project_id) + ) + total = count_result.scalar_one() + + # 获取角色列表 + result = await db.execute( + select(Character) + .where(Character.project_id == project_id) + .order_by(Character.created_at.desc()) + ) + characters = result.scalars().all() + + return CharacterListResponse(total=total, items=characters) + + +@router.get("/project/{project_id}", response_model=CharacterListResponse, summary="获取项目的所有角色") +async def get_project_characters( + project_id: str, + db: AsyncSession = Depends(get_db) +): + """获取指定项目的所有角色(路径参数版本)""" + # 获取总数 + count_result = await db.execute( + select(func.count(Character.id)).where(Character.project_id == project_id) + ) + total = count_result.scalar_one() + + # 获取角色列表 + result = await db.execute( + select(Character) + .where(Character.project_id == project_id) + .order_by(Character.created_at.desc()) + ) + characters = result.scalars().all() + + return CharacterListResponse(total=total, items=characters) + + +@router.get("/{character_id}", response_model=CharacterResponse, summary="获取角色详情") +async def get_character( + character_id: str, + db: AsyncSession = Depends(get_db) +): + """根据ID获取角色详情""" + result = await db.execute( + select(Character).where(Character.id == character_id) + ) + character = result.scalar_one_or_none() + + if not character: + raise HTTPException(status_code=404, detail="角色不存在") + + return character + + +@router.put("/{character_id}", response_model=CharacterResponse, summary="更新角色") +async def update_character( + character_id: str, + character_update: CharacterUpdate, + db: AsyncSession = Depends(get_db) +): + """更新角色信息""" + result = await db.execute( + select(Character).where(Character.id == character_id) + ) + character = result.scalar_one_or_none() + + if not character: + raise HTTPException(status_code=404, detail="角色不存在") + + # 更新字段 + update_data = character_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(character, field, value) + + await db.commit() + await db.refresh(character) + return character + + +@router.delete("/{character_id}", summary="删除角色") +async def delete_character( + character_id: str, + db: AsyncSession = Depends(get_db) +): + """删除角色""" + result = await db.execute( + select(Character).where(Character.id == character_id) + ) + character = result.scalar_one_or_none() + + if not character: + raise HTTPException(status_code=404, detail="角色不存在") + + await db.delete(character) + await db.commit() + + return {"message": "角色删除成功"} + + +@router.post("/generate", response_model=CharacterResponse, summary="AI生成角色") +async def generate_character( + request: CharacterGenerateRequest, + db: AsyncSession = Depends(get_db) +): + """ + 使用AI生成角色卡 + + 根据用户输入的信息,结合项目的世界观、主题等背景, + AI会生成一个完整、详细的角色设定卡片。 + + 生成内容包括:姓名、年龄、性别、性格、外貌、背景故事、人际关系等 + """ + # 验证项目是否存在并获取项目信息 + result = await db.execute( + select(Project).where(Project.id == request.project_id) + ) + project = result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="项目不存在") + + try: + # 获取已存在的角色列表,用于关系网络 + existing_chars_result = await db.execute( + select(Character) + .where(Character.project_id == request.project_id) + .order_by(Character.created_at.desc()) + ) + existing_characters = existing_chars_result.scalars().all() + + # 构建现有角色信息摘要(包含组织) + existing_chars_info = "" + character_list = [] + organization_list = [] + + if existing_characters: + for c in existing_characters[:10]: # 最多显示10个 + if c.is_organization: + organization_list.append(f"- {c.name} [{c.organization_type or '组织'}]") + else: + character_list.append(f"- {c.name}({c.role_type or '未知'})") + + if character_list: + existing_chars_info += "\n已有角色:\n" + "\n".join(character_list) + if organization_list: + existing_chars_info += "\n\n已有组织:\n" + "\n".join(organization_list) + + # 构建项目上下文信息 + project_context = f""" +项目信息: +- 书名:{project.title} +- 主题:{project.theme or '未设定'} +- 类型:{project.genre or '未设定'} +- 时间背景:{project.world_time_period or '未设定'} +- 地理位置:{project.world_location or '未设定'} +- 氛围基调:{project.world_atmosphere or '未设定'} +- 世界规则:{project.world_rules or '未设定'} +{existing_chars_info} +""" + + # 构建用户输入信息 + user_input = f""" +用户要求: +- 角色名称:{request.name or '请AI生成'} +- 角色定位:{request.role_type or 'supporting'}(protagonist=主角, supporting=配角, antagonist=反派) +- 背景设定:{request.background or '无特殊要求'} +- 其他要求:{request.requirements or '无'} +""" + + # 使用统一的提示词服务 + prompt = prompt_service.get_single_character_prompt( + project_context=project_context, + user_input=user_input + ) + + # 调用AI生成角色 + logger.info(f"🎯 开始为项目 {request.project_id} 生成角色") + logger.info(f" - 角色名:{request.name or 'AI生成'}") + logger.info(f" - 角色定位:{request.role_type}") + logger.info(f" - 背景设定:{request.background or '无'}") + logger.info(f" - AI提供商:{request.provider or 'default'}") + logger.info(f" - AI模型:{request.model or 'default'}") + logger.info(f" - Prompt长度:{len(prompt)} 字符") + + try: + ai_response = await ai_service.generate_text( + prompt=prompt, + provider=request.provider, + model=request.model + ) + logger.info(f"✅ AI响应接收完成,长度:{len(ai_response) if ai_response else 0} 字符") + except Exception as ai_error: + logger.error(f"❌ AI服务调用异常:{str(ai_error)}") + raise HTTPException( + status_code=500, + detail=f"AI服务调用失败:{str(ai_error)}" + ) + + # 检查AI响应 + if not ai_response or not ai_response.strip(): + logger.error("❌ AI返回了空响应") + raise HTTPException( + status_code=500, + detail="AI服务返回空响应。可能原因:1) API配置错误 2) 模型不支持 3) 网络问题。请检查后端日志。" + ) + + logger.info(f"📝 开始清理AI响应") + # 清理AI响应,移除可能的markdown标记 + cleaned_response = ai_response.strip() + original_length = len(cleaned_response) + + if cleaned_response.startswith("```json"): + cleaned_response = cleaned_response[7:] + logger.info(" - 移除了 ```json 标记") + if cleaned_response.startswith("```"): + cleaned_response = cleaned_response[3:] + logger.info(" - 移除了 ``` 标记") + if cleaned_response.endswith("```"): + cleaned_response = cleaned_response[:-3] + logger.info(" - 移除了末尾 ``` 标记") + cleaned_response = cleaned_response.strip() + + logger.info(f" - 清理前长度:{original_length},清理后长度:{len(cleaned_response)}") + logger.info(f" - 清理后内容预览(前300字符):{cleaned_response[:300]}") + + # 解析AI响应 + logger.info(f"🔍 开始解析JSON") + try: + character_data = json.loads(cleaned_response) + logger.info(f"✅ JSON解析成功") + logger.info(f" - 解析后的字段:{list(character_data.keys())}") + except json.JSONDecodeError as e: + logger.error(f"❌ JSON解析失败") + logger.error(f" - 错误位置:line {e.lineno}, column {e.colno}") + logger.error(f" - 错误信息:{str(e)}") + logger.error(f" - 完整响应内容(前1000字符):{cleaned_response[:1000]}") + + raise HTTPException( + status_code=500, + detail=f"AI返回的内容无法解析为JSON。错误:{str(e)}。响应内容已记录到日志,请查看后端日志排查。" + ) + + # 转换traits为JSON字符串 + traits_json = json.dumps(character_data.get("traits", []), ensure_ascii=False) if character_data.get("traits") else None + + # 判断是否为组织 + is_organization = character_data.get("is_organization", False) + + # 创建角色 + character = Character( + project_id=request.project_id, + name=character_data.get("name", request.name or "未命名角色"), + age=str(character_data.get("age", "")), + gender=character_data.get("gender"), + is_organization=is_organization, + role_type=request.role_type or "supporting", + personality=character_data.get("personality", ""), + background=character_data.get("background", ""), + appearance=character_data.get("appearance", ""), + relationships=character_data.get("relationships_text", character_data.get("relationships", "")), # 优先使用文本描述 + organization_type=character_data.get("organization_type") if is_organization else None, + organization_purpose=character_data.get("organization_purpose") if is_organization else None, + organization_members=json.dumps(character_data.get("organization_members", []), ensure_ascii=False) if is_organization else None, + traits=traits_json + ) + db.add(character) + await db.flush() # 获取character.id + + logger.info(f"✅ 角色创建成功:{character.name} (ID: {character.id}, 是否组织: {is_organization})") + + # 如果是组织,自动创建Organization详情记录 + if is_organization: + org_check = await db.execute( + select(Organization).where(Organization.character_id == character.id) + ) + existing_org = org_check.scalar_one_or_none() + + if not existing_org: + organization = Organization( + character_id=character.id, + project_id=request.project_id, + member_count=0, + power_level=character_data.get("power_level", 50), + location=character_data.get("location"), + motto=character_data.get("motto") + ) + db.add(organization) + await db.flush() + logger.info(f"✅ 自动创建组织详情:{character.name} (Org ID: {organization.id})") + else: + logger.info(f"ℹ️ 组织详情已存在:{character.name}") + + # 处理结构化关系数据(仅针对非组织角色) + if not is_organization: + relationships_data = character_data.get("relationships", []) + if relationships_data and isinstance(relationships_data, list): + logger.info(f"📊 开始处理 {len(relationships_data)} 条关系数据") + created_rels = 0 + + for rel in relationships_data: + try: + target_name = rel.get("target_character_name") + if not target_name: + logger.debug(f" ⚠️ 关系缺少target_character_name,跳过") + continue + + target_result = await db.execute( + select(Character).where( + Character.project_id == request.project_id, + Character.name == target_name + ) + ) + target_char = target_result.scalar_one_or_none() + + if target_char: + # 检查是否已存在相同关系 + existing_rel = await db.execute( + select(CharacterRelationship).where( + CharacterRelationship.project_id == request.project_id, + CharacterRelationship.character_from_id == character.id, + CharacterRelationship.character_to_id == target_char.id + ) + ) + if existing_rel.scalar_one_or_none(): + logger.debug(f" ℹ️ 关系已存在:{character.name} -> {target_name}") + continue + + relationship = CharacterRelationship( + project_id=request.project_id, + character_from_id=character.id, + character_to_id=target_char.id, + relationship_name=rel.get("relationship_type", "未知关系"), + intimacy_level=rel.get("intimacy_level", 50), + description=rel.get("description", ""), + started_at=rel.get("started_at"), + source="ai" + ) + + # 匹配预定义关系类型 + rel_type_result = await db.execute( + select(RelationshipType).where( + RelationshipType.name == rel.get("relationship_type") + ) + ) + rel_type = rel_type_result.scalar_one_or_none() + if rel_type: + relationship.relationship_type_id = rel_type.id + + db.add(relationship) + created_rels += 1 + logger.info(f" ✅ 创建关系:{character.name} -> {target_name} ({rel.get('relationship_type')})") + else: + logger.warning(f" ⚠️ 目标角色不存在:{target_name}") + + except Exception as rel_error: + logger.warning(f" ❌ 创建关系失败:{str(rel_error)}") + continue + + logger.info(f"✅ 成功创建 {created_rels} 条关系记录") + + # 处理组织成员关系(仅针对非组织角色) + if not is_organization: + org_memberships = character_data.get("organization_memberships", []) + if org_memberships and isinstance(org_memberships, list): + logger.info(f"🏢 开始处理 {len(org_memberships)} 条组织成员关系") + created_members = 0 + + for membership in org_memberships: + try: + org_name = membership.get("organization_name") + if not org_name: + logger.debug(f" ⚠️ 组织成员关系缺少organization_name,跳过") + continue + + org_char_result = await db.execute( + select(Character).where( + Character.project_id == request.project_id, + Character.name == org_name, + Character.is_organization == True + ) + ) + org_char = org_char_result.scalar_one_or_none() + + if org_char: + # 获取或创建Organization记录 + org_result = await db.execute( + select(Organization).where(Organization.character_id == org_char.id) + ) + org = org_result.scalar_one_or_none() + + if not org: + # 如果组织Character存在但Organization不存在,自动创建 + org = Organization( + character_id=org_char.id, + project_id=request.project_id, + member_count=0 + ) + db.add(org) + await db.flush() + logger.info(f" ℹ️ 自动创建缺失的组织详情:{org_name}") + + # 检查是否已存在成员关系 + existing_member = await db.execute( + select(OrganizationMember).where( + OrganizationMember.organization_id == org.id, + OrganizationMember.character_id == character.id + ) + ) + if existing_member.scalar_one_or_none(): + logger.debug(f" ℹ️ 成员关系已存在:{character.name} -> {org_name}") + continue + + # 创建成员关系 + member = OrganizationMember( + organization_id=org.id, + character_id=character.id, + position=membership.get("position", "成员"), + rank=membership.get("rank", 0), + loyalty=membership.get("loyalty", 50), + joined_at=membership.get("joined_at"), + status=membership.get("status", "active"), + source="ai" + ) + db.add(member) + + # 更新组织成员计数 + org.member_count += 1 + + created_members += 1 + logger.info(f" ✅ 添加成员:{character.name} -> {org_name} ({membership.get('position')})") + else: + logger.warning(f" ⚠️ 组织不存在:{org_name}") + + except Exception as org_error: + logger.warning(f" ❌ 添加组织成员失败:{str(org_error)}") + continue + + logger.info(f"✅ 成功创建 {created_members} 条组织成员记录") + + # 记录生成历史 + history = GenerationHistory( + project_id=request.project_id, + prompt=prompt, + generated_content=ai_response, + model=request.model or "default" + ) + db.add(history) + + await db.commit() + await db.refresh(character) + + logger.info(f"🎉 成功为项目 {request.project_id} 生成角色: {character.name}") + + return character + + except Exception as e: + logger.error(f"生成角色失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"生成角色失败: {str(e)}") \ No newline at end of file diff --git a/backend/app/api/organizations.py b/backend/app/api/organizations.py new file mode 100644 index 0000000..43f9ef7 --- /dev/null +++ b/backend/app/api/organizations.py @@ -0,0 +1,341 @@ +"""组织管理API""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_ +from typing import List + +from app.database import get_db +from app.models.relationship import Organization, OrganizationMember +from app.models.character import Character +from app.schemas.relationship import ( + OrganizationCreate, + OrganizationUpdate, + OrganizationResponse, + OrganizationDetailResponse, + OrganizationMemberCreate, + OrganizationMemberUpdate, + OrganizationMemberResponse, + OrganizationMemberDetailResponse +) +from app.logger import get_logger + +router = APIRouter(prefix="/organizations", tags=["组织管理"]) +logger = get_logger(__name__) + + +@router.get("/project/{project_id}", response_model=List[OrganizationDetailResponse], summary="获取项目的所有组织") +async def get_project_organizations( + project_id: str, + db: AsyncSession = Depends(get_db) +): + """ + 获取项目中的所有组织及其详情 + + 返回组织的基本信息和统计数据 + """ + result = await db.execute( + select(Organization).where(Organization.project_id == project_id) + ) + organizations = result.scalars().all() + + # 获取每个组织的角色信息 + org_list = [] + for org in organizations: + char_result = await db.execute( + select(Character).where(Character.id == org.character_id) + ) + char = char_result.scalar_one_or_none() + + if char: + org_list.append(OrganizationDetailResponse( + id=org.id, + character_id=org.character_id, + name=char.name, + type=char.organization_type, + purpose=char.organization_purpose, + member_count=org.member_count, + power_level=org.power_level, + location=org.location, + motto=org.motto, + color=org.color + )) + + logger.info(f"获取项目 {project_id} 的组织列表,共 {len(org_list)} 个") + return org_list + + +@router.get("/{org_id}", response_model=OrganizationResponse, summary="获取组织详情") +async def get_organization( + org_id: str, + db: AsyncSession = Depends(get_db) +): + """获取组织的详细信息""" + result = await db.execute( + select(Organization).where(Organization.id == org_id) + ) + org = result.scalar_one_or_none() + + if not org: + raise HTTPException(status_code=404, detail="组织不存在") + + return org + + +@router.post("/", response_model=OrganizationResponse, summary="创建组织") +async def create_organization( + organization: OrganizationCreate, + db: AsyncSession = Depends(get_db) +): + """ + 创建新组织 + + - 需要关联到一个已存在的角色记录(is_organization=True) + - 可以设置父组织、势力等级等属性 + """ + # 验证角色是否存在且是组织 + char_result = await db.execute( + select(Character).where(Character.id == organization.character_id) + ) + char = char_result.scalar_one_or_none() + + if not char: + raise HTTPException(status_code=404, detail="关联的角色不存在") + if not char.is_organization: + raise HTTPException(status_code=400, detail="关联的角色不是组织类型") + + # 检查是否已存在 + existing = await db.execute( + select(Organization).where(Organization.character_id == organization.character_id) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="该角色已有组织详情记录") + + # 创建组织 + db_org = Organization(**organization.model_dump()) + db.add(db_org) + await db.commit() + await db.refresh(db_org) + + logger.info(f"创建组织成功:{db_org.id} - {char.name}") + return db_org + + +@router.put("/{org_id}", response_model=OrganizationResponse, summary="更新组织") +async def update_organization( + org_id: str, + organization: OrganizationUpdate, + db: AsyncSession = Depends(get_db) +): + """更新组织的属性""" + result = await db.execute( + select(Organization).where(Organization.id == org_id) + ) + db_org = result.scalar_one_or_none() + + if not db_org: + raise HTTPException(status_code=404, detail="组织不存在") + + # 更新字段 + update_data = organization.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_org, field, value) + + await db.commit() + await db.refresh(db_org) + + logger.info(f"更新组织成功:{org_id}") + return db_org + + +@router.delete("/{org_id}", summary="删除组织") +async def delete_organization( + org_id: str, + db: AsyncSession = Depends(get_db) +): + """删除组织(会级联删除所有成员关系)""" + result = await db.execute( + select(Organization).where(Organization.id == org_id) + ) + db_org = result.scalar_one_or_none() + + if not db_org: + raise HTTPException(status_code=404, detail="组织不存在") + + await db.delete(db_org) + await db.commit() + + logger.info(f"删除组织成功:{org_id}") + return {"message": "组织删除成功", "id": org_id} + + +# ============ 组织成员管理 ============ + +@router.get("/{org_id}/members", response_model=List[OrganizationMemberDetailResponse], summary="获取组织成员") +async def get_organization_members( + org_id: str, + db: AsyncSession = Depends(get_db) +): + """ + 获取组织的所有成员 + + 按职位等级(rank)降序排列 + """ + # 验证组织存在 + org_result = await db.execute( + select(Organization).where(Organization.id == org_id) + ) + if not org_result.scalar_one_or_none(): + raise HTTPException(status_code=404, detail="组织不存在") + + # 获取成员列表 + result = await db.execute( + select(OrganizationMember) + .where(OrganizationMember.organization_id == org_id) + .order_by(OrganizationMember.rank.desc(), OrganizationMember.created_at) + ) + members = result.scalars().all() + + # 获取成员角色信息 + member_list = [] + for member in members: + char_result = await db.execute( + select(Character).where(Character.id == member.character_id) + ) + char = char_result.scalar_one_or_none() + + if char: + member_list.append(OrganizationMemberDetailResponse( + id=member.id, + character_id=member.character_id, + character_name=char.name, + position=member.position, + rank=member.rank, + loyalty=member.loyalty, + contribution=member.contribution, + status=member.status, + joined_at=member.joined_at, + left_at=member.left_at, + notes=member.notes + )) + + logger.info(f"获取组织 {org_id} 的成员列表,共 {len(member_list)} 人") + return member_list + + +@router.post("/{org_id}/members", response_model=OrganizationMemberResponse, summary="添加组织成员") +async def add_organization_member( + org_id: str, + member: OrganizationMemberCreate, + db: AsyncSession = Depends(get_db) +): + """ + 添加角色到组织 + + - 一个角色在同一组织中只能有一个职位 + - 会自动更新组织的成员计数 + """ + # 验证组织存在 + org_result = await db.execute( + select(Organization).where(Organization.id == org_id) + ) + org = org_result.scalar_one_or_none() + if not org: + raise HTTPException(status_code=404, detail="组织不存在") + + # 验证角色存在 + char_result = await db.execute( + select(Character).where(Character.id == member.character_id) + ) + char = char_result.scalar_one_or_none() + if not char: + raise HTTPException(status_code=404, detail="角色不存在") + if char.is_organization: + raise HTTPException(status_code=400, detail="不能将组织添加为成员") + + # 检查是否已存在 + existing = await db.execute( + select(OrganizationMember).where( + and_( + OrganizationMember.organization_id == org_id, + OrganizationMember.character_id == member.character_id + ) + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException(status_code=400, detail="该角色已在组织中") + + # 创建成员关系 + db_member = OrganizationMember( + organization_id=org_id, + **member.model_dump(), + source="manual" + ) + db.add(db_member) + + # 更新组织成员计数 + org.member_count += 1 + + await db.commit() + await db.refresh(db_member) + + logger.info(f"添加成员成功:{char.name} 加入组织 {org_id}") + return db_member + + +@router.put("/members/{member_id}", response_model=OrganizationMemberResponse, summary="更新成员信息") +async def update_organization_member( + member_id: str, + member: OrganizationMemberUpdate, + db: AsyncSession = Depends(get_db) +): + """更新组织成员的职位、忠诚度等信息""" + result = await db.execute( + select(OrganizationMember).where(OrganizationMember.id == member_id) + ) + db_member = result.scalar_one_or_none() + + if not db_member: + raise HTTPException(status_code=404, detail="成员记录不存在") + + # 更新字段 + update_data = member.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_member, field, value) + + await db.commit() + await db.refresh(db_member) + + logger.info(f"更新成员信息成功:{member_id}") + return db_member + + +@router.delete("/members/{member_id}", summary="移除组织成员") +async def remove_organization_member( + member_id: str, + db: AsyncSession = Depends(get_db) +): + """ + 从组织中移除成员 + + 会自动更新组织的成员计数 + """ + result = await db.execute( + select(OrganizationMember).where(OrganizationMember.id == member_id) + ) + db_member = result.scalar_one_or_none() + + if not db_member: + raise HTTPException(status_code=404, detail="成员记录不存在") + + # 更新组织成员计数 + org_result = await db.execute( + select(Organization).where(Organization.id == db_member.organization_id) + ) + org = org_result.scalar_one() + org.member_count = max(0, org.member_count - 1) + + await db.delete(db_member) + await db.commit() + + logger.info(f"移除成员成功:{member_id}") + return {"message": "成员移除成功", "id": member_id} \ No newline at end of file diff --git a/backend/app/api/outlines.py b/backend/app/api/outlines.py new file mode 100644 index 0000000..b5493fa --- /dev/null +++ b/backend/app/api/outlines.py @@ -0,0 +1,657 @@ +"""大纲管理API""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, delete +from typing import List +import json + +from app.database import get_db +from app.models.outline import Outline +from app.models.project import Project +from app.models.chapter import Chapter +from app.models.character import Character +from app.models.generation_history import GenerationHistory +from app.schemas.outline import ( + OutlineCreate, + OutlineUpdate, + OutlineResponse, + OutlineListResponse, + OutlineGenerateRequest, + OutlineReorderRequest +) +from app.services.ai_service import ai_service +from app.services.prompt_service import prompt_service +from app.logger import get_logger + +router = APIRouter(prefix="/outlines", tags=["大纲管理"]) +logger = get_logger(__name__) + + +@router.post("", response_model=OutlineResponse, summary="创建大纲") +async def create_outline( + outline: OutlineCreate, + db: AsyncSession = Depends(get_db) +): + """创建新的章节大纲,同时创建对应的章节记录""" + # 验证项目是否存在 + result = await db.execute( + select(Project).where(Project.id == outline.project_id) + ) + project = result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="项目不存在") + + # 创建大纲 + db_outline = Outline(**outline.model_dump()) + db.add(db_outline) + + # 同步创建对应的章节记录 + chapter = Chapter( + project_id=outline.project_id, + chapter_number=outline.order_index, + title=outline.title, + summary=outline.content[:500] if len(outline.content) > 500 else outline.content, + status="draft" + ) + db.add(chapter) + + await db.commit() + await db.refresh(db_outline) + return db_outline + + +@router.get("", response_model=OutlineListResponse, summary="获取大纲列表") +async def get_outlines( + project_id: str, + db: AsyncSession = Depends(get_db) +): + """获取指定项目的所有大纲""" + # 获取总数 + count_result = await db.execute( + select(func.count(Outline.id)).where(Outline.project_id == project_id) + ) + total = count_result.scalar_one() + + # 获取大纲列表 + result = await db.execute( + select(Outline) + .where(Outline.project_id == project_id) + .order_by(Outline.order_index) + ) + outlines = result.scalars().all() + + return OutlineListResponse(total=total, items=outlines) + + +@router.get("/project/{project_id}", response_model=OutlineListResponse, summary="获取项目的所有大纲") +async def get_project_outlines( + project_id: str, + db: AsyncSession = Depends(get_db) +): + """获取指定项目的所有大纲(路径参数版本)""" + # 获取总数 + count_result = await db.execute( + select(func.count(Outline.id)).where(Outline.project_id == project_id) + ) + total = count_result.scalar_one() + + # 获取大纲列表 + result = await db.execute( + select(Outline) + .where(Outline.project_id == project_id) + .order_by(Outline.order_index) + ) + outlines = result.scalars().all() + + return OutlineListResponse(total=total, items=outlines) + + +@router.get("/{outline_id}", response_model=OutlineResponse, summary="获取大纲详情") +async def get_outline( + outline_id: str, + db: AsyncSession = Depends(get_db) +): + """根据ID获取大纲详情""" + result = await db.execute( + select(Outline).where(Outline.id == outline_id) + ) + outline = result.scalar_one_or_none() + + if not outline: + raise HTTPException(status_code=404, detail="大纲不存在") + + return outline + + +@router.put("/{outline_id}", response_model=OutlineResponse, summary="更新大纲") +async def update_outline( + outline_id: str, + outline_update: OutlineUpdate, + db: AsyncSession = Depends(get_db) +): + """更新大纲信息,同步更新对应章节和structure字段""" + result = await db.execute( + select(Outline).where(Outline.id == outline_id) + ) + outline = result.scalar_one_or_none() + + if not outline: + raise HTTPException(status_code=404, detail="大纲不存在") + + # 更新字段 + update_data = outline_update.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(outline, field, value) + + # 如果修改了content或title,同步更新structure字段 + if 'content' in update_data or 'title' in update_data: + try: + # 尝试解析现有的structure + if outline.structure: + structure_data = json.loads(outline.structure) + else: + structure_data = {} + + # 更新structure中的对应字段 + if 'title' in update_data: + structure_data['title'] = outline.title + if 'content' in update_data: + structure_data['summary'] = outline.content + structure_data['content'] = outline.content + + # 保存更新后的structure + outline.structure = json.dumps(structure_data, ensure_ascii=False) + logger.info(f"同步更新大纲 {outline_id} 的structure字段") + except json.JSONDecodeError: + logger.warning(f"大纲 {outline_id} 的structure字段格式错误,跳过更新") + + # 同步更新对应的章节标题和摘要 + if 'title' in update_data or 'content' in update_data: + chapter_result = await db.execute( + select(Chapter).where( + Chapter.project_id == outline.project_id, + Chapter.chapter_number == outline.order_index + ) + ) + chapter = chapter_result.scalar_one_or_none() + + if chapter: + if 'title' in update_data: + chapter.title = outline.title + if 'content' in update_data: + # 更新章节摘要(取content前500字符) + chapter.summary = outline.content[:500] if len(outline.content) > 500 else outline.content + logger.info(f"同步更新章节 {chapter.id} 的标题和摘要") + else: + logger.warning(f"未找到对应的章节记录 (order_index={outline.order_index})") + + await db.commit() + await db.refresh(outline) + return outline + + +@router.delete("/{outline_id}", summary="删除大纲") +async def delete_outline( + outline_id: str, + db: AsyncSession = Depends(get_db) +): + """删除大纲,同步删除章节,并重新排序后续项""" + result = await db.execute( + select(Outline).where(Outline.id == outline_id) + ) + outline = result.scalar_one_or_none() + + if not outline: + raise HTTPException(status_code=404, detail="大纲不存在") + + project_id = outline.project_id + deleted_order = outline.order_index + + # 删除对应的章节 + await db.execute( + delete(Chapter).where( + Chapter.project_id == project_id, + Chapter.chapter_number == deleted_order + ) + ) + + # 删除大纲 + await db.delete(outline) + + # 重新排序后续的大纲和章节(序号-1) + result = await db.execute( + select(Outline).where( + Outline.project_id == project_id, + Outline.order_index > deleted_order + ) + ) + subsequent_outlines = result.scalars().all() + + for o in subsequent_outlines: + old_order = o.order_index + o.order_index -= 1 + + # 同步更新对应的章节 + chapter_result = await db.execute( + select(Chapter).where( + Chapter.project_id == project_id, + Chapter.chapter_number == old_order + ) + ) + chapter = chapter_result.scalar_one_or_none() + if chapter: + chapter.chapter_number = old_order - 1 + + await db.commit() + + return {"message": "大纲删除成功"} + + +@router.post("/reorder", summary="批量重排序大纲") +async def reorder_outlines( + request: OutlineReorderRequest, + db: AsyncSession = Depends(get_db) +): + """ + 批量调整大纲顺序,同步更新章节序号 + + 策略:先收集所有变更,最后一次性提交,避免临时冲突 + """ + try: + # 第一步:收集所有大纲和对应的章节 + outline_chapter_map = {} # {outline_id: (outline, chapter, old_order, new_order)} + + for item in request.orders: + outline_id = item.id + new_order = item.order_index + + # 获取大纲 + result = await db.execute( + select(Outline).where(Outline.id == outline_id) + ) + outline = result.scalar_one_or_none() + + if not outline: + logger.warning(f"大纲 {outline_id} 不存在,跳过") + continue + + old_order = outline.order_index + + # 获取对应的章节(通过旧的chapter_number匹配) + chapter_result = await db.execute( + select(Chapter).where( + Chapter.project_id == outline.project_id, + Chapter.chapter_number == old_order + ) + ) + chapter = chapter_result.first() + chapter_obj = chapter[0] if chapter else None + + outline_chapter_map[outline_id] = (outline, chapter_obj, old_order, new_order) + + # 第二步:批量更新所有大纲和章节 + updated_outlines = 0 + updated_chapters = 0 + + for outline_id, (outline, chapter, old_order, new_order) in outline_chapter_map.items(): + # 更新大纲 + outline.order_index = new_order + updated_outlines += 1 + + # 更新章节 + if chapter: + chapter.chapter_number = new_order + chapter.title = outline.title # 同步更新标题 + updated_chapters += 1 + else: + logger.warning(f"章节 {old_order} 不存在,跳过") + + # 第三步:一次性提交所有更改 + await db.commit() + + logger.info(f"重排序成功:更新了 {updated_outlines} 个大纲,{updated_chapters} 个章节") + + return { + "message": "重排序成功", + "updated_outlines": updated_outlines, + "updated_chapters": updated_chapters + } + + except Exception as e: + await db.rollback() + logger.error(f"重排序失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"重排序失败: {str(e)}") + + +@router.post("/generate", response_model=OutlineListResponse, summary="AI生成/续写大纲") +async def generate_outline( + request: OutlineGenerateRequest, + db: AsyncSession = Depends(get_db) +): + """ + 使用AI生成或续写小说大纲 - 智能模式 + + 支持三种模式: + - auto: 自动判断(无大纲→新建,有大纲→续写) + - new: 强制全新生成 + - continue: 强制续写模式 + """ + # 验证项目是否存在 + result = await db.execute( + select(Project).where(Project.id == request.project_id) + ) + project = result.scalar_one_or_none() + if not project: + raise HTTPException(status_code=404, detail="项目不存在") + + try: + # 获取现有大纲(强制从数据库获取最新数据,包括用户手动修改的内容) + existing_result = await db.execute( + select(Outline) + .where(Outline.project_id == request.project_id) + .order_by(Outline.order_index) + .execution_options(populate_existing=True) + ) + existing_outlines = existing_result.scalars().all() + + # 判断实际执行模式 + actual_mode = request.mode + if actual_mode == "auto": + actual_mode = "continue" if existing_outlines else "new" + logger.info(f"自动判断模式:{'续写' if existing_outlines else '新建'}") + + # 模式:全新生成 + if actual_mode == "new": + return await _generate_new_outline( + request, project, db + ) + + # 模式:续写 + elif actual_mode == "continue": + if not existing_outlines: + raise HTTPException( + status_code=400, + detail="续写模式需要已有大纲,当前项目没有大纲" + ) + + return await _continue_outline( + request, project, existing_outlines, db + ) + + else: + raise HTTPException( + status_code=400, + detail=f"不支持的模式: {request.mode}" + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"生成大纲失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"生成大纲失败: {str(e)}") + + +async def _generate_new_outline( + request: OutlineGenerateRequest, + project: Project, + db: AsyncSession +) -> OutlineListResponse: + """全新生成大纲""" + logger.info(f"全新生成大纲 - 项目: {project.id}, keep_existing: {request.keep_existing}") + + # 获取角色信息 + characters_result = await db.execute( + select(Character).where(Character.project_id == project.id) + ) + characters = characters_result.scalars().all() + characters_info = "\n".join([ + f"- {char.name} ({'组织' if char.is_organization else '角色'}, {char.role_type}): " + f"{char.personality[:100] if char.personality else '暂无描述'}" + for char in characters + ]) + + # 使用完整提示词 + prompt = prompt_service.get_complete_outline_prompt( + title=project.title, + theme=request.theme or project.theme or "未设定", + genre=request.genre or project.genre or "通用", + chapter_count=request.chapter_count, + narrative_perspective=request.narrative_perspective, + target_words=request.target_words, + time_period=project.world_time_period or "未设定", + location=project.world_location or "未设定", + atmosphere=project.world_atmosphere or "未设定", + rules=project.world_rules or "未设定", + characters_info=characters_info or "暂无角色信息", + requirements=request.requirements or "" + ) + + # 调用AI + ai_response = await ai_service.generate_text( + prompt=prompt, + provider=request.provider, + model=request.model + ) + + # 解析响应 + outline_data = _parse_ai_response(ai_response) + + # 全新生成模式:必须删除旧大纲和章节 + # 注意:这是"new"模式的核心逻辑,应该始终删除旧数据 + logger.info(f"删除项目 {project.id} 的旧大纲和章节") + await db.execute( + delete(Outline).where(Outline.project_id == project.id) + ) + await db.execute( + delete(Chapter).where(Chapter.project_id == project.id) + ) + + # 保存新大纲 + outlines = await _save_outlines( + project.id, outline_data, db, start_index=1 + ) + + # 记录历史 + history = GenerationHistory( + project_id=project.id, + prompt=prompt, + generated_content=ai_response, + model=request.model or "default" + ) + db.add(history) + + await db.commit() + + for outline in outlines: + await db.refresh(outline) + + logger.info(f"全新生成完成 - {len(outlines)} 章") + return OutlineListResponse(total=len(outlines), items=outlines) + + +async def _continue_outline( + request: OutlineGenerateRequest, + project: Project, + existing_outlines: List[Outline], + db: AsyncSession +) -> OutlineListResponse: + """续写大纲""" + logger.info(f"续写大纲 - 项目: {project.id}, 已有: {len(existing_outlines)} 章") + + # 分析已有大纲 + current_chapter_count = len(existing_outlines) + last_chapter_number = existing_outlines[-1].order_index + + # 获取最近2章的剧情 + recent_outlines = existing_outlines[-2:] if len(existing_outlines) >= 2 else existing_outlines + recent_plot = "\n".join([ + f"第{o.order_index}章《{o.title}》: {o.content}" + for o in recent_outlines + ]) + # logger.debug(f"最近三章内容:{recent_plot}") + # 全部章节概览 + all_chapters_brief = "\n".join([ + f"第{o.order_index}章: {o.title}" + for o in existing_outlines + ]) + + # 获取角色信息 + characters_result = await db.execute( + select(Character).where(Character.project_id == project.id) + ) + characters = characters_result.scalars().all() + characters_info = "\n".join([ + f"- {char.name} ({'组织' if char.is_organization else '角色'}, {char.role_type}): " + f"{char.personality[:100] if char.personality else '暂无描述'}" + for char in characters + ]) + + # 情节阶段指导 + stage_instructions = { + "development": "继续展开情节,深化角色关系,推进主线冲突", + "climax": "进入故事高潮,矛盾激化,关键冲突爆发", + "ending": "解决主要冲突,收束伏笔,给出结局" + } + stage_instruction = stage_instructions.get(request.plot_stage, "") + + # 使用标准续写提示词模板 + prompt = prompt_service.get_outline_continue_prompt( + title=project.title, + theme=request.theme or project.theme or "未设定", + genre=request.genre or project.genre or "通用", + narrative_perspective=request.narrative_perspective, + chapter_count=request.chapter_count, + time_period=project.world_time_period or "未设定", + location=project.world_location or "未设定", + atmosphere=project.world_atmosphere or "未设定", + rules=project.world_rules or "未设定", + characters_info=characters_info or "暂无角色信息", + current_chapter_count=current_chapter_count, + all_chapters_brief=all_chapters_brief, + recent_plot=recent_plot, + plot_stage_instruction=stage_instruction, + start_chapter=last_chapter_number + 1, + story_direction=request.story_direction or "自然延续", + requirements=request.requirements or "" + ) + + # 调用AI + ai_response = await ai_service.generate_text( + prompt=prompt, + provider=request.provider, + model=request.model + ) + + # 解析响应 + outline_data = _parse_ai_response(ai_response) + + # 保存续写的大纲 + new_outlines = await _save_outlines( + project.id, outline_data, db, start_index=last_chapter_number + 1 + ) + + # 记录历史 + history = GenerationHistory( + project_id=project.id, + prompt=prompt, + generated_content=ai_response, + model=request.model or "default" + ) + db.add(history) + + await db.commit() + + for outline in new_outlines: + await db.refresh(outline) + + # 返回所有大纲(包括旧的和新的) + all_result = await db.execute( + select(Outline) + .where(Outline.project_id == project.id) + .order_by(Outline.order_index) + ) + all_outlines = all_result.scalars().all() + + logger.info(f"续写完成 - 新增 {len(new_outlines)} 章,总计 {len(all_outlines)} 章") + return OutlineListResponse(total=len(all_outlines), items=all_outlines) + + +def _parse_ai_response(ai_response: str) -> list: + """解析AI响应为章节数据列表""" + try: + # 清理响应文本 + cleaned_text = ai_response.strip() + if cleaned_text.startswith('```json'): + cleaned_text = cleaned_text[7:] + if cleaned_text.startswith('```'): + cleaned_text = cleaned_text[3:] + if cleaned_text.endswith('```'): + cleaned_text = cleaned_text[:-3] + cleaned_text = cleaned_text.strip() + + outline_data = json.loads(cleaned_text) + + # 确保是列表格式 + if not isinstance(outline_data, list): + # 如果是对象,尝试提取chapters字段 + if isinstance(outline_data, dict): + outline_data = outline_data.get("chapters", [outline_data]) + else: + outline_data = [outline_data] + + return outline_data + + except json.JSONDecodeError as e: + logger.error(f"AI响应解析失败: {e}") + # 返回一个包含原始内容的章节 + return [{ + "title": "AI生成的大纲", + "content": ai_response[:1000], + "summary": ai_response[:1000] + }] + + +async def _save_outlines( + project_id: str, + outline_data: list, + db: AsyncSession, + start_index: int = 1 +) -> List[Outline]: + """保存大纲到数据库""" + outlines = [] + + for idx, chapter_data in enumerate(outline_data): + order_idx = chapter_data.get("chapter_number", start_index + idx) + title = chapter_data.get("title", f"第{order_idx}章") + + # 优先使用summary,其次content + content = chapter_data.get("summary") or chapter_data.get("content", "") + + # 如果有额外信息,添加到内容中 + if "key_events" in chapter_data: + content += f"\n\n关键事件:" + "、".join(chapter_data["key_events"]) + if "characters_involved" in chapter_data: + content += f"\n涉及角色:" + "、".join(chapter_data["characters_involved"]) + + # 创建大纲 + outline = Outline( + project_id=project_id, + title=title, + content=content, + structure=json.dumps(chapter_data, ensure_ascii=False), + order_index=order_idx + ) + db.add(outline) + outlines.append(outline) + + # 同步创建章节记录 + chapter = Chapter( + project_id=project_id, + chapter_number=order_idx, + title=title, + summary=content[:500] if len(content) > 500 else content, + status="draft" + ) + db.add(chapter) + + return outlines \ No newline at end of file diff --git a/backend/app/api/polish.py b/backend/app/api/polish.py new file mode 100644 index 0000000..d07839e --- /dev/null +++ b/backend/app/api/polish.py @@ -0,0 +1,124 @@ +"""AI去味API - 核心特色功能""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.generation_history import GenerationHistory +from app.schemas.polish import PolishRequest, PolishResponse +from app.services.ai_service import ai_service +from app.services.prompt_service import prompt_service +from app.logger import get_logger + +router = APIRouter(prefix="/polish", tags=["AI去味"]) +logger = get_logger(__name__) + + +@router.post("", response_model=PolishResponse, summary="AI去味") +async def polish_text( + request: PolishRequest, + db: AsyncSession = Depends(get_db) +): + """ + AI去味 - 将AI生成的文本改写得更像人类作家的手笔 + + 核心功能: + - 去除AI痕迹(工整排比、重复修辞、机械总结) + - 增加人性化(口语化、不完美细节、真实情感) + - 优化叙事(自然节奏、简单词汇、松弛感) + - 让对话更生活化 + + 这是本项目的核心特色功能! + """ + try: + # 构建AI去味提示词 + prompt = prompt_service.get_denoising_prompt( + original_text=request.original_text + ) + + logger.info(f"开始AI去味处理,原文长度: {len(request.original_text)}") + + # 调用AI进行去味处理 + polished_text = await ai_service.generate_text( + prompt=prompt, + provider=request.provider, + model=request.model, + temperature=request.temperature, + max_tokens=len(request.original_text) * 2 # 预留足够token + ) + + # 计算字数 + word_count_before = len(request.original_text) + word_count_after = len(polished_text) + + logger.info(f"AI去味完成,处理后长度: {word_count_after}") + + # 如果提供了项目ID,记录到历史 + if request.project_id: + history = GenerationHistory( + project_id=request.project_id, + generation_type="polish", + prompt=f"原文: {request.original_text[:100]}...", + result=polished_text, + provider=request.provider or "default", + model=request.model or "default" + ) + db.add(history) + await db.commit() + + return PolishResponse( + original_text=request.original_text, + polished_text=polished_text, + word_count_before=word_count_before, + word_count_after=word_count_after + ) + + except Exception as e: + logger.error(f"AI去味失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"AI去味失败: {str(e)}") + + +@router.post("/batch", summary="批量AI去味") +async def polish_batch( + texts: list[str], + project_id: int = None, + provider: str = None, + model: str = None, + db: AsyncSession = Depends(get_db) +): + """ + 批量处理多个文本的AI去味 + + 适用于一次性处理多个章节或段落 + """ + try: + results = [] + + for idx, text in enumerate(texts): + logger.info(f"处理第 {idx+1}/{len(texts)} 个文本") + + prompt = prompt_service.get_denoising_prompt(original_text=text) + + polished_text = await ai_service.generate_text( + prompt=prompt, + provider=provider, + model=model + ) + + results.append({ + "index": idx, + "original": text, + "polished": polished_text, + "word_count_before": len(text), + "word_count_after": len(polished_text) + }) + + logger.info(f"批量AI去味完成,共处理 {len(results)} 个文本") + + return { + "total": len(results), + "results": results + } + + except Exception as e: + logger.error(f"批量AI去味失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"批量AI去味失败: {str(e)}") \ No newline at end of file diff --git a/backend/app/api/projects.py b/backend/app/api/projects.py new file mode 100644 index 0000000..4b747a8 --- /dev/null +++ b/backend/app/api/projects.py @@ -0,0 +1,414 @@ +"""项目管理API""" +from fastapi import APIRouter, Depends, HTTPException +from fastapi.responses import Response +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, delete +from typing import List +from app.database import get_db +from app.models.project import Project +from app.models.character import Character +from app.models.outline import Outline +from app.models.chapter import Chapter +from app.models.generation_history import GenerationHistory +from app.models.relationship import CharacterRelationship, Organization, OrganizationMember +from app.schemas.project import ( + ProjectCreate, + ProjectUpdate, + ProjectResponse, + ProjectListResponse +) +from app.logger import get_logger +from app.utils.data_consistency import ( + run_full_data_consistency_check, + fix_missing_organization_records, + fix_organization_member_counts +) + +logger = get_logger(__name__) +router = APIRouter(prefix="/projects", tags=["项目管理"]) + + +@router.post("", response_model=ProjectResponse, summary="创建项目") +async def create_project( + project: ProjectCreate, + db: AsyncSession = Depends(get_db) +): + try: + logger.info(f"创建新项目: {project.title}") + db_project = Project(**project.model_dump()) + db.add(db_project) + await db.commit() + await db.refresh(db_project) + logger.info(f"项目创建成功: {db_project.id}") + return db_project + except Exception as e: + logger.error(f"创建项目失败: {str(e)}", exc_info=True) + raise + + +@router.get("", response_model=ProjectListResponse, summary="获取项目列表") +async def get_projects( + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db) +): + """获取所有项目列表""" + try: + logger.debug(f"获取项目列表: skip={skip}, limit={limit}") + count_result = await db.execute(select(func.count(Project.id))) + total = count_result.scalar_one() + + result = await db.execute( + select(Project) + .order_by(Project.updated_at.desc()) + .offset(skip) + .limit(limit) + ) + projects = result.scalars().all() + logger.info(f"获取项目列表成功: 共{total}个项目") + + return ProjectListResponse(total=total, items=projects) + except Exception as e: + logger.error(f"获取项目列表失败: {str(e)}", exc_info=True) + raise + + +@router.get("/{project_id}", response_model=ProjectResponse, summary="获取项目详情") +async def get_project( + project_id: str, + db: AsyncSession = Depends(get_db) +): + try: + logger.debug(f"获取项目详情: {project_id}") + result = await db.execute( + select(Project).where(Project.id == project_id) + ) + project = result.scalar_one_or_none() + + if not project: + logger.warning(f"项目不存在: {project_id}") + raise HTTPException(status_code=404, detail="项目不存在") + + logger.info(f"获取项目详情成功: {project.title}") + return project + except HTTPException: + raise + except Exception as e: + logger.error(f"获取项目详情失败: {str(e)}", exc_info=True) + raise + + +@router.put("/{project_id}", response_model=ProjectResponse, summary="更新项目") +async def update_project( + project_id: str, + project_update: ProjectUpdate, + db: AsyncSession = Depends(get_db) +): + try: + logger.info(f"更新项目: {project_id}") + result = await db.execute( + select(Project).where(Project.id == project_id) + ) + project = result.scalar_one_or_none() + + if not project: + logger.warning(f"项目不存在: {project_id}") + raise HTTPException(status_code=404, detail="项目不存在") + + update_data = project_update.model_dump(exclude_unset=True) + logger.debug(f"更新字段: {list(update_data.keys())}") + for field, value in update_data.items(): + setattr(project, field, value) + + await db.commit() + await db.refresh(project) + logger.info(f"项目更新成功: {project.title}") + return project + except HTTPException: + raise + except Exception as e: + logger.error(f"更新项目失败: {str(e)}", exc_info=True) + raise + + +@router.delete("/{project_id}", summary="删除项目") +async def delete_project( + project_id: str, + db: AsyncSession = Depends(get_db) +): + try: + logger.info(f"删除项目: {project_id}") + result = await db.execute( + select(Project).where(Project.id == project_id) + ) + project = result.scalar_one_or_none() + + if not project: + logger.warning(f"项目不存在: {project_id}") + raise HTTPException(status_code=404, detail="项目不存在") + + project_title = project.title + + relationships_result = await db.execute( + delete(CharacterRelationship).where(CharacterRelationship.project_id == project_id) + ) + logger.debug(f"删除角色关系数: {relationships_result.rowcount}") + + orgs_result = await db.execute( + select(Organization).where(Organization.project_id == project_id) + ) + orgs = orgs_result.scalars().all() + org_member_count = 0 + for org in orgs: + members_result = await db.execute( + delete(OrganizationMember).where(OrganizationMember.organization_id == org.id) + ) + org_member_count += members_result.rowcount + logger.debug(f"删除组织成员数: {org_member_count}") + + organizations_result = await db.execute( + delete(Organization).where(Organization.project_id == project_id) + ) + logger.debug(f"删除组织数: {organizations_result.rowcount}") + + history_result = await db.execute( + delete(GenerationHistory).where(GenerationHistory.project_id == project_id) + ) + logger.debug(f"删除生成历史数: {history_result.rowcount}") + + chapters_result = await db.execute( + delete(Chapter).where(Chapter.project_id == project_id) + ) + logger.debug(f"删除章节数: {chapters_result.rowcount}") + + outlines_result = await db.execute( + delete(Outline).where(Outline.project_id == project_id) + ) + logger.debug(f"删除大纲数: {outlines_result.rowcount}") + + characters_result = await db.execute( + delete(Character).where(Character.project_id == project_id) + ) + logger.debug(f"删除角色数: {characters_result.rowcount}") + + await db.delete(project) + await db.commit() + + logger.info(f"项目删除成功: {project_title}") + return {"message": "项目及所有关联数据删除成功"} + except HTTPException: + raise + except Exception as e: + logger.error(f"删除项目失败: {str(e)}", exc_info=True) + raise + + +@router.get("/{project_id}/export", summary="导出项目章节为TXT") +async def export_project_chapters( + project_id: str, + db: AsyncSession = Depends(get_db) +): + """ + 导出项目的所有章节内容为TXT文本文件 + 按章节顺序组织,包含项目基本信息 + """ + try: + logger.info(f"开始导出项目: {project_id}") + + result = await db.execute( + select(Project).where(Project.id == project_id) + ) + project = result.scalar_one_or_none() + + if not project: + logger.warning(f"项目不存在: {project_id}") + raise HTTPException(status_code=404, detail="项目不存在") + + chapters_result = await db.execute( + select(Chapter) + .where(Chapter.project_id == project_id) + .order_by(Chapter.chapter_number) + ) + chapters = chapters_result.scalars().all() + + if not chapters: + logger.warning(f"项目没有章节: {project_id}") + raise HTTPException(status_code=404, detail="项目没有任何章节") + + txt_content = [] + + txt_content.append("=" * 80) + txt_content.append(f"项目标题: {project.title}") + txt_content.append("=" * 80) + + if project.description: + txt_content.append(f"\n简介: {project.description}\n") + + if project.theme: + txt_content.append(f"主题: {project.theme}") + + if project.genre: + txt_content.append(f"类型: {project.genre}") + + txt_content.append(f"总章节数: {len(chapters)}") + txt_content.append(f"总字数: {project.current_words}") + txt_content.append("\n" + "=" * 80 + "\n\n") + + for chapter in chapters: + txt_content.append(f"第 {chapter.chapter_number} 章 {chapter.title}") + txt_content.append("-" * 80) + txt_content.append("") # 空行 + + if chapter.content: + txt_content.append(chapter.content) + else: + txt_content.append("(本章暂无内容)") + + txt_content.append("\n\n" + "=" * 80 + "\n\n") + + txt_content.append(f"--- 全文完 ---") + txt_content.append(f"\n导出时间: {func.now()}") + + final_content = "\n".join(txt_content) + + safe_title = "".join(c for c in project.title if c.isalnum() or c in (' ', '-', '_', ',', '。', '、')) + filename = f"{safe_title}.txt" + + from urllib.parse import quote + encoded_filename = quote(filename) + + logger.info(f"导出成功: {filename}, 共{len(chapters)}章, {len(final_content)}字符") + + return Response( + content=final_content.encode('utf-8'), + media_type="text/plain; charset=utf-8", + headers={ + "Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}", + "Content-Type": "text/plain; charset=utf-8" + } + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"导出项目失败: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"导出失败: {str(e)}") + + +@router.post("/{project_id}/check-consistency", summary="检查数据一致性") +async def check_project_consistency( + project_id: str, + auto_fix: bool = True, + db: AsyncSession = Depends(get_db) +): + """ + 检查并修复项目的数据一致性问题 + + Args: + project_id: 项目ID + auto_fix: 是否自动修复问题(默认True) + + 返回检查报告,包含: + - organization_records: 检查并修复缺失的Organization记录 + - member_counts: 检查并修复组织成员计数 + - relationships: 验证关系数据完整性 + - organization_members: 验证组织成员数据完整性 + """ + try: + logger.info(f"开始数据一致性检查: {project_id}, auto_fix={auto_fix}") + + result = await db.execute( + select(Project).where(Project.id == project_id) + ) + project = result.scalar_one_or_none() + + if not project: + logger.warning(f"项目不存在: {project_id}") + raise HTTPException(status_code=404, detail="项目不存在") + + report = await run_full_data_consistency_check(project_id, db, auto_fix) + + logger.info(f"数据一致性检查完成: {project_id}") + return report + + except HTTPException: + raise + except Exception as e: + logger.error(f"数据一致性检查失败: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"检查失败: {str(e)}") + + +@router.post("/{project_id}/fix-organizations", summary="修复组织记录") +async def fix_project_organizations( + project_id: str, + db: AsyncSession = Depends(get_db) +): + """ + 修复项目中缺失的Organization记录 + + 为所有is_organization=True但没有Organization记录的Character创建记录 + """ + try: + logger.info(f"开始修复组织记录: {project_id}") + + result = await db.execute( + select(Project).where(Project.id == project_id) + ) + project = result.scalar_one_or_none() + + if not project: + logger.warning(f"项目不存在: {project_id}") + raise HTTPException(status_code=404, detail="项目不存在") + + fixed_count, total_count = await fix_missing_organization_records(project_id, db) + + logger.info(f"组织记录修复完成: {project_id}, 修复{fixed_count}/{total_count}") + return { + "message": "组织记录修复完成", + "fixed": fixed_count, + "total": total_count + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"修复组织记录失败: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"修复失败: {str(e)}") + + +@router.post("/{project_id}/fix-member-counts", summary="修复成员计数") +async def fix_project_member_counts( + project_id: str, + db: AsyncSession = Depends(get_db) +): + """ + 修复项目中所有组织的成员计数 + + 从实际成员记录重新计算每个组织的member_count + """ + try: + logger.info(f"开始修复成员计数: {project_id}") + + result = await db.execute( + select(Project).where(Project.id == project_id) + ) + project = result.scalar_one_or_none() + + if not project: + logger.warning(f"项目不存在: {project_id}") + raise HTTPException(status_code=404, detail="项目不存在") + + fixed_count, total_count = await fix_organization_member_counts(project_id, db) + + logger.info(f"成员计数修复完成: {project_id}, 修复{fixed_count}/{total_count}") + return { + "message": "成员计数修复完成", + "fixed": fixed_count, + "total": total_count + } + + except HTTPException: + raise + except Exception as e: + logger.error(f"修复成员计数失败: {str(e)}", exc_info=True) + raise HTTPException(status_code=500, detail=f"修复失败: {str(e)}") \ No newline at end of file diff --git a/backend/app/api/relationships.py b/backend/app/api/relationships.py new file mode 100644 index 0000000..1ea4fcd --- /dev/null +++ b/backend/app/api/relationships.py @@ -0,0 +1,209 @@ +"""关系管理API""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, or_, and_ +from typing import List, Optional + +from app.database import get_db +from app.models.relationship import ( + RelationshipType, + CharacterRelationship, + Organization, + OrganizationMember +) +from app.models.character import Character +from app.schemas.relationship import ( + RelationshipTypeResponse, + CharacterRelationshipCreate, + CharacterRelationshipUpdate, + CharacterRelationshipResponse, + RelationshipGraphData, + RelationshipGraphNode, + RelationshipGraphLink +) +from app.logger import get_logger + +router = APIRouter(prefix="/relationships", tags=["关系管理"]) +logger = get_logger(__name__) + + +@router.get("/types", response_model=List[RelationshipTypeResponse], summary="获取关系类型列表") +async def get_relationship_types(db: AsyncSession = Depends(get_db)): + """获取所有预定义的关系类型""" + result = await db.execute(select(RelationshipType).order_by(RelationshipType.category, RelationshipType.id)) + types = result.scalars().all() + return types + + +@router.get("/project/{project_id}", response_model=List[CharacterRelationshipResponse], summary="获取项目的所有关系") +async def get_project_relationships( + project_id: str, + character_id: Optional[str] = Query(None, description="筛选特定角色的关系"), + db: AsyncSession = Depends(get_db) +): + """ + 获取项目中的所有角色关系 + + - 如果提供character_id,则只返回与该角色相关的关系(作为发起方或接收方) + - 否则返回项目中的所有关系 + """ + query = select(CharacterRelationship).where( + CharacterRelationship.project_id == project_id + ) + + if character_id: + query = query.where( + or_( + CharacterRelationship.character_from_id == character_id, + CharacterRelationship.character_to_id == character_id + ) + ) + + query = query.order_by(CharacterRelationship.created_at.desc()) + result = await db.execute(query) + relationships = result.scalars().all() + + logger.info(f"获取项目 {project_id} 的关系列表,共 {len(relationships)} 条") + return relationships + + +@router.get("/graph/{project_id}", response_model=RelationshipGraphData, summary="获取关系图谱数据") +async def get_relationship_graph( + project_id: str, + db: AsyncSession = Depends(get_db) +): + """ + 获取用于可视化的关系图谱数据 + + 返回格式: + - nodes: 角色节点列表 + - links: 关系连线列表 + """ + # 获取所有角色(节点) + chars_result = await db.execute( + select(Character).where(Character.project_id == project_id) + ) + characters = chars_result.scalars().all() + + nodes = [ + RelationshipGraphNode( + id=c.id, + name=c.name, + type="organization" if c.is_organization else "character", + role_type=c.role_type, + avatar=c.avatar_url + ) + for c in characters + ] + + # 获取所有关系(边) + rels_result = await db.execute( + select(CharacterRelationship).where( + CharacterRelationship.project_id == project_id + ) + ) + relationships = rels_result.scalars().all() + + links = [ + RelationshipGraphLink( + source=r.character_from_id, + target=r.character_to_id, + relationship=r.relationship_name or "未知关系", + intimacy=r.intimacy_level, + status=r.status + ) + for r in relationships + ] + + logger.info(f"获取项目 {project_id} 的关系图谱:{len(nodes)} 个节点,{len(links)} 条关系") + return RelationshipGraphData(nodes=nodes, links=links) + + +@router.post("/", response_model=CharacterRelationshipResponse, summary="创建角色关系") +async def create_relationship( + relationship: CharacterRelationshipCreate, + db: AsyncSession = Depends(get_db) +): + """ + 手动创建角色关系 + + - 需要提供角色A和角色B的ID + - 可以指定预定义的关系类型或自定义关系名称 + - 可以设置亲密度、状态等属性 + """ + # 验证角色是否存在 + char_from = await db.execute( + select(Character).where(Character.id == relationship.character_from_id) + ) + char_to = await db.execute( + select(Character).where(Character.id == relationship.character_to_id) + ) + + if not char_from.scalar_one_or_none(): + raise HTTPException(status_code=404, detail=f"角色A(ID: {relationship.character_from_id})不存在") + if not char_to.scalar_one_or_none(): + raise HTTPException(status_code=404, detail=f"角色B(ID: {relationship.character_to_id})不存在") + + # 创建关系 + db_relationship = CharacterRelationship( + **relationship.model_dump(), + source="manual" + ) + db.add(db_relationship) + await db.commit() + await db.refresh(db_relationship) + + logger.info(f"创建关系成功:{relationship.character_from_id} -> {relationship.character_to_id}") + return db_relationship + + +@router.put("/{relationship_id}", response_model=CharacterRelationshipResponse, summary="更新关系") +async def update_relationship( + relationship_id: str, + relationship: CharacterRelationshipUpdate, + db: AsyncSession = Depends(get_db) +): + """更新角色关系的属性(亲密度、状态等)""" + result = await db.execute( + select(CharacterRelationship).where( + CharacterRelationship.id == relationship_id + ) + ) + db_rel = result.scalar_one_or_none() + + if not db_rel: + raise HTTPException(status_code=404, detail="关系不存在") + + # 更新字段 + update_data = relationship.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_rel, field, value) + + await db.commit() + await db.refresh(db_rel) + + logger.info(f"更新关系成功:{relationship_id}") + return db_rel + + +@router.delete("/{relationship_id}", summary="删除关系") +async def delete_relationship( + relationship_id: str, + db: AsyncSession = Depends(get_db) +): + """删除角色关系""" + result = await db.execute( + select(CharacterRelationship).where( + CharacterRelationship.id == relationship_id + ) + ) + db_rel = result.scalar_one_or_none() + + if not db_rel: + raise HTTPException(status_code=404, detail="关系不存在") + + await db.delete(db_rel) + await db.commit() + + logger.info(f"删除关系成功:{relationship_id}") + return {"message": "关系删除成功", "id": relationship_id} \ No newline at end of file diff --git a/backend/app/api/users.py b/backend/app/api/users.py new file mode 100644 index 0000000..623a085 --- /dev/null +++ b/backend/app/api/users.py @@ -0,0 +1,125 @@ +""" +用户管理 API +""" +from fastapi import APIRouter, HTTPException, Request, Depends +from pydantic import BaseModel +from typing import List, Optional +from app.user_manager import user_manager, User + +router = APIRouter(prefix="/users", tags=["用户管理"]) + + +def require_login(request: Request): + """依赖:要求用户已登录""" + if not hasattr(request.state, "user") or not request.state.user: + raise HTTPException(status_code=401, detail="需要登录") + return request.state.user + + +def require_admin(request: Request): + """依赖:要求用户为管理员""" + user = require_login(request) + if not request.state.is_admin: + raise HTTPException(status_code=403, detail="需要管理员权限") + return user + + +class SetAdminRequest(BaseModel): + user_id: str + is_admin: bool + + +@router.get("/current") +async def get_current_user(user: User = Depends(require_login)): + """获取当前登录用户信息""" + return user.dict() + + +@router.get("", response_model=List[dict]) +async def list_users(admin_user: User = Depends(require_admin)): + """ + 获取所有用户列表(仅管理员) + """ + users = await user_manager.get_all_users() + return [user.dict() for user in users] + + +@router.post("/set-admin") +async def set_admin( + data: SetAdminRequest, + request: Request, + admin_user: User = Depends(require_admin) +): + """ + 设置用户的管理员权限(仅管理员) + + 限制: + - 不能撤销自己的管理员权限 + - 至少保留一个管理员 + """ + # 检查是否尝试撤销自己的权限 + if data.user_id == admin_user.user_id and not data.is_admin: + raise HTTPException( + status_code=400, + detail="不能撤销自己的管理员权限" + ) + + # 尝试设置管理员权限 + success = await user_manager.set_admin(data.user_id, data.is_admin) + + if not success: + if not data.is_admin: + raise HTTPException( + status_code=400, + detail="无法撤销管理员权限,至少需要保留一个管理员" + ) + else: + raise HTTPException( + status_code=404, + detail="用户不存在" + ) + + return { + "message": f"已{'授予' if data.is_admin else '撤销'}管理员权限", + "user_id": data.user_id, + "is_admin": data.is_admin + } + + +@router.delete("/{user_id}") +async def delete_user( + user_id: str, + admin_user: User = Depends(require_admin) +): + """ + 删除用户(仅管理员) + + 限制: + - 不能删除管理员用户 + """ + success = await user_manager.delete_user(user_id) + + if not success: + raise HTTPException( + status_code=400, + detail="无法删除该用户(用户不存在或为管理员)" + ) + + return { + "message": "用户已删除", + "user_id": user_id + } + + +@router.get("/{user_id}") +async def get_user( + user_id: str, + admin_user: User = Depends(require_admin) +): + """获取指定用户信息(仅管理员)""" + user = await user_manager.get_user(user_id) + + if not user: + raise HTTPException(status_code=404, detail="用户不存在") + + return user.dict() \ No newline at end of file diff --git a/backend/app/api/wizard_stream.py b/backend/app/api/wizard_stream.py new file mode 100644 index 0000000..ed05d0f --- /dev/null +++ b/backend/app/api/wizard_stream.py @@ -0,0 +1,1262 @@ +"""项目创建向导流式API - 使用SSE避免超时""" +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import Dict, Any, AsyncGenerator +import json + +from app.database import get_db +from app.models.project import Project +from app.models.character import Character +from app.models.outline import Outline +from app.models.chapter import Chapter +from app.models.relationship import CharacterRelationship, Organization, OrganizationMember, RelationshipType +from app.services.ai_service import ai_service +from app.services.prompt_service import prompt_service +from app.logger import get_logger +from app.utils.sse_response import SSEResponse, create_sse_response + +router = APIRouter(prefix="/wizard-stream", tags=["项目创建向导(流式)"]) +logger = get_logger(__name__) + + +async def world_building_generator( + data: Dict[str, Any], + db: AsyncSession +) -> AsyncGenerator[str, None]: + """世界构建流式生成器""" + # 标记数据库会话是否已提交 + db_committed = False + try: + # 发送开始消息 + yield await SSEResponse.send_progress("开始生成世界观...", 10) + + # 提取参数 + title = data.get("title") + description = data.get("description") + theme = data.get("theme") + genre = data.get("genre") + narrative_perspective = data.get("narrative_perspective") + target_words = data.get("target_words") + chapter_count = data.get("chapter_count") + character_count = data.get("character_count") + provider = data.get("provider") + model = data.get("model") + + if not title or not description or not theme or not genre: + yield await SSEResponse.send_error("title、description、theme 和 genre 是必需的参数", 400) + return + + # 获取提示词 + yield await SSEResponse.send_progress("准备AI提示词...", 20) + prompt = prompt_service.get_world_building_prompt( + title=title, + theme=theme, + genre=genre + ) + + # 流式调用AI + yield await SSEResponse.send_progress("正在调用AI生成...", 30) + + accumulated_text = "" + chunk_count = 0 + + async for chunk in ai_service.generate_text_stream( + prompt=prompt, + provider=provider, + model=model + ): + chunk_count += 1 + accumulated_text += chunk + + # 发送内容块 + yield await SSEResponse.send_chunk(chunk) + + # 定期更新进度 + if chunk_count % 5 == 0: + progress = min(30 + (chunk_count // 5), 70) + yield await SSEResponse.send_progress(f"生成中... ({len(accumulated_text)}字符)", progress) + + # 每20个块发送心跳 + if chunk_count % 20 == 0: + yield await SSEResponse.send_heartbeat() + + # 解析结果 + yield await SSEResponse.send_progress("解析AI返回结果...", 80) + + world_data = {} + try: + cleaned_text = accumulated_text.strip() + if cleaned_text.startswith('```json'): + cleaned_text = cleaned_text[7:] + if cleaned_text.startswith('```'): + cleaned_text = cleaned_text[3:] + if cleaned_text.endswith('```'): + cleaned_text = cleaned_text[:-3] + cleaned_text = cleaned_text.strip() + + world_data = json.loads(cleaned_text) + except json.JSONDecodeError as e: + logger.error(f"AI返回非JSON格式: {e}") + world_data = { + "time_period": accumulated_text[:300] if len(accumulated_text) > 300 else accumulated_text, + "location": "AI返回格式错误,请重试", + "atmosphere": "AI返回格式错误,请重试", + "rules": "AI返回格式错误,请重试" + } + + # 保存到数据库 + yield await SSEResponse.send_progress("保存到数据库...", 90) + + project = Project( + title=title, + description=description, + theme=theme, + genre=genre, + world_time_period=world_data.get("time_period"), + world_location=world_data.get("location"), + world_atmosphere=world_data.get("atmosphere"), + world_rules=world_data.get("rules"), + narrative_perspective=narrative_perspective, + target_words=target_words, + chapter_count=chapter_count, + character_count=character_count, + wizard_status="incomplete", + wizard_step=1, + status="planning" + ) + db.add(project) + await db.commit() + db_committed = True + await db.refresh(project) + + # 发送最终结果 + yield await SSEResponse.send_result({ + "project_id": project.id, + "time_period": world_data.get("time_period"), + "location": world_data.get("location"), + "atmosphere": world_data.get("atmosphere"), + "rules": world_data.get("rules") + }) + + yield await SSEResponse.send_progress("完成!", 100, "success") + yield await SSEResponse.send_done() + + except GeneratorExit: + # SSE连接断开,回滚未提交的事务 + logger.warning("世界构建生成器被提前关闭") + if not db_committed and db.in_transaction(): + await db.rollback() + logger.info("世界构建事务已回滚(GeneratorExit)") + except Exception as e: + logger.error(f"世界构建流式生成失败: {str(e)}") + # 异常时回滚事务 + if not db_committed and db.in_transaction(): + await db.rollback() + logger.info("世界构建事务已回滚(异常)") + yield await SSEResponse.send_error(f"生成失败: {str(e)}") + + +@router.post("/world-building", summary="流式生成世界构建") +async def generate_world_building_stream( + data: Dict[str, Any], + db: AsyncSession = Depends(get_db) +): + """ + 使用SSE流式生成世界构建,避免超时 + 前端使用EventSource接收实时进度和结果 + """ + return create_sse_response(world_building_generator(data, db)) + + +async def characters_generator( + data: Dict[str, Any], + db: AsyncSession +) -> AsyncGenerator[str, None]: + """角色批量生成流式生成器 - 优化版:分批+重试""" + db_committed = False + try: + yield await SSEResponse.send_progress("开始生成角色...", 5) + + project_id = data.get("project_id") + count = data.get("count", 5) + world_context = data.get("world_context") + theme = data.get("theme", "") + genre = data.get("genre", "") + requirements = data.get("requirements", "") + provider = data.get("provider") + model = data.get("model") + + # 验证项目 + yield await SSEResponse.send_progress("验证项目...", 10) + result = await db.execute( + select(Project).where(Project.id == project_id) + ) + project = result.scalar_one_or_none() + if not project: + yield await SSEResponse.send_error("项目不存在", 404) + return + + project.wizard_step = 2 + + world_context = world_context or { + "time_period": project.world_time_period or "未设定", + "location": project.world_location or "未设定", + "atmosphere": project.world_atmosphere or "未设定", + "rules": project.world_rules or "未设定" + } + + # 优化的分批策略:每批生成3个,平衡效率和成功率 + BATCH_SIZE = 3 # 每批生成3个角色 + MAX_RETRIES = 3 # 每批最多重试3次 + all_characters = [] + total_batches = (count + BATCH_SIZE - 1) // BATCH_SIZE + + for batch_idx in range(total_batches): + # 精确计算当前批次应该生成的数量 + remaining = count - len(all_characters) + current_batch_size = min(BATCH_SIZE, remaining) + + # 如果已经达到目标数量,直接退出 + if current_batch_size <= 0: + logger.info(f"已生成{len(all_characters)}个角色,达到目标数量{count}") + break + + batch_progress = 15 + (batch_idx * 60 // total_batches) + + # 重试逻辑 + retry_count = 0 + batch_success = False + + while retry_count < MAX_RETRIES and not batch_success: + try: + retry_suffix = f" (重试{retry_count}/{MAX_RETRIES})" if retry_count > 0 else "" + yield await SSEResponse.send_progress( + f"生成第{batch_idx+1}/{total_batches}批角色 ({current_batch_size}个){retry_suffix}...", + batch_progress + ) + + # 构建批次要求 - 包含已生成角色信息保持连贯 + existing_chars_context = "" + if all_characters: + existing_chars_context = "\n\n【已生成的角色】:\n" + for char in all_characters: + existing_chars_context += f"- {char.get('name')}: {char.get('role_type', '未知')}, {char.get('personality', '暂无')[:50]}...\n" + existing_chars_context += "\n请确保新角色与已有角色形成合理的关系网络和互动。\n" + + # 构建精确的批次要求,明确告诉AI要生成的数量 + if batch_idx == 0: + if current_batch_size == 1: + batch_requirements = f"{requirements}\n请生成1个主角(protagonist)" + else: + batch_requirements = f"{requirements}\n请精确生成{current_batch_size}个角色:1个主角(protagonist)和{current_batch_size-1}个核心配角(supporting)" + else: + batch_requirements = f"{requirements}\n请精确生成{current_batch_size}个角色{existing_chars_context}" + if batch_idx == total_batches - 1: + batch_requirements += "\n可以包含组织或反派(antagonist)" + else: + batch_requirements += "\n主要是配角(supporting)和反派(antagonist)" + + prompt = prompt_service.get_characters_batch_prompt( + count=current_batch_size, # 传递精确数量 + time_period=world_context.get("time_period", ""), + location=world_context.get("location", ""), + atmosphere=world_context.get("atmosphere", ""), + rules=world_context.get("rules", ""), + theme=theme or project.theme or "", + genre=genre or project.genre or "", + requirements=batch_requirements + ) + + # 流式生成 + accumulated_text = "" + async for chunk in ai_service.generate_text_stream( + prompt=prompt, + provider=provider, + model=model + ): + accumulated_text += chunk + yield await SSEResponse.send_chunk(chunk) + + # 解析批次结果 + cleaned_text = accumulated_text.strip() + if cleaned_text.startswith('```json'): + cleaned_text = cleaned_text[7:] + if cleaned_text.startswith('```'): + cleaned_text = cleaned_text[3:] + if cleaned_text.endswith('```'): + cleaned_text = cleaned_text[:-3] + cleaned_text = cleaned_text.strip() + + characters_data = json.loads(cleaned_text) + if not isinstance(characters_data, list): + characters_data = [characters_data] + + # 验证生成数量是否精确 + if len(characters_data) != current_batch_size: + logger.warning(f"批次{batch_idx+1}生成数量不匹配: 期望{current_batch_size}, 实际{len(characters_data)}") + + # 如果数量不足,重试 + if len(characters_data) < current_batch_size: + if retry_count < MAX_RETRIES - 1: + retry_count += 1 + yield await SSEResponse.send_progress( + f"⚠️ 生成数量不足(期望{current_batch_size},实际{len(characters_data)}),准备重试...", + batch_progress, + "warning" + ) + continue + else: + # 最后一次重试仍不足,记录但继续使用 + logger.warning(f"批次{batch_idx+1}多次重试后仍数量不足,使用当前结果") + yield await SSEResponse.send_progress( + f"⚠️ 批次{batch_idx+1}生成{len(characters_data)}个(期望{current_batch_size}),继续处理", + batch_progress, + "warning" + ) + # 如果数量过多,只取需要的数量并发出警告 + else: + logger.warning(f"批次{batch_idx+1}生成过多角色({len(characters_data)}>{current_batch_size}),将只取前{current_batch_size}个") + yield await SSEResponse.send_progress( + f"⚠️ AI生成过多,截取前{current_batch_size}个角色", + batch_progress, + "warning" + ) + characters_data = characters_data[:current_batch_size] + + all_characters.extend(characters_data) + batch_success = True + logger.info(f"批次{batch_idx+1}成功添加{len(characters_data)}个角色,当前总数{len(all_characters)}/{count}") + + except json.JSONDecodeError as e: + logger.error(f"批次{batch_idx+1}解析失败(尝试{retry_count+1}/{MAX_RETRIES}): {e}") + retry_count += 1 + if retry_count < MAX_RETRIES: + yield await SSEResponse.send_progress( + f"解析失败,准备重试...", + batch_progress, + "warning" + ) + else: + yield await SSEResponse.send_progress( + f"批次{batch_idx+1}多次重试失败,跳过", + batch_progress, + "warning" + ) + except Exception as e: + logger.error(f"批次{batch_idx+1}生成异常(尝试{retry_count+1}/{MAX_RETRIES}): {e}") + retry_count += 1 + if retry_count < MAX_RETRIES: + yield await SSEResponse.send_progress( + f"生成异常,准备重试...", + batch_progress, + "warning" + ) + else: + yield await SSEResponse.send_progress( + f"批次{batch_idx+1}多次重试失败,跳过", + batch_progress, + "warning" + ) + + if not all_characters: + yield await SSEResponse.send_error("所有批次都生成失败,请重试") + return + + # 保存到数据库 - 分阶段处理以保证一致性 + yield await SSEResponse.send_progress("验证角色数据...", 82) + + # 预处理:构建本批次所有实体的名称集合 + valid_entity_names = set() + valid_organization_names = set() + + for char_data in all_characters: + entity_name = char_data.get("name", "") + if entity_name: + valid_entity_names.add(entity_name) + if char_data.get("is_organization", False): + valid_organization_names.add(entity_name) + + # 清理幻觉引用 + cleaned_count = 0 + for char_data in all_characters: + # 清理关系数组中的无效引用 + if "relationships_array" in char_data and isinstance(char_data["relationships_array"], list): + original_rels = char_data["relationships_array"] + valid_rels = [] + for rel in original_rels: + target_name = rel.get("target_character_name", "") + if target_name in valid_entity_names: + valid_rels.append(rel) + else: + cleaned_count += 1 + logger.debug(f" 🧹 清理无效关系引用:{char_data.get('name')} -> {target_name}") + char_data["relationships_array"] = valid_rels + + # 清理组织成员关系中的无效引用 + if "organization_memberships" in char_data and isinstance(char_data["organization_memberships"], list): + original_orgs = char_data["organization_memberships"] + valid_orgs = [] + for org_mem in original_orgs: + org_name = org_mem.get("organization_name", "") + if org_name in valid_organization_names: + valid_orgs.append(org_mem) + else: + cleaned_count += 1 + logger.debug(f" 🧹 清理无效组织引用:{char_data.get('name')} -> {org_name}") + char_data["organization_memberships"] = valid_orgs + + if cleaned_count > 0: + logger.info(f"✨ 清理了{cleaned_count}个AI幻觉引用") + yield await SSEResponse.send_progress(f"已清理{cleaned_count}个无效引用", 84) + + yield await SSEResponse.send_progress("保存角色到数据库...", 85) + + # 第一阶段:创建所有Character记录 + created_characters = [] + character_name_to_obj = {} # 名称到对象的映射,用于后续关系创建 + + for char_data in all_characters: + # 从relationships_array提取文本描述以保持向后兼容 + relationships_text = "" + relationships_array = char_data.get("relationships_array", []) + if relationships_array and isinstance(relationships_array, list): + # 将关系数组转换为可读文本 + rel_descriptions = [] + for rel in relationships_array: + target = rel.get("target_character_name", "未知") + rel_type = rel.get("relationship_type", "关系") + desc = rel.get("description", "") + rel_descriptions.append(f"{target}({rel_type}): {desc}") + relationships_text = "; ".join(rel_descriptions) + # 兼容旧格式 + elif isinstance(char_data.get("relationships"), dict): + relationships_text = json.dumps(char_data.get("relationships"), ensure_ascii=False) + elif isinstance(char_data.get("relationships"), str): + relationships_text = char_data.get("relationships") + + character = Character( + project_id=project_id, + name=char_data.get("name", "未命名角色"), + age=char_data.get("age"), + gender=char_data.get("gender"), + is_organization=char_data.get("is_organization", False), + role_type=char_data.get("role_type", "supporting"), + personality=char_data.get("personality", ""), + background=char_data.get("background", ""), + appearance=char_data.get("appearance", ""), + relationships=relationships_text, + organization_type=char_data.get("organization_type"), + organization_purpose=char_data.get("organization_purpose"), + organization_members=json.dumps(char_data.get("organization_members", []), ensure_ascii=False), + traits=json.dumps(char_data.get("traits", []), ensure_ascii=False) + ) + db.add(character) + created_characters.append((character, char_data)) + + await db.flush() # 获取所有角色的ID + + # 刷新并建立名称映射 + for character, _ in created_characters: + await db.refresh(character) + character_name_to_obj[character.name] = character + logger.info(f"向导创建角色:{character.name} (ID: {character.id}, 是否组织: {character.is_organization})") + + # 为is_organization=True的角色创建Organization记录 + yield await SSEResponse.send_progress("创建组织记录...", 87) + organization_name_to_obj = {} # 组织名称到Organization对象的映射 + + for character, char_data in created_characters: + if character.is_organization: + # 检查是否已存在Organization记录 + org_check = await db.execute( + select(Organization).where(Organization.character_id == character.id) + ) + existing_org = org_check.scalar_one_or_none() + + if not existing_org: + # 创建Organization记录 + org = Organization( + character_id=character.id, + project_id=project_id, + member_count=0, # 初始为0,后续添加成员时会更新 + power_level=char_data.get("power_level", 5), + location=char_data.get("location"), + motto=char_data.get("motto") + ) + db.add(org) + logger.info(f"向导创建组织记录:{character.name}") + else: + org = existing_org + + # 建立组织名称映射(无论是新建还是已存在) + organization_name_to_obj[character.name] = org + + await db.flush() # 确保Organization记录有ID + + # 刷新角色以获取ID + for character, _ in created_characters: + await db.refresh(character) + + # 第三阶段:创建角色间的关系 + yield await SSEResponse.send_progress("创建角色关系...", 90) + relationships_created = 0 + + for character, char_data in created_characters: + # 跳过组织实体的角色关系处理(组织通过成员关系关联) + if character.is_organization: + continue + + # 处理relationships数组 + relationships_data = char_data.get("relationships_array", []) + if not relationships_data and isinstance(char_data.get("relationships"), list): + relationships_data = char_data.get("relationships") + + if relationships_data and isinstance(relationships_data, list): + for rel in relationships_data: + try: + target_name = rel.get("target_character_name") + if not target_name: + logger.debug(f" ⚠️ {character.name}的关系缺少target_character_name,跳过") + continue + + # 使用名称映射快速查找 + target_char = character_name_to_obj.get(target_name) + + if target_char: + # 避免创建重复关系 + existing_rel = await db.execute( + select(CharacterRelationship).where( + CharacterRelationship.project_id == project_id, + CharacterRelationship.character_from_id == character.id, + CharacterRelationship.character_to_id == target_char.id + ) + ) + if existing_rel.scalar_one_or_none(): + logger.debug(f" ℹ️ 关系已存在:{character.name} -> {target_name}") + continue + + relationship = CharacterRelationship( + project_id=project_id, + character_from_id=character.id, + character_to_id=target_char.id, + relationship_name=rel.get("relationship_type", "未知关系"), + intimacy_level=rel.get("intimacy_level", 50), + description=rel.get("description", ""), + started_at=rel.get("started_at"), + source="ai" + ) + + # 匹配预定义关系类型 + rel_type_result = await db.execute( + select(RelationshipType).where( + RelationshipType.name == rel.get("relationship_type") + ) + ) + rel_type = rel_type_result.scalar_one_or_none() + if rel_type: + relationship.relationship_type_id = rel_type.id + + db.add(relationship) + relationships_created += 1 + logger.info(f" ✅ 向导创建关系:{character.name} -> {target_name} ({rel.get('relationship_type')})") + else: + logger.warning(f" ⚠️ 目标角色不存在:{character.name} -> {target_name}(可能是AI幻觉)") + except Exception as e: + logger.warning(f" ❌ 向导创建关系失败:{character.name} - {str(e)}") + continue + + # 第四阶段:创建组织成员关系 + yield await SSEResponse.send_progress("创建组织成员关系...", 93) + members_created = 0 + + for character, char_data in created_characters: + # 跳过组织实体本身 + if character.is_organization: + continue + + # 处理组织成员关系 + org_memberships = char_data.get("organization_memberships", []) + if org_memberships and isinstance(org_memberships, list): + for membership in org_memberships: + try: + org_name = membership.get("organization_name") + if not org_name: + logger.debug(f" ⚠️ {character.name}的组织成员关系缺少organization_name,跳过") + continue + + # 使用映射快速查找组织 + org = organization_name_to_obj.get(org_name) + + if org: + # 检查是否已存在成员关系 + existing_member = await db.execute( + select(OrganizationMember).where( + OrganizationMember.organization_id == org.id, + OrganizationMember.character_id == character.id + ) + ) + if existing_member.scalar_one_or_none(): + logger.debug(f" ℹ️ 成员关系已存在:{character.name} -> {org_name}") + continue + + # 创建成员关系 + member = OrganizationMember( + organization_id=org.id, + character_id=character.id, + position=membership.get("position", "成员"), + rank=membership.get("rank", 0), + loyalty=membership.get("loyalty", 50), + joined_at=membership.get("joined_at"), + status=membership.get("status", "active"), + source="ai" + ) + db.add(member) + + # 更新组织成员计数 + org.member_count += 1 + + members_created += 1 + logger.info(f" ✅ 向导添加成员:{character.name} -> {org_name} ({membership.get('position')})") + else: + # 这种情况理论上已经被预处理清理了,但保留日志以防万一 + logger.debug(f" ℹ️ 组织引用已被清理:{character.name} -> {org_name}") + except Exception as e: + logger.warning(f" ❌ 向导添加组织成员失败:{character.name} - {str(e)}") + continue + + logger.info(f"📊 向导数据统计:") + logger.info(f" - 创建角色/组织:{len(created_characters)} 个") + logger.info(f" - 创建组织详情:{len(organization_name_to_obj)} 个") + logger.info(f" - 创建角色关系:{relationships_created} 条") + logger.info(f" - 创建组织成员:{members_created} 条") + + await db.commit() + db_committed = True + + # 重新提取character对象 + created_characters = [char for char, _ in created_characters] + + # 发送结果 + yield await SSEResponse.send_result({ + "message": f"成功生成{len(created_characters)}个角色/组织(分{total_batches}批完成)", + "count": len(created_characters), + "batches": total_batches, + "characters": [ + { + "id": char.id, + "project_id": char.project_id, + "name": char.name, + "age": char.age, + "gender": char.gender, + "is_organization": char.is_organization, + "role_type": char.role_type, + "personality": char.personality, + "background": char.background, + "appearance": char.appearance, + "relationships": char.relationships, + "organization_type": char.organization_type, + "organization_purpose": char.organization_purpose, + "organization_members": char.organization_members, + "traits": char.traits, + "created_at": char.created_at.isoformat() if char.created_at else None, + "updated_at": char.updated_at.isoformat() if char.updated_at else None + } for char in created_characters + ] + }) + + yield await SSEResponse.send_progress("完成!", 100, "success") + yield await SSEResponse.send_done() + + except GeneratorExit: + logger.warning("角色生成器被提前关闭") + if not db_committed and db.in_transaction(): + await db.rollback() + logger.info("角色生成事务已回滚(GeneratorExit)") + except Exception as e: + logger.error(f"角色生成失败: {str(e)}") + if not db_committed and db.in_transaction(): + await db.rollback() + logger.info("角色生成事务已回滚(异常)") + yield await SSEResponse.send_error(f"生成失败: {str(e)}") + + +@router.post("/characters", summary="流式批量生成角色") +async def generate_characters_stream( + data: Dict[str, Any], + db: AsyncSession = Depends(get_db) +): + """ + 使用SSE流式批量生成角色,避免超时 + """ + return create_sse_response(characters_generator(data, db)) + + +async def outline_generator( + data: Dict[str, Any], + db: AsyncSession +) -> AsyncGenerator[str, None]: + """大纲生成流式生成器 - 向导固定生成前8章作为开局""" + db_committed = False + try: + yield await SSEResponse.send_progress("开始生成大纲...", 5) + + project_id = data.get("project_id") + # 向导固定生成8章,忽略传入的chapter_count + chapter_count = 8 + narrative_perspective = data.get("narrative_perspective") + target_words = data.get("target_words", 100000) + requirements = data.get("requirements", "") + provider = data.get("provider") + model = data.get("model") + + # 8章一次性生成,不需要分批 + BATCH_SIZE = 8 + MAX_RETRIES = 3 + + # 获取项目信息 + yield await SSEResponse.send_progress("加载项目信息...", 10) + result = await db.execute( + select(Project).where(Project.id == project_id) + ) + project = result.scalar_one_or_none() + if not project: + yield await SSEResponse.send_error("项目不存在", 404) + return + + # 获取角色信息 + yield await SSEResponse.send_progress("加载角色信息...", 15) + result = await db.execute( + select(Character).where(Character.project_id == project_id) + ) + characters = result.scalars().all() + + characters_info = "\n".join([ + f"- {char.name} ({'组织' if char.is_organization else '角色'}, {char.role_type}): {char.personality[:100] if char.personality else '暂无描述'}" + for char in characters + ]) + + # 分批生成大纲 + yield await SSEResponse.send_progress("准备分批生成大纲...", 20) + + all_outlines = [] + total_batches = (chapter_count + BATCH_SIZE - 1) // BATCH_SIZE + + for batch_idx in range(total_batches): + start_chapter = batch_idx * BATCH_SIZE + 1 + end_chapter = min((batch_idx + 1) * BATCH_SIZE, chapter_count) + current_batch_size = end_chapter - start_chapter + 1 + + batch_progress = 20 + (batch_idx * 55 // total_batches) + + # 重试逻辑 + retry_count = 0 + batch_success = False + + while retry_count < MAX_RETRIES and not batch_success: + try: + retry_suffix = f" (重试{retry_count}/{MAX_RETRIES})" if retry_count > 0 else "" + yield await SSEResponse.send_progress( + f"生成第{start_chapter}-{end_chapter}章大纲{retry_suffix}...", + batch_progress + ) + + # 构建批次提示词 - 包含前文摘要保持故事连贯 + previous_context = "" + if all_outlines: + previous_context = "\n\n【前文情节摘要】:\n" + for outline in all_outlines[-3:]: # 只包含最近3章,避免过长 + ch_num = outline.get("chapter_number", "?") + ch_title = outline.get("title", "未命名") + ch_summary = outline.get("summary", "")[:100] + previous_context += f"第{ch_num}章《{ch_title}》: {ch_summary}...\n" + previous_context += f"\n请确保第{start_chapter}-{end_chapter}章与前文情节自然衔接,保持故事连贯性。\n" + + # 向导专用的开局大纲要求 + batch_requirements = f"{requirements}\n\n【重要说明】这是小说的开局部分,请生成前8章大纲,重点关注:\n" + batch_requirements += "1. 引入主要角色和世界观设定\n" + batch_requirements += "2. 建立主线冲突和故事钩子\n" + batch_requirements += "3. 展开初期情节,为后续发展埋下伏笔\n" + batch_requirements += "4. 不要试图完结故事,这只是开始部分\n" + + batch_prompt = prompt_service.get_complete_outline_prompt( + title=project.title, + theme=project.theme or "未设定", + genre=project.genre or "通用", + chapter_count=8, # 固定8章 + narrative_perspective=narrative_perspective, + target_words=target_words // 10, # 开局约占总字数的1/10 + time_period=project.world_time_period or "未设定", + location=project.world_location or "未设定", + atmosphere=project.world_atmosphere or "未设定", + rules=project.world_rules or "未设定", + characters_info=characters_info or "暂无角色信息", + requirements=batch_requirements + ) + + # 流式生成 + accumulated_text = "" + async for chunk in ai_service.generate_text_stream( + prompt=batch_prompt, + provider=provider, + model=model + ): + accumulated_text += chunk + yield await SSEResponse.send_chunk(chunk) + + # 解析结果 + cleaned_text = accumulated_text.strip() + if cleaned_text.startswith('```json'): + cleaned_text = cleaned_text[7:] + if cleaned_text.startswith('```'): + cleaned_text = cleaned_text[3:] + if cleaned_text.endswith('```'): + cleaned_text = cleaned_text[:-3] + cleaned_text = cleaned_text.strip() + + batch_outline_data = json.loads(cleaned_text) + if not isinstance(batch_outline_data, list): + batch_outline_data = [batch_outline_data] + + # 验证生成数量 + if len(batch_outline_data) < current_batch_size: + logger.warning(f"批次{batch_idx+1}生成数量不足: 期望{current_batch_size}, 实际{len(batch_outline_data)}") + if retry_count < MAX_RETRIES - 1: + retry_count += 1 + yield await SSEResponse.send_progress( + f"生成数量不足,准备重试...", + batch_progress, + "warning" + ) + continue + + # 修正章节编号 + for i, chapter_data in enumerate(batch_outline_data): + chapter_data["chapter_number"] = start_chapter + i + + all_outlines.extend(batch_outline_data) + batch_success = True + logger.info(f"批次{batch_idx+1}成功生成{len(batch_outline_data)}章大纲") + + except json.JSONDecodeError as e: + logger.error(f"批次{batch_idx+1}解析失败(尝试{retry_count+1}/{MAX_RETRIES}): {e}") + retry_count += 1 + if retry_count < MAX_RETRIES: + yield await SSEResponse.send_progress( + f"解析失败,准备重试...", + batch_progress, + "warning" + ) + else: + yield await SSEResponse.send_progress( + f"批次{batch_idx+1}多次重试失败,跳过", + batch_progress, + "warning" + ) + except Exception as e: + logger.error(f"批次{batch_idx+1}生成异常(尝试{retry_count+1}/{MAX_RETRIES}): {e}") + retry_count += 1 + if retry_count < MAX_RETRIES: + yield await SSEResponse.send_progress( + f"生成异常,准备重试...", + batch_progress, + "warning" + ) + else: + yield await SSEResponse.send_progress( + f"批次{batch_idx+1}多次重试失败,跳过", + batch_progress, + "warning" + ) + + if not all_outlines: + yield await SSEResponse.send_error("所有批次都生成失败,请重试") + return + + outline_data = all_outlines + + # 保存到数据库 + yield await SSEResponse.send_progress("保存大纲到数据库...", 90) + + created_outlines = [] + for index, chapter_data in enumerate(outline_data[:chapter_count], 1): + chapter_num = chapter_data.get("chapter_number", index) + + outline = Outline( + project_id=project_id, + title=chapter_data.get("title", f"第{chapter_num}章"), + content=chapter_data.get("summary", chapter_data.get("content", "")), + structure=json.dumps(chapter_data, ensure_ascii=False), + order_index=chapter_num + ) + db.add(outline) + created_outlines.append(outline) + + chapter = Chapter( + project_id=project_id, + chapter_number=chapter_num, + title=chapter_data.get("title", f"第{chapter_num}章"), + summary=chapter_data.get("summary", chapter_data.get("content", ""))[:500] if chapter_data.get("summary") or chapter_data.get("content") else "", + status="draft" + ) + db.add(chapter) + + # 更新项目(向导固定生成8章作为开局) + project.chapter_count = 8 + project.narrative_perspective = narrative_perspective + project.target_words = target_words + project.status = "writing" + project.wizard_status = "completed" + + project.wizard_step = 4 + + await db.commit() + db_committed = True + + # 发送结果 + yield await SSEResponse.send_result({ + "message": f"成功生成{len(created_outlines)}章大纲", + "count": len(created_outlines), + "outlines": [ + { + "order_index": outline.order_index, + "title": outline.title, + "content": outline.content[:100] + "..." if len(outline.content) > 100 else outline.content + } for outline in created_outlines + ] + }) + + yield await SSEResponse.send_progress("完成!", 100, "success") + yield await SSEResponse.send_done() + + except GeneratorExit: + logger.warning("大纲生成器被提前关闭") + if not db_committed and db.in_transaction(): + await db.rollback() + logger.info("大纲生成事务已回滚(GeneratorExit)") + except Exception as e: + logger.error(f"大纲生成失败: {str(e)}") + if not db_committed and db.in_transaction(): + await db.rollback() + logger.info("大纲生成事务已回滚(异常)") + yield await SSEResponse.send_error(f"生成失败: {str(e)}") + + +@router.post("/outline", summary="流式生成完整大纲") +async def generate_outline_stream( + data: Dict[str, Any], + db: AsyncSession = Depends(get_db) +): + """ + 使用SSE流式生成完整大纲,避免超时 + """ + return create_sse_response(outline_generator(data, db)) + + +async def update_world_building_generator( + project_id: str, + data: Dict[str, Any], + db: AsyncSession +) -> AsyncGenerator[str, None]: + """更新世界观流式生成器""" + db_committed = False + try: + yield await SSEResponse.send_progress("开始更新世界观...", 10) + + # 获取项目 + result = await db.execute( + select(Project).where(Project.id == project_id) + ) + project = result.scalar_one_or_none() + if not project: + yield await SSEResponse.send_error("项目不存在", 404) + return + + yield await SSEResponse.send_progress("验证数据...", 30) + + # 更新世界观字段 + if "time_period" in data: + project.world_time_period = data["time_period"] + if "location" in data: + project.world_location = data["location"] + if "atmosphere" in data: + project.world_atmosphere = data["atmosphere"] + if "rules" in data: + project.world_rules = data["rules"] + + yield await SSEResponse.send_progress("保存到数据库...", 70) + + await db.commit() + db_committed = True + await db.refresh(project) + + # 发送结果 + yield await SSEResponse.send_result({ + "project_id": project.id, + "time_period": project.world_time_period, + "location": project.world_location, + "atmosphere": project.world_atmosphere, + "rules": project.world_rules + }) + + yield await SSEResponse.send_progress("完成!", 100, "success") + yield await SSEResponse.send_done() + + except GeneratorExit: + logger.warning("更新世界观生成器被提前关闭") + if not db_committed and db.in_transaction(): + await db.rollback() + logger.info("更新世界观事务已回滚(GeneratorExit)") + except Exception as e: + logger.error(f"更新世界观失败: {str(e)}") + if not db_committed and db.in_transaction(): + await db.rollback() + logger.info("更新世界观事务已回滚(异常)") + yield await SSEResponse.send_error(f"更新失败: {str(e)}") + + +@router.post("/world-building/{project_id}", summary="流式更新世界观") +async def update_world_building_stream( + project_id: str, + data: Dict[str, Any], + db: AsyncSession = Depends(get_db) +): + """ + 使用SSE流式更新项目的世界观信息 + 请求体格式: + { + "time_period": "时间背景", + "location": "地理位置", + "atmosphere": "氛围基调", + "rules": "世界规则" + } + """ + return create_sse_response(update_world_building_generator(project_id, data, db)) + + +async def regenerate_world_building_generator( + project_id: str, + data: Dict[str, Any], + db: AsyncSession +) -> AsyncGenerator[str, None]: + """重新生成世界观流式生成器""" + db_committed = False + try: + yield await SSEResponse.send_progress("开始重新生成世界观...", 10) + + # 获取项目 + result = await db.execute( + select(Project).where(Project.id == project_id) + ) + project = result.scalar_one_or_none() + if not project: + yield await SSEResponse.send_error("项目不存在", 404) + return + + provider = data.get("provider") + model = data.get("model") + + # 获取世界构建提示词 + yield await SSEResponse.send_progress("准备AI提示词...", 20) + prompt = prompt_service.get_world_building_prompt( + title=project.title, + theme=project.theme or "", + genre=project.genre or "" + ) + + # 流式调用AI + yield await SSEResponse.send_progress("正在调用AI生成...", 30) + + accumulated_text = "" + chunk_count = 0 + + async for chunk in ai_service.generate_text_stream( + prompt=prompt, + provider=provider, + model=model + ): + chunk_count += 1 + accumulated_text += chunk + + # 发送内容块 + yield await SSEResponse.send_chunk(chunk) + + # 定期更新进度 + if chunk_count % 5 == 0: + progress = min(30 + (chunk_count // 5), 70) + yield await SSEResponse.send_progress(f"生成中... ({len(accumulated_text)}字符)", progress) + + # 每20个块发送心跳 + if chunk_count % 20 == 0: + yield await SSEResponse.send_heartbeat() + + # 解析结果 + yield await SSEResponse.send_progress("解析AI返回结果...", 80) + + world_data = {} + try: + cleaned_text = accumulated_text.strip() + if cleaned_text.startswith('```json'): + cleaned_text = cleaned_text[7:] + if cleaned_text.startswith('```'): + cleaned_text = cleaned_text[3:] + if cleaned_text.endswith('```'): + cleaned_text = cleaned_text[:-3] + cleaned_text = cleaned_text.strip() + + world_data = json.loads(cleaned_text) + except json.JSONDecodeError as e: + logger.error(f"AI返回非JSON格式: {e}") + world_data = { + "time_period": accumulated_text[:300] if len(accumulated_text) > 300 else accumulated_text, + "location": "AI返回格式错误,请重试", + "atmosphere": "AI返回格式错误,请重试", + "rules": "AI返回格式错误,请重试" + } + + # 更新项目世界观 + yield await SSEResponse.send_progress("保存到数据库...", 90) + + project.world_time_period = world_data.get("time_period") + project.world_location = world_data.get("location") + project.world_atmosphere = world_data.get("atmosphere") + project.world_rules = world_data.get("rules") + + await db.commit() + db_committed = True + await db.refresh(project) + + # 发送结果 + yield await SSEResponse.send_result({ + "project_id": project.id, + "time_period": project.world_time_period, + "location": project.world_location, + "atmosphere": project.world_atmosphere, + "rules": project.world_rules + }) + + yield await SSEResponse.send_progress("完成!", 100, "success") + yield await SSEResponse.send_done() + + except GeneratorExit: + logger.warning("重新生成世界观生成器被提前关闭") + if not db_committed and db.in_transaction(): + await db.rollback() + logger.info("重新生成世界观事务已回滚(GeneratorExit)") + except Exception as e: + logger.error(f"重新生成世界观失败: {str(e)}") + if not db_committed and db.in_transaction(): + await db.rollback() + logger.info("重新生成世界观事务已回滚(异常)") + yield await SSEResponse.send_error(f"重新生成失败: {str(e)}") + + +@router.post("/world-building/{project_id}/regenerate", summary="流式重新生成世界观") +async def regenerate_world_building_stream( + project_id: str, + data: Dict[str, Any], + db: AsyncSession = Depends(get_db) +): + """ + 使用SSE流式重新生成项目的世界观 + 请求体格式: + { + "provider": "AI提供商(可选)", + "model": "模型名称(可选)" + } + """ + return create_sse_response(regenerate_world_building_generator(project_id, data, db)) + + +async def cleanup_wizard_data_generator( + project_id: str, + db: AsyncSession +) -> AsyncGenerator[str, None]: + """清理向导数据流式生成器""" + db_committed = False + try: + yield await SSEResponse.send_progress("开始清理向导数据...", 10) + + # 获取项目 + result = await db.execute( + select(Project).where(Project.id == project_id) + ) + project = result.scalar_one_or_none() + if not project: + yield await SSEResponse.send_error("项目不存在", 404) + return + + # 删除相关的角色 + yield await SSEResponse.send_progress("删除角色数据...", 30) + characters = await db.execute( + select(Character).where(Character.project_id == project_id) + ) + char_count = 0 + for character in characters.scalars(): + await db.delete(character) + char_count += 1 + + # 删除相关的大纲 + yield await SSEResponse.send_progress("删除大纲数据...", 50) + outlines = await db.execute( + select(Outline).where(Outline.project_id == project_id) + ) + outline_count = 0 + for outline in outlines.scalars(): + await db.delete(outline) + outline_count += 1 + + # 删除相关的章节 + yield await SSEResponse.send_progress("删除章节数据...", 70) + chapters = await db.execute( + select(Chapter).where(Chapter.project_id == project_id) + ) + chapter_count = 0 + for chapter in chapters.scalars(): + await db.delete(chapter) + chapter_count += 1 + + # 删除项目 + yield await SSEResponse.send_progress("删除项目...", 85) + await db.delete(project) + + yield await SSEResponse.send_progress("提交数据库更改...", 95) + await db.commit() + db_committed = True + + # 发送结果 + yield await SSEResponse.send_result({ + "message": "项目及相关数据已清理", + "deleted": { + "characters": char_count, + "outlines": outline_count, + "chapters": chapter_count + } + }) + + yield await SSEResponse.send_progress("完成!", 100, "success") + yield await SSEResponse.send_done() + + except GeneratorExit: + logger.warning("清理向导数据生成器被提前关闭") + if not db_committed and db.in_transaction(): + await db.rollback() + logger.info("清理向导数据事务已回滚(GeneratorExit)") + except Exception as e: + logger.error(f"清理数据失败: {str(e)}") + if not db_committed and db.in_transaction(): + await db.rollback() + logger.info("清理向导数据事务已回滚(异常)") + yield await SSEResponse.send_error(f"清理失败: {str(e)}") + + +@router.post("/cleanup/{project_id}", summary="流式清理向导数据") +async def cleanup_wizard_data_stream( + project_id: str, + db: AsyncSession = Depends(get_db) +): + """ + 使用SSE流式清理向导过程中创建的项目及相关数据 + 用于返回上一步时清理已生成的内容 + """ + return create_sse_response(cleanup_wizard_data_generator(project_id, db)) \ No newline at end of file diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..c15bbf2 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,90 @@ +"""应用配置管理""" +from pydantic_settings import BaseSettings +from typing import Optional +from pathlib import Path +import logging + +# 获取项目根目录(从backend/app/config.py向上两级) +PROJECT_ROOT = Path(__file__).parent.parent +DATA_DIR = PROJECT_ROOT / "data" +DATA_DIR.mkdir(exist_ok=True) + +# 配置模块使用标准logging(在logger.py初始化之前) +config_logger = logging.getLogger(__name__) + +# 数据库文件路径(绝对路径) +DB_FILE = DATA_DIR / "ai_story.db" + +# 生成数据库URL(在类外部生成,确保使用绝对路径) +# 将Windows反斜杠转换为正斜杠,SQLite URL格式要求 +DATABASE_URL = f"sqlite+aiosqlite:///{str(DB_FILE.absolute()).replace(chr(92), '/')}" +config_logger.debug(f"数据库文件路径: {DB_FILE}") +config_logger.debug(f"数据库URL: {DATABASE_URL}") + +class Settings(BaseSettings): + """应用配置""" + + # 应用配置 + app_name: str = "MuMuAINovel" + app_version: str = "1.0.0" + app_host: str = "0.0.0.0" + app_port: int = 8000 + debug: bool = True + + # 日志配置 + log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR, CRITICAL + log_to_file: bool = True # 是否输出到文件 + log_file_path: str = str(PROJECT_ROOT / "logs" / "app.log") + log_max_bytes: int = 10 * 1024 * 1024 # 10MB + log_backup_count: int = 30 # 保留30个备份文件 + + # CORS配置 + cors_origins: list[str] = ["http://localhost:8000", "http://127.0.0.1:8000"] + + # 数据库配置 - 使用预先计算好的绝对路径URL + database_url: str = DATABASE_URL + + # AI服务配置 + openai_api_key: Optional[str] = None + openai_base_url: Optional[str] = None + gemini_api_key: Optional[str] = None + gemini_base_url: Optional[str] = None + anthropic_api_key: Optional[str] = None + anthropic_base_url: Optional[str] = None + default_ai_provider: str = "openai" + default_model: str = "gpt-4" + default_temperature: float = 0.7 + default_max_tokens: int = 2000 + + # LinuxDO OAuth2 配置 + LINUXDO_CLIENT_ID: Optional[str] = None + LINUXDO_CLIENT_SECRET: Optional[str] = None + # 回调地址:Docker部署时必须使用实际域名或服务器IP,不能使用localhost + # 本地开发: http://localhost:8000/api/auth/callback + # 生产环境: https://your-domain.com/api/auth/callback 或 http://your-ip:8000/api/auth/callback + LINUXDO_REDIRECT_URI: Optional[str] = None + + # 前端URL配置(用于OAuth回调后重定向) + # 本地开发: http://localhost:8000 + # 生产环境: https://your-domain.com 或 http://your-ip:8000 + FRONTEND_URL: str = "http://localhost:8000" + + # 初始管理员配置(LinuxDO user_id) + INITIAL_ADMIN_LINUXDO_ID: Optional[str] = None + + # 本地账户登录配置 + LOCAL_AUTH_ENABLED: bool = True # 是否启用本地账户登录 + LOCAL_AUTH_USERNAME: Optional[str] = None # 本地登录用户名 + LOCAL_AUTH_PASSWORD: Optional[str] = None # 本地登录密码 + LOCAL_AUTH_DISPLAY_NAME: str = "本地用户" # 本地用户显示名称 + + class Config: + env_file = ".env" + case_sensitive = False + + +# 创建全局配置实例 +settings = Settings() +config_logger.info(f"配置加载完成: {settings.app_name} v{settings.app_version}") +config_logger.debug(f"调试模式: {settings.debug}") +config_logger.debug(f"AI提供商: {settings.default_ai_provider}") diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..8223d51 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,261 @@ +"""数据库连接和会话管理 - 支持多用户数据隔离""" +import asyncio +from typing import Dict, Any +from datetime import datetime +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker +from sqlalchemy.orm import declarative_base +from sqlalchemy.pool import StaticPool +from fastapi import Request, HTTPException +from app.config import settings +from app.logger import get_logger + +logger = get_logger(__name__) + +# 创建基类 +Base = declarative_base() + +# 引擎缓存:每个用户一个引擎 +_engine_cache: Dict[str, Any] = {} + +# 锁管理:用于保护引擎创建过程 +_engine_locks: Dict[str, asyncio.Lock] = {} +_cache_lock = asyncio.Lock() + +# 会话统计(用于监控连接泄漏) +_session_stats = { + "created": 0, + "closed": 0, + "active": 0, + "errors": 0, + "generator_exits": 0, + "last_check": None +} + + +async def get_engine(user_id: str): + """获取或创建用户专属的数据库引擎(线程安全) + + Args: + user_id: 用户ID + + Returns: + 用户专属的异步引擎 + """ + if user_id in _engine_cache: + return _engine_cache[user_id] + + async with _cache_lock: + if user_id not in _engine_locks: + _engine_locks[user_id] = asyncio.Lock() + user_lock = _engine_locks[user_id] + + async with user_lock: + if user_id not in _engine_cache: + db_url = f"sqlite+aiosqlite:///data/ai_story_user_{user_id}.db" + engine = create_async_engine( + db_url, + echo=False, + future=True, + poolclass=StaticPool, + pool_pre_ping=True, + pool_recycle=3600, + connect_args={ + "timeout": 30, + "check_same_thread": False + } + ) + + try: + async with engine.begin() as conn: + await conn.execute(text("PRAGMA journal_mode=WAL")) + await conn.execute(text("PRAGMA synchronous=NORMAL")) + await conn.execute(text("PRAGMA cache_size=-64000")) + await conn.execute(text("PRAGMA temp_store=MEMORY")) + await conn.execute(text("PRAGMA busy_timeout=5000")) + + logger.info(f"✅ 用户 {user_id} 的数据库已优化(WAL模式 + 64MB缓存)") + except Exception as e: + logger.warning(f"⚠️ 用户 {user_id} 数据库优化失败: {str(e)}") + _engine_cache[user_id] = engine + logger.info(f"为用户 {user_id} 创建数据库引擎") + + return _engine_cache[user_id] + + +async def get_db(request: Request): + """获取数据库会话的依赖函数 + + 从 request.state.user_id 获取用户ID,然后返回该用户的数据库会话 + """ + user_id = getattr(request.state, "user_id", None) + + if not user_id: + raise HTTPException(status_code=401, detail="未登录或用户ID缺失") + + engine = await get_engine(user_id) + + AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False + ) + + session = AsyncSessionLocal() + session_id = id(session) + + global _session_stats + _session_stats["created"] += 1 + _session_stats["active"] += 1 + + logger.debug(f"📊 会话创建 [User:{user_id}][ID:{session_id}] - 活跃:{_session_stats['active']}, 总创建:{_session_stats['created']}, 总关闭:{_session_stats['closed']}") + + try: + yield session + if session.in_transaction(): + await session.rollback() + except GeneratorExit: + _session_stats["generator_exits"] += 1 + logger.warning(f"⚠️ GeneratorExit [User:{user_id}][ID:{session_id}] - SSE连接断开(总计:{_session_stats['generator_exits']}次)") + try: + if session.in_transaction(): + await session.rollback() + logger.info(f"✅ 事务已回滚 [User:{user_id}][ID:{session_id}](GeneratorExit)") + except Exception as rollback_error: + _session_stats["errors"] += 1 + logger.error(f"❌ GeneratorExit回滚失败 [User:{user_id}][ID:{session_id}]: {str(rollback_error)}") + except Exception as e: + _session_stats["errors"] += 1 + logger.error(f"❌ 会话异常 [User:{user_id}][ID:{session_id}]: {str(e)}") + try: + if session.in_transaction(): + await session.rollback() + logger.info(f"✅ 事务已回滚 [User:{user_id}][ID:{session_id}](异常)") + except Exception as rollback_error: + logger.error(f"❌ 异常回滚失败 [User:{user_id}][ID:{session_id}]: {str(rollback_error)}") + raise + finally: + try: + if session.in_transaction(): + await session.rollback() + logger.warning(f"⚠️ finally中发现未提交事务 [User:{user_id}][ID:{session_id}],已回滚") + + await session.close() + + _session_stats["closed"] += 1 + _session_stats["active"] -= 1 + _session_stats["last_check"] = datetime.now().isoformat() + + logger.debug(f"📊 会话关闭 [User:{user_id}][ID:{session_id}] - 活跃:{_session_stats['active']}, 总创建:{_session_stats['created']}, 总关闭:{_session_stats['closed']}, 错误:{_session_stats['errors']}") + + if _session_stats["active"] > 10: + logger.warning(f"🚨 活跃会话数过多: {_session_stats['active']},可能存在连接泄漏!") + elif _session_stats["active"] < 0: + logger.error(f"🚨 活跃会话数异常: {_session_stats['active']},统计可能不准确!") + + except Exception as e: + _session_stats["errors"] += 1 + logger.error(f"❌ 关闭会话时出错 [User:{user_id}][ID:{session_id}]: {str(e)}", exc_info=True) + try: + await session.close() + except: + pass + +async def _init_relationship_types(user_id: str): + """为指定用户初始化预置的关系类型数据 + + Args: + user_id: 用户ID + """ + from app.models.relationship import RelationshipType + + relationship_types = [ + {"name": "父亲", "category": "family", "reverse_name": "子女", "intimacy_range": "high", "icon": "👨"}, + {"name": "母亲", "category": "family", "reverse_name": "子女", "intimacy_range": "high", "icon": "👩"}, + {"name": "兄弟", "category": "family", "reverse_name": "兄弟", "intimacy_range": "high", "icon": "👬"}, + {"name": "姐妹", "category": "family", "reverse_name": "姐妹", "intimacy_range": "high", "icon": "👭"}, + {"name": "子女", "category": "family", "reverse_name": "父母", "intimacy_range": "high", "icon": "👶"}, + {"name": "配偶", "category": "family", "reverse_name": "配偶", "intimacy_range": "high", "icon": "💑"}, + {"name": "恋人", "category": "family", "reverse_name": "恋人", "intimacy_range": "high", "icon": "💕"}, + + {"name": "师父", "category": "social", "reverse_name": "徒弟", "intimacy_range": "high", "icon": "🎓"}, + {"name": "徒弟", "category": "social", "reverse_name": "师父", "intimacy_range": "high", "icon": "📚"}, + {"name": "朋友", "category": "social", "reverse_name": "朋友", "intimacy_range": "medium", "icon": "🤝"}, + {"name": "同学", "category": "social", "reverse_name": "同学", "intimacy_range": "medium", "icon": "🎒"}, + {"name": "邻居", "category": "social", "reverse_name": "邻居", "intimacy_range": "low", "icon": "🏘️"}, + {"name": "知己", "category": "social", "reverse_name": "知己", "intimacy_range": "high", "icon": "💙"}, + + {"name": "上司", "category": "professional", "reverse_name": "下属", "intimacy_range": "low", "icon": "👔"}, + {"name": "下属", "category": "professional", "reverse_name": "上司", "intimacy_range": "low", "icon": "💼"}, + {"name": "同事", "category": "professional", "reverse_name": "同事", "intimacy_range": "medium", "icon": "🤵"}, + {"name": "合作伙伴", "category": "professional", "reverse_name": "合作伙伴", "intimacy_range": "medium", "icon": "🤜🤛"}, + + {"name": "敌人", "category": "hostile", "reverse_name": "敌人", "intimacy_range": "low", "icon": "⚔️"}, + {"name": "仇人", "category": "hostile", "reverse_name": "仇人", "intimacy_range": "low", "icon": "💢"}, + {"name": "竞争对手", "category": "hostile", "reverse_name": "竞争对手", "intimacy_range": "low", "icon": "🎯"}, + {"name": "宿敌", "category": "hostile", "reverse_name": "宿敌", "intimacy_range": "low", "icon": "⚡"}, + ] + + try: + engine = await get_engine(user_id) + AsyncSessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False + ) + + async with AsyncSessionLocal() as session: + result = await session.execute(select(RelationshipType)) + existing = result.scalars().first() + + if existing: + logger.info(f"用户 {user_id} 的关系类型数据已存在,跳过初始化") + return + + logger.info(f"开始为用户 {user_id} 插入关系类型数据...") + for rt_data in relationship_types: + relationship_type = RelationshipType(**rt_data) + session.add(relationship_type) + + await session.commit() + logger.info(f"成功为用户 {user_id} 插入 {len(relationship_types)} 条关系类型数据") + + except Exception as e: + logger.error(f"用户 {user_id} 初始化关系类型数据失败: {str(e)}", exc_info=True) + raise + + + +async def init_db(user_id: str): + """初始化指定用户的数据库,创建所有表并插入预置数据 + + Args: + user_id: 用户ID + """ + try: + logger.info(f"开始初始化用户 {user_id} 的数据库...") + engine = await get_engine(user_id) + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + await _init_relationship_types(user_id) + + logger.info(f"用户 {user_id} 的数据库初始化成功") + except Exception as e: + logger.error(f"用户 {user_id} 的数据库初始化失败: {str(e)}", exc_info=True) + raise + + +async def close_db(): + """关闭所有数据库连接""" + try: + logger.info("正在关闭所有数据库连接...") + for user_id, engine in _engine_cache.items(): + await engine.dispose() + logger.info(f"用户 {user_id} 的数据库连接已关闭") + _engine_cache.clear() + logger.info("所有数据库连接已关闭") + except Exception as e: + logger.error(f"关闭数据库连接失败: {str(e)}", exc_info=True) + raise \ No newline at end of file diff --git a/backend/app/init_relationship_types.py b/backend/app/init_relationship_types.py new file mode 100644 index 0000000..60f21f9 --- /dev/null +++ b/backend/app/init_relationship_types.py @@ -0,0 +1,73 @@ +"""初始化关系类型数据""" +import asyncio +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.database import AsyncSessionLocal +from app.models.relationship import RelationshipType +from app.logger import get_logger + +logger = get_logger(__name__) + + +async def init_relationship_types(): + """初始化预置的关系类型数据""" + + # 预置关系类型数据 + relationship_types = [ + # 家族关系 + {"name": "父亲", "category": "family", "reverse_name": "子女", "intimacy_range": "high", "icon": "👨"}, + {"name": "母亲", "category": "family", "reverse_name": "子女", "intimacy_range": "high", "icon": "👩"}, + {"name": "兄弟", "category": "family", "reverse_name": "兄弟", "intimacy_range": "high", "icon": "👬"}, + {"name": "姐妹", "category": "family", "reverse_name": "姐妹", "intimacy_range": "high", "icon": "👭"}, + {"name": "子女", "category": "family", "reverse_name": "父母", "intimacy_range": "high", "icon": "👶"}, + {"name": "配偶", "category": "family", "reverse_name": "配偶", "intimacy_range": "high", "icon": "💑"}, + {"name": "恋人", "category": "family", "reverse_name": "恋人", "intimacy_range": "high", "icon": "💕"}, + + # 社交关系 + {"name": "师父", "category": "social", "reverse_name": "徒弟", "intimacy_range": "high", "icon": "🎓"}, + {"name": "徒弟", "category": "social", "reverse_name": "师父", "intimacy_range": "high", "icon": "📚"}, + {"name": "朋友", "category": "social", "reverse_name": "朋友", "intimacy_range": "medium", "icon": "🤝"}, + {"name": "同学", "category": "social", "reverse_name": "同学", "intimacy_range": "medium", "icon": "🎒"}, + {"name": "邻居", "category": "social", "reverse_name": "邻居", "intimacy_range": "low", "icon": "🏘️"}, + {"name": "知己", "category": "social", "reverse_name": "知己", "intimacy_range": "high", "icon": "💙"}, + + # 职业关系 + {"name": "上司", "category": "professional", "reverse_name": "下属", "intimacy_range": "low", "icon": "👔"}, + {"name": "下属", "category": "professional", "reverse_name": "上司", "intimacy_range": "low", "icon": "💼"}, + {"name": "同事", "category": "professional", "reverse_name": "同事", "intimacy_range": "medium", "icon": "🤵"}, + {"name": "合作伙伴", "category": "professional", "reverse_name": "合作伙伴", "intimacy_range": "medium", "icon": "🤜🤛"}, + + # 敌对关系 + {"name": "敌人", "category": "hostile", "reverse_name": "敌人", "intimacy_range": "low", "icon": "⚔️"}, + {"name": "仇人", "category": "hostile", "reverse_name": "仇人", "intimacy_range": "low", "icon": "💢"}, + {"name": "竞争对手", "category": "hostile", "reverse_name": "竞争对手", "intimacy_range": "low", "icon": "🎯"}, + {"name": "宿敌", "category": "hostile", "reverse_name": "宿敌", "intimacy_range": "low", "icon": "⚡"}, + ] + + async with AsyncSessionLocal() as session: + try: + # 检查是否已经有数据 + result = await session.execute(select(RelationshipType)) + existing = result.scalars().first() + + if existing: + logger.info("关系类型数据已存在,跳过初始化") + return + + # 插入预置数据 + logger.info("开始插入关系类型数据...") + for rt_data in relationship_types: + relationship_type = RelationshipType(**rt_data) + session.add(relationship_type) + + await session.commit() + logger.info(f"成功插入 {len(relationship_types)} 条关系类型数据") + + except Exception as e: + logger.error(f"初始化关系类型数据失败: {str(e)}", exc_info=True) + await session.rollback() + raise + + +if __name__ == "__main__": + asyncio.run(init_relationship_types()) \ No newline at end of file diff --git a/backend/app/logger.py b/backend/app/logger.py new file mode 100644 index 0000000..0c699c0 --- /dev/null +++ b/backend/app/logger.py @@ -0,0 +1,158 @@ +"""统一日志配置模块 - Uvicorn风格""" +import logging +import sys +from pathlib import Path +from logging.handlers import RotatingFileHandler +from typing import Optional + + +class UvicornFormatter(logging.Formatter): + """Uvicorn风格的日志格式化器""" + + # 日志级别颜色(ANSI转义码) + COLORS = { + 'DEBUG': '\033[36m', # 青色 + 'INFO': '\033[32m', # 绿色 + 'WARNING': '\033[33m', # 黄色 + 'ERROR': '\033[31m', # 红色 + 'CRITICAL': '\033[35m', # 紫色 + } + RESET = '\033[0m' + + def __init__(self, use_colors: bool = True): + """ + 初始化格式化器 + + Args: + use_colors: 是否使用颜色(控制台输出使用,文件输出不使用) + """ + super().__init__() + self.use_colors = use_colors + + def format(self, record): + """格式化日志记录为 Uvicorn 风格""" + # 获取日志级别名称 + levelname = record.levelname + + # 添加颜色(如果启用且终端支持) + if self.use_colors and sys.stderr.isatty(): + colored_level = f"{self.COLORS.get(levelname, '')}{levelname}{self.RESET}" + else: + colored_level = levelname + + # 添加请求追踪ID(如果存在) + request_id = getattr(record, 'request_id', None) + request_id_str = f" [{request_id}]" if request_id else "" + + # Uvicorn风格格式: INFO: module_name - message [request_id] + # 注意:INFO后面有5个空格,保持对齐 + return f"{colored_level}: {record.name}{request_id_str} - {record.getMessage()}" + + +# 全局标志,防止重复初始化 +_logging_configured = False + +def setup_logging( + level: str = "INFO", + log_to_file: bool = False, + log_file_path: Optional[str] = None, + max_bytes: int = 10 * 1024 * 1024, + backup_count: int = 30 +): + """ + 配置统一的 Uvicorn 风格日志系统 + + Args: + level: 日志级别 (DEBUG, INFO, WARNING, ERROR, CRITICAL) + log_to_file: 是否输出到文件 + log_file_path: 日志文件路径 + max_bytes: 单个日志文件最大字节数(默认10MB) + backup_count: 保留的备份文件数量(默认30个) + """ + global _logging_configured + + # 如果已经配置过,直接返回 + if _logging_configured: + return logging.getLogger() + + # 获取根日志器 + root_logger = logging.getLogger() + root_logger.setLevel(getattr(logging, level.upper())) + + # 清除已有的处理器,避免重复 + root_logger.handlers.clear() + + # 1. 创建控制台处理器(带颜色) + console_handler = logging.StreamHandler(sys.stderr) + console_handler.setLevel(getattr(logging, level.upper())) + console_formatter = UvicornFormatter(use_colors=True) + console_handler.setFormatter(console_formatter) + root_logger.addHandler(console_handler) + + # 2. 创建文件处理器(如果启用) + if log_to_file and log_file_path: + # 确保日志目录存在 + log_file = Path(log_file_path) + log_file.parent.mkdir(parents=True, exist_ok=True) + + # 使用RotatingFileHandler实现日志轮转 + file_handler = RotatingFileHandler( + filename=log_file_path, + maxBytes=max_bytes, + backupCount=backup_count, + encoding='utf-8' + ) + file_handler.setLevel(getattr(logging, level.upper())) + + # 文件日志不使用颜色 + file_formatter = UvicornFormatter(use_colors=False) + file_handler.setFormatter(file_formatter) + root_logger.addHandler(file_handler) + + # 记录日志配置信息 + root_logger.info(f"日志文件输出已启用: {log_file_path}") + root_logger.info(f"日志轮转配置: 单文件最大{max_bytes / 1024 / 1024:.1f}MB, 保留{backup_count}个备份") + + # 配置第三方库的日志级别 + _configure_third_party_loggers() + + # 标记为已配置 + _logging_configured = True + + return root_logger + + +def _configure_third_party_loggers(): + """配置第三方库的日志级别""" + # SQLAlchemy - 禁用SQL日志 + logging.getLogger('sqlalchemy.engine').setLevel(logging.WARNING) + logging.getLogger('sqlalchemy.pool').setLevel(logging.WARNING) + logging.getLogger('sqlalchemy.dialects').setLevel(logging.WARNING) + logging.getLogger('sqlalchemy.orm').setLevel(logging.WARNING) + + # Watchfiles - 开发时的文件监控,降低级别 + logging.getLogger('watchfiles').setLevel(logging.WARNING) + + # httpx - HTTP客户端 + logging.getLogger('httpx').setLevel(logging.WARNING) + + # openai/anthropic - AI客户端库 + logging.getLogger('openai').setLevel(logging.WARNING) + logging.getLogger('anthropic').setLevel(logging.WARNING) + + # 应用模块 - 可根据需要调整 + logging.getLogger('app.services.ai_service').setLevel(logging.WARNING) + logging.getLogger('app.api.wizard').setLevel(logging.WARNING) + + +def get_logger(name: str) -> logging.Logger: + """ + 获取指定名称的日志器 + + Args: + name: 日志器名称,通常使用 __name__ + + Returns: + 配置好的日志器实例 + """ + return logging.getLogger(name) \ No newline at end of file diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..0152612 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,176 @@ +"""FastAPI应用主入口""" +from fastapi import FastAPI, Request, status +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import JSONResponse, FileResponse +from fastapi.exceptions import RequestValidationError +from contextlib import asynccontextmanager +from pathlib import Path + +from app.config import settings +from app.database import close_db, _session_stats +from app.logger import setup_logging, get_logger +from app.middleware import RequestIDMiddleware +from app.middleware.auth_middleware import AuthMiddleware + +setup_logging( + level=settings.log_level, + log_to_file=settings.log_to_file, + log_file_path=settings.log_file_path, + max_bytes=settings.log_max_bytes, + backup_count=settings.log_backup_count +) +logger = get_logger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """应用生命周期管理""" + logger.info("应用启动,等待用户登录...") + + yield + await close_db() + logger.info("应用已关闭") + + +app = FastAPI( + title=settings.app_name, + version=settings.app_version, + description="AI写小说工具 - 智能小说创作助手", + lifespan=lifespan +) + +@app.exception_handler(RequestValidationError) +async def validation_exception_handler(request: Request, exc: RequestValidationError): + """处理请求验证错误""" + logger.error(f"请求验证失败: {exc.errors()}") + return JSONResponse( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + content={ + "detail": "请求参数验证失败", + "errors": exc.errors() + } + ) + +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + """处理所有未捕获的异常""" + logger.error(f"未处理的异常: {type(exc).__name__}: {str(exc)}", exc_info=True) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content={ + "detail": "服务器内部错误", + "message": str(exc) if settings.debug else "请稍后重试" + } + ) + +app.add_middleware(RequestIDMiddleware) +app.add_middleware(AuthMiddleware) + +if settings.debug: + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) +else: + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + +@app.get("/health") +async def health_check(): + """健康检查""" + return {"status": "ok"} + + +@app.get("/health/db-sessions") +async def db_session_stats(): + """ + 数据库会话统计(监控连接泄漏) + + 返回: + - created: 总创建会话数 + - closed: 总关闭会话数 + - active: 当前活跃会话数(应该接近0) + - errors: 错误次数 + - generator_exits: SSE断开次数 + - last_check: 最后检查时间 + """ + return { + "status": "ok", + "session_stats": _session_stats, + "warning": "活跃会话数过多" if _session_stats["active"] > 10 else None + } + + +from app.api import ( + projects, outlines, characters, chapters, + wizard_stream, relationships, organizations, + auth, users +) + +app.include_router(auth.router, prefix="/api") +app.include_router(users.router, prefix="/api") + +app.include_router(projects.router, prefix="/api") +app.include_router(wizard_stream.router, prefix="/api") +app.include_router(outlines.router, prefix="/api") +app.include_router(characters.router, prefix="/api") +app.include_router(chapters.router, prefix="/api") +app.include_router(relationships.router, prefix="/api") +app.include_router(organizations.router, prefix="/api") + +static_dir = Path(__file__).parent.parent / "static" +if static_dir.exists(): + app.mount("/assets", StaticFiles(directory=str(static_dir / "assets")), name="assets") + + @app.get("/{full_path:path}") + async def serve_spa(full_path: str): + """服务单页应用,所有非API路径返回index.html""" + if full_path.startswith("api/"): + return JSONResponse( + status_code=404, + content={"detail": "API路径不存在"} + ) + + file_path = static_dir / full_path + if file_path.is_file(): + return FileResponse(file_path) + + index_file = static_dir / "index.html" + if index_file.exists(): + return FileResponse(index_file) + + return JSONResponse( + status_code=404, + content={"detail": "页面不存在"} + ) +else: + logger.warning("静态文件目录不存在,请先构建前端: cd frontend && npm run build") + + @app.get("/") + async def root(): + return { + "message": "欢迎使用AI Story Creator", + "version": settings.app_version, + "docs": "/docs", + "notice": "请先构建前端: cd frontend && npm run build" + } + + +if __name__ == "__main__": + import uvicorn + uvicorn.run( + "app.main:app", + host=settings.app_host, + port=settings.app_port, + reload=settings.debug + ) \ No newline at end of file diff --git a/backend/app/middleware/__init__.py b/backend/app/middleware/__init__.py new file mode 100644 index 0000000..34a2b94 --- /dev/null +++ b/backend/app/middleware/__init__.py @@ -0,0 +1,4 @@ +"""中间件模块""" +from .request_id import RequestIDMiddleware + +__all__ = ['RequestIDMiddleware'] \ No newline at end of file diff --git a/backend/app/middleware/auth_middleware.py b/backend/app/middleware/auth_middleware.py new file mode 100644 index 0000000..e173504 --- /dev/null +++ b/backend/app/middleware/auth_middleware.py @@ -0,0 +1,39 @@ +""" +认证中间件 - 从 Cookie 中提取用户信息并注入到 request.state +""" +from fastapi import Request +from starlette.middleware.base import BaseHTTPMiddleware +from app.user_manager import user_manager + + +class AuthMiddleware(BaseHTTPMiddleware): + """认证中间件""" + + async def dispatch(self, request: Request, call_next): + """ + 处理请求,从 Cookie 中提取用户 ID 并注入到 request.state + """ + # 从 Cookie 中获取用户 ID + user_id = request.cookies.get("user_id") + + # 注入到 request.state + if user_id: + user = await user_manager.get_user(user_id) + if user: + request.state.user_id = user_id + request.state.user = user + request.state.is_admin = user.is_admin + else: + # 用户不存在,清除状态 + request.state.user_id = None + request.state.user = None + request.state.is_admin = False + else: + # 未登录 + request.state.user_id = None + request.state.user = None + request.state.is_admin = False + + # 继续处理请求 + response = await call_next(request) + return response \ No newline at end of file diff --git a/backend/app/middleware/request_id.py b/backend/app/middleware/request_id.py new file mode 100644 index 0000000..71a8212 --- /dev/null +++ b/backend/app/middleware/request_id.py @@ -0,0 +1,78 @@ +"""请求追踪ID中间件""" +import uuid +import logging +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response +from typing import Callable + + +class RequestIDMiddleware(BaseHTTPMiddleware): + """ + 请求追踪ID中间件 + + 为每个请求生成唯一ID,并添加到日志上下文中 + """ + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + """ + 处理请求,添加追踪ID + + Args: + request: 请求对象 + call_next: 下一个处理器 + + Returns: + 响应对象 + """ + # 从请求头获取追踪ID,或生成新的 + request_id = request.headers.get('X-Request-ID') or str(uuid.uuid4()) + + # 将请求ID存储到request.state中,方便后续访问 + request.state.request_id = request_id + + # 创建日志过滤器,自动添加request_id到日志记录 + log_filter = RequestIDFilter(request_id) + + # 获取根日志器并添加过滤器 + root_logger = logging.getLogger() + root_logger.addFilter(log_filter) + + try: + # 处理请求 + response = await call_next(request) + + # 将请求ID添加到响应头 + response.headers['X-Request-ID'] = request_id + + return response + finally: + # 移除过滤器,避免影响其他请求 + root_logger.removeFilter(log_filter) + + +class RequestIDFilter(logging.Filter): + """日志过滤器,为日志记录添加request_id属性""" + + def __init__(self, request_id: str): + """ + 初始化过滤器 + + Args: + request_id: 请求追踪ID + """ + super().__init__() + self.request_id = request_id + + def filter(self, record: logging.LogRecord) -> bool: + """ + 为日志记录添加request_id属性 + + Args: + record: 日志记录 + + Returns: + True(不过滤任何日志) + """ + record.request_id = self.request_id + return True \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..545c7a3 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,26 @@ +"""数据库模型""" +from app.models.project import Project +from app.models.outline import Outline +from app.models.character import Character +from app.models.chapter import Chapter +from app.models.generation_history import GenerationHistory +from app.models.settings import Settings +from app.models.relationship import ( + RelationshipType, + CharacterRelationship, + Organization, + OrganizationMember +) + +__all__ = [ + "Project", + "Outline", + "Character", + "Chapter", + "GenerationHistory", + "Settings", + "RelationshipType", + "CharacterRelationship", + "Organization", + "OrganizationMember", +] \ No newline at end of file diff --git a/backend/app/models/chapter.py b/backend/app/models/chapter.py new file mode 100644 index 0000000..7353073 --- /dev/null +++ b/backend/app/models/chapter.py @@ -0,0 +1,24 @@ +"""章节数据模型""" +from sqlalchemy import Column, String, Text, Integer, DateTime, ForeignKey +from sqlalchemy.sql import func +from app.database import Base +import uuid + + +class Chapter(Base): + """章节表""" + __tablename__ = "chapters" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + project_id = Column(String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False) + chapter_number = Column(Integer, nullable=False, comment="章节序号") + title = Column(String(200), nullable=False, comment="章节标题") + content = Column(Text, comment="章节内容") + summary = Column(Text, comment="章节摘要") + word_count = Column(Integer, default=0, comment="字数统计") + status = Column(String(20), default="draft", comment="章节状态") + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/backend/app/models/character.py b/backend/app/models/character.py new file mode 100644 index 0000000..2363dca --- /dev/null +++ b/backend/app/models/character.py @@ -0,0 +1,44 @@ +"""角色数据模型""" +from sqlalchemy import Column, String, Text, DateTime, ForeignKey, Boolean +from sqlalchemy.sql import func +from app.database import Base +import uuid + + +class Character(Base): + """角色表(包括角色和组织)""" + __tablename__ = "characters" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + project_id = Column(String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False) + + # 基本信息 + name = Column(String(100), nullable=False, comment="角色/组织名称") + age = Column(String(20), comment="年龄") + gender = Column(String(20), comment="性别") + is_organization = Column(Boolean, default=False, comment="是否为组织") + + # 角色类型:protagonist(主角)/supporting(配角)/antagonist(反派) + role_type = Column(String(50), comment="角色类型") + + # 角色详细信息 + personality = Column(Text, comment="性格特点/组织特性") + background = Column(Text, comment="背景故事") + appearance = Column(Text, comment="外貌描述") + relationships = Column(Text, comment="人物关系(JSON)") + + # 组织特有字段 + organization_type = Column(String(100), comment="组织类型") + organization_purpose = Column(String(500), comment="组织目的") + organization_members = Column(Text, comment="组织成员(JSON)") + + # 其他 + avatar_url = Column(String(500), comment="头像URL") + traits = Column(Text, comment="特征标签(JSON)") + + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + def __repr__(self): + entity_type = "组织" if self.is_organization else "角色" + return f"" \ No newline at end of file diff --git a/backend/app/models/generation_history.py b/backend/app/models/generation_history.py new file mode 100644 index 0000000..c309859 --- /dev/null +++ b/backend/app/models/generation_history.py @@ -0,0 +1,23 @@ +"""生成历史数据模型""" +from sqlalchemy import Column, String, Text, Integer, Float, DateTime, ForeignKey +from sqlalchemy.sql import func +from app.database import Base +import uuid + + +class GenerationHistory(Base): + """生成历史表""" + __tablename__ = "generation_history" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + project_id = Column(String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False) + chapter_id = Column(String(36), ForeignKey("chapters.id", ondelete="SET NULL"), nullable=True) + prompt = Column(Text, comment="使用的提示词") + generated_content = Column(Text, comment="生成的内容") + model = Column(String(50), comment="使用的模型") + tokens_used = Column(Integer, comment="消耗的token数") + generation_time = Column(Float, comment="生成耗时(秒)") + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/backend/app/models/outline.py b/backend/app/models/outline.py new file mode 100644 index 0000000..443dcdc --- /dev/null +++ b/backend/app/models/outline.py @@ -0,0 +1,22 @@ +"""大纲数据模型""" +from sqlalchemy import Column, String, Text, Integer, DateTime, ForeignKey +from sqlalchemy.sql import func +from app.database import Base +import uuid + + +class Outline(Base): + """大纲表""" + __tablename__ = "outlines" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + project_id = Column(String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False) + title = Column(String(200), nullable=False, comment="大纲标题") + content = Column(Text, comment="大纲内容") + structure = Column(Text, comment="结构化大纲数据(JSON)") + order_index = Column(Integer, comment="排序序号") + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/backend/app/models/project.py b/backend/app/models/project.py new file mode 100644 index 0000000..1913067 --- /dev/null +++ b/backend/app/models/project.py @@ -0,0 +1,38 @@ +"""项目数据模型""" +from sqlalchemy import Column, String, Text, DateTime, Integer +from sqlalchemy.sql import func +from app.database import Base +import uuid + + +class Project(Base): + """项目表""" + __tablename__ = "projects" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + title = Column(String(200), nullable=False, comment="项目标题") + description = Column(Text, comment="项目简介") + theme = Column(Text, comment="主题") + genre = Column(String(50), comment="小说类型") + target_words = Column(Integer, default=0, comment="目标字数") + current_words = Column(Integer, default=0, comment="当前字数") + status = Column(String(20), default="planning", comment="创作状态") + wizard_status = Column(String(20), default="incomplete", comment="向导完成状态: incomplete/completed") + wizard_step = Column(Integer, default=0, comment="向导当前步骤: 0-4") + + # 世界构建字段 + world_time_period = Column(Text, comment="时间背景") + world_location = Column(Text, comment="地理位置") + world_atmosphere = Column(Text, comment="氛围基调") + world_rules = Column(Text, comment="世界规则") + + # 项目配置 + chapter_count = Column(Integer, comment="章节数量") + narrative_perspective = Column(String(50), comment="叙事视角:first_person/third_person/omniscient") + character_count = Column(Integer, default=5, comment="角色数量") + + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/backend/app/models/relationship.py b/backend/app/models/relationship.py new file mode 100644 index 0000000..70879dc --- /dev/null +++ b/backend/app/models/relationship.py @@ -0,0 +1,116 @@ +"""角色关系和组织管理数据模型""" +from sqlalchemy import Column, String, Integer, Text, DateTime, ForeignKey, Boolean +from sqlalchemy.sql import func +from app.database import Base +import uuid + + +class RelationshipType(Base): + """关系类型定义表""" + __tablename__ = "relationship_types" + + id = Column(Integer, primary_key=True, index=True, autoincrement=True) + name = Column(String(50), nullable=False, comment="关系名称") + category = Column(String(20), nullable=False, comment="分类:family/social/hostile/professional") + reverse_name = Column(String(50), comment="反向关系名称") + intimacy_range = Column(String(20), comment="亲密度范围:high/medium/low") + icon = Column(String(50), comment="图标标识") + description = Column(Text, comment="关系描述") + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + + def __repr__(self): + return f"" + + +class CharacterRelationship(Base): + """角色关系表""" + __tablename__ = "character_relationships" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="关系ID") + project_id = Column(String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True, comment="项目ID") + + # 关系双方 + character_from_id = Column(String(36), ForeignKey("characters.id", ondelete="CASCADE"), nullable=False, index=True, comment="角色A的ID") + character_to_id = Column(String(36), ForeignKey("characters.id", ondelete="CASCADE"), nullable=False, index=True, comment="角色B的ID") + + # 关系类型 + relationship_type_id = Column(Integer, ForeignKey("relationship_types.id"), index=True, comment="关系类型ID") + relationship_name = Column(String(100), comment="自定义关系名称") + + # 关系属性 + intimacy_level = Column(Integer, default=50, comment="亲密度:0-100") + status = Column(String(20), default="active", comment="状态:active/broken/past/complicated") + description = Column(Text, comment="关系详细描述") + + # 故事时间线 + started_at = Column(String(100), comment="关系开始时间(故事时间)") + ended_at = Column(String(100), comment="关系结束时间(故事时间)") + + # 来源标识 + source = Column(String(20), default="ai", comment="来源:ai/manual/imported") + + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + def __repr__(self): + return f"" + + +class Organization(Base): + """组织详情表""" + __tablename__ = "organizations" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="组织ID") + character_id = Column(String(36), ForeignKey("characters.id", ondelete="CASCADE"), nullable=False, unique=True, comment="关联的角色ID") + project_id = Column(String(36), ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True, comment="项目ID") + + # 组织层级 + parent_org_id = Column(String(36), ForeignKey("organizations.id", ondelete="SET NULL"), comment="父组织ID") + level = Column(Integer, default=0, comment="组织层级") + + # 组织属性 + power_level = Column(Integer, default=50, comment="势力等级:0-100") + member_count = Column(Integer, default=0, comment="成员数量") + location = Column(Text, comment="所在地") + + # 组织特色 + motto = Column(String(200), comment="宗旨/口号") + color = Column(String(20), comment="代表颜色") + + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + def __repr__(self): + return f"" + + +class OrganizationMember(Base): + """组织成员关系表""" + __tablename__ = "organization_members" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()), comment="成员关系ID") + organization_id = Column(String(36), ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True, comment="组织ID") + character_id = Column(String(36), ForeignKey("characters.id", ondelete="CASCADE"), nullable=False, index=True, comment="角色ID") + + # 职位信息 + position = Column(String(100), nullable=False, comment="职位名称") + rank = Column(Integer, default=0, comment="职位等级") + + # 成员状态 + status = Column(String(20), default="active", comment="状态:active/retired/expelled/deceased") + joined_at = Column(String(100), comment="加入时间(故事时间)") + left_at = Column(String(100), comment="离开时间(故事时间)") + + # 成员属性 + loyalty = Column(Integer, default=50, comment="忠诚度:0-100") + contribution = Column(Integer, default=0, comment="贡献度:0-100") + + # 来源标识 + source = Column(String(20), default="ai", comment="来源:ai/manual") + + notes = Column(Text, comment="备注") + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py new file mode 100644 index 0000000..c7aa33a --- /dev/null +++ b/backend/app/models/settings.py @@ -0,0 +1,24 @@ +"""设置数据模型""" +from sqlalchemy import Column, String, Text, Float, Integer, DateTime +from sqlalchemy.sql import func +from app.database import Base +import uuid + + +class Settings(Base): + """设置表""" + __tablename__ = "settings" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + api_provider = Column(String(50), default="openai", comment="API提供商") + api_key = Column(String(500), comment="API密钥") + api_base_url = Column(String(500), comment="自定义API地址") + model_name = Column(String(100), default="gpt-4", comment="模型名称") + temperature = Column(Float, default=0.7, comment="温度参数") + max_tokens = Column(Integer, default=2000, comment="最大token数") + preferences = Column(Text, comment="其他偏好设置(JSON)") + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..1bf303e --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic数据模型""" \ No newline at end of file diff --git a/backend/app/schemas/chapter.py b/backend/app/schemas/chapter.py new file mode 100644 index 0000000..064ac1d --- /dev/null +++ b/backend/app/schemas/chapter.py @@ -0,0 +1,57 @@ +"""章节相关的Pydantic模型""" +from pydantic import BaseModel, Field +from typing import Optional +from datetime import datetime + + +class ChapterBase(BaseModel): + """章节基础模型""" + title: str = Field(..., description="章节标题") + chapter_number: int = Field(..., description="章节序号") + content: Optional[str] = Field(None, description="章节内容") + summary: Optional[str] = Field(None, description="章节摘要") + word_count: Optional[int] = Field(0, description="字数") + status: Optional[str] = Field("draft", description="章节状态") + + +class ChapterCreate(BaseModel): + """创建章节的请求模型""" + project_id: str = Field(..., description="所属项目ID") + title: str = Field(..., description="章节标题") + chapter_number: int = Field(..., description="章节序号") + content: Optional[str] = Field(None, description="章节内容") + summary: Optional[str] = Field(None, description="章节摘要") + status: Optional[str] = Field("draft", description="章节状态") + + +class ChapterUpdate(BaseModel): + """更新章节的请求模型""" + title: Optional[str] = None + content: Optional[str] = None + # chapter_number 不允许修改,只能通过大纲的重排序来调整 + summary: Optional[str] = None + # word_count 自动计算,不允许手动修改 + status: Optional[str] = None + + +class ChapterResponse(BaseModel): + """章节响应模型""" + id: str + project_id: str + title: str + chapter_number: int + content: Optional[str] = None + summary: Optional[str] = None + word_count: int = 0 + status: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class ChapterListResponse(BaseModel): + """章节列表响应模型""" + total: int + items: list[ChapterResponse] \ No newline at end of file diff --git a/backend/app/schemas/character.py b/backend/app/schemas/character.py new file mode 100644 index 0000000..864c998 --- /dev/null +++ b/backend/app/schemas/character.py @@ -0,0 +1,67 @@ +"""角色相关的Pydantic模型""" +from pydantic import BaseModel, Field +from typing import Optional, List, Dict, Any +from datetime import datetime + + +class CharacterBase(BaseModel): + """角色基础模型""" + name: str = Field(..., description="角色/组织姓名") + age: Optional[str] = Field(None, description="年龄") + gender: Optional[str] = Field(None, description="性别") + is_organization: bool = Field(False, description="是否为组织") + role_type: Optional[str] = Field(None, description="角色类型:protagonist/supporting/antagonist") + personality: Optional[str] = Field(None, description="性格特点/组织特性") + background: Optional[str] = Field(None, description="背景故事") + appearance: Optional[str] = Field(None, description="外貌特征") + relationships: Optional[str] = Field(None, description="人际关系(JSON)") + organization_type: Optional[str] = Field(None, description="组织类型") + organization_purpose: Optional[str] = Field(None, description="组织目的") + organization_members: Optional[str] = Field(None, description="组织成员(JSON)") + traits: Optional[str] = Field(None, description="特征标签(JSON)") + + +class CharacterUpdate(BaseModel): + """更新角色的请求模型""" + name: Optional[str] = None + age: Optional[str] = None + gender: Optional[str] = None + is_organization: Optional[bool] = None + role_type: Optional[str] = None + personality: Optional[str] = None + background: Optional[str] = None + appearance: Optional[str] = None + relationships: Optional[str] = None + organization_type: Optional[str] = None + organization_purpose: Optional[str] = None + organization_members: Optional[str] = None + traits: Optional[str] = None + + +class CharacterResponse(CharacterBase): + """角色响应模型""" + id: str + project_id: str + avatar_url: Optional[str] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class CharacterGenerateRequest(BaseModel): + """AI生成角色的请求模型""" + project_id: str = Field(..., description="项目ID") + name: Optional[str] = Field(None, description="角色名称") + role_type: Optional[str] = Field(None, description="角色类型") + background: Optional[str] = Field(None, description="角色背景") + requirements: Optional[str] = Field(None, description="特殊要求") + provider: Optional[str] = Field(None, description="AI提供商") + model: Optional[str] = Field(None, description="AI模型") + + +class CharacterListResponse(BaseModel): + """角色列表响应模型""" + total: int + items: List[CharacterResponse] \ No newline at end of file diff --git a/backend/app/schemas/outline.py b/backend/app/schemas/outline.py new file mode 100644 index 0000000..2ff9619 --- /dev/null +++ b/backend/app/schemas/outline.py @@ -0,0 +1,88 @@ +"""大纲相关的Pydantic模型""" +from pydantic import BaseModel, Field +from typing import Optional +from datetime import datetime + + +class OutlineBase(BaseModel): + """大纲基础模型""" + title: str = Field(..., description="章节标题") + content: str = Field(..., description="章节内容概要") + + +class OutlineCreate(BaseModel): + """创建大纲的请求模型""" + project_id: str = Field(..., description="所属项目ID") + title: str = Field(..., description="章节标题") + content: str = Field(..., description="章节内容概要") + order_index: int = Field(..., description="章节序号", ge=1) + structure: Optional[str] = Field(None, description="结构化大纲数据(JSON)") + + +class OutlineUpdate(BaseModel): + """更新大纲的请求模型""" + title: Optional[str] = None + content: Optional[str] = None + # order_index 不允许通过普通更新修改,只能通过 reorder_outlines 接口批量调整 + # structure 暂不支持修改 + + +class OutlineResponse(BaseModel): + """大纲响应模型""" + id: str + project_id: str + title: str + content: str + structure: Optional[str] = None + order_index: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class OutlineGenerateRequest(BaseModel): + """AI生成大纲的请求模型 - 支持全新生成和智能续写""" + project_id: str = Field(..., description="项目ID") + genre: Optional[str] = Field(None, description="小说类型,如:玄幻、都市、悬疑等") + theme: str = Field(..., description="小说主题") + chapter_count: int = Field(..., ge=1, description="章节数量") + narrative_perspective: str = Field(..., description="叙事视角") + world_context: Optional[dict] = Field(None, description="世界观背景") + characters_context: Optional[list] = Field(None, description="角色信息") + target_words: int = Field(100000, description="目标字数") + requirements: Optional[str] = Field(None, description="其他特殊要求") + provider: Optional[str] = Field(None, description="AI提供商") + model: Optional[str] = Field(None, description="AI模型") + + # 续写相关参数 + mode: str = Field("auto", description="生成模式: auto(自动判断), new(全新生成), continue(续写)") + story_direction: Optional[str] = Field(None, description="故事发展方向提示(续写时使用)") + plot_stage: str = Field("development", description="情节阶段: development(发展), climax(高潮), ending(结局)") + keep_existing: bool = Field(False, description="是否保留现有大纲(续写时)") + + +class ChapterOutlineGenerateRequest(BaseModel): + """为单个章节生成大纲的请求模型""" + outline_id: str = Field(..., description="大纲ID") + context: Optional[str] = Field(None, description="额外上下文") + provider: Optional[str] = Field(None, description="AI提供商") + model: Optional[str] = Field(None, description="AI模型") + + +class OutlineListResponse(BaseModel): + """大纲列表响应模型""" + total: int + items: list[OutlineResponse] + + +class OutlineReorderItem(BaseModel): + """单个大纲重排序项""" + id: str = Field(..., description="大纲ID") + order_index: int = Field(..., description="新的序号", ge=1) + + +class OutlineReorderRequest(BaseModel): + """大纲批量重排序请求""" + orders: list[OutlineReorderItem] = Field(..., description="排序列表") \ No newline at end of file diff --git a/backend/app/schemas/polish.py b/backend/app/schemas/polish.py new file mode 100644 index 0000000..dffc5a6 --- /dev/null +++ b/backend/app/schemas/polish.py @@ -0,0 +1,20 @@ +"""AI去味相关的Pydantic模型""" +from pydantic import BaseModel, Field +from typing import Optional + + +class PolishRequest(BaseModel): + """AI去味请求模型""" + original_text: str = Field(..., description="原始文本(AI生成的文本)") + project_id: Optional[int] = Field(None, description="项目ID(可选,用于记录历史)") + provider: Optional[str] = Field(None, description="AI提供商") + model: Optional[str] = Field(None, description="AI模型") + temperature: Optional[float] = Field(0.8, description="温度参数,建议0.7-0.9") + + +class PolishResponse(BaseModel): + """AI去味响应模型""" + original_text: str = Field(..., description="原始文本") + polished_text: str = Field(..., description="去味后的文本") + word_count_before: int = Field(..., description="处理前字数") + word_count_after: int = Field(..., description="处理后字数") \ No newline at end of file diff --git a/backend/app/schemas/project.py b/backend/app/schemas/project.py new file mode 100644 index 0000000..8625f01 --- /dev/null +++ b/backend/app/schemas/project.py @@ -0,0 +1,83 @@ +"""项目相关的Pydantic模型""" +from pydantic import BaseModel, Field +from typing import Optional +from datetime import datetime + + +class ProjectBase(BaseModel): + """项目基础模型""" + title: str = Field(..., description="项目标题") + description: Optional[str] = Field(None, description="项目描述") + theme: Optional[str] = Field(None, description="主题") + genre: Optional[str] = Field(None, description="小说类型") + target_words: Optional[int] = Field(None, description="目标字数") + + +class ProjectCreate(ProjectBase): + """创建项目的请求模型""" + pass + + +class ProjectUpdate(BaseModel): + """更新项目的请求模型""" + title: Optional[str] = None + description: Optional[str] = None + theme: Optional[str] = None + genre: Optional[str] = None + target_words: Optional[int] = None + status: Optional[str] = None + # wizard_status 和 wizard_step 只能通过向导API修改,普通更新不允许 + world_time_period: Optional[str] = None + world_location: Optional[str] = None + world_atmosphere: Optional[str] = None + world_rules: Optional[str] = None + chapter_count: Optional[int] = None + narrative_perspective: Optional[str] = None + character_count: Optional[int] = None + # current_words 由章节内容自动计算,不允许手动修改 + + +class ProjectResponse(ProjectBase): + """项目响应模型""" + id: str # UUID字符串 + status: str + current_words: int + wizard_status: Optional[str] = None + wizard_step: Optional[int] = None + world_time_period: Optional[str] = None + world_location: Optional[str] = None + world_atmosphere: Optional[str] = None + world_rules: Optional[str] = None + chapter_count: Optional[int] = None + narrative_perspective: Optional[str] = None + character_count: Optional[int] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class ProjectListResponse(BaseModel): + """项目列表响应模型""" + total: int + items: list[ProjectResponse] + + +class ProjectWizardRequest(BaseModel): + """项目创建向导请求模型""" + title: str = Field(..., description="书名") + theme: str = Field(..., description="主题") + genre: Optional[str] = Field(None, description="类型") + chapter_count: int = Field(..., ge=1, description="章节数量") + narrative_perspective: str = Field(..., description="叙事视角") + character_count: int = Field(5, ge=5, description="角色数量(至少5个)") + target_words: Optional[int] = Field(None, description="目标字数") + + +class WorldBuildingResponse(BaseModel): + """世界构建响应模型""" + time_period: str = Field(..., description="时间背景") + location: str = Field(..., description="地理位置") + atmosphere: str = Field(..., description="氛围基调") + rules: str = Field(..., description="世界规则") \ No newline at end of file diff --git a/backend/app/schemas/relationship.py b/backend/app/schemas/relationship.py new file mode 100644 index 0000000..ba94db8 --- /dev/null +++ b/backend/app/schemas/relationship.py @@ -0,0 +1,204 @@ +"""关系管理相关的Pydantic模型""" +from pydantic import BaseModel, Field +from typing import Optional, List +from datetime import datetime + + +# ============ 关系类型相关 ============ + +class RelationshipTypeResponse(BaseModel): + """关系类型响应模型""" + id: int + name: str + category: str + reverse_name: Optional[str] = None + intimacy_range: Optional[str] = None + icon: Optional[str] = None + description: Optional[str] = None + created_at: datetime + + class Config: + from_attributes = True + + +# ============ 角色关系相关 ============ + +class CharacterRelationshipBase(BaseModel): + """角色关系基础模型""" + relationship_type_id: Optional[int] = Field(None, description="关系类型ID") + relationship_name: Optional[str] = Field(None, description="自定义关系名称") + intimacy_level: int = Field(50, ge=0, le=100, description="亲密度:0-100") + status: str = Field("active", description="状态:active/broken/past/complicated") + description: Optional[str] = Field(None, description="关系描述") + started_at: Optional[str] = Field(None, description="关系开始时间(故事时间)") + ended_at: Optional[str] = Field(None, description="关系结束时间") + + +class CharacterRelationshipCreate(CharacterRelationshipBase): + """创建角色关系的请求模型""" + project_id: str = Field(..., description="项目ID") + character_from_id: str = Field(..., description="角色A的ID") + character_to_id: str = Field(..., description="角色B的ID") + + +class CharacterRelationshipUpdate(BaseModel): + """更新角色关系的请求模型""" + relationship_type_id: Optional[int] = None + relationship_name: Optional[str] = None + intimacy_level: Optional[int] = Field(None, ge=0, le=100) + status: Optional[str] = None + description: Optional[str] = None + started_at: Optional[str] = None + ended_at: Optional[str] = None + + +class CharacterRelationshipResponse(CharacterRelationshipBase): + """角色关系响应模型""" + id: str + project_id: str + character_from_id: str + character_to_id: str + source: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class RelationshipGraphNode(BaseModel): + """关系图谱节点""" + id: str + name: str + type: str # character / organization + role_type: Optional[str] = None + avatar: Optional[str] = None + + +class RelationshipGraphLink(BaseModel): + """关系图谱连线""" + source: str + target: str + relationship: str + intimacy: int + status: str + + +class RelationshipGraphData(BaseModel): + """关系图谱数据""" + nodes: List[RelationshipGraphNode] + links: List[RelationshipGraphLink] + + +# ============ 组织相关 ============ + +class OrganizationBase(BaseModel): + """组织基础模型""" + parent_org_id: Optional[str] = Field(None, description="父组织ID") + level: int = Field(0, description="组织层级") + power_level: int = Field(50, ge=0, le=100, description="势力等级") + location: Optional[str] = Field(None, description="所在地") + motto: Optional[str] = Field(None, description="组织宗旨") + color: Optional[str] = Field(None, description="代表颜色") + + +class OrganizationCreate(OrganizationBase): + """创建组织的请求模型""" + character_id: str = Field(..., description="关联的角色ID(组织记录)") + project_id: str = Field(..., description="项目ID") + + +class OrganizationUpdate(BaseModel): + """更新组织的请求模型""" + parent_org_id: Optional[str] = None + level: Optional[int] = None + power_level: Optional[int] = Field(None, ge=0, le=100) + location: Optional[str] = None + motto: Optional[str] = None + color: Optional[str] = None + + +class OrganizationResponse(OrganizationBase): + """组织响应模型""" + id: str + character_id: str + project_id: str + member_count: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class OrganizationDetailResponse(BaseModel): + """组织详情响应(包含基本信息)""" + id: str + character_id: str + name: str + type: Optional[str] = None + purpose: Optional[str] = None + member_count: int + power_level: int + location: Optional[str] = None + motto: Optional[str] = None + color: Optional[str] = None + + +# ============ 组织成员相关 ============ + +class OrganizationMemberBase(BaseModel): + """组织成员基础模型""" + position: str = Field(..., description="职位名称") + rank: int = Field(0, description="职位等级") + status: str = Field("active", description="状态:active/retired/expelled/deceased") + joined_at: Optional[str] = Field(None, description="加入时间(故事时间)") + left_at: Optional[str] = Field(None, description="离开时间") + loyalty: int = Field(50, ge=0, le=100, description="忠诚度") + contribution: int = Field(0, ge=0, le=100, description="贡献度") + notes: Optional[str] = Field(None, description="备注") + + +class OrganizationMemberCreate(OrganizationMemberBase): + """创建组织成员的请求模型""" + character_id: str = Field(..., description="角色ID") + + +class OrganizationMemberUpdate(BaseModel): + """更新组织成员的请求模型""" + position: Optional[str] = None + rank: Optional[int] = None + status: Optional[str] = None + joined_at: Optional[str] = None + left_at: Optional[str] = None + loyalty: Optional[int] = Field(None, ge=0, le=100) + contribution: Optional[int] = Field(None, ge=0, le=100) + notes: Optional[str] = None + + +class OrganizationMemberResponse(OrganizationMemberBase): + """组织成员响应模型""" + id: str + organization_id: str + character_id: str + source: str + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class OrganizationMemberDetailResponse(BaseModel): + """组织成员详情响应(包含角色信息)""" + id: str + character_id: str + character_name: str + position: str + rank: int + loyalty: int + contribution: int + status: str + joined_at: Optional[str] = None + left_at: Optional[str] = None + notes: Optional[str] = None \ No newline at end of file diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e0f7344 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""服务层模块""" \ No newline at end of file diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py new file mode 100644 index 0000000..f2de62e --- /dev/null +++ b/backend/app/services/ai_service.py @@ -0,0 +1,363 @@ +"""AI服务封装 - 统一的OpenAI和Claude接口""" +from typing import Optional, AsyncGenerator, List, Dict, Any +from openai import AsyncOpenAI +from anthropic import AsyncAnthropic +from app.config import settings +from app.logger import get_logger +import httpx + +logger = get_logger(__name__) + + +class AIService: + """AI服务统一接口""" + + def __init__(self): + """初始化AI客户端(优化并发性能)""" + # 初始化OpenAI客户端 + if settings.openai_api_key: + # 创建自定义的httpx客户端来避免proxies参数问题 + try: + # 配置连接池限制,支持高并发 + # max_keepalive_connections: 保持活跃的连接数(提高复用率) + # max_connections: 最大并发连接数(防止资源耗尽) + limits = httpx.Limits( + max_keepalive_connections=50, # 保持50个活跃连接 + max_connections=100, # 最多100个并发连接 + keepalive_expiry=30.0 # 30秒后过期未使用的连接 + ) + + # 使用httpx.AsyncClient并设置超时和连接池 + # connect: 连接超时10秒 + # read: 读取超时180秒(3分钟,适合长文本生成) + # write: 写入超时10秒 + # pool: 连接池超时10秒 + http_client = httpx.AsyncClient( + timeout=httpx.Timeout( + connect=10.0, + read=180.0, + write=10.0, + pool=10.0 + ), + limits=limits + ) + + client_kwargs = { + "api_key": settings.openai_api_key, + "http_client": http_client + } + + if settings.openai_base_url: + client_kwargs["base_url"] = settings.openai_base_url + + self.openai_client = AsyncOpenAI(**client_kwargs) + logger.info("✅ OpenAI客户端初始化成功") + logger.info(" - 超时设置:连接10s,读取180s") + logger.info(" - 连接池:50个保活连接,最大100个并发") + except Exception as e: + logger.error(f"OpenAI客户端初始化失败: {e}") + self.openai_client = None + else: + self.openai_client = None + logger.warning("OpenAI API key未配置") + + # 初始化Anthropic客户端 + if settings.anthropic_api_key: + try: + # 为Anthropic设置相同的超时和连接池配置 + limits = httpx.Limits( + max_keepalive_connections=50, + max_connections=100, + keepalive_expiry=30.0 + ) + + http_client = httpx.AsyncClient( + timeout=httpx.Timeout( + connect=10.0, + read=180.0, + write=10.0, + pool=10.0 + ), + limits=limits + ) + + client_kwargs = { + "api_key": settings.anthropic_api_key, + "http_client": http_client + } + + if settings.anthropic_base_url: + client_kwargs["base_url"] = settings.anthropic_base_url + + self.anthropic_client = AsyncAnthropic(**client_kwargs) + logger.info("✅ Anthropic客户端初始化成功") + logger.info(" - 超时设置:连接10s,读取180s") + logger.info(" - 连接池:50个保活连接,最大100个并发") + except Exception as e: + logger.error(f"Anthropic客户端初始化失败: {e}") + self.anthropic_client = None + else: + self.anthropic_client = None + logger.warning("Anthropic API key未配置") + + async def generate_text( + self, + prompt: str, + provider: Optional[str] = None, + model: Optional[str] = None, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + system_prompt: Optional[str] = None + ) -> str: + """ + 生成文本 + + Args: + prompt: 用户提示词 + provider: AI提供商 (openai/anthropic) + model: 模型名称 + temperature: 温度参数 + max_tokens: 最大token数 + system_prompt: 系统提示词 + + Returns: + 生成的文本 + """ + provider = provider or settings.default_ai_provider + model = model or settings.default_model + temperature = temperature or settings.default_temperature + max_tokens = max_tokens or settings.default_max_tokens + + if provider == "openai": + return await self._generate_openai( + prompt, model, temperature, max_tokens, system_prompt + ) + elif provider == "anthropic": + return await self._generate_anthropic( + prompt, model, temperature, max_tokens, system_prompt + ) + else: + raise ValueError(f"不支持的AI提供商: {provider}") + + async def generate_text_stream( + self, + prompt: str, + provider: Optional[str] = None, + model: Optional[str] = None, + temperature: Optional[float] = None, + max_tokens: Optional[int] = None, + system_prompt: Optional[str] = None + ) -> AsyncGenerator[str, None]: + """ + 流式生成文本 + + Args: + prompt: 用户提示词 + provider: AI提供商 + model: 模型名称 + temperature: 温度参数 + max_tokens: 最大token数 + system_prompt: 系统提示词 + + Yields: + 生成的文本片段 + """ + provider = provider or settings.default_ai_provider + model = model or settings.default_model + temperature = temperature or settings.default_temperature + max_tokens = max_tokens or settings.default_max_tokens + + if provider == "openai": + async for chunk in self._generate_openai_stream( + prompt, model, temperature, max_tokens, system_prompt + ): + yield chunk + elif provider == "anthropic": + async for chunk in self._generate_anthropic_stream( + prompt, model, temperature, max_tokens, system_prompt + ): + yield chunk + else: + raise ValueError(f"不支持的AI提供商: {provider}") + + async def _generate_openai( + self, + prompt: str, + model: str, + temperature: float, + max_tokens: int, + system_prompt: Optional[str] + ) -> str: + """使用OpenAI生成文本""" + if not self.openai_client: + raise ValueError("OpenAI客户端未初始化,请检查API key配置") + + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + try: + logger.info(f"🔵 开始调用OpenAI API") + logger.info(f" - 模型: {model}") + logger.info(f" - 温度: {temperature}") + logger.info(f" - 最大tokens: {max_tokens}") + logger.info(f" - Prompt长度: {len(prompt)} 字符") + logger.info(f" - 消息数量: {len(messages)}") + + response = await self.openai_client.chat.completions.create( + model=model, + messages=messages, + temperature=temperature, + max_tokens=max_tokens + ) + + logger.info(f"✅ OpenAI API调用成功") + logger.info(f" - 响应ID: {response.id if hasattr(response, 'id') else 'N/A'}") + logger.info(f" - 选项数量: {len(response.choices)}") + + if not response.choices: + logger.error("❌ OpenAI返回的choices为空") + return "" + + content = response.choices[0].message.content + logger.info(f" - 返回内容长度: {len(content) if content else 0} 字符") + + if content: + logger.info(f" - 返回内容预览(前200字符): {content[:200]}") + return content + else: + logger.error("❌ OpenAI返回了空内容") + logger.error(f" - 完整响应: {response}") + raise ValueError("AI返回了空内容,请检查API配置或稍后重试") + + except Exception as e: + logger.error(f"❌ OpenAI API调用失败") + logger.error(f" - 错误类型: {type(e).__name__}") + logger.error(f" - 错误信息: {str(e)}") + logger.error(f" - 模型: {model}") + raise + + async def _generate_openai_stream( + self, + prompt: str, + model: str, + temperature: float, + max_tokens: int, + system_prompt: Optional[str] + ) -> AsyncGenerator[str, None]: + """使用OpenAI流式生成文本""" + if not self.openai_client: + raise ValueError("OpenAI客户端未初始化,请检查API key配置") + + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + try: + logger.info(f"🔵 开始调用OpenAI流式API") + logger.info(f" - 模型: {model}") + logger.info(f" - Prompt长度: {len(prompt)} 字符") + logger.info(f" - 最大tokens: {max_tokens}") + + stream = await self.openai_client.chat.completions.create( + model=model, + messages=messages, + temperature=temperature, + max_tokens=max_tokens, + stream=True + ) + + logger.info(f"✅ OpenAI流式API连接成功,开始接收数据...") + + chunk_count = 0 + async for chunk in stream: + if chunk.choices and len(chunk.choices) > 0: + if chunk.choices[0].delta.content: + chunk_count += 1 + yield chunk.choices[0].delta.content + + logger.info(f"✅ OpenAI流式生成完成,共接收 {chunk_count} 个chunk") + + except httpx.TimeoutException as e: + logger.error(f"❌ OpenAI流式API超时") + logger.error(f" - 错误: {str(e)}") + logger.error(f" - 提示: 请检查网络连接或考虑缩短prompt长度") + raise TimeoutError(f"AI服务超时(180秒),请稍后重试或减少上下文长度") from e + except Exception as e: + logger.error(f"❌ OpenAI流式API调用失败: {str(e)}") + logger.error(f" - 错误类型: {type(e).__name__}") + raise + + async def _generate_anthropic( + self, + prompt: str, + model: str, + temperature: float, + max_tokens: int, + system_prompt: Optional[str] + ) -> str: + """使用Anthropic生成文本""" + if not self.anthropic_client: + raise ValueError("Anthropic客户端未初始化,请检查API key配置") + + try: + response = await self.anthropic_client.messages.create( + model=model, + max_tokens=max_tokens, + temperature=temperature, + system=system_prompt or "", + messages=[{"role": "user", "content": prompt}] + ) + return response.content[0].text + except Exception as e: + logger.error(f"Anthropic API调用失败: {str(e)}") + raise + + async def _generate_anthropic_stream( + self, + prompt: str, + model: str, + temperature: float, + max_tokens: int, + system_prompt: Optional[str] + ) -> AsyncGenerator[str, None]: + """使用Anthropic流式生成文本""" + if not self.anthropic_client: + raise ValueError("Anthropic客户端未初始化,请检查API key配置") + + try: + logger.info(f"🔵 开始调用Anthropic流式API") + logger.info(f" - 模型: {model}") + logger.info(f" - Prompt长度: {len(prompt)} 字符") + logger.info(f" - 最大tokens: {max_tokens}") + + async with self.anthropic_client.messages.stream( + model=model, + max_tokens=max_tokens, + temperature=temperature, + system=system_prompt or "", + messages=[{"role": "user", "content": prompt}] + ) as stream: + logger.info(f"✅ Anthropic流式API连接成功,开始接收数据...") + + chunk_count = 0 + async for text in stream.text_stream: + chunk_count += 1 + yield text + + logger.info(f"✅ Anthropic流式生成完成,共接收 {chunk_count} 个chunk") + + except httpx.TimeoutException as e: + logger.error(f"❌ Anthropic流式API超时") + logger.error(f" - 错误: {str(e)}") + raise TimeoutError(f"AI服务超时(180秒),请稍后重试或减少上下文长度") from e + except Exception as e: + logger.error(f"❌ Anthropic流式API调用失败: {str(e)}") + logger.error(f" - 错误类型: {type(e).__name__}") + raise + + +# 创建全局AI服务实例 +ai_service = AIService() \ No newline at end of file diff --git a/backend/app/services/oauth_service.py b/backend/app/services/oauth_service.py new file mode 100644 index 0000000..4578029 --- /dev/null +++ b/backend/app/services/oauth_service.py @@ -0,0 +1,149 @@ +""" +LinuxDO OAuth2 服务 +""" +import httpx +import secrets +from typing import Optional, Dict, Any +from app.config import settings + + +class LinuxDOOAuthService: + """LinuxDO OAuth2 服务类""" + + # LinuxDO OAuth2 端点 + AUTHORIZE_URL = "https://connect.linux.do/oauth2/authorize" + TOKEN_URL = "https://connect.linux.do/oauth2/token" + USERINFO_URL = "https://connect.linux.do/api/user" # 修复:使用正确的用户信息端点 + + def __init__(self): + self.client_id = settings.LINUXDO_CLIENT_ID + self.client_secret = settings.LINUXDO_CLIENT_SECRET + self.redirect_uri = settings.LINUXDO_REDIRECT_URI + + # 验证redirect_uri配置 + if not self.redirect_uri: + raise ValueError( + "LINUXDO_REDIRECT_URI 未配置!\n" + "请在 .env 文件中设置正确的回调地址:\n" + "本地开发: LINUXDO_REDIRECT_URI=http://localhost:8000/api/auth/callback\n" + "Docker部署: LINUXDO_REDIRECT_URI=https://your-domain.com/api/auth/callback" + ) + + # 警告:检查是否使用了localhost(在非开发环境) + if not settings.debug and "localhost" in self.redirect_uri.lower(): + import logging + logger = logging.getLogger(__name__) + logger.warning( + f"⚠️ 生产环境检测到使用 localhost 作为回调地址: {self.redirect_uri}\n" + "这可能导致OAuth回调失败!请使用实际的域名或服务器IP。" + ) + + def generate_state(self) -> str: + """生成随机 state 参数""" + return secrets.token_urlsafe(32) + + def get_authorization_url(self, state: str) -> str: + """ + 获取授权 URL + + Args: + state: 随机 state 参数 + + Returns: + 授权 URL + """ + params = { + "client_id": self.client_id, + "redirect_uri": self.redirect_uri, + "response_type": "code", + "scope": "read", + "state": state + } + + query_string = "&".join([f"{k}={v}" for k, v in params.items()]) + return f"{self.AUTHORIZE_URL}?{query_string}" + + async def get_access_token(self, code: str) -> Optional[Dict[str, Any]]: + """ + 使用授权码获取访问令牌 + + Args: + code: 授权码 + + Returns: + 包含 access_token 的字典,失败返回 None + """ + data = { + "client_id": self.client_id, + "client_secret": self.client_secret, + "code": code, + "grant_type": "authorization_code", + "redirect_uri": self.redirect_uri + } + + try: + async with httpx.AsyncClient() as client: + response = await client.post( + self.TOKEN_URL, + data=data, + headers={"Content-Type": "application/x-www-form-urlencoded"} + ) + + if response.status_code == 200: + return response.json() + else: + print(f"获取访问令牌失败: {response.status_code} {response.text}") + return None + + except Exception as e: + print(f"获取访问令牌异常: {e}") + return None + + async def get_user_info(self, access_token: str) -> Optional[Dict[str, Any]]: + """ + 使用访问令牌获取用户信息 + + Args: + access_token: 访问令牌 + + Returns: + 用户信息字典,失败返回 None + """ + try: + # 添加真实浏览器请求头,避免被 Cloudflare 拦截 + headers = { + "Authorization": f"Bearer {access_token}", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "application/json", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + } + + # 不自动处理编码,让 httpx 自动解压 + async with httpx.AsyncClient(follow_redirects=True, timeout=30.0) as client: + response = await client.get( + self.USERINFO_URL, + headers=headers + ) + + print(f"获取用户信息响应状态: {response.status_code}") + print(f"响应头: {response.headers}") + + if response.status_code == 200: + try: + user_data = response.json() + print(f"用户信息: {user_data}") + return user_data + except Exception as json_error: + print(f"解析 JSON 失败: {json_error}") + print(f"响应内容前100字符: {response.text[:100]}") + return None + else: + print(f"获取用户信息失败: {response.status_code}") + print(f"响应内容: {response.text[:200]}") + return None + + except Exception as e: + print(f"获取用户信息异常: {type(e).__name__}: {str(e)}") + import traceback + traceback.print_exc() + return None \ No newline at end of file diff --git a/backend/app/services/prompt_service.py b/backend/app/services/prompt_service.py new file mode 100644 index 0000000..bad9a86 --- /dev/null +++ b/backend/app/services/prompt_service.py @@ -0,0 +1,730 @@ +"""提示词管理服务""" +from typing import Dict, Any +import json + + +class PromptService: + """提示词模板管理""" + + # 世界构建提示词 + WORLD_BUILDING = """你是一位资深的世界观设计师。请根据以下信息构建一个完整的小说世界观: + +书名:{title} +主题:{theme} +类型:{genre} + +请生成包含以下内容的世界构建框架: + +1. **时间背景**:具体的时代设定、时间流逝特点、重要历史事件 +2. **地理位置**:主要地点描述、地理环境特征、空间布局 +3. **氛围基调**:整体氛围感觉、情感色彩、视觉风格 +4. **世界规则**:基本运行法则、特殊设定、社会规则和禁忌、权力结构 + +要求: +- 与主题高度契合 +- 设定要合理自洽 +- 为故事发展提供支撑 +- 具有独特性和吸引力 + +**重要:你必须只返回纯JSON格式,不要包含任何markdown标记、代码块标记或其他说明文字。** + +请严格按照以下JSON格式返回(每个字段为200-300字的文本描述): +{{ + "time_period": "时间背景的详细描述,包括时代设定、时间特点、历史事件", + "location": "地理位置的详细描述,包括主要地点、环境特征、空间布局", + "atmosphere": "氛围基调的详细描述,包括整体氛围、情感色彩、视觉风格", + "rules": "世界规则的详细描述,包括运行法则、特殊设定、社会规则、权力结构" +}} + +再次强调:只返回纯JSON对象,不要有```json```这样的标记,不要有任何额外的文字说明。""" + + # 批量角色生成提示词 + CHARACTERS_BATCH_GENERATION = """你是一位专业的角色设定师。请根据以下世界观和要求,生成{count}个立体丰满的角色和组织: + +世界观信息: +- 时间背景:{time_period} +- 地理位置:{location} +- 氛围基调:{atmosphere} +- 世界规则:{rules} + +主题:{theme} +类型:{genre} +特殊要求:{requirements} + +【数量要求 - 必须严格遵守】 +请精确生成{count}个实体,不多不少。数组中必须包含且仅包含{count}个对象。 + +实体类型分配: +- 至少1个主角(protagonist) +- 多个配角(supporting) +- 可以包含反派(antagonist) +- 可以包含1-2个重要组织 + +要求: +- 角色要符合世界观设定 +- 性格和背景要有深度 +- 角色之间要有关系网络 +- 组织要有存在的合理性 +- 所有实体要为故事服务 + +**重要:你必须只返回纯JSON数组格式,不要包含任何markdown标记、代码块标记或其他说明文字。** + +请严格按照以下JSON数组格式返回(每个角色为数组中的一个对象): +[ + {{ + "name": "角色姓名", + "age": 25, + "gender": "男/女/其他", + "is_organization": false, + "role_type": "protagonist/supporting/antagonist", + "personality": "性格特点的详细描述(100-200字),包括核心性格、优缺点、特殊习惯", + "background": "背景故事的详细描述(100-200字),包括家庭背景、成长经历、重要转折", + "appearance": "外貌描述(50-100字),包括身高、体型、面容、着装风格", + "traits": ["特长1", "特长2", "特长3"], + "relationships_array": [ + {{ + "target_character_name": "已生成的角色名称", + "relationship_type": "关系类型(师父/朋友/敌人/父亲/母亲等)", + "intimacy_level": 75, + "description": "关系描述" + }} + ], + "organization_memberships": [ + {{ + "organization_name": "已生成的组织名称", + "position": "职位", + "rank": 5, + "loyalty": 80 + }} + ] + }}, + {{ + "name": "组织名称", + "is_organization": true, + "role_type": "supporting", + "personality": "组织特性描述(100-200字),包括运作方式、核心理念、行事风格", + "background": "组织背景(100-200字),包括建立历史、发展历程、重要事件", + "appearance": "组织外在表现(50-100字),如总部位置、标志性建筑等", + "organization_type": "组织类型", + "organization_purpose": "组织目的", + "organization_members": ["成员1", "成员2"], + "traits": [] + }} +] + +**关系类型参考(从中选择或自定义):** +- 家族:父亲、母亲、兄弟、姐妹、子女、配偶、恋人 +- 社交:师父、徒弟、朋友、同学、同事、邻居、知己 +- 职业:上司、下属、合作伙伴 +- 敌对:敌人、仇人、竞争对手、宿敌 + +**重要说明:** +1. **数量控制**:数组中必须精确包含{count}个对象,不能多也不能少 +2. **关系约束**:relationships_array只能引用本批次中已经出现的角色名称 +3. **组织约束**:organization_memberships只能引用本批次中is_organization=true的实体名称 +4. **禁止幻觉**:不要引用任何不存在的角色或组织,如果没有可引用的就留空数组[] +5. intimacy_level和loyalty都是0-100的整数 +6. 角色之间要形成合理的关系网络 + +**示例说明**: +- 如果生成了角色A、组织B、角色C,则角色A的organization_memberships只能是[组织B],不能是其他组织 +- 如果角色A在数组第一位,它的relationships_array必须为空[],因为还没有其他角色 +- 如果角色C在数组第三位,它的relationships_array可以引用角色A,但不能引用不存在的角色D + +再次强调: +1. 只返回纯JSON数组,不要有```json```这样的标记 +2. 数组中必须精确包含{count}个对象 +3. 不要引用任何本批次中不存在的角色或组织名称""" + + # 完整大纲生成提示词 + COMPLETE_OUTLINE_GENERATION = """你是一位经验丰富的小说作家和编剧。请根据以下信息生成完整的{chapter_count}章小说大纲: + +基本信息: +- 书名:{title} +- 主题:{theme} +- 类型:{genre} +- 章节数:{chapter_count} +- 叙事视角:{narrative_perspective} +- 目标字数:{target_words} + +世界观: +- 时间背景:{time_period} +- 地理位置:{location} +- 氛围基调:{atmosphere} +- 世界规则:{rules} + +角色信息: +{characters_info} + +其他要求:{requirements} + +整体要求: +- 结构完整:起承转合清晰 +- 情节连贯:章节之间紧密衔接 +- 冲突递进:矛盾逐步升级 +- 人物成长:角色有明确的变化弧线 +- 节奏把控:有张有弛 +- 视角统一:采用{narrative_perspective}视角叙事 + +**重要:你必须只返回纯JSON数组格式,不要包含任何markdown标记、代码块标记或其他说明文字。** + +请严格按照以下JSON数组格式返回(共{chapter_count}个章节对象): +[ + {{ + "chapter_number": 1, + "title": "第一章标题", + "summary": "章节概要的详细描述(100-200字),包含主要情节、冲突、转折等", + "scenes": ["场景1描述", "场景2描述", "场景3描述"], + "characters": ["角色1", "角色2"], + "key_points": ["情节要点1", "情节要点2"], + "emotion": "本章情感基调", + "goal": "本章叙事目标" + }}, + {{ + "chapter_number": 2, + "title": "第二章标题", + "summary": "章节概要...", + "scenes": ["场景1", "场景2"], + "characters": ["角色1", "角色2"], + "key_points": ["要点1", "要点2"], + "emotion": "情感基调", + "goal": "叙事目标" + }} +] + +再次强调:只返回纯JSON数组,不要有```json```这样的标记,不要有任何额外的文字说明。数组中要包含{chapter_count}个章节对象。""" + + # 大纲续写提示词 + OUTLINE_CONTINUE_GENERATION = """你是一位经验丰富的小说作家和编剧。请基于以下信息续写小说大纲: + +【项目信息】 +- 书名:{title} +- 主题:{theme} +- 类型:{genre} +- 叙事视角:{narrative_perspective} +- 续写章节数:{chapter_count}章 + +【世界观】 +- 时间背景:{time_period} +- 地理位置:{location} +- 氛围基调:{atmosphere} +- 世界规则:{rules} + +【角色信息】 +{characters_info} + +【已有章节概览】(共{current_chapter_count}章) +{all_chapters_brief} + +【最近剧情】 +{recent_plot} + +【续写指导】 +- 当前情节阶段:{plot_stage_instruction} +- 起始章节编号:第{start_chapter}章 +- 故事发展方向:{story_direction} +- 其他要求:{requirements} + +请生成第{start_chapter}章到第{end_chapter}章的大纲。 +要求: +- 与前文自然衔接,保持故事连贯性 +- 遵循情节阶段的发展要求 +- 保持与已有章节相同的风格和详细程度 +- 推进角色成长和情节发展 + +**重要:你必须只返回纯JSON数组格式,不要包含任何markdown标记、代码块标记或其他说明文字。** + +请严格按照以下JSON数组格式返回(共{chapter_count}个章节对象): +[ + {{ + "chapter_number": {start_chapter}, + "title": "章节标题", + "summary": "章节概要的详细描述(100-200字),包含主要情节、角色互动、关键事件、冲突与转折", + "scenes": ["场景1描述", "场景2描述", "场景3描述"], + "characters": ["涉及角色1", "涉及角色2"], + "key_points": ["情节要点1", "情节要点2"], + "emotion": "本章情感基调", + "goal": "本章叙事目标" + }}, + {{ + "chapter_number": {start_chapter} + 1, + "title": "章节标题", + "summary": "章节概要...", + "scenes": ["场景1", "场景2"], + "characters": ["角色1", "角色2"], + "key_points": ["要点1", "要点2"], + "emotion": "情感基调", + "goal": "叙事目标" + }} +] + +再次强调: +1. 只返回纯JSON数组,不要有```json```这样的标记 +2. 数组中要包含{chapter_count}个章节对象 +3. 每个summary必须是100-200字的详细描述 +4. 确保字段结构与已有章节完全一致""" + + # AI去味提示词(核心特色功能) + AI_DENOISING = """你是一位追求自然写作风格的编辑。你的任务是将AI生成的文本改写得更像人类作家的手笔。 + +原文: +{original_text} + +修改要求: +1. 去除AI痕迹: + - 删除过于工整的排比句 + - 减少重复的修辞手法 + - 去掉刻意的对称结构 + - 避免机械式的总结陈词 + +2. 增加人性化: + - 使用更口语化的表达 + - 添加不完美的细节 + - 保留适度的随意性 + - 增加真实的情感波动 + +3. 优化叙事: + - 让节奏更自然不做作 + - 用简单词汇替换华丽辞藻 + - 保持叙述的松弛感 + - 让对话更生活化 + +4. 保持原意: + - 不改变核心情节 + - 保留关键信息点 + - 维持角色性格 + - 确保逻辑连贯 + +修改风格: +- 像是一个喜欢讲故事的普通人写的 +- 有点粗糙但很真诚 +- 自然流畅不刻意 +- 让人读起来很舒服 + +请直接输出修改后的文本,无需解释。""" + + # 章节完整创作提示词 + CHAPTER_GENERATION = """你是一位专业的小说作家。请根据以下信息创作本章内容: + +项目信息: +- 书名:{title} +- 主题:{theme} +- 类型:{genre} +- 叙事视角:{narrative_perspective} + +世界观: +- 时间背景:{time_period} +- 地理位置:{location} +- 氛围基调:{atmosphere} +- 世界规则:{rules} + +角色信息: +{characters_info} + +全书大纲: +{outlines_context} + +本章信息: +- 章节序号:第{chapter_number}章 +- 章节标题:{chapter_title} +- 章节大纲:{chapter_outline} + +创作要求: +1. 严格按照大纲内容展开情节 +2. 保持与前后章节的连贯性 +3. 符合角色性格设定 +4. 体现世界观特色 +5. 使用{narrative_perspective}视角 +6. 字数不得低于3000字 +7. 语言自然流畅,避免AI痕迹 + +**写作风格要求(重要):** +- 让故事自然流淌,写到哪算哪 +- 结尾处直接结束情节,不要加总结性段落 +- 不要在章节末尾写"这一天/这一夜就这样过去了"之类的总结句 +- 不要用"他/她陷入了沉思"作为结尾 +- 避免刻意的情感升华或哲理感悟收尾 +- 章节结尾可以戛然而止,可以是对话,可以是动作,可以是悬念 +- 就像在讲一个故事,讲完了就停,不需要画龙点睛 + +请直接输出章节正文内容,不要包含章节标题和其他说明文字。""" + + # 章节完整创作提示词(带前置章节上下文) + CHAPTER_GENERATION_WITH_CONTEXT = """你是一位专业的小说作家。请根据以下信息创作本章内容: + +项目信息: +- 书名:{title} +- 主题:{theme} +- 类型:{genre} +- 叙事视角:{narrative_perspective} + +世界观: +- 时间背景:{time_period} +- 地理位置:{location} +- 氛围基调:{atmosphere} +- 世界规则:{rules} + +角色信息: +{characters_info} + +全书大纲: +{outlines_context} + +【已完成的前置章节内容】 +{previous_content} + +本章信息: +- 章节序号:第{chapter_number}章 +- 章节标题:{chapter_title} +- 章节大纲:{chapter_outline} + +创作要求: +1. **剧情连贯性(最重要)**: +- 必须承接前面章节的剧情发展 +- 注意角色状态、情节进展、时间线的连续性 +- 不能出现与前文矛盾的内容 +- 自然过渡,避免突兀的跳跃 + +2. **情节推进**: +- 严格按照本章大纲展开情节 +- 推动故事向前发展 +- 保持与全书大纲的一致性 + +3. **角色一致性**: +- 符合角色性格设定 +- 延续角色在前文中的成长和变化 +- 保持角色关系的连贯性 + +4. **写作风格**: +- 使用{narrative_perspective}视角 +- 字数不得低于3000字 +- 语言自然流畅,避免AI痕迹 +- 体现世界观特色 + +5. **承上启下**: +- 开头自然衔接上一章结尾 +- 结尾为下一章做好铺垫 + +**写作风格要求(重要):** +- 让故事自然流淌,写到哪算哪 +- 结尾处直接结束情节,不要加总结性段落 +- 不要在章节末尾写"这一天/这一夜就这样过去了"之类的总结句 +- 不要用"他/她陷入了沉思"作为结尾 +- 避免刻意的情感升华或哲理感悟收尾 +- 章节结尾可以戛然而止,可以是对话,可以是动作,可以是悬念 +- 就像在讲一个故事,讲完了就停,不需要画龙点睛 + +请直接输出章节正文内容,不要包含章节标题和其他说明文字。""" + + # 大纲生成提示词 + OUTLINE_GENERATION = """你是一位经验丰富的小说作家和编剧。请根据以下信息生成小说大纲: + +类型:{genre} +主题:{theme} +目标字数:{target_words} +其他要求:{requirements} + +请生成一个完整的章节大纲框架,包含: +1. 合理的章节数量(根据字数) +2. 每章的标题和内容概要 +3. 清晰的故事结构(起承转合) +4. 情节的递进和冲突升级 +5. 角色的成长弧线 + +**重要:你必须只返回纯JSON格式,不要包含任何markdown标记、代码块标记或其他说明文字。** + +请严格按照以下JSON格式返回: +{{ + "chapters": [ + {{ + "order": 1, + "title": "章节标题", + "content": "章节内容概要(150-200字)" + }} + ] +}} + +再次强调:只返回纯JSON对象,不要有```json```这样的标记,不要有任何额外的文字说明。""" + + # 单个角色生成提示词 + SINGLE_CHARACTER_GENERATION = """你是一位专业的角色设定师。请根据以下信息创建一个立体饱满的小说角色。 + +{project_context} + +{user_input} + +请生成一个完整的角色卡片,包含以下所有信息: + +1. **基本信息**: + - 姓名:如果用户未提供,请生成一个符合世界观的名字 + - 年龄:具体数字或年龄段 + - 性别:男/女/其他 + +2. **外貌特征**(100-150字): + - 身高体型、面容特征、着装风格 + - 要符合角色定位和世界观设定 + +3. **性格特点**(150-200字): + - 核心性格特质(至少3个) + - 优点和缺点 + - 特殊习惯或癖好 + - 性格要有复杂性和矛盾性 + +4. **背景故事**(200-300字): + - 家庭背景 + - 成长经历 + - 重要转折事件 + - 如何与项目主题关联 + - 融入用户提供的背景设定 + +5. **人际关系**: + - 与现有角色的关系(如果有) + - 重要的人际纽带 + - 社会地位和人脉 + +6. **特殊能力/特长**: + - 擅长的领域 + - 特殊技能或知识 + - 符合世界观设定 + +**你必须只返回纯JSON格式,不要包含任何markdown标记、代码块标记或其他说明文字。** + +请严格按照以下JSON格式返回: +{{ + "name": "角色姓名", + "age": "年龄", + "gender": "性别", + "appearance": "外貌描述(100-150字)", + "personality": "性格特点(150-200字)", + "background": "背景故事(200-300字)", + "traits": ["特长1", "特长2", "特长3"], + + "relationships_text": "人际关系的文字描述(用于显示)", + + "relationships": [ + {{ + "target_character_name": "已存在的角色名称", + "relationship_type": "关系类型(如:师父、朋友、敌人、父亲、母亲等)", + "intimacy_level": 75, + "description": "这段关系的详细描述", + "started_at": "关系开始的故事时间点(可选)" + }} + ], + + "organization_memberships": [ + {{ + "organization_name": "已存在的组织名称", + "position": "职位名称", + "rank": 8, + "loyalty": 80, + "joined_at": "加入时间(可选)", + "status": "active" + }} + ] +}} + +**关系类型参考(请从中选择或自定义):** +- 家族关系:父亲、母亲、兄弟、姐妹、子女、配偶、恋人 +- 社交关系:师父、徒弟、朋友、同学、同事、邻居、知己 +- 职业关系:上司、下属、合作伙伴 +- 敌对关系:敌人、仇人、竞争对手、宿敌 + +**重要说明:** +1. relationships数组:只包含与上面列出的已存在角色的关系,通过target_character_name匹配 +2. organization_memberships数组:只包含与上面列出的已存在组织的关系 +3. intimacy_level和loyalty都是0-100的整数 +4. 如果没有关系或组织,对应数组为空[] +5. relationships_text是自然语言描述,用于展示给用户看 + +**角色设定要求:** +- 角色要符合项目的世界观和主题 +- 如果是主角,要有明确的成长空间和目标动机 +- 如果是反派,要有合理的动机,不能脸谱化 +- 配角要有独特性,不能是工具人 +- 所有设定要为故事服务 + +再次强调:只返回纯JSON对象,不要有```json```这样的标记,不要有任何额外的文字说明。""" + + @staticmethod + def format_prompt(template: str, **kwargs) -> str: + """ + 格式化提示词模板 + + Args: + template: 提示词模板 + **kwargs: 模板参数 + + Returns: + 格式化后的提示词 + """ + try: + return template.format(**kwargs) + except KeyError as e: + raise ValueError(f"缺少必需的参数: {e}") + + @classmethod + def get_denoising_prompt(cls, original_text: str) -> str: + """获取AI去味提示词""" + return cls.format_prompt( + cls.AI_DENOISING, + original_text=original_text + ) + + @classmethod + def get_world_building_prompt(cls, title: str, theme: str, genre: str = "") -> str: + """获取世界构建提示词""" + return cls.format_prompt( + cls.WORLD_BUILDING, + title=title, + theme=theme, + genre=genre or "通用类型" + ) + + @classmethod + def get_characters_batch_prompt(cls, count: int, time_period: str, location: str, + atmosphere: str, rules: str, theme: str, + genre: str = "", requirements: str = "") -> str: + """获取批量角色生成提示词""" + return cls.format_prompt( + cls.CHARACTERS_BATCH_GENERATION, + count=count, + time_period=time_period, + location=location, + atmosphere=atmosphere, + rules=rules, + theme=theme, + genre=genre or "通用类型", + requirements=requirements or "无特殊要求" + ) + + @classmethod + def get_complete_outline_prompt(cls, title: str, theme: str, genre: str, + chapter_count: int, narrative_perspective: str, + target_words: int, time_period: str, location: str, + atmosphere: str, rules: str, characters_info: str, + requirements: str = "") -> str: + """获取完整大纲生成提示词""" + return cls.format_prompt( + cls.COMPLETE_OUTLINE_GENERATION, + title=title, + theme=theme, + genre=genre, + chapter_count=chapter_count, + narrative_perspective=narrative_perspective, + target_words=target_words, + time_period=time_period, + location=location, + atmosphere=atmosphere, + rules=rules, + characters_info=characters_info, + requirements=requirements or "无特殊要求" + ) + + @classmethod + def get_chapter_generation_prompt(cls, title: str, theme: str, genre: str, + narrative_perspective: str, time_period: str, + location: str, atmosphere: str, rules: str, + characters_info: str, outlines_context: str, + chapter_number: int, chapter_title: str, + chapter_outline: str) -> str: + """获取章节完整创作提示词""" + return cls.format_prompt( + cls.CHAPTER_GENERATION, + title=title, + theme=theme, + genre=genre, + narrative_perspective=narrative_perspective, + time_period=time_period, + location=location, + atmosphere=atmosphere, + rules=rules, + characters_info=characters_info, + outlines_context=outlines_context, + chapter_number=chapter_number, + chapter_title=chapter_title, + chapter_outline=chapter_outline + ) + + @classmethod + def get_chapter_generation_with_context_prompt(cls, title: str, theme: str, genre: str, + narrative_perspective: str, time_period: str, + location: str, atmosphere: str, rules: str, + characters_info: str, outlines_context: str, + previous_content: str, chapter_number: int, + chapter_title: str, chapter_outline: str) -> str: + """获取章节完整创作提示词(带前置章节上下文)""" + return cls.format_prompt( + cls.CHAPTER_GENERATION_WITH_CONTEXT, + title=title, + theme=theme, + genre=genre, + narrative_perspective=narrative_perspective, + time_period=time_period, + location=location, + atmosphere=atmosphere, + rules=rules, + characters_info=characters_info, + outlines_context=outlines_context, + previous_content=previous_content, + chapter_number=chapter_number, + chapter_title=chapter_title, + chapter_outline=chapter_outline + ) + + @classmethod + def get_outline_prompt(cls, genre: str, theme: str, target_words: int, + requirements: str = "") -> str: + """获取大纲生成提示词""" + return cls.format_prompt( + cls.OUTLINE_GENERATION, + genre=genre, + theme=theme, + target_words=target_words, + requirements=requirements or "无特殊要求" + ) + + @classmethod + def get_outline_continue_prompt(cls, title: str, theme: str, genre: str, + narrative_perspective: str, chapter_count: int, + time_period: str, location: str, atmosphere: str, + rules: str, characters_info: str, + current_chapter_count: int, all_chapters_brief: str, + recent_plot: str, plot_stage_instruction: str, + start_chapter: int, story_direction: str, + requirements: str = "") -> str: + """获取大纲续写提示词""" + end_chapter = start_chapter + chapter_count - 1 + return cls.format_prompt( + cls.OUTLINE_CONTINUE_GENERATION, + title=title, + theme=theme, + genre=genre, + narrative_perspective=narrative_perspective, + chapter_count=chapter_count, + time_period=time_period, + location=location, + atmosphere=atmosphere, + rules=rules, + characters_info=characters_info, + current_chapter_count=current_chapter_count, + all_chapters_brief=all_chapters_brief, + recent_plot=recent_plot, + plot_stage_instruction=plot_stage_instruction, + start_chapter=start_chapter, + end_chapter=end_chapter, + story_direction=story_direction, + requirements=requirements or "无特殊要求" + ) + + @classmethod + def get_single_character_prompt(cls, project_context: str, user_input: str) -> str: + """获取单个角色生成提示词""" + return cls.format_prompt( + cls.SINGLE_CHARACTER_GENERATION, + project_context=project_context, + user_input=user_input + ) + + +# 创建全局提示词服务实例 +prompt_service = PromptService() \ No newline at end of file diff --git a/backend/app/user_manager.py b/backend/app/user_manager.py new file mode 100644 index 0000000..4e82406 --- /dev/null +++ b/backend/app/user_manager.py @@ -0,0 +1,294 @@ +""" +用户管理模块 - 支持 LinuxDO OAuth2 +""" +import json +import os +import asyncio +from datetime import datetime +from typing import Optional, Dict, List +from pydantic import BaseModel +from app.config import settings, DATA_DIR + + +class User(BaseModel): + """用户模型""" + user_id: str # 格式: linuxdo_{linuxdo_id} + username: str + display_name: str + avatar_url: Optional[str] = None + trust_level: int = 0 # 仅用于显示 + is_admin: bool = False # 手动设置的管理员权限 + linuxdo_id: str # LinuxDO 用户 ID + created_at: str + last_login: str + + +class UserManager: + """用户管理器 - 线程安全版本""" + + USERS_FILE = str(DATA_DIR / "users.json") + ADMINS_FILE = str(DATA_DIR / "admins.json") + + def __init__(self): + """初始化用户管理器""" + # DATA_DIR 已在 config.py 中创建,无需重复创建 + # 添加文件锁保护并发读写 + self._users_lock = asyncio.Lock() + self._admins_lock = asyncio.Lock() + self._ensure_files_exist() + + def _ensure_files_exist(self): + """确保必要的文件存在""" + if not os.path.exists(self.USERS_FILE): + with open(self.USERS_FILE, "w", encoding="utf-8") as f: + json.dump({}, f, ensure_ascii=False, indent=2) + + if not os.path.exists(self.ADMINS_FILE): + with open(self.ADMINS_FILE, "w", encoding="utf-8") as f: + json.dump({"admins": []}, f, ensure_ascii=False, indent=2) + + def _load_users_unsafe(self) -> Dict[str, dict]: + """加载用户数据(不加锁,内部使用)""" + try: + with open(self.USERS_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except Exception as e: + print(f"加载用户数据失败: {e}") + return {} + + def _save_users_unsafe(self, users: Dict[str, dict]): + """保存用户数据(不加锁,内部使用)""" + try: + with open(self.USERS_FILE, "w", encoding="utf-8") as f: + json.dump(users, f, ensure_ascii=False, indent=2) + except Exception as e: + print(f"保存用户数据失败: {e}") + + async def _load_users(self) -> Dict[str, dict]: + """加载用户数据(加锁)""" + async with self._users_lock: + return self._load_users_unsafe() + + async def _save_users(self, users: Dict[str, dict]): + """保存用户数据(加锁)""" + async with self._users_lock: + self._save_users_unsafe(users) + + def _load_admin_list_unsafe(self) -> List[str]: + """加载管理员列表(不加锁,内部使用)""" + try: + with open(self.ADMINS_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("admins", []) + except Exception as e: + print(f"加载管理员列表失败: {e}") + return [] + + def _save_admin_list_unsafe(self, admin_list: List[str]): + """保存管理员列表(不加锁,内部使用)""" + try: + with open(self.ADMINS_FILE, "w", encoding="utf-8") as f: + json.dump({"admins": admin_list}, f, ensure_ascii=False, indent=2) + except Exception as e: + print(f"保存管理员列表失败: {e}") + + async def _load_admin_list(self) -> List[str]: + """加载管理员列表(加锁)""" + async with self._admins_lock: + return self._load_admin_list_unsafe() + + async def _save_admin_list(self, admin_list: List[str]): + """保存管理员列表(加锁)""" + async with self._admins_lock: + self._save_admin_list_unsafe(admin_list) + + async def create_or_update_from_linuxdo( + self, + linuxdo_id: str, + username: str, + display_name: str, + avatar_url: Optional[str], + trust_level: int + ) -> User: + """ + 从 LinuxDO 用户信息创建或更新用户(线程安全) + + Args: + linuxdo_id: LinuxDO 用户 ID(本地用户时为 local_xxx 格式) + username: 用户名 + display_name: 显示名称 + avatar_url: 头像 URL + trust_level: 信任等级 (仅用于显示) + + Returns: + 用户对象 + """ + # 如果已经是 local_ 开头,直接使用;否则添加 linuxdo_ 前缀 + if linuxdo_id.startswith("local_"): + user_id = linuxdo_id + else: + user_id = f"linuxdo_{linuxdo_id}" + + # 使用锁保护整个读-改-写操作 + async with self._users_lock: + async with self._admins_lock: + users = self._load_users_unsafe() + admin_list = self._load_admin_list_unsafe() + + now = datetime.now().isoformat() + + # 检查是否为初始管理员 + initial_admin_id = settings.INITIAL_ADMIN_LINUXDO_ID + is_initial_admin = (initial_admin_id and linuxdo_id == initial_admin_id) + + # 检查是否为本地用户(所有 local_ 开头的用户默认为管理员) + is_local_user = user_id.startswith("local_") + + if user_id in users: + # 更新现有用户 + user_data = users[user_id] + user_data["username"] = username + user_data["display_name"] = display_name + user_data["avatar_url"] = avatar_url + user_data["trust_level"] = trust_level + user_data["last_login"] = now + + # 如果是初始管理员或本地用户且还不在管理员列表中,添加进去 + if (is_initial_admin or is_local_user) and user_id not in admin_list: + admin_list.append(user_id) + self._save_admin_list_unsafe(admin_list) + user_data["is_admin"] = True + else: + # 从管理员列表同步 is_admin 状态 + user_data["is_admin"] = user_id in admin_list + else: + # 创建新用户(本地用户默认为管理员) + is_admin = is_initial_admin or is_local_user + if is_admin and user_id not in admin_list: + admin_list.append(user_id) + self._save_admin_list_unsafe(admin_list) + + user_data = { + "user_id": user_id, + "username": username, + "display_name": display_name, + "avatar_url": avatar_url, + "trust_level": trust_level, + "is_admin": is_admin, + "linuxdo_id": linuxdo_id, + "created_at": now, + "last_login": now + } + users[user_id] = user_data + + self._save_users_unsafe(users) + return User(**user_data) + + async def get_user(self, user_id: str) -> Optional[User]: + """获取用户(线程安全)""" + users = await self._load_users() + user_data = users.get(user_id) + if user_data: + # 同步管理员状态 + admin_list = await self._load_admin_list() + user_data["is_admin"] = user_id in admin_list + return User(**user_data) + return None + + async def get_all_users(self) -> List[User]: + """获取所有用户(线程安全)""" + users = await self._load_users() + admin_list = await self._load_admin_list() + + user_list = [] + for user_data in users.values(): + # 同步管理员状态 + user_data["is_admin"] = user_data["user_id"] in admin_list + user_list.append(User(**user_data)) + + return user_list + + async def set_admin(self, user_id: str, is_admin: bool) -> bool: + """ + 设置用户的管理员权限(线程安全) + + Args: + user_id: 用户 ID + is_admin: 是否为管理员 + + Returns: + 是否成功 + """ + # 使用锁保护整个读-改-写操作 + async with self._users_lock: + async with self._admins_lock: + users = self._load_users_unsafe() + if user_id not in users: + return False + + admin_list = self._load_admin_list_unsafe() + + if is_admin: + # 授予管理员权限 + if user_id not in admin_list: + admin_list.append(user_id) + self._save_admin_list_unsafe(admin_list) + else: + # 撤销管理员权限 + if user_id in admin_list: + # 确保至少保留一个管理员 + if len(admin_list) <= 1: + return False + admin_list.remove(user_id) + self._save_admin_list_unsafe(admin_list) + + # 更新用户数据中的 is_admin 字段 + users[user_id]["is_admin"] = is_admin + self._save_users_unsafe(users) + + return True + + async def delete_user(self, user_id: str) -> bool: + """ + 删除用户(线程安全) + + Args: + user_id: 用户 ID + + Returns: + 是否成功 + """ + # 使用锁保护整个读-改-写操作 + async with self._users_lock: + async with self._admins_lock: + users = self._load_users_unsafe() + if user_id not in users: + return False + + # 不能删除管理员 + admin_list = self._load_admin_list_unsafe() + if user_id in admin_list: + return False + + # 删除用户数据 + del users[user_id] + self._save_users_unsafe(users) + + # 删除用户数据库文件(在锁外执行,避免阻塞) + db_file = str(DATA_DIR / f"ai_story_user_{user_id}.db") + if os.path.exists(db_file): + try: + os.remove(db_file) + except Exception as e: + print(f"删除用户数据库文件失败: {e}") + + return True + + async def is_admin(self, user_id: str) -> bool: + """检查用户是否为管理员(线程安全)""" + admin_list = await self._load_admin_list() + return user_id in admin_list + + +# 全局用户管理器实例 +user_manager = UserManager() \ No newline at end of file diff --git a/backend/app/utils/data_consistency.py b/backend/app/utils/data_consistency.py new file mode 100644 index 0000000..df7d4c2 --- /dev/null +++ b/backend/app/utils/data_consistency.py @@ -0,0 +1,347 @@ +"""数据一致性辅助函数""" +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import Optional, Tuple, List +from app.models.character import Character +from app.models.relationship import Organization, OrganizationMember, CharacterRelationship +from app.logger import get_logger + +logger = get_logger(__name__) + + +async def ensure_organization_record( + character: Character, + db: AsyncSession, + power_level: int = 50, + location: Optional[str] = None, + motto: Optional[str] = None +) -> Optional[Organization]: + """ + 确保组织角色拥有对应的Organization记录 + + Args: + character: Character对象(必须是is_organization=True) + db: 数据库会话 + power_level: 势力等级(默认50) + location: 所在地 + motto: 宗旨/口号 + + Returns: + Organization对象,如果character不是组织则返回None + """ + if not character.is_organization: + logger.debug(f"角色 {character.name} 不是组织,跳过Organization记录创建") + return None + + # 检查是否已存在 + result = await db.execute( + select(Organization).where(Organization.character_id == character.id) + ) + org = result.scalar_one_or_none() + + if not org: + # 创建新的Organization记录 + org = Organization( + character_id=character.id, + project_id=character.project_id, + member_count=0, + power_level=power_level, + location=location, + motto=motto + ) + db.add(org) + await db.flush() + await db.refresh(org) + logger.info(f"✅ 自动创建组织详情:{character.name} (Org ID: {org.id})") + else: + logger.debug(f"组织详情已存在:{character.name} (Org ID: {org.id})") + + return org + + +async def sync_organization_member_count( + organization: Organization, + db: AsyncSession +) -> int: + """ + 同步组织的成员计数,从实际成员记录计算 + + Args: + organization: Organization对象 + db: 数据库会话 + + Returns: + 实际成员数量 + """ + result = await db.execute( + select(OrganizationMember).where( + OrganizationMember.organization_id == organization.id, + OrganizationMember.status == "active" + ) + ) + members = result.scalars().all() + actual_count = len(members) + + if organization.member_count != actual_count: + logger.warning( + f"组织 {organization.id} 成员计数不一致:" + f"记录值={organization.member_count}, 实际值={actual_count},已修正" + ) + organization.member_count = actual_count + await db.flush() + + return actual_count + + +async def fix_missing_organization_records( + project_id: str, + db: AsyncSession +) -> Tuple[int, int]: + """ + 修复项目中缺失的Organization记录 + + 为所有is_organization=True但没有Organization记录的Character创建记录 + + Args: + project_id: 项目ID + db: 数据库会话 + + Returns: + (修复数量, 检查总数) + """ + # 查找所有组织角色 + result = await db.execute( + select(Character).where( + Character.project_id == project_id, + Character.is_organization == True + ) + ) + org_characters = result.scalars().all() + + fixed_count = 0 + for char in org_characters: + org = await ensure_organization_record(char, db) + if org and org.id: # 新创建的才计数 + # 检查是否是新创建的(通过查询历史) + result = await db.execute( + select(Organization).where(Organization.character_id == char.id) + ) + if result.scalar_one_or_none(): + fixed_count += 1 + + await db.commit() + + logger.info(f"📊 修复统计 - 检查了 {len(org_characters)} 个组织,修复了 {fixed_count} 个缺失的Organization记录") + return fixed_count, len(org_characters) + + +async def fix_organization_member_counts( + project_id: str, + db: AsyncSession +) -> Tuple[int, int]: + """ + 修复项目中所有组织的成员计数 + + Args: + project_id: 项目ID + db: 数据库会话 + + Returns: + (修复数量, 检查总数) + """ + # 查找所有组织 + result = await db.execute( + select(Organization).where(Organization.project_id == project_id) + ) + organizations = result.scalars().all() + + fixed_count = 0 + for org in organizations: + old_count = org.member_count + actual_count = await sync_organization_member_count(org, db) + if old_count != actual_count: + fixed_count += 1 + + await db.commit() + + logger.info(f"📊 修复统计 - 检查了 {len(organizations)} 个组织,修复了 {fixed_count} 个计数错误") + return fixed_count, len(organizations) + + +async def validate_relationships( + project_id: str, + db: AsyncSession +) -> List[dict]: + """ + 验证项目中的关系数据完整性 + + 检查所有关系中的character_from_id和character_to_id是否都指向存在的角色 + + Args: + project_id: 项目ID + db: 数据库会话 + + Returns: + 问题列表,每个问题包含 {issue_type, relationship_id, details} + """ + issues = [] + + # 获取所有关系 + result = await db.execute( + select(CharacterRelationship).where(CharacterRelationship.project_id == project_id) + ) + relationships = result.scalars().all() + + for rel in relationships: + # 检查from角色 + from_char = await db.execute( + select(Character).where(Character.id == rel.character_from_id) + ) + if not from_char.scalar_one_or_none(): + issues.append({ + "issue_type": "missing_from_character", + "relationship_id": rel.id, + "details": f"关系 {rel.id} 的源角色 {rel.character_from_id} 不存在" + }) + + # 检查to角色 + to_char = await db.execute( + select(Character).where(Character.id == rel.character_to_id) + ) + if not to_char.scalar_one_or_none(): + issues.append({ + "issue_type": "missing_to_character", + "relationship_id": rel.id, + "details": f"关系 {rel.id} 的目标角色 {rel.character_to_id} 不存在" + }) + + if issues: + logger.warning(f"⚠️ 发现 {len(issues)} 个关系数据问题") + for issue in issues: + logger.warning(f" - {issue['details']}") + else: + logger.info(f"✅ 所有 {len(relationships)} 条关系数据完整") + + return issues + + +async def validate_organization_members( + project_id: str, + db: AsyncSession +) -> List[dict]: + """ + 验证项目中的组织成员数据完整性 + + 检查所有成员关系中的organization_id和character_id是否都有效 + + Args: + project_id: 项目ID + db: 数据库会话 + + Returns: + 问题列表 + """ + issues = [] + + # 获取所有成员关系 + result = await db.execute( + select(OrganizationMember).where( + OrganizationMember.organization_id.in_( + select(Organization.id).where(Organization.project_id == project_id) + ) + ) + ) + members = result.scalars().all() + + for member in members: + # 检查组织 + org = await db.execute( + select(Organization).where(Organization.id == member.organization_id) + ) + if not org.scalar_one_or_none(): + issues.append({ + "issue_type": "missing_organization", + "member_id": member.id, + "details": f"成员 {member.id} 的组织 {member.organization_id} 不存在" + }) + + # 检查角色 + char = await db.execute( + select(Character).where(Character.id == member.character_id) + ) + if not char.scalar_one_or_none(): + issues.append({ + "issue_type": "missing_character", + "member_id": member.id, + "details": f"成员 {member.id} 的角色 {member.character_id} 不存在" + }) + + if issues: + logger.warning(f"⚠️ 发现 {len(issues)} 个组织成员数据问题") + for issue in issues: + logger.warning(f" - {issue['details']}") + else: + logger.info(f"✅ 所有 {len(members)} 条组织成员数据完整") + + return issues + + +async def run_full_data_consistency_check( + project_id: str, + db: AsyncSession, + auto_fix: bool = True +) -> dict: + """ + 对项目运行完整的数据一致性检查和修复 + + Args: + project_id: 项目ID + db: 数据库会话 + auto_fix: 是否自动修复问题(默认True) + + Returns: + 检查报告字典 + """ + logger.info(f"🔍 开始数据一致性检查 - 项目 {project_id}") + + report = { + "project_id": project_id, + "checks": {} + } + + # 1. 检查并修复缺失的Organization记录 + if auto_fix: + fixed, total = await fix_missing_organization_records(project_id, db) + report["checks"]["organization_records"] = { + "checked": total, + "fixed": fixed, + "status": "ok" if fixed == 0 else "fixed" + } + + # 2. 检查并修复成员计数 + if auto_fix: + fixed, total = await fix_organization_member_counts(project_id, db) + report["checks"]["member_counts"] = { + "checked": total, + "fixed": fixed, + "status": "ok" if fixed == 0 else "fixed" + } + + # 3. 验证关系数据 + rel_issues = await validate_relationships(project_id, db) + report["checks"]["relationships"] = { + "issues_found": len(rel_issues), + "issues": rel_issues, + "status": "ok" if len(rel_issues) == 0 else "warning" + } + + # 4. 验证组织成员数据 + member_issues = await validate_organization_members(project_id, db) + report["checks"]["organization_members"] = { + "issues_found": len(member_issues), + "issues": member_issues, + "status": "ok" if len(member_issues) == 0 else "warning" + } + + logger.info(f"✅ 数据一致性检查完成") + return report \ No newline at end of file diff --git a/backend/app/utils/sse_response.py b/backend/app/utils/sse_response.py new file mode 100644 index 0000000..5b94e43 --- /dev/null +++ b/backend/app/utils/sse_response.py @@ -0,0 +1,169 @@ +"""Server-Sent Events (SSE) 响应工具类""" +import json +import asyncio +from typing import AsyncGenerator, Dict, Any, Optional +from fastapi.responses import StreamingResponse +from app.logger import get_logger + +logger = get_logger(__name__) + + +class SSEResponse: + """SSE响应构建器""" + + @staticmethod + def format_sse(data: Dict[str, Any], event: Optional[str] = None) -> str: + """ + 格式化SSE消息 + + Args: + data: 要发送的数据字典 + event: 事件类型(可选) + + Returns: + 格式化后的SSE消息字符串 + """ + message = "" + if event: + message += f"event: {event}\n" + message += f"data: {json.dumps(data, ensure_ascii=False)}\n\n" + return message + + @staticmethod + async def send_progress( + message: str, + progress: int, + status: str = "processing" + ) -> str: + """ + 发送进度消息 + + Args: + message: 进度消息 + progress: 进度百分比(0-100) + status: 状态(processing/success/error) + """ + return SSEResponse.format_sse({ + "type": "progress", + "message": message, + "progress": progress, + "status": status + }) + + @staticmethod + async def send_chunk(content: str) -> str: + """ + 发送内容块(用于流式输出AI生成内容) + + Args: + content: 内容块 + """ + return SSEResponse.format_sse({ + "type": "chunk", + "content": content + }) + + @staticmethod + async def send_result(data: Dict[str, Any]) -> str: + """ + 发送最终结果 + + Args: + data: 结果数据 + """ + return SSEResponse.format_sse({ + "type": "result", + "data": data + }) + + @staticmethod + async def send_error(error: str, code: int = 500) -> str: + """ + 发送错误消息 + + Args: + error: 错误描述 + code: 错误码 + """ + return SSEResponse.format_sse({ + "type": "error", + "error": error, + "code": code + }) + + @staticmethod + async def send_done() -> str: + """发送完成消息""" + return SSEResponse.format_sse({ + "type": "done" + }) + + @staticmethod + async def send_heartbeat() -> str: + """发送心跳消息(保持连接活跃)""" + return ": heartbeat\n\n" + + +async def create_sse_generator( + async_gen: AsyncGenerator[str, None], + show_progress: bool = True +) -> AsyncGenerator[str, None]: + """ + 创建SSE生成器包装器 + + Args: + async_gen: 异步生成器 + show_progress: 是否显示进度 + + Yields: + 格式化的SSE消息 + """ + try: + if show_progress: + yield await SSEResponse.send_progress("开始生成...", 0) + + # 累积内容用于进度计算 + accumulated_content = "" + chunk_count = 0 + + async for chunk in async_gen: + chunk_count += 1 + accumulated_content += chunk + + # 发送内容块 + yield await SSEResponse.send_chunk(chunk) + + # 每10个块发送一次心跳 + if chunk_count % 10 == 0: + yield await SSEResponse.send_heartbeat() + + if show_progress: + yield await SSEResponse.send_progress("生成完成", 100, "success") + + # 发送完成信号 + yield await SSEResponse.send_done() + + except Exception as e: + logger.error(f"SSE生成器错误: {str(e)}") + yield await SSEResponse.send_error(str(e)) + + +def create_sse_response(generator: AsyncGenerator[str, None]) -> StreamingResponse: + """ + 创建SSE StreamingResponse + + Args: + generator: SSE消息生成器 + + Returns: + StreamingResponse对象 + """ + return StreamingResponse( + generator, + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", # 禁用nginx缓冲 + } + ) \ No newline at end of file diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..6a4af8f --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,20 @@ +# Web框架 +fastapi==0.109.0 +uvicorn[standard]==0.27.0 +python-multipart==0.0.6 + +# 数据库 +sqlalchemy==2.0.25 +aiosqlite==0.19.0 + +# 数据验证 +pydantic==2.5.3 +pydantic-settings==2.1.0 + +# AI服务 +openai==1.10.0 +anthropic==0.18.0 + +# 工具库 +httpx==0.26.0 +python-dotenv==1.0.0 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..65b962e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +services: + ai-story: + build: + context: . + dockerfile: Dockerfile + image: ai-story-creator:latest + container_name: ai-story-creator + ports: + - "8000:8000" + volumes: + # 持久化数据库和日志 + - ./data:/app/data + - ./logs:/app/logs + # 挂载环境变量文件(可选) + - ./.env:/app/.env:ro + environment: + # 应用配置 + - APP_NAME=AI Story Creator + - APP_VERSION=1.0.0 + - APP_HOST=0.0.0.0 + - APP_PORT=8000 + - DEBUG=false + + # 重要:环境变量会从 .env 文件自动加载 + # 也可以在这里显式设置,优先级:此处设置 > .env 文件 + + # AI服务配置(建议在 .env 文件中设置) + # - OPENAI_API_KEY=${OPENAI_API_KEY} + # - GEMINI_API_KEY=${GEMINI_API_KEY} + # - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} + # - DEFAULT_AI_PROVIDER=${DEFAULT_AI_PROVIDER:-gemini} + # - DEFAULT_MODEL=${DEFAULT_MODEL:-gemini-2.5-flash} + + # LinuxDO OAuth配置(⚠️ 必须设置正确的回调地址) + # - LINUXDO_CLIENT_ID=${LINUXDO_CLIENT_ID} + # - LINUXDO_CLIENT_SECRET=${LINUXDO_CLIENT_SECRET} + # - LINUXDO_REDIRECT_URI=${LINUXDO_REDIRECT_URI} + restart: unless-stopped + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 10s + networks: + - ai-story-network + +networks: + ai-story-network: + driver: bridge diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..d2e7761 --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..b19330b --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..f0190a0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + MuMuのAI小说 + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..4034e75 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,4997 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@ant-design/icons": "^5.6.1", + "antd": "^5.27.6", + "axios": "^1.12.2", + "dayjs": "^1.11.13", + "react": "^18.3.1", + "react-beautiful-dnd": "^13.1.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "@types/node": "^24.6.0", + "@types/react": "^18.3.12", + "@types/react-beautiful-dnd": "^13.1.8", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^5.0.4", + "eslint": "^9.36.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.22", + "globals": "^16.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.45.0", + "vite": "^7.1.7" + } + }, + "node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", + "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", + "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.4.2.tgz", + "integrity": "sha512-vHbT+zJEVzllwP+CM+ul7reTEfBR0vgxFe7+lREAsAA7YGsYpboiq2sQNeQeRvh09GfQgs/GyFEvZpJ9cLXpXA==", + "license": "MIT" + }, + "node_modules/@ant-design/react-slick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz", + "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.4.tgz", + "integrity": "sha512-YsmSKC29MJwf0gF8Rjjrg5LQCmyh+j/nD8/eP7f+BeoQTKYqs9RoWbjGOdy0+1Ekr68RJZMUOPVQaQisnIo4Rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.4.tgz", + "integrity": "sha512-2BCOP7TN8M+gVDj7/ht3hsaO/B/n5oDbiAyyvnRlNOs+u1o+JWNYTQrmpuNp1/Wq2gcFrI01JAW+paEKDMx/CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.4", + "@babel/types": "^7.28.4", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.4" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", + "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", + "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", + "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", + "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", + "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", + "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", + "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", + "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", + "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", + "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", + "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", + "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", + "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", + "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", + "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", + "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", + "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", + "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", + "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", + "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", + "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", + "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", + "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", + "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", + "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", + "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz", + "integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.16.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", + "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", + "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", + "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.16.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@rc-component/async-validator": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.0.4.tgz", + "integrity": "sha512-qgGdcVIF604M9EqjNF0hbUTz42bz/RDtxWdWuU5EQe3hi7M8ob54B6B35rOsvX5eSvIHIzT9iH1R3n+hk3CGfg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz", + "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6", + "@babel/runtime": "^7.23.6", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.0.tgz", + "integrity": "sha512-jS4E7T9Li2GuYwI6PyiVXmxTiM6b07rlD9Ge8uGZSCz3WlzcG5ZK7g5bbuKNeZ9pgUuPK/5guV781ujdVpm4HQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.0.1.tgz", + "integrity": "sha512-g8eeeaMyFXVlq8cZUeaxCDhfIYjpao0l9cvm5gFwKXy/Vm1yDWV7h2sjH5jHYzdFedlVKBpATFB1VKMrHzwaWQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "classnames": "^2.3.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz", + "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/trigger": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.3.0.tgz", + "integrity": "sha512-iwaxZyzOuK0D7lS+0AQEtW52zUWxoGqTGkke3dRyb8pYiShmRpCjB/8TzPI4R6YySCH7Vm9BZj/31VPiiQTLBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.44.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", + "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.38", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.38.tgz", + "integrity": "sha512-N/ICGKleNhA5nc9XXQG/kkKHJ7S55u0x0XUJbbkmdCnFuoRkM1Il12q9q0eX19+M7KKUEPw/daUPIRnxhcxAIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", + "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", + "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", + "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", + "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", + "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", + "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", + "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", + "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", + "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", + "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", + "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", + "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", + "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", + "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", + "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", + "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", + "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", + "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", + "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", + "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", + "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", + "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/hoist-non-react-statics": { + "version": "3.3.7", + "resolved": "https://registry.npmjs.org/@types/hoist-non-react-statics/-/hoist-non-react-statics-3.3.7.tgz", + "integrity": "sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==", + "license": "MIT", + "dependencies": { + "hoist-non-react-statics": "^3.3.0" + }, + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.9.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.9.1.tgz", + "integrity": "sha512-QoiaXANRkSXK6p0Duvt56W208du4P9Uye9hWLWgGMDTEoKPhuenzNcC4vGUmrNkiOKTlIrBoyNQYNpSwfEZXSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.26", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", + "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-beautiful-dnd": { + "version": "13.1.8", + "resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.8.tgz", + "integrity": "sha512-E3TyFsro9pQuK4r8S/OL6G99eq7p8v29sX0PM7oT8Z+PJfZvSQTx4zTQbUJ+QZXioAF0e7TGBEcA1XhYhCweyQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/react-redux": { + "version": "7.1.34", + "resolved": "https://registry.npmjs.org/@types/react-redux/-/react-redux-7.1.34.tgz", + "integrity": "sha512-GdFaVjEbYv4Fthm2ZLvj1VSCedV7TqE5y1kNwnjSdBOTXuRSgowux6J8TAct15T3CKBr63UMk+2CO7ilRhyrAQ==", + "license": "MIT", + "dependencies": { + "@types/hoist-non-react-statics": "^3.3.0", + "@types/react": "*", + "hoist-non-react-statics": "^3.3.0", + "redux": "^4.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz", + "integrity": "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/type-utils": "8.46.2", + "@typescript-eslint/utils": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.46.2", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz", + "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.2.tgz", + "integrity": "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.46.2", + "@typescript-eslint/types": "^8.46.2", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz", + "integrity": "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz", + "integrity": "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz", + "integrity": "sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2", + "@typescript-eslint/utils": "8.46.2", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz", + "integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz", + "integrity": "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.46.2", + "@typescript-eslint/tsconfig-utils": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.2.tgz", + "integrity": "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz", + "integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.46.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.0.4.tgz", + "integrity": "sha512-La0KD0vGkVkSk6K+piWDKRUyg8Rl5iAIKRMH0vMJI0Eg47bq1eOxmoObAaQG37WMW9MSyk7Cs8EIWwJC1PtzKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.4", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.38", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/antd": { + "version": "5.27.6", + "resolved": "https://registry.npmjs.org/antd/-/antd-5.27.6.tgz", + "integrity": "sha512-70HrjVbzDXvtiUQ5MP1XdNudr/wGAk9Ivaemk6f36yrAeJurJSmZ8KngOIilolLRHdGuNc6/Vk+4T1OZpSjpag==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.2.1", + "@ant-design/cssinjs": "^1.23.0", + "@ant-design/cssinjs-utils": "^1.1.3", + "@ant-design/fast-color": "^2.0.6", + "@ant-design/icons": "^5.6.1", + "@ant-design/react-slick": "~1.1.2", + "@babel/runtime": "^7.26.0", + "@rc-component/color-picker": "~2.0.1", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/qrcode": "~1.0.1", + "@rc-component/tour": "~1.15.1", + "@rc-component/trigger": "^2.3.0", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.11", + "rc-cascader": "~3.34.0", + "rc-checkbox": "~3.5.0", + "rc-collapse": "~3.9.0", + "rc-dialog": "~9.6.0", + "rc-drawer": "~7.3.0", + "rc-dropdown": "~4.2.1", + "rc-field-form": "~2.7.0", + "rc-image": "~7.12.0", + "rc-input": "~1.8.0", + "rc-input-number": "~9.5.0", + "rc-mentions": "~2.20.0", + "rc-menu": "~9.16.1", + "rc-motion": "^2.9.5", + "rc-notification": "~5.6.4", + "rc-pagination": "~5.1.0", + "rc-picker": "~4.11.3", + "rc-progress": "~4.0.0", + "rc-rate": "~2.13.1", + "rc-resize-observer": "^1.4.3", + "rc-segmented": "~2.7.0", + "rc-select": "~14.16.8", + "rc-slider": "~11.1.9", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.54.0", + "rc-tabs": "~15.7.0", + "rc-textarea": "~1.10.2", + "rc-tooltip": "~6.4.0", + "rc-tree": "~5.13.1", + "rc-tree-select": "~5.27.0", + "rc-upload": "~4.9.2", + "rc-util": "^5.44.4", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.12.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz", + "integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.4", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.18", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.18.tgz", + "integrity": "sha512-UYmTpOBwgPScZpS4A+YbapwWuBwasxvO/2IOHArSsAhL/+ZdmATBXTex3t+l2hXwLVYK382ibr/nKoY9GKe86w==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.26.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.26.3.tgz", + "integrity": "sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.8.9", + "caniuse-lite": "^1.0.30001746", + "electron-to-chromium": "^1.5.227", + "node-releases": "^2.0.21", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001751", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", + "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-box-model": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz", + "integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==", + "license": "MIT", + "dependencies": { + "tiny-invariant": "^1.0.6" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.18", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.18.tgz", + "integrity": "sha512-zFBQ7WFRvVRhKcWoUh+ZA1g2HVgUbsZm9sbddh8EC5iv93sui8DVVz1Npvz+r6meo9VKfa8NyLWBsQK1VvIKPA==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.237", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.237.tgz", + "integrity": "sha512-icUt1NvfhGLar5lSWH3tHNzablaA5js3HVHacQimfP8ViEBOQv+L7DKEuHdbTZ0SKCO1ogTJTIL1Gwk9S6Qvcg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", + "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.11", + "@esbuild/android-arm": "0.25.11", + "@esbuild/android-arm64": "0.25.11", + "@esbuild/android-x64": "0.25.11", + "@esbuild/darwin-arm64": "0.25.11", + "@esbuild/darwin-x64": "0.25.11", + "@esbuild/freebsd-arm64": "0.25.11", + "@esbuild/freebsd-x64": "0.25.11", + "@esbuild/linux-arm": "0.25.11", + "@esbuild/linux-arm64": "0.25.11", + "@esbuild/linux-ia32": "0.25.11", + "@esbuild/linux-loong64": "0.25.11", + "@esbuild/linux-mips64el": "0.25.11", + "@esbuild/linux-ppc64": "0.25.11", + "@esbuild/linux-riscv64": "0.25.11", + "@esbuild/linux-s390x": "0.25.11", + "@esbuild/linux-x64": "0.25.11", + "@esbuild/netbsd-arm64": "0.25.11", + "@esbuild/netbsd-x64": "0.25.11", + "@esbuild/openbsd-arm64": "0.25.11", + "@esbuild/openbsd-x64": "0.25.11", + "@esbuild/openharmony-arm64": "0.25.11", + "@esbuild/sunos-x64": "0.25.11", + "@esbuild/win32-arm64": "0.25.11", + "@esbuild/win32-ia32": "0.25.11", + "@esbuild/win32-x64": "0.25.11" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", + "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.1", + "@eslint/core": "^0.16.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.38.0", + "@eslint/plugin-kit": "^0.4.0", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.25", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.25.tgz", + "integrity": "sha512-4auku8B/vw5psvTiiN9j1dAOsXvMoGqJuKJcR+dTdqiXEK20mMTk1UEo3HS16LeGQsVG6+qKTPM9u/qQ2LqATA==", + "dev": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==", + "license": "MIT" + }, + "node_modules/rc-cascader": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", + "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "^2.3.1", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz", + "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz", + "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz", + "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz", + "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz", + "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.44.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.0.tgz", + "integrity": "sha512-hgKsCay2taxzVnBPZl+1n4ZondsV78G++XVsMIJCAoioMjlMQR9YwAp7JZDIECzIu2Z66R+f4SFIRrO2DjDNAA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz", + "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.6.0", + "rc-motion": "^2.6.2", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz", + "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz", + "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/mini-decimal": "^1.0.1", + "classnames": "^2.2.5", + "rc-input": "~1.8.0", + "rc-util": "^5.40.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz", + "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.5", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-input": "~1.8.0", + "rc-menu": "~9.16.0", + "rc-textarea": "~1.10.0", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.16.1", + "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz", + "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.0.0", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.3.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz", + "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-overflow": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.5.0.tgz", + "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz", + "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz", + "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.1", + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/rc-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz", + "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz", + "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", + "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.44.1", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.0.tgz", + "integrity": "sha512-liijAjXz+KnTRVnxxXG2sYDGd6iLL7VpGGdR8gwoxAXy2KglviKCxLWZdjKYJzYzGSUwKDSTdYk8brj54Bn5BA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.16.8", + "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz", + "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.1.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.3.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.9.tgz", + "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz", + "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz", + "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0", + "classnames": "^2.2.1", + "rc-util": "^5.30.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.54.0", + "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.54.0.tgz", + "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.44.3", + "rc-virtual-list": "^3.14.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "15.7.0", + "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.7.0.tgz", + "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.2.0", + "rc-menu": "~9.16.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.34.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.2.tgz", + "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-input": "~1.8.0", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz", + "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1", + "rc-util": "^5.44.3" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz", + "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz", + "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "2.x", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-upload": { + "version": "4.9.2", + "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.9.2.tgz", + "integrity": "sha512-nHx+9rbd1FKMiMRYsqQ3NkXUv7COHPBo3X1Obwq9SWS6/diF/A0aJ5OHubvwUAIDs+4RMleljV0pcrNUc823GQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-virtual-list": { + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz", + "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-beautiful-dnd": { + "version": "13.1.1", + "resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz", + "integrity": "sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==", + "deprecated": "react-beautiful-dnd is now deprecated. Context and options: https://github.com/atlassian/react-beautiful-dnd/issues/2672", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.9.2", + "css-box-model": "^1.2.0", + "memoize-one": "^5.1.1", + "raf-schd": "^4.0.2", + "react-redux": "^7.2.0", + "redux": "^4.0.4", + "use-memo-one": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8.5 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.5 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-redux": { + "version": "7.2.9", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.9.tgz", + "integrity": "sha512-Gx4L3uM182jEEayZfRbI/G11ZpYdNAnBs70lFVMNdHJI76XYtR+7m0MN+eAs7UHBPhWXcnFPaS+9owSCJQHNpQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.15.4", + "@types/react-redux": "^7.1.20", + "hoist-non-react-statics": "^3.3.2", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^17.0.2" + }, + "peerDependencies": { + "react": "^16.8.3 || ^17 || ^18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + } + } + }, + "node_modules/react-redux/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", + "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", + "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0", + "react-router": "6.30.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", + "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.52.5", + "@rollup/rollup-android-arm64": "4.52.5", + "@rollup/rollup-darwin-arm64": "4.52.5", + "@rollup/rollup-darwin-x64": "4.52.5", + "@rollup/rollup-freebsd-arm64": "4.52.5", + "@rollup/rollup-freebsd-x64": "4.52.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", + "@rollup/rollup-linux-arm-musleabihf": "4.52.5", + "@rollup/rollup-linux-arm64-gnu": "4.52.5", + "@rollup/rollup-linux-arm64-musl": "4.52.5", + "@rollup/rollup-linux-loong64-gnu": "4.52.5", + "@rollup/rollup-linux-ppc64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-musl": "4.52.5", + "@rollup/rollup-linux-s390x-gnu": "4.52.5", + "@rollup/rollup-linux-x64-gnu": "4.52.5", + "@rollup/rollup-linux-x64-musl": "4.52.5", + "@rollup/rollup-openharmony-arm64": "4.52.5", + "@rollup/rollup-win32-arm64-msvc": "4.52.5", + "@rollup/rollup-win32-ia32-msvc": "4.52.5", + "@rollup/rollup-win32-x64-gnu": "4.52.5", + "@rollup/rollup-win32-x64-msvc": "4.52.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "license": "MIT" + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.2.tgz", + "integrity": "sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.46.2", + "@typescript-eslint/parser": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2", + "@typescript-eslint/utils": "8.46.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-memo-one": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.3.tgz", + "integrity": "sha512-g66/K7ZQGYrI6dy8GLpVcMsBp4s17xNkYJVSMvTEevGy3nDxHOfE6z8BVE22+5G5x7t3+bhzrlTDB7ObrEE0cQ==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/vite": { + "version": "7.1.11", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.11.tgz", + "integrity": "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zustand": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..247ea9e --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,38 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@ant-design/icons": "^5.6.1", + "antd": "^5.27.6", + "axios": "^1.12.2", + "dayjs": "^1.11.13", + "react": "^18.3.1", + "react-beautiful-dnd": "^13.1.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.28.0", + "zustand": "^5.0.8" + }, + "devDependencies": { + "@eslint/js": "^9.36.0", + "@types/node": "^24.6.0", + "@types/react": "^18.3.12", + "@types/react-beautiful-dnd": "^13.1.8", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^5.0.4", + "eslint": "^9.36.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.22", + "globals": "^16.4.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.45.0", + "vite": "^7.1.7" + } +} diff --git a/frontend/public/favicon.ico b/frontend/public/favicon.ico new file mode 100644 index 0000000..9839fb9 Binary files /dev/null and b/frontend/public/favicon.ico differ diff --git a/frontend/public/logo.svg b/frontend/public/logo.svg new file mode 100644 index 0000000..6adefc0 --- /dev/null +++ b/frontend/public/logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/vite.svg b/frontend/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/frontend/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..45a791e --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,5 @@ +#root { + width: 100%; + margin: 0; + padding: 0; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..18d76b6 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,50 @@ +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { ConfigProvider } from 'antd'; +import zhCN from 'antd/locale/zh_CN'; +import ProjectList from './pages/ProjectList'; +import ProjectWizardNew from './pages/ProjectWizardNew'; +import ProjectDetail from './pages/ProjectDetail'; +import WorldSetting from './pages/WorldSetting'; +import Outline from './pages/Outline'; +import Characters from './pages/Characters'; +import Relationships from './pages/Relationships'; +import Organizations from './pages/Organizations'; +import Chapters from './pages/Chapters'; +// import Polish from './pages/Polish'; +import Login from './pages/Login'; +import AuthCallback from './pages/AuthCallback'; +import ProtectedRoute from './components/ProtectedRoute'; +import './App.css'; + +function App() { + return ( + + + + } /> + } /> + + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + {/* } /> */} + + + + + ); +} + +export default App; diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/components/CardStyles.tsx b/frontend/src/components/CardStyles.tsx new file mode 100644 index 0000000..6658a68 --- /dev/null +++ b/frontend/src/components/CardStyles.tsx @@ -0,0 +1,127 @@ +import type { CSSProperties } from 'react'; + +// 统一的卡片样式配置 +export const cardStyles = { + // 基础卡片样式 + base: { + borderRadius: 12, + transition: 'all 0.3s ease', + } as CSSProperties, + + // 悬浮效果 + hoverable: { + cursor: 'pointer', + } as CSSProperties, + + // 角色卡片样式 + character: { + // height: 320, + display: 'flex', + flexDirection: 'column', + borderColor: '#1890ff', + borderRadius: 12, + } as CSSProperties, + + // 组织卡片样式 + organization: { + // height: 320, + display: 'flex', + flexDirection: 'column', + borderColor: '#52c41a', + backgroundColor: '#f6ffed', + borderRadius: 12, + } as CSSProperties, + + // 项目卡片样式 + project: { + height: '100%', + borderRadius: 16, + overflow: 'hidden', + background: '#fff', + boxShadow: '0 4px 16px rgba(0, 0, 0, 0.08)', + transition: 'all 0.3s ease', + } as CSSProperties, + + // 卡片内容区域样式 + body: { + padding: 20, + display: 'flex', + flexDirection: 'column' as const, + } as CSSProperties, + + // 卡片描述区域样式(固定高度,内容截断) + description: { + marginTop: 12, + maxHeight: 200, + overflow: 'hidden' as const, + } as CSSProperties, + + // 文本截断样式 + ellipsis: { + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap' as const, + } as CSSProperties, + + // 多行文本截断 + ellipsisMultiline: (lines: number = 2) => ({ + display: '-webkit-box', + WebkitLineClamp: lines, + WebkitBoxOrient: 'vertical' as const, + overflow: 'hidden', + textOverflow: 'ellipsis', + } as CSSProperties), +}; + +// 卡片悬浮动画 +export const cardHoverHandlers = { + onMouseEnter: (e: React.MouseEvent) => { + const target = e.currentTarget; + target.style.transform = 'translateY(-8px)'; + target.style.boxShadow = '0 12px 32px rgba(0, 0, 0, 0.15)'; + }, + onMouseLeave: (e: React.MouseEvent) => { + const target = e.currentTarget; + target.style.transform = 'translateY(0)'; + target.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.08)'; + }, +}; + +// 响应式网格配置 +export const gridConfig = { + gutter: [16, 16] as [number, number], + xs: 24, + sm: 12, + lg: 8, + xl: 6, +}; + +// 角色卡片网格配置 +export const characterGridConfig = { + gutter: 0, // 移除 gutter,避免负边距 + xs: 24, // 手机:1列 + sm: 12, // 平板:2列 + md: 12, // 中等屏幕:3列 + lg: 6, // 大屏:4列 + xl: 6, // 超大屏:4列 + xxl: 5, // 超超大屏:6列 +}; + +// 文本样式 +export const textStyles = { + label: { + fontSize: 12, + color: 'rgba(0, 0, 0, 0.45)', + } as CSSProperties, + + value: { + fontSize: 14, + color: 'rgba(0, 0, 0, 0.85)', + } as CSSProperties, + + description: { + fontSize: 12, + color: 'rgba(0, 0, 0, 0.45)', + lineHeight: 1.6, + } as CSSProperties, +}; \ No newline at end of file diff --git a/frontend/src/components/CharacterCard.tsx b/frontend/src/components/CharacterCard.tsx new file mode 100644 index 0000000..07490ae --- /dev/null +++ b/frontend/src/components/CharacterCard.tsx @@ -0,0 +1,173 @@ +import { Card, Space, Tag, Typography, Popconfirm } from 'antd'; +import { EditOutlined, DeleteOutlined, UserOutlined, BankOutlined } from '@ant-design/icons'; +import { cardStyles } from './CardStyles'; +import type { Character } from '../types'; + +const { Text, Paragraph } = Typography; + +interface CharacterCardProps { + character: Character; + onEdit?: (character: Character) => void; + onDelete: (id: string) => void; +} + +export const CharacterCard: React.FC = ({ character, onEdit, onDelete }) => { + const getRoleTypeColor = (roleType?: string) => { + const roleColors: Record = { + 'protagonist': 'blue', + 'supporting': 'green', + 'antagonist': 'red', + }; + return roleColors[roleType || ''] || 'default'; + }; + + const getRoleTypeLabel = (roleType?: string) => { + const roleLabels: Record = { + 'protagonist': '主角', + 'supporting': '配角', + 'antagonist': '反派', + }; + return roleLabels[roleType || ''] || '其他'; + }; + + const isOrganization = character.is_organization; + + return ( + onEdit(character)} />] : []), + onDelete(character.id)} + okText="确定" + cancelText="取消" + > + + , + ]} + > + + ) : ( + + ) + } + title={ + + {character.name} + {isOrganization ? ( + 组织 + ) : ( + character.role_type && ( + + {getRoleTypeLabel(character.role_type)} + + ) + )} + + } + description={ +
+ {/* 角色特有字段 */} + {!isOrganization && ( + <> + {character.age && ( +
+ 年龄: + {character.age} +
+ )} + {character.gender && ( +
+ 性别: + {character.gender} +
+ )} + {character.personality && ( +
+ 性格: + + {character.personality} + +
+ )} + + )} + + {/* 组织特有字段 */} + {isOrganization && ( + <> + {character.organization_type && ( +
+ 类型: + {character.organization_type} +
+ )} + {character.organization_purpose && ( +
+ 目的: + + {character.organization_purpose} + +
+ )} + {character.organization_members && ( +
+ 成员: + + {typeof character.organization_members === 'string' + ? character.organization_members + : JSON.stringify(character.organization_members)} + +
+ )} + + )} + + {/* 通用字段 - 背景信息截断显示 */} + {character.background && ( +
+ + {character.background} + +
+ )} +
+ } + /> +
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/ProtectedRoute.tsx b/frontend/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..407c1da --- /dev/null +++ b/frontend/src/components/ProtectedRoute.tsx @@ -0,0 +1,45 @@ +import { useEffect, useState } from 'react'; +import type { ReactNode } from 'react'; +import { Navigate, useLocation } from 'react-router-dom'; +import { Spin } from 'antd'; +import { authApi } from '../services/api'; + +interface ProtectedRouteProps { + children: ReactNode; +} + +export default function ProtectedRoute({ children }: ProtectedRouteProps) { + const [isAuthenticated, setIsAuthenticated] = useState(null); + const location = useLocation(); + + useEffect(() => { + const checkAuth = async () => { + try { + await authApi.getCurrentUser(); + setIsAuthenticated(true); + } catch { + setIsAuthenticated(false); + } + }; + checkAuth(); + }, []); + + if (isAuthenticated === null) { + return ( +
+ +
+ ); + } + + if (!isAuthenticated) { + return ; + } + + return <>{children}; +} \ No newline at end of file diff --git a/frontend/src/components/SSELoadingOverlay.tsx b/frontend/src/components/SSELoadingOverlay.tsx new file mode 100644 index 0000000..65ce7b4 --- /dev/null +++ b/frontend/src/components/SSELoadingOverlay.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { Spin } from 'antd'; +import { LoadingOutlined } from '@ant-design/icons'; + +interface SSELoadingOverlayProps { + loading: boolean; + progress: number; + message: string; +} + +export const SSELoadingOverlay: React.FC = ({ + loading, + progress, + message +}) => { + if (!loading) return null; + + return ( +
+
+ {/* 标题和图标 */} +
+ } + /> +
+ AI生成中... +
+
+ + {/* 进度条 */} +
+
+
0 ? '0 0 10px rgba(24, 144, 255, 0.3)' : 'none' + }} /> +
+ + {/* 进度百分比 */} +
+ {progress}% +
+
+ + {/* 状态消息 */} +
+ {message || '准备生成...'} +
+ + {/* 提示文字 */} +
+ 请勿关闭页面,生成过程需要一定时间 +
+
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/SSEProgressBar.tsx b/frontend/src/components/SSEProgressBar.tsx new file mode 100644 index 0000000..4465f37 --- /dev/null +++ b/frontend/src/components/SSEProgressBar.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +interface SSEProgressBarProps { + loading: boolean; + progress: number; + message: string; +} + +export const SSEProgressBar: React.FC = ({ + loading, + progress, + message +}) => { + if (!loading) return null; + + return ( +
+ {/* 进度条 */} +
+
+
+ + {/* 进度信息 */} +
+ + {message || '准备生成...'} + + + {progress}% + +
+
+ ); +}; \ No newline at end of file diff --git a/frontend/src/components/UserMenu.tsx b/frontend/src/components/UserMenu.tsx new file mode 100644 index 0000000..63f1b6e --- /dev/null +++ b/frontend/src/components/UserMenu.tsx @@ -0,0 +1,346 @@ +import { useState, useEffect } from 'react'; +import { Dropdown, Avatar, Space, Typography, message, Modal, Table, Button, Tag, Popconfirm, Pagination } from 'antd'; +import { UserOutlined, LogoutOutlined, TeamOutlined, CrownOutlined } from '@ant-design/icons'; +import { authApi, userApi } from '../services/api'; +import type { User } from '../types'; +import type { MenuProps } from 'antd'; + +const { Text } = Typography; + +export default function UserMenu() { + const [currentUser, setCurrentUser] = useState(null); + const [showUserManagement, setShowUserManagement] = useState(false); + const [users, setUsers] = useState([]); + const [loading, setLoading] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [pageSize, setPageSize] = useState(10); + + useEffect(() => { + loadCurrentUser(); + }, []); + + const loadCurrentUser = async () => { + try { + const user = await authApi.getCurrentUser(); + setCurrentUser(user); + } catch (error) { + console.error('获取用户信息失败:', error); + } + }; + + const handleLogout = async () => { + try { + await authApi.logout(); + message.success('已退出登录'); + window.location.href = '/login'; + } catch (error) { + console.error('退出登录失败:', error); + message.error('退出登录失败'); + } + }; + + const handleShowUserManagement = async () => { + if (!currentUser?.is_admin) { + message.warning('只有管理员可以访问用户管理'); + return; + } + + setShowUserManagement(true); + loadUsers(); + }; + + const loadUsers = async () => { + try { + setLoading(true); + const userList = await userApi.listUsers(); + setUsers(userList); + } catch (error) { + console.error('获取用户列表失败:', error); + message.error('获取用户列表失败'); + } finally { + setLoading(false); + } + }; + + const handleSetAdmin = async (userId: string, isAdmin: boolean) => { + try { + await userApi.setAdmin(userId, isAdmin); + message.success(isAdmin ? '已设置为管理员' : '已取消管理员权限'); + loadUsers(); + } catch (error) { + console.error('设置管理员失败:', error); + message.error('设置管理员失败'); + } + }; + + const handleDeleteUser = async (userId: string) => { + try { + await userApi.deleteUser(userId); + message.success('用户已删除'); + loadUsers(); + } catch (error) { + console.error('删除用户失败:', error); + message.error('删除用户失败'); + } + }; + + const menuItems: MenuProps['items'] = [ + { + key: 'user-info', + label: ( +
+ {currentUser?.display_name || currentUser?.username} +
+ + Trust Level: {currentUser?.trust_level} + {currentUser?.is_admin && ' · 管理员'} + +
+ ), + disabled: true, + }, + { + type: 'divider', + }, + ...(currentUser?.is_admin ? [{ + key: 'user-management', + icon: , + label: '用户管理', + onClick: handleShowUserManagement, + }, { + type: 'divider' as const, + }] : []), + { + key: 'logout', + icon: , + label: '退出登录', + onClick: handleLogout, + }, + ]; + + const columns = [ + { + title: '用户名', + dataIndex: 'username', + key: 'username', + render: (text: string, record: User) => ( + + } size="small" /> +
+
{record.display_name || text}
+ {text} +
+
+ ), + }, + { + title: 'Trust Level', + dataIndex: 'trust_level', + key: 'trust_level', + width: 120, + render: (level: number) => {level}, + }, + { + title: '角色', + dataIndex: 'is_admin', + key: 'is_admin', + width: 100, + render: (isAdmin: boolean) => ( + isAdmin ? }>管理员 : 普通用户 + ), + }, + { + title: '最后登录', + dataIndex: 'last_login', + key: 'last_login', + width: 180, + render: (date: string) => new Date(date).toLocaleString('zh-CN'), + }, + { + title: '操作', + key: 'actions', + width: 200, + render: (_: unknown, record: User) => { + const isSelf = record.user_id === currentUser?.user_id; + return ( + + {record.is_admin ? ( + handleSetAdmin(record.user_id, false)} + disabled={isSelf} + > + + + ) : ( + + )} + handleDeleteUser(record.user_id)} + disabled={isSelf} + > + + + + ); + }, + }, + ]; + + if (!currentUser) { + return null; + } + + return ( + <> + +
{ + e.currentTarget.style.background = 'rgba(255, 255, 255, 1)'; + e.currentTarget.style.transform = 'translateY(-2px)'; + e.currentTarget.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.3)'; + }} + onMouseLeave={(e) => { + e.currentTarget.style.background = 'rgba(255, 255, 255, 0.95)'; + e.currentTarget.style.transform = 'translateY(0)'; + e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.1)'; + }} + > +
+ } + size={40} + style={{ + backgroundColor: '#1890ff', + border: '3px solid #fff', + boxShadow: '0 2px 8px rgba(102, 126, 234, 0.3)', + }} + /> + {currentUser.is_admin && ( +
+ +
+ )} +
+ + + {currentUser.display_name || currentUser.username} + + + {currentUser.is_admin ? '👑 管理员' : `🎖️ Trust Level ${currentUser.trust_level}`} + + +
+
+ + setShowUserManagement(false)} + footer={null} + width={900} + centered + styles={{ + body: { + padding: 0, + display: 'flex', + flexDirection: 'column', + height: 'calc(100vh - 200px)', + } + }} + > +
+
+ + +
+ `共 ${total} 个用户`} + pageSizeOptions={['10', '20', '50', '100']} + onChange={(page, newPageSize) => { + setCurrentPage(page); + setPageSize(newPageSize); + }} + /> +
+ + + + ); +} \ No newline at end of file diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..397fdd0 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,129 @@ +:root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(0, 0, 0, 0.87); + background-color: #f0f2f5; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + /* 移动端视口适配 */ + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; +} + +body { + margin: 0; + min-height: 100vh; + /* 禁止移动端双击缩放 */ + touch-action: manipulation; +} + +#root { + min-height: 100vh; +} + +* { + box-sizing: border-box; +} + +/* 自定义滚动条样式 */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 4px; + transition: background 0.3s ease; +} + +::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; +} + +/* Firefox 滚动条样式 */ +* { + scrollbar-width: thin; + scrollbar-color: #c1c1c1 #f1f1f1; +} + +/* 移动端响应式样式 */ +@media (max-width: 768px) { + :root { + font-size: 14px; + } + + /* 移动端隐藏滚动条 */ + ::-webkit-scrollbar { + width: 4px; + height: 4px; + } + + /* 移动端优化触摸区域 */ + button, a, [role="button"] { + min-height: 44px; + min-width: 44px; + } +} + +@media (max-width: 576px) { + :root { + font-size: 13px; + } +} + +/* 移动端安全区域适配 (iPhone X+) */ +@supports (padding: max(0px)) { + body { + padding-left: env(safe-area-inset-left); + padding-right: env(safe-area-inset-right); + padding-bottom: env(safe-area-inset-bottom); + } +} + +/* 移动端禁止长按选择 */ +@media (max-width: 768px) { + img, button { + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + -webkit-touch-callout: none; + } + + /* 修复移动端表格分页器对齐问题 */ + .ant-pagination-simple { + display: flex; + align-items: center; + justify-content: center; + } + + .ant-pagination-simple .ant-pagination-simple-pager { + display: inline-flex; + align-items: center; + margin: 0 8px; + } + + .ant-pagination-simple .ant-pagination-simple-pager input { + margin: 0 4px; + } + + .ant-pagination-simple .ant-pagination-prev, + .ant-pagination-simple .ant-pagination-next { + display: inline-flex; + align-items: center; + justify-content: center; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..4f17553 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,15 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { ConfigProvider } from 'antd' +import zhCN from 'antd/locale/zh_CN' +import 'antd/dist/reset.css' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + + + , +) diff --git a/frontend/src/pages/AuthCallback.tsx b/frontend/src/pages/AuthCallback.tsx new file mode 100644 index 0000000..112eee7 --- /dev/null +++ b/frontend/src/pages/AuthCallback.tsx @@ -0,0 +1,97 @@ +import { useEffect, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Spin, Result, Button } from 'antd'; +import { authApi } from '../services/api'; + +export default function AuthCallback() { + const navigate = useNavigate(); + const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + const handleCallback = async () => { + try { + // 后端会通过 Cookie 自动设置认证信息 + // 这里只需要验证登录状态 + await authApi.getCurrentUser(); + + setStatus('success'); + + // 从 sessionStorage 获取重定向地址 + const redirect = sessionStorage.getItem('login_redirect') || '/'; + sessionStorage.removeItem('login_redirect'); + + // 延迟一下再跳转,让用户看到成功提示 + setTimeout(() => { + navigate(redirect); + }, 1000); + } catch (error) { + console.error('登录失败:', error); + setStatus('error'); + setErrorMessage('登录失败,请重试'); + } + }; + + handleCallback(); + }, [navigate]); + + if (status === 'loading') { + return ( +
+
+ +
+ 正在处理登录... +
+
+
+ ); + } + + if (status === 'error') { + return ( +
+ navigate('/login')}> + 返回登录 + + } + style={{ background: 'white', padding: 40, borderRadius: 8 }} + /> +
+ ); + } + + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/frontend/src/pages/Chapters.tsx b/frontend/src/pages/Chapters.tsx new file mode 100644 index 0000000..cf31e8e --- /dev/null +++ b/frontend/src/pages/Chapters.tsx @@ -0,0 +1,570 @@ +import { useState, useEffect, useRef } from 'react'; +import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip } from 'antd'; +import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined } from '@ant-design/icons'; +import { useStore } from '../store'; +import { useChapterSync } from '../store/hooks'; +import { projectApi } from '../services/api'; +import type { Chapter, ChapterUpdate, ApiError } from '../types'; +import { cardStyles } from '../components/CardStyles'; + +const { TextArea } = Input; + +export default function Chapters() { + const { currentProject, chapters, setCurrentChapter, setCurrentProject } = useStore(); + const [isModalOpen, setIsModalOpen] = useState(false); + const [isEditorOpen, setIsEditorOpen] = useState(false); + const [isContinuing, setIsContinuing] = useState(false); + const [isGenerating, setIsGenerating] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form] = Form.useForm(); + const [editorForm] = Form.useForm(); + const [isMobile, setIsMobile] = useState(window.innerWidth <= 768); + const contentTextAreaRef = useRef(null); + + useEffect(() => { + const handleResize = () => { + setIsMobile(window.innerWidth <= 768); + }; + + window.addEventListener('resize', handleResize); + return () => window.removeEventListener('resize', handleResize); + }, []); + + const { + refreshChapters, + updateChapter, + generateChapterContentStream + } = useChapterSync(); + + useEffect(() => { + if (currentProject?.id) { + refreshChapters(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [currentProject?.id]); + + if (!currentProject) return null; + + const canGenerateChapter = (chapter: Chapter): boolean => { + if (chapter.chapter_number === 1) { + return true; + } + + const previousChapters = chapters.filter( + c => c.chapter_number < chapter.chapter_number + ); + + return previousChapters.every(c => c.content && c.content.trim() !== ''); + }; + + const getGenerateDisabledReason = (chapter: Chapter): string => { + if (chapter.chapter_number === 1) { + return ''; + } + + const previousChapters = chapters.filter( + c => c.chapter_number < chapter.chapter_number + ); + + const incompleteChapters = previousChapters.filter( + c => !c.content || c.content.trim() === '' + ); + + if (incompleteChapters.length > 0) { + const numbers = incompleteChapters.map(c => c.chapter_number).join('、'); + return `需要先完成前置章节:第 ${numbers} 章`; + } + + return ''; + }; + + const handleOpenModal = (id: string) => { + const chapter = chapters.find(c => c.id === id); + if (chapter) { + form.setFieldsValue(chapter); + setEditingId(id); + setIsModalOpen(true); + } + }; + + const handleSubmit = async (values: ChapterUpdate) => { + if (!editingId) return; + + try { + await updateChapter(editingId, values); + message.success('章节更新成功'); + setIsModalOpen(false); + form.resetFields(); + } catch { + message.error('操作失败'); + } + }; + + const handleOpenEditor = (id: string) => { + const chapter = chapters.find(c => c.id === id); + if (chapter) { + setCurrentChapter(chapter); + editorForm.setFieldsValue({ + title: chapter.title, + content: chapter.content, + }); + setEditingId(id); + setIsEditorOpen(true); + } + }; + + const handleEditorSubmit = async (values: ChapterUpdate) => { + if (!editingId || !currentProject) return; + + try { + await updateChapter(editingId, values); + + // 刷新项目信息以更新总字数统计 + const updatedProject = await projectApi.getProject(currentProject.id); + setCurrentProject(updatedProject); + + message.success('章节保存成功'); + setIsEditorOpen(false); + } catch { + message.error('保存失败'); + } + }; + + const handleGenerate = async () => { + if (!editingId) return; + + try { + setIsContinuing(true); + setIsGenerating(true); + + await generateChapterContentStream(editingId, (content) => { + editorForm.setFieldsValue({ content }); + + if (contentTextAreaRef.current) { + const textArea = contentTextAreaRef.current.resizableTextArea?.textArea; + if (textArea) { + textArea.scrollTop = textArea.scrollHeight; + } + } + }); + + message.success('AI创作成功'); + } catch (error) { + const apiError = error as ApiError; + message.error('AI创作失败:' + (apiError.response?.data?.detail || apiError.message || '未知错误')); + } finally { + setIsContinuing(false); + setIsGenerating(false); + } + }; + + const showGenerateModal = (chapter: Chapter) => { + const previousChapters = chapters.filter( + c => c.chapter_number < chapter.chapter_number + ).sort((a, b) => a.chapter_number - b.chapter_number); + + const modal = Modal.confirm({ + title: 'AI创作章节内容', + width: 700, + centered: true, + content: ( +
+

AI将根据以下信息创作本章内容:

+
    +
  • 章节大纲和要求
  • +
  • 项目的世界观设定
  • +
  • 相关角色信息
  • +
  • 前面已完成章节的内容(确保剧情连贯)
  • +
+ + {previousChapters.length > 0 && ( +
+
+ 📚 将引用的前置章节(共{previousChapters.length}章): +
+
+ {previousChapters.map(ch => ( +
+ ✓ 第{ch.chapter_number}章:{ch.title} ({ch.word_count || 0}字) +
+ ))} +
+
+ 💡 AI会参考这些章节内容,确保情节连贯、角色状态一致 +
+
+ )} + +

+ ⚠️ 注意:此操作将覆盖当前章节内容 +

+
+ ), + okText: '开始创作', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + modal.update({ + okButtonProps: { danger: true, loading: true }, + cancelButtonProps: { disabled: true }, + closable: false, + maskClosable: false, + keyboard: false, + }); + + try { + await handleGenerate(); + modal.destroy(); + } catch (error) { + modal.update({ + okButtonProps: { danger: true, loading: false }, + cancelButtonProps: { disabled: false }, + closable: true, + maskClosable: true, + keyboard: true, + }); + } + }, + onCancel: () => { + if (isGenerating) { + message.warning('AI正在创作中,请等待完成'); + return false; + } + }, + }); + }; + + const getStatusColor = (status: string) => { + const colors: Record = { + 'draft': 'default', + 'writing': 'processing', + 'completed': 'success', + }; + return colors[status] || 'default'; + }; + + const getStatusText = (status: string) => { + const texts: Record = { + 'draft': '草稿', + 'writing': '创作中', + 'completed': '已完成', + }; + return texts[status] || status; + }; + + const sortedChapters = [...chapters].sort((a, b) => a.chapter_number - b.chapter_number); + + const handleExport = () => { + if (chapters.length === 0) { + message.warning('当前项目没有章节,无法导出'); + return; + } + + Modal.confirm({ + title: '导出项目章节', + content: `确定要将《${currentProject.title}》的所有章节导出为TXT文件吗?`, + centered: true, + okText: '确定导出', + cancelText: '取消', + onOk: () => { + try { + projectApi.exportProject(currentProject.id); + message.success('开始下载导出文件'); + } catch { + message.error('导出失败,请重试'); + } + }, + }); + }; + + return ( +
+
+

章节管理

+ + + {!isMobile && 章节由大纲管理,请在大纲页面添加/删除} + +
+ +
+ {chapters.length === 0 ? ( + + ) : ( + + ( + } + onClick={() => handleOpenEditor(item.id)} + > + 编辑内容 + , + , + ]} + > +
+ } + title={ +
+ 第{item.chapter_number}章:{item.title} + {getStatusText(item.status)} + + {!canGenerateChapter(item) && ( + + } color="warning"> + 需前置章节 + + + )} +
+ } + description={ + item.content ? ( +
+ {item.content.substring(0, isMobile ? 80 : 150)} + {item.content.length > (isMobile ? 80 : 150) && '...'} +
+ ) : ( + 暂无内容 + ) + } + /> + + {isMobile && ( + +
+
+ )} + /> +
+ )} +
+ + setIsModalOpen(false)} + footer={null} + centered={!isMobile} + width={isMobile ? 'calc(100% - 32px)' : 520} + style={isMobile ? { + top: 20, + paddingBottom: 0, + maxWidth: 'calc(100vw - 32px)', + margin: '0 16px' + } : undefined} + styles={{ + body: { + maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(80vh - 110px)', + overflowY: 'auto' + } + }} + > +
+ + + + + + + + + + + + + + + + + + + +
+ + { + if (isGenerating) { + message.warning('AI正在创作中,请等待完成后再关闭'); + return; + } + setIsEditorOpen(false); + }} + closable={!isGenerating} + maskClosable={!isGenerating} + keyboard={!isGenerating} + width={isMobile ? 'calc(100% - 32px)' : '85%'} + centered={!isMobile} + style={isMobile ? { + top: 20, + paddingBottom: 0, + maxWidth: 'calc(100vw - 32px)', + margin: '0 16px' + } : undefined} + styles={{ + body: { + maxHeight: isMobile ? 'calc(100vh - 150px)' : 'calc(85vh - 110px)', + overflowY: 'auto', + padding: isMobile ? '16px 12px' : '8px' + } + }} + footer={null} + > +
+ + + + + + {editingId && (() => { + const currentChapter = chapters.find(c => c.id === editingId); + const canGenerate = currentChapter ? canGenerateChapter(currentChapter) : false; + const disabledReason = currentChapter ? getGenerateDisabledReason(currentChapter) : ''; + + return ( + + + + ); + })()} + + + + +