This commit is contained in:
xiamuceer
2025-10-30 11:14:43 +08:00
parent b97410d973
commit 0f6c2d344a
91 changed files with 22309 additions and 0 deletions
+73
View File
@@ -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
+105
View File
@@ -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/
+71
View File
@@ -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"]
+600
View File
@@ -0,0 +1,600 @@
# MuMuAINovel 📚✨
<div align="center">
![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 的智能小说创作助手,帮助你轻松创作精彩故事**
[特性](#特性) • [快速开始](#快速开始) • [部署方式](#部署方式) • [配置说明](#配置说明) • [项目结构](#项目结构)
</div>
---
## ✨ 特性
- 🤖 **多 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 KeyOpenAI/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)
---
<div align="center">
**如果这个项目对你有帮助,请给个 ⭐️ Star 支持一下!**
Made with ❤️
</div>
## 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")
+48
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
"""AI Story Creator - 后端应用包"""
__version__ = "1.0.0"
+1
View File
@@ -0,0 +1 @@
"""API路由模块"""
+230
View File
@@ -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}")
# 设置 Cookie7天有效)
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)
# 临时存储 state5分钟有效)
_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 Cookie7天有效)
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()
+655
View File
@@ -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"
}
)
+491
View File
@@ -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)}")
+341
View File
@@ -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}
+657
View File
@@ -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
+124
View File
@@ -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)}")
+414
View File
@@ -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)}")
+209
View File
@@ -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"角色AID: {relationship.character_from_id})不存在")
if not char_to.scalar_one_or_none():
raise HTTPException(status_code=404, detail=f"角色BID: {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}
+125
View File
@@ -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()
File diff suppressed because it is too large Load Diff
+90
View File
@@ -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}")
+261
View File
@@ -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
+73
View File
@@ -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())
+158
View File
@@ -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)
+176
View File
@@ -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
)
+4
View File
@@ -0,0 +1,4 @@
"""中间件模块"""
from .request_id import RequestIDMiddleware
__all__ = ['RequestIDMiddleware']
+39
View File
@@ -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
+78
View File
@@ -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
+26
View File
@@ -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",
]
+24
View File
@@ -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"<Chapter(id={self.id}, chapter_number={self.chapter_number}, title={self.title})>"
+44
View File
@@ -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"<Character(id={self.id}, name={self.name}, type={entity_type})>"
+23
View File
@@ -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"<GenerationHistory(id={self.id}, model={self.model})>"
+22
View File
@@ -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"<Outline(id={self.id}, title={self.title})>"
+38
View File
@@ -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"<Project(id={self.id}, title={self.title})>"
+116
View File
@@ -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"<RelationshipType(id={self.id}, name={self.name}, category={self.category})>"
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"<CharacterRelationship(id={self.id}, from={self.character_from_id}, to={self.character_to_id})>"
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"<Organization(id={self.id}, character_id={self.character_id})>"
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"<OrganizationMember(id={self.id}, org={self.organization_id}, char={self.character_id})>"
+24
View File
@@ -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"<Settings(id={self.id}, api_provider={self.api_provider})>"
+1
View File
@@ -0,0 +1 @@
"""Pydantic数据模型"""
+57
View File
@@ -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]
+67
View File
@@ -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]
+88
View File
@@ -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="排序列表")
+20
View File
@@ -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="处理后字数")
+83
View File
@@ -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="世界规则")
+204
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""服务层模块"""
+363
View File
@@ -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()
+149
View File
@@ -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
+730
View File
@@ -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()
+294
View File
@@ -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()
+347
View File
@@ -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
+169
View File
@@ -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缓冲
}
)
+20
View File
@@ -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
+50
View File
@@ -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
+24
View File
@@ -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?
+73
View File
@@ -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...
},
},
])
```
+23
View File
@@ -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,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MuMuのAI小说</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4997
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -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"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.1 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

+5
View File
@@ -0,0 +1,5 @@
#root {
width: 100%;
margin: 0;
padding: 0;
}
+50
View File
@@ -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 (
<ConfigProvider locale={zhCN}>
<BrowserRouter
future={{
v7_startTransition: true,
v7_relativeSplatPath: true,
}}
>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/auth/callback" element={<AuthCallback />} />
<Route path="/" element={<ProtectedRoute><ProjectList /></ProtectedRoute>} />
<Route path="/wizard" element={<ProtectedRoute><ProjectWizardNew /></ProtectedRoute>} />
<Route path="/project/:projectId" element={<ProtectedRoute><ProjectDetail /></ProtectedRoute>}>
<Route index element={<Navigate to="world-setting" replace />} />
<Route path="world-setting" element={<WorldSetting />} />
<Route path="outline" element={<Outline />} />
<Route path="characters" element={<Characters />} />
<Route path="relationships" element={<Relationships />} />
<Route path="organizations" element={<Organizations />} />
<Route path="chapters" element={<Chapters />} />
{/* <Route path="polish" element={<Polish />} /> */}
</Route>
</Routes>
</BrowserRouter>
</ConfigProvider>
);
}
export default App;
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

+127
View File
@@ -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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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,
};
+173
View File
@@ -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<CharacterCardProps> = ({ character, onEdit, onDelete }) => {
const getRoleTypeColor = (roleType?: string) => {
const roleColors: Record<string, string> = {
'protagonist': 'blue',
'supporting': 'green',
'antagonist': 'red',
};
return roleColors[roleType || ''] || 'default';
};
const getRoleTypeLabel = (roleType?: string) => {
const roleLabels: Record<string, string> = {
'protagonist': '主角',
'supporting': '配角',
'antagonist': '反派',
};
return roleLabels[roleType || ''] || '其他';
};
const isOrganization = character.is_organization;
return (
<Card
hoverable
style={isOrganization ? cardStyles.organization : cardStyles.character}
styles={{
body: {
flex: 1,
overflow: 'auto',
display: 'flex',
flexDirection: 'column'
},
actions: {
borderRadius: '0 0 12px 12px'
}
}}
actions={[
...(onEdit ? [<EditOutlined key="edit" onClick={() => onEdit(character)} />] : []),
<Popconfirm
key="delete"
title={`确定删除这个${isOrganization ? '组织' : '角色'}吗?`}
onConfirm={() => onDelete(character.id)}
okText="确定"
cancelText="取消"
>
<DeleteOutlined />
</Popconfirm>,
]}
>
<Card.Meta
avatar={
isOrganization ? (
<BankOutlined style={{ fontSize: 32, color: '#52c41a' }} />
) : (
<UserOutlined style={{ fontSize: 32, color: '#1890ff' }} />
)
}
title={
<Space>
<span style={cardStyles.ellipsis}>{character.name}</span>
{isOrganization ? (
<Tag color="green"></Tag>
) : (
character.role_type && (
<Tag color={getRoleTypeColor(character.role_type)}>
{getRoleTypeLabel(character.role_type)}
</Tag>
)
)}
</Space>
}
description={
<div style={cardStyles.description}>
{/* 角色特有字段 */}
{!isOrganization && (
<>
{character.age && (
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
<Text type="secondary" style={{ flexShrink: 0 }}></Text>
<Text style={{ flex: 1 }}>{character.age}</Text>
</div>
)}
{character.gender && (
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
<Text type="secondary" style={{ flexShrink: 0 }}></Text>
<Text style={{ flex: 1 }}>{character.gender}</Text>
</div>
)}
{character.personality && (
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
<Text type="secondary" style={{ flexShrink: 0 }}></Text>
<Text
style={{ flex: 1, minWidth: 0 }}
ellipsis={{ tooltip: character.personality }}
>
{character.personality}
</Text>
</div>
)}
</>
)}
{/* 组织特有字段 */}
{isOrganization && (
<>
{character.organization_type && (
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center' }}>
<Text type="secondary" style={{ flexShrink: 0 }}></Text>
<Tag color="cyan">{character.organization_type}</Tag>
</div>
)}
{character.organization_purpose && (
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
<Text type="secondary" style={{ flexShrink: 0 }}></Text>
<Text
style={{ flex: 1, minWidth: 0 }}
ellipsis={{ tooltip: character.organization_purpose }}
>
{character.organization_purpose}
</Text>
</div>
)}
{character.organization_members && (
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'flex-start' }}>
<Text type="secondary" style={{ flexShrink: 0 }}></Text>
<Text
style={{ flex: 1, minWidth: 0 }}
ellipsis={{
tooltip: typeof character.organization_members === 'string'
? character.organization_members
: JSON.stringify(character.organization_members)
}}
>
{typeof character.organization_members === 'string'
? character.organization_members
: JSON.stringify(character.organization_members)}
</Text>
</div>
)}
</>
)}
{/* 通用字段 - 背景信息截断显示 */}
{character.background && (
<div style={{ marginTop: 12 }}>
<Paragraph
type="secondary"
style={{ fontSize: 12, marginBottom: 0 }}
ellipsis={{ tooltip: character.background, rows: 3 }}
>
{character.background}
</Paragraph>
</div>
)}
</div>
}
/>
</Card>
);
};
@@ -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<boolean | null>(null);
const location = useLocation();
useEffect(() => {
const checkAuth = async () => {
try {
await authApi.getCurrentUser();
setIsAuthenticated(true);
} catch {
setIsAuthenticated(false);
}
};
checkAuth();
}, []);
if (isAuthenticated === null) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
}}>
<Spin size="large" />
</div>
);
}
if (!isAuthenticated) {
return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname)}`} replace />;
}
return <>{children}</>;
}
@@ -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<SSELoadingOverlayProps> = ({
loading,
progress,
message
}) => {
if (!loading) return null;
return (
<div style={{
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(0, 0, 0, 0.45)',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
zIndex: 9999
}}>
<div style={{
background: '#fff',
borderRadius: 12,
padding: '40px 60px',
minWidth: 400,
maxWidth: 600,
boxShadow: '0 8px 32px rgba(0,0,0,0.2)'
}}>
{/* 标题和图标 */}
<div style={{
textAlign: 'center',
marginBottom: 24
}}>
<Spin
indicator={<LoadingOutlined style={{ fontSize: 48, color: '#1890ff' }} spin />}
/>
<div style={{
fontSize: 20,
fontWeight: 'bold',
marginTop: 16,
color: '#262626'
}}>
AI生成中...
</div>
</div>
{/* 进度条 */}
<div style={{
marginBottom: 16
}}>
<div style={{
height: 12,
background: '#f0f0f0',
borderRadius: 6,
overflow: 'hidden',
marginBottom: 12
}}>
<div style={{
height: '100%',
background: progress === 100
? 'linear-gradient(90deg, #52c41a 0%, #73d13d 100%)'
: 'linear-gradient(90deg, #1890ff 0%, #40a9ff 100%)',
width: `${progress}%`,
transition: 'all 0.3s ease',
borderRadius: 6,
boxShadow: progress > 0 ? '0 0 10px rgba(24, 144, 255, 0.3)' : 'none'
}} />
</div>
{/* 进度百分比 */}
<div style={{
textAlign: 'center',
fontSize: 32,
fontWeight: 'bold',
color: progress === 100 ? '#52c41a' : '#1890ff',
marginBottom: 8
}}>
{progress}%
</div>
</div>
{/* 状态消息 */}
<div style={{
textAlign: 'center',
fontSize: 16,
color: '#595959',
minHeight: 24,
padding: '0 20px'
}}>
{message || '准备生成...'}
</div>
{/* 提示文字 */}
<div style={{
textAlign: 'center',
fontSize: 13,
color: '#8c8c8c',
marginTop: 16
}}>
,
</div>
</div>
</div>
);
};
@@ -0,0 +1,54 @@
import React from 'react';
interface SSEProgressBarProps {
loading: boolean;
progress: number;
message: string;
}
export const SSEProgressBar: React.FC<SSEProgressBarProps> = ({
loading,
progress,
message
}) => {
if (!loading) return null;
return (
<div style={{ marginTop: 16 }}>
{/* 进度条 */}
<div style={{
height: 8,
background: '#f0f0f0',
borderRadius: 4,
overflow: 'hidden',
marginBottom: 8
}}>
<div style={{
height: '100%',
background: progress === 100 ? '#52c41a' : '#1890ff',
width: `${progress}%`,
transition: 'all 0.3s ease',
borderRadius: 4
}} />
</div>
{/* 进度信息 */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: 14
}}>
<span style={{ color: '#666' }}>
{message || '准备生成...'}
</span>
<span style={{
fontWeight: 'bold',
color: progress === 100 ? '#52c41a' : '#1890ff'
}}>
{progress}%
</span>
</div>
</div>
);
};
+346
View File
@@ -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<User | null>(null);
const [showUserManagement, setShowUserManagement] = useState(false);
const [users, setUsers] = useState<User[]>([]);
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: (
<div style={{ padding: '8px 0' }}>
<Text strong>{currentUser?.display_name || currentUser?.username}</Text>
<br />
<Text type="secondary" style={{ fontSize: 12 }}>
Trust Level: {currentUser?.trust_level}
{currentUser?.is_admin && ' · 管理员'}
</Text>
</div>
),
disabled: true,
},
{
type: 'divider',
},
...(currentUser?.is_admin ? [{
key: 'user-management',
icon: <TeamOutlined />,
label: '用户管理',
onClick: handleShowUserManagement,
}, {
type: 'divider' as const,
}] : []),
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: handleLogout,
},
];
const columns = [
{
title: '用户名',
dataIndex: 'username',
key: 'username',
render: (text: string, record: User) => (
<Space>
<Avatar src={record.avatar_url} icon={<UserOutlined />} size="small" />
<div>
<div>{record.display_name || text}</div>
<Text type="secondary" style={{ fontSize: 12 }}>{text}</Text>
</div>
</Space>
),
},
{
title: 'Trust Level',
dataIndex: 'trust_level',
key: 'trust_level',
width: 120,
render: (level: number) => <Tag color="blue">{level}</Tag>,
},
{
title: '角色',
dataIndex: 'is_admin',
key: 'is_admin',
width: 100,
render: (isAdmin: boolean) => (
isAdmin ? <Tag color="gold" icon={<CrownOutlined />}></Tag> : <Tag></Tag>
),
},
{
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 (
<Space>
{record.is_admin ? (
<Popconfirm
title="确定要取消管理员权限吗?"
onConfirm={() => handleSetAdmin(record.user_id, false)}
disabled={isSelf}
>
<Button size="small" disabled={isSelf}>
</Button>
</Popconfirm>
) : (
<Button
size="small"
type="primary"
onClick={() => handleSetAdmin(record.user_id, true)}
>
</Button>
)}
<Popconfirm
title="确定要删除该用户吗?此操作不可恢复!"
onConfirm={() => handleDeleteUser(record.user_id)}
disabled={isSelf}
>
<Button size="small" danger disabled={isSelf}>
</Button>
</Popconfirm>
</Space>
);
},
},
];
if (!currentUser) {
return null;
}
return (
<>
<Dropdown menu={{ items: menuItems }} placement="bottomRight">
<div
style={{
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 16px',
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)',
WebkitBackdropFilter: 'blur(10px)',
borderRadius: 24,
border: '1px solid rgba(102, 126, 234, 0.2)',
transition: 'all 0.3s ease',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}}
onMouseEnter={(e) => {
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)';
}}
>
<div style={{ position: 'relative' }}>
<Avatar
src={currentUser.avatar_url}
icon={<UserOutlined />}
size={40}
style={{
backgroundColor: '#1890ff',
border: '3px solid #fff',
boxShadow: '0 2px 8px rgba(102, 126, 234, 0.3)',
}}
/>
{currentUser.is_admin && (
<div style={{
position: 'absolute',
bottom: -2,
right: -2,
width: 18,
height: 18,
background: 'linear-gradient(135deg, #ffd700 0%, #ffaa00 100%)',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
border: '2px solid white',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.2)',
}}>
<CrownOutlined style={{ fontSize: 9, color: '#fff' }} />
</div>
)}
</div>
<Space direction="vertical" size={0} style={{ display: window.innerWidth <= 768 ? 'none' : 'flex' }}>
<Text strong style={{
color: '#262626',
fontSize: 14,
lineHeight: '20px',
}}>
{currentUser.display_name || currentUser.username}
</Text>
<Text style={{
color: '#8c8c8c',
fontSize: 12,
lineHeight: '18px',
}}>
{currentUser.is_admin ? '👑 管理员' : `🎖️ Trust Level ${currentUser.trust_level}`}
</Text>
</Space>
</div>
</Dropdown>
<Modal
title="用户管理"
open={showUserManagement}
onCancel={() => setShowUserManagement(false)}
footer={null}
width={900}
centered
styles={{
body: {
padding: 0,
display: 'flex',
flexDirection: 'column',
height: 'calc(100vh - 200px)',
}
}}
>
<div style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
}}>
<div style={{
flex: 1,
overflow: 'hidden',
padding: '0 12px',
display: 'flex',
flexDirection: 'column',
}}>
<Table
columns={columns}
dataSource={users.slice((currentPage - 1) * pageSize, currentPage * pageSize)}
rowKey="user_id"
loading={loading}
pagination={false}
scroll={{ x: 800, y: 'calc(100vh - 340px)' }}
sticky
/>
</div>
<div style={{
padding: '16px 24px',
borderTop: '1px solid #f0f0f0',
background: '#fff',
display: 'flex',
justifyContent: 'center',
flexShrink: 0,
}}>
<Pagination
current={currentPage}
pageSize={pageSize}
total={users.length}
showSizeChanger
showTotal={(total) => `${total} 个用户`}
pageSizeOptions={['10', '20', '50', '100']}
onChange={(page, newPageSize) => {
setCurrentPage(page);
setPageSize(newPageSize);
}}
/>
</div>
</div>
</Modal>
</>
);
}
+129
View File
@@ -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;
}
}
+15
View File
@@ -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(
<StrictMode>
<ConfigProvider locale={zhCN}>
<App />
</ConfigProvider>
</StrictMode>,
)
+97
View File
@@ -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 (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}>
<div style={{ textAlign: 'center' }}>
<Spin size="large" />
<div style={{ marginTop: 20, color: 'white', fontSize: 16 }}>
...
</div>
</div>
</div>
);
}
if (status === 'error') {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}>
<Result
status="error"
title="登录失败"
subTitle={errorMessage}
extra={
<Button type="primary" onClick={() => navigate('/login')}>
</Button>
}
style={{ background: 'white', padding: 40, borderRadius: 8 }}
/>
</div>
);
}
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}>
<Result
status="success"
title="登录成功"
subTitle="正在跳转..."
style={{ background: 'white', padding: 40, borderRadius: 8 }}
/>
</div>
);
}
+570
View File
@@ -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<string | null>(null);
const [form] = Form.useForm();
const [editorForm] = Form.useForm();
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
const contentTextAreaRef = useRef<any>(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: (
<div style={{ marginTop: 16 }}>
<p>AI将根据以下信息创作本章内容</p>
<ul>
<li></li>
<li></li>
<li></li>
<li><strong></strong></li>
</ul>
{previousChapters.length > 0 && (
<div style={{
marginTop: 16,
padding: 12,
background: '#f0f5ff',
borderRadius: 4,
border: '1px solid #adc6ff'
}}>
<div style={{ marginBottom: 8, fontWeight: 500, color: '#1890ff' }}>
📚 {previousChapters.length}
</div>
<div style={{ maxHeight: 150, overflowY: 'auto' }}>
{previousChapters.map(ch => (
<div key={ch.id} style={{ padding: '4px 0', fontSize: 13 }}>
{ch.chapter_number}{ch.title} ({ch.word_count || 0})
</div>
))}
</div>
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
💡 AI会参考这些章节内容
</div>
</div>
)}
<p style={{ color: '#ff4d4f', marginTop: 16, marginBottom: 0 }}>
</p>
</div>
),
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<string, string> = {
'draft': 'default',
'writing': 'processing',
'completed': 'success',
};
return colors[status] || 'default';
};
const getStatusText = (status: string) => {
const texts: Record<string, string> = {
'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 (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: '#fff',
padding: isMobile ? '12px 0' : '16px 0',
marginBottom: isMobile ? 12 : 16,
borderBottom: '1px solid #f0f0f0',
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
gap: isMobile ? 12 : 0,
justifyContent: 'space-between',
alignItems: isMobile ? 'stretch' : 'center'
}}>
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}></h2>
<Space direction={isMobile ? 'vertical' : 'horizontal'} style={{ width: isMobile ? '100%' : 'auto' }}>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={handleExport}
disabled={chapters.length === 0}
block={isMobile}
size={isMobile ? 'middle' : 'middle'}
>
TXT
</Button>
{!isMobile && <Tag color="blue">/</Tag>}
</Space>
</div>
<div style={{ flex: 1, overflowY: 'auto' }}>
{chapters.length === 0 ? (
<Empty description="还没有章节,开始创作吧!" />
) : (
<Card style={cardStyles.base}>
<List
dataSource={sortedChapters}
renderItem={(item) => (
<List.Item
style={{
padding: '16px 0',
borderRadius: 8,
transition: 'background 0.3s ease',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'flex-start' : 'center'
}}
actions={isMobile ? undefined : [
<Button
type="primary"
icon={<EditOutlined />}
onClick={() => handleOpenEditor(item.id)}
>
</Button>,
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleOpenModal(item.id)}
>
</Button>,
]}
>
<div style={{ width: '100%' }}>
<List.Item.Meta
avatar={!isMobile && <FileTextOutlined style={{ fontSize: 32, color: '#1890ff' }} />}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? 4 : 8, flexWrap: 'wrap', fontSize: isMobile ? 14 : 16 }}>
<span>{item.chapter_number}{item.title}</span>
<Tag color={getStatusColor(item.status)}>{getStatusText(item.status)}</Tag>
<Badge count={`${item.word_count || 0}`} style={{ backgroundColor: '#52c41a' }} />
{!canGenerateChapter(item) && (
<Tooltip title={getGenerateDisabledReason(item)}>
<Tag icon={<LockOutlined />} color="warning">
</Tag>
</Tooltip>
)}
</div>
}
description={
item.content ? (
<div style={{ marginTop: 8, color: 'rgba(0,0,0,0.65)', lineHeight: 1.6, fontSize: isMobile ? 12 : 14 }}>
{item.content.substring(0, isMobile ? 80 : 150)}
{item.content.length > (isMobile ? 80 : 150) && '...'}
</div>
) : (
<span style={{ color: 'rgba(0,0,0,0.45)', fontSize: isMobile ? 12 : 14 }}></span>
)
}
/>
{isMobile && (
<Space style={{ marginTop: 12, width: '100%', justifyContent: 'flex-end' }} wrap>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleOpenEditor(item.id)}
size="small"
title="编辑内容"
/>
<Button
type="text"
icon={<SettingOutlined />}
onClick={() => handleOpenModal(item.id)}
size="small"
title="修改信息"
/>
</Space>
)}
</div>
</List.Item>
)}
/>
</Card>
)}
</div>
<Modal
title={editingId ? '编辑章节信息' : '添加章节'}
open={isModalOpen}
onCancel={() => 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'
}
}}
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item
label="章节标题"
name="title"
tooltip="章节标题由大纲管理,建议在大纲页面统一修改"
>
<Input placeholder="输入章节标题" disabled />
</Form.Item>
<Form.Item
label="章节序号"
name="chapter_number"
tooltip="章节序号由大纲的顺序决定,无法修改。请在大纲页面使用上移/下移功能调整顺序"
>
<Input type="number" placeholder="章节排序序号" disabled />
</Form.Item>
<Form.Item label="状态" name="status">
<Select placeholder="选择状态">
<Select.Option value="draft">稿</Select.Option>
<Select.Option value="writing"></Select.Option>
<Select.Option value="completed"></Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Space style={{ float: 'right' }}>
<Button onClick={() => setIsModalOpen(false)}></Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
<Modal
title="编辑章节内容"
open={isEditorOpen}
onCancel={() => {
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}
>
<Form form={editorForm} layout="vertical" onFinish={handleEditorSubmit}>
<Form.Item
label="章节标题"
tooltip="章节标题由大纲统一管理,建议在大纲页面修改以保持一致性"
>
<Space.Compact style={{ width: '100%' }}>
<Form.Item
name="title"
noStyle
>
<Input size="large" disabled style={{ flex: 1 }} />
</Form.Item>
{editingId && (() => {
const currentChapter = chapters.find(c => c.id === editingId);
const canGenerate = currentChapter ? canGenerateChapter(currentChapter) : false;
const disabledReason = currentChapter ? getGenerateDisabledReason(currentChapter) : '';
return (
<Tooltip title={!canGenerate ? disabledReason : '根据大纲和前置章节内容创作'}>
<Button
type="primary"
icon={canGenerate ? <ThunderboltOutlined /> : <LockOutlined />}
onClick={() => currentChapter && showGenerateModal(currentChapter)}
loading={isContinuing}
disabled={!canGenerate}
danger={!canGenerate}
size="large"
style={{ fontWeight: 'bold' }}
>
{isMobile ? 'AI创作' : 'AI创作章节内容'}
</Button>
</Tooltip>
);
})()}
</Space.Compact>
</Form.Item>
<Form.Item label="章节内容" name="content">
<TextArea
ref={contentTextAreaRef}
rows={isMobile ? 12 : 20}
placeholder="开始写作..."
style={{ fontFamily: 'monospace', fontSize: isMobile ? 12 : 14 }}
disabled={isGenerating}
/>
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end', flexDirection: isMobile ? 'column' : 'row', alignItems: isMobile ? 'stretch' : 'center' }}>
<Space style={{ width: isMobile ? '100%' : 'auto' }}>
<Button
onClick={() => {
if (isGenerating) {
message.warning('AI正在创作中,请等待完成后再关闭');
return;
}
setIsEditorOpen(false);
}}
block={isMobile}
disabled={isGenerating}
>
</Button>
<Button
type="primary"
htmlType="submit"
block={isMobile}
disabled={isGenerating}
>
</Button>
</Space>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
}
+453
View File
@@ -0,0 +1,453 @@
import { useState, useEffect } from 'react';
import { Button, Modal, Form, Input, Select, message, Row, Col, Empty, Tabs, Divider, Typography, Space } from 'antd';
import { ThunderboltOutlined, UserOutlined, TeamOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { useCharacterSync } from '../store/hooks';
import { characterGridConfig } from '../components/CardStyles';
import { CharacterCard } from '../components/CharacterCard';
import type { Character, CharacterUpdate } from '../types';
import { characterApi } from '../services/api';
const { Title } = Typography;
const { TextArea } = Input;
export default function Characters() {
const { currentProject, characters } = useStore();
const [isGenerating, setIsGenerating] = useState(false);
const [activeTab, setActiveTab] = useState<'all' | 'character' | 'organization'>('all');
const [generateForm] = Form.useForm();
const [editForm] = Form.useForm();
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editingCharacter, setEditingCharacter] = useState<Character | null>(null);
const {
refreshCharacters,
deleteCharacter,
generateCharacter
} = useCharacterSync();
useEffect(() => {
if (currentProject?.id) {
refreshCharacters();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentProject?.id]);
if (!currentProject) return null;
const handleDeleteCharacter = async (id: string) => {
try {
await deleteCharacter(id);
message.success('删除成功');
} catch {
message.error('删除失败');
}
};
const handleGenerate = async (values: { name?: string; role_type: string; background?: string }) => {
try {
setIsGenerating(true);
await generateCharacter({
project_id: currentProject.id,
name: values.name,
role_type: values.role_type,
background: values.background,
});
message.success('AI生成角色成功');
Modal.destroyAll();
} catch {
message.error('AI生成失败');
} finally {
setIsGenerating(false);
}
};
const handleEditCharacter = (character: Character) => {
setEditingCharacter(character);
editForm.setFieldsValue(character);
setIsEditModalOpen(true);
};
const handleUpdateCharacter = async (values: CharacterUpdate) => {
if (!editingCharacter) return;
try {
await characterApi.updateCharacter(editingCharacter.id, values);
message.success('更新成功');
setIsEditModalOpen(false);
editForm.resetFields();
setEditingCharacter(null);
await refreshCharacters();
} catch {
message.error('更新失败');
}
};
const handleDeleteCharacterWrapper = (id: string) => {
handleDeleteCharacter(id);
};
const showGenerateModal = () => {
Modal.confirm({
title: 'AI生成角色',
width: 600,
centered: true,
content: (
<Form form={generateForm} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
label="角色名称"
name="name"
>
<Input placeholder="如:张三、李四(可选,AI会自动生成)" />
</Form.Item>
<Form.Item
label="角色定位"
name="role_type"
rules={[{ required: true, message: '请选择角色定位' }]}
>
<Select placeholder="选择角色定位">
<Select.Option value="protagonist"></Select.Option>
<Select.Option value="supporting"></Select.Option>
<Select.Option value="antagonist"></Select.Option>
</Select>
</Form.Item>
<Form.Item label="背景设定" name="background">
<TextArea rows={3} placeholder="简要描述角色背景和故事环境..." />
</Form.Item>
</Form>
),
okText: '生成',
cancelText: '取消',
onOk: async () => {
const values = await generateForm.validateFields();
await handleGenerate(values);
},
});
};
const characterList = characters.filter(c => !c.is_organization);
const organizationList = characters.filter(c => c.is_organization);
const getDisplayList = () => {
if (activeTab === 'character') return characterList;
if (activeTab === 'organization') return organizationList;
return characters;
};
const displayList = getDisplayList();
const isMobile = window.innerWidth <= 768;
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: '#fff',
padding: isMobile ? '12px 0' : '16px 0',
marginBottom: isMobile ? 12 : 16,
borderBottom: '1px solid #f0f0f0',
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
gap: isMobile ? 12 : 0,
justifyContent: 'space-between',
alignItems: isMobile ? 'stretch' : 'center'
}}>
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}></h2>
<Button
type="dashed"
icon={<ThunderboltOutlined />}
onClick={showGenerateModal}
loading={isGenerating}
block={isMobile}
>
AI生成角色
</Button>
</div>
{characters.length > 0 && (
<div style={{
position: 'sticky',
top: isMobile ? 60 : 72,
zIndex: 9,
backgroundColor: '#fff',
paddingBottom: 8,
borderBottom: '1px solid #f0f0f0',
}}>
<Tabs
activeKey={activeTab}
onChange={(key) => setActiveTab(key as 'all' | 'character' | 'organization')}
items={[
{
key: 'all',
label: `全部 (${characters.length})`,
},
{
key: 'character',
label: (
<span>
<UserOutlined /> ({characterList.length})
</span>
),
},
{
key: 'organization',
label: (
<span>
<TeamOutlined /> ({organizationList.length})
</span>
),
},
]}
/>
</div>
)}
<div style={{ flex: 1, overflowY: 'auto' }}>
{characters.length === 0 ? (
<Empty description="还没有角色或组织,开始创建吧!" />
) : (
<>
<Row gutter={isMobile ? [8, 8] : characterGridConfig.gutter}>
{activeTab === 'all' && (
<>
{characterList.length > 0 && (
<>
<Col span={24}>
<Divider orientation="left">
<Title level={5} style={{ margin: 0 }}>
<UserOutlined style={{ marginRight: 8 }} />
({characterList.length})
</Title>
</Divider>
</Col>
{characterList.map((character) => (
<Col
xs={24}
sm={characterGridConfig.sm}
md={characterGridConfig.md}
lg={characterGridConfig.lg}
xl={characterGridConfig.xl}
key={character.id}
style={{ padding: isMobile ? '4px' : '8px' }}
>
<CharacterCard
character={character}
onEdit={handleEditCharacter}
onDelete={handleDeleteCharacterWrapper}
/>
</Col>
))}
</>
)}
{organizationList.length > 0 && (
<>
<Col span={24}>
<Divider orientation="left">
<Title level={5} style={{ margin: 0 }}>
<TeamOutlined style={{ marginRight: 8 }} />
({organizationList.length})
</Title>
</Divider>
</Col>
{organizationList.map((org) => (
<Col
xs={24}
sm={characterGridConfig.sm}
md={characterGridConfig.md}
lg={characterGridConfig.lg}
xl={characterGridConfig.xl}
key={org.id}
style={{ padding: isMobile ? '4px' : '8px' }}
>
<CharacterCard
character={org}
onEdit={handleEditCharacter}
onDelete={handleDeleteCharacterWrapper}
/>
</Col>
))}
</>
)}
</>
)}
{activeTab === 'character' && characterList.map((character) => (
<Col
xs={24}
sm={characterGridConfig.sm}
md={characterGridConfig.md}
lg={characterGridConfig.lg}
xl={characterGridConfig.xl}
key={character.id}
style={{ padding: isMobile ? '4px' : '8px' }}
>
<CharacterCard
character={character}
onEdit={handleEditCharacter}
onDelete={handleDeleteCharacterWrapper}
/>
</Col>
))}
{activeTab === 'organization' && organizationList.map((org) => (
<Col
xs={24}
sm={characterGridConfig.sm}
md={characterGridConfig.md}
lg={characterGridConfig.lg}
xl={characterGridConfig.xl}
key={org.id}
style={{ padding: isMobile ? '4px' : '8px' }}
>
<CharacterCard
character={org}
onEdit={handleEditCharacter}
onDelete={handleDeleteCharacterWrapper}
/>
</Col>
))}
</Row>
{displayList.length === 0 && (
<Empty
description={
activeTab === 'character'
? '暂无角色'
: activeTab === 'organization'
? '暂无组织'
: '暂无数据'
}
/>
)}
</>
)}
</div>
<Modal
title={editingCharacter?.is_organization ? '编辑组织' : '编辑角色'}
open={isEditModalOpen}
onCancel={() => {
setIsEditModalOpen(false);
editForm.resetFields();
setEditingCharacter(null);
}}
footer={null}
centered={!isMobile}
width={isMobile ? '100%' : 600}
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
styles={isMobile ? { body: { maxHeight: 'calc(100vh - 110px)', overflowY: 'auto' } } : undefined}
>
<Form form={editForm} layout="vertical" onFinish={handleUpdateCharacter}>
<Row gutter={16}>
<Col span={editingCharacter?.is_organization ? 24 : 12}>
<Form.Item
label={editingCharacter?.is_organization ? '组织名称' : '角色名称'}
name="name"
rules={[{ required: true, message: `请输入${editingCharacter?.is_organization ? '组织' : '角色'}名称` }]}
>
<Input placeholder={`输入${editingCharacter?.is_organization ? '组织' : '角色'}名称`} />
</Form.Item>
</Col>
{!editingCharacter?.is_organization && (
<Col span={12}>
<Form.Item label="角色定位" name="role_type">
<Select>
<Select.Option value="protagonist"></Select.Option>
<Select.Option value="supporting"></Select.Option>
<Select.Option value="antagonist"></Select.Option>
</Select>
</Form.Item>
</Col>
)}
</Row>
{!editingCharacter?.is_organization && (
<>
<Row gutter={16}>
<Col span={12}>
<Form.Item label="年龄" name="age">
<Input placeholder="如:25、30岁" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="性别" name="gender">
<Select placeholder="选择性别">
<Select.Option value="男"></Select.Option>
<Select.Option value="女"></Select.Option>
<Select.Option value="其他"></Select.Option>
</Select>
</Form.Item>
</Col>
</Row>
<Form.Item label="性格特点" name="personality">
<TextArea rows={2} placeholder="描述角色的性格特点..." />
</Form.Item>
<Form.Item label="外貌描写" name="appearance">
<TextArea rows={2} placeholder="描述角色的外貌特征..." />
</Form.Item>
<Form.Item label="人际关系" name="relationships">
<TextArea rows={2} placeholder="描述角色与其他角色的关系..." />
</Form.Item>
</>
)}
{editingCharacter?.is_organization && (
<>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="组织类型"
name="organization_type"
rules={[{ required: true, message: '请输入组织类型' }]}
>
<Input placeholder="如:帮派、公司、门派、学院" />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="主要成员" name="organization_members">
<Input placeholder="如:张三、李四、王五" />
</Form.Item>
</Col>
</Row>
<Form.Item
label="组织目的"
name="organization_purpose"
rules={[{ required: true, message: '请输入组织目的' }]}
>
<TextArea rows={2} placeholder="描述组织的宗旨和目标..." />
</Form.Item>
</>
)}
<Form.Item label={editingCharacter?.is_organization ? '组织背景' : '角色背景'} name="background">
<TextArea rows={3} placeholder={`描述${editingCharacter?.is_organization ? '组织' : '角色'}的背景故事...`} />
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => {
setIsEditModalOpen(false);
editForm.resetFields();
setEditingCharacter(null);
}}>
</Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
}
+329
View File
@@ -0,0 +1,329 @@
import { useEffect, useState } from 'react';
import { Button, Card, Space, Typography, message, Spin, Form, Input, Tabs } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import { authApi } from '../services/api';
import { useNavigate, useSearchParams } from 'react-router-dom';
const { Title, Paragraph } = Typography;
export default function Login() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [loading, setLoading] = useState(false);
const [checking, setChecking] = useState(true);
const [localAuthEnabled, setLocalAuthEnabled] = useState(false);
const [linuxdoEnabled, setLinuxdoEnabled] = useState(false);
const [form] = Form.useForm();
// 检查是否已登录和获取认证配置
useEffect(() => {
const checkAuth = async () => {
try {
await authApi.getCurrentUser();
// 已登录,重定向到首页
const redirect = searchParams.get('redirect') || '/';
navigate(redirect);
} catch {
// 未登录,获取认证配置
try {
const config = await authApi.getAuthConfig();
setLocalAuthEnabled(config.local_auth_enabled);
setLinuxdoEnabled(config.linuxdo_enabled);
} catch (error) {
console.error('获取认证配置失败:', error);
// 默认显示LinuxDO登录
setLinuxdoEnabled(true);
}
setChecking(false);
}
};
checkAuth();
}, [navigate, searchParams]);
const handleLocalLogin = async (values: { username: string; password: string }) => {
try {
setLoading(true);
const response = await authApi.localLogin(values.username, values.password);
if (response.success) {
message.success('登录成功!');
const redirect = searchParams.get('redirect') || '/';
navigate(redirect);
}
} catch (error) {
console.error('本地登录失败:', error);
setLoading(false);
}
};
const handleLinuxDOLogin = async () => {
try {
setLoading(true);
const response = await authApi.getLinuxDOAuthUrl();
// 保存重定向地址到 sessionStorage
const redirect = searchParams.get('redirect');
if (redirect) {
sessionStorage.setItem('login_redirect', redirect);
}
// 跳转到 LinuxDO 授权页面
window.location.href = response.auth_url;
} catch (error) {
console.error('获取授权地址失败:', error);
message.error('获取授权地址失败,请稍后重试');
setLoading(false);
}
};
if (checking) {
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
}}>
<Spin size="large" style={{ color: '#fff' }} />
</div>
);
}
// 渲染本地登录表单
const renderLocalLogin = () => (
<Form
form={form}
onFinish={handleLocalLogin}
size="large"
style={{ marginTop: '24px' }}
>
<Form.Item
name="username"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input
prefix={<UserOutlined style={{ color: '#999' }} />}
placeholder="用户名"
autoComplete="username"
/>
</Form.Item>
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: '#999' }} />}
placeholder="密码"
autoComplete="current-password"
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0 }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
style={{
height: 48,
fontSize: 16,
fontWeight: 600,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
borderRadius: '12px',
boxShadow: '0 4px 16px rgba(102, 126, 234, 0.4)',
}}
>
</Button>
</Form.Item>
</Form>
);
// 渲染LinuxDO登录
const renderLinuxDOLogin = () => (
<div style={{ padding: '24px 0 8px' }}>
<Button
type="primary"
size="large"
icon={
<img
src="/favicon.ico"
alt="LinuxDO"
style={{
width: 20,
height: 20,
marginRight: 8,
verticalAlign: 'middle',
}}
/>
}
loading={loading}
onClick={handleLinuxDOLogin}
block
style={{
height: 52,
fontSize: 16,
fontWeight: 600,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
borderRadius: '12px',
boxShadow: '0 4px 16px rgba(102, 126, 234, 0.4)',
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = '0 6px 24px rgba(102, 126, 234, 0.5)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 16px rgba(102, 126, 234, 0.4)';
}}
>
使 LinuxDO
</Button>
</div>
);
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: '20px',
position: 'relative',
overflow: 'hidden',
}}>
{/* 装饰性背景元素 */}
<div style={{
position: 'absolute',
top: '-10%',
right: '-5%',
width: '400px',
height: '400px',
background: 'rgba(255, 255, 255, 0.1)',
borderRadius: '50%',
filter: 'blur(60px)',
}} />
<div style={{
position: 'absolute',
bottom: '-10%',
left: '-5%',
width: '350px',
height: '350px',
background: 'rgba(255, 255, 255, 0.08)',
borderRadius: '50%',
filter: 'blur(60px)',
}} />
<Card
style={{
width: '100%',
maxWidth: 420,
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.3), 0 0 0 1px rgba(255, 255, 255, 0.2)',
border: '1px solid rgba(255, 255, 255, 0.3)',
borderRadius: '16px',
position: 'relative',
zIndex: 1,
}}
bodyStyle={{
padding: '40px 32px',
}}
>
<Space direction="vertical" size="large" style={{ width: '100%', textAlign: 'center' }}>
{/* Logo区域 */}
<div style={{ marginBottom: '8px' }}>
<div style={{
width: '72px',
height: '72px',
margin: '0 auto 20px',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
borderRadius: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 8px 24px rgba(102, 126, 234, 0.4)',
}}>
<img
src="/logo.svg"
alt="Logo"
style={{
width: '48px',
height: '48px',
filter: 'brightness(0) invert(1)',
}}
/>
</div>
<Title level={2} style={{
marginBottom: 8,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent',
backgroundClip: 'text',
fontWeight: 700,
}}>
AI小说创作助手
</Title>
<Paragraph style={{
color: '#666',
fontSize: '14px',
marginBottom: 0,
}}>
{localAuthEnabled && linuxdoEnabled ? '选择登录方式' :
localAuthEnabled ? '使用账户密码登录' :
'使用 LinuxDO 账号登录'}
</Paragraph>
</div>
{/* 登录方式 */}
{localAuthEnabled && linuxdoEnabled ? (
<Tabs
defaultActiveKey="local"
centered
items={[
{
key: 'local',
label: '账户密码',
children: renderLocalLogin(),
},
{
key: 'linuxdo',
label: 'LinuxDO',
children: renderLinuxDOLogin(),
},
]}
/>
) : localAuthEnabled ? (
renderLocalLogin()
) : (
renderLinuxDOLogin()
)}
{/* 提示信息 */}
<div style={{
padding: '16px',
background: 'rgba(102, 126, 234, 0.08)',
borderRadius: '12px',
border: '1px solid rgba(102, 126, 234, 0.1)',
}}>
<Paragraph style={{
fontSize: 13,
color: '#666',
marginBottom: 0,
lineHeight: 1.6,
}}>
🎉
<br />
🔒
</Paragraph>
</div>
</Space>
</Card>
</div>
);
}
+450
View File
@@ -0,0 +1,450 @@
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { Card, Table, Tag, Button, Space, message, Modal, Form, Select, InputNumber, Input, Descriptions } from 'antd';
import { PlusOutlined, TeamOutlined, UserOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import axios from 'axios';
interface Organization {
id: string;
character_id: string;
name: string;
type: string;
purpose: string;
member_count: number;
power_level: number;
location?: string;
motto?: string;
}
interface OrganizationMember {
id: string;
character_id: string;
character_name: string;
position: string;
rank: number;
loyalty: number;
contribution: number;
status: string;
joined_at?: string;
}
interface Character {
id: string;
name: string;
is_organization: boolean;
}
export default function Organizations() {
const { projectId } = useParams<{ projectId: string }>();
const { currentProject } = useStore();
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [selectedOrg, setSelectedOrg] = useState<Organization | null>(null);
const [members, setMembers] = useState<OrganizationMember[]>([]);
const [characters, setCharacters] = useState<Character[]>([]);
const [loading, setLoading] = useState(false);
const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false);
const [form] = Form.useForm();
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth <= 768);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const loadOrganizations = useCallback(async () => {
setLoading(true);
try {
const res = await axios.get(`/api/organizations/project/${projectId}`);
setOrganizations(res.data);
if (res.data.length > 0 && !selectedOrg) {
setSelectedOrg(res.data[0]);
loadMembers(res.data[0].id);
}
} catch (error) {
message.error('加载组织列表失败');
console.error(error);
} finally {
setLoading(false);
}
}, [projectId, selectedOrg]);
const loadCharacters = useCallback(async () => {
try {
const res = await axios.get(`/api/characters?project_id=${projectId}`);
setCharacters(res.data.items || []);
} catch (error) {
console.error('加载角色列表失败', error);
}
}, [projectId]);
useEffect(() => {
if (projectId) {
loadOrganizations();
loadCharacters();
}
}, [projectId, loadOrganizations, loadCharacters]);
const loadMembers = async (orgId: string) => {
try {
const res = await axios.get(`/api/organizations/${orgId}/members`);
setMembers(res.data);
} catch (error) {
message.error('加载成员列表失败');
console.error(error);
}
};
const handleSelectOrganization = (org: Organization) => {
setSelectedOrg(org);
loadMembers(org.id);
};
const handleAddMember = async (values: Record<string, unknown>) => {
if (!selectedOrg) return;
try {
await axios.post(`/api/organizations/${selectedOrg.id}/members`, values);
message.success('成员添加成功');
setIsAddMemberModalOpen(false);
form.resetFields();
loadMembers(selectedOrg.id);
loadOrganizations(); // 刷新成员计数
} catch (error) {
message.error('添加成员失败');
console.error(error);
}
};
const handleRemoveMember = async (memberId: string) => {
Modal.confirm({
title: '确认移除',
content: '确定要移除该成员吗?',
centered: true,
okText: '移除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await axios.delete(`/api/organizations/members/${memberId}`);
message.success('成员移除成功');
if (selectedOrg) {
loadMembers(selectedOrg.id);
loadOrganizations(); // 刷新成员计数
}
} catch (error) {
message.error('移除失败');
console.error(error);
}
}
});
};
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
active: 'green',
retired: 'default',
expelled: 'red',
deceased: 'black'
};
return colors[status] || 'default';
};
const getStatusText = (status: string) => {
const texts: Record<string, string> = {
active: '在职',
retired: '退休',
expelled: '除名',
deceased: '已故'
};
return texts[status] || status;
};
const memberColumns = [
{
title: '姓名',
dataIndex: 'character_name',
key: 'name',
render: (name: string) => (
<Space>
<UserOutlined />
<span>{name}</span>
</Space>
),
width: isMobile ? 80 : undefined,
},
{
title: '职位',
dataIndex: 'position',
key: 'position',
render: (position: string, record: OrganizationMember) => (
<Tag color="blue">{position} {!isMobile && `(级别 ${record.rank})`}</Tag>
),
width: isMobile ? 80 : undefined,
},
...(!isMobile ? [
{
title: '忠诚度',
dataIndex: 'loyalty',
key: 'loyalty',
render: (loyalty: number) => (
<span style={{ color: loyalty >= 70 ? 'green' : loyalty >= 40 ? 'orange' : 'red' }}>
{loyalty}%
</span>
),
},
{
title: '贡献度',
dataIndex: 'contribution',
key: 'contribution',
render: (contribution: number) => `${contribution}%`,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => (
<Tag color={getStatusColor(status)}>{getStatusText(status)}</Tag>
),
},
{
title: '加入时间',
dataIndex: 'joined_at',
key: 'joined_at',
render: (time: string) => time || '-',
}
] : []),
{
title: '操作',
key: 'action',
render: (_: unknown, record: OrganizationMember) => (
<Space>
{!isMobile && (
<Button
type="link"
size="small"
icon={<EditOutlined />}
>
</Button>
)}
<Button
type="link"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => handleRemoveMember(record.id)}
>
{isMobile ? '删除' : '移除'}
</Button>
</Space>
),
width: isMobile ? 60 : undefined,
fixed: isMobile ? 'right' as const : undefined,
},
];
// 过滤掉已是成员的角色
const availableCharacters = characters.filter(
c => !c.is_organization && !members.some(m => m.character_id === c.id)
);
return (
<div>
<Card
title={
<Space wrap>
<TeamOutlined />
<span style={{ fontSize: isMobile ? 14 : 16 }}></span>
{!isMobile && <Tag color="blue">{currentProject?.title}</Tag>}
</Space>
}
>
<div style={{
display: isMobile ? 'flex' : 'grid',
flexDirection: isMobile ? 'column' : undefined,
gridTemplateColumns: isMobile ? undefined : '300px 1fr',
gap: isMobile ? '16px' : '24px',
maxHeight: isMobile ? 'calc(100vh - 200px)' : undefined,
overflowY: isMobile ? 'auto' : undefined
}}>
{/* 左侧:组织列表 */}
<div>
<Card
size="small"
title={`组织列表 (${organizations.length})`}
loading={loading}
>
<Space direction="vertical" style={{ width: '100%' }}>
{organizations.map(org => (
<Card
key={org.id}
size="small"
hoverable
style={{
cursor: 'pointer',
border: selectedOrg?.id === org.id ? '2px solid #1890ff' : '1px solid #d9d9d9'
}}
onClick={() => handleSelectOrganization(org)}
>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<strong>{org.name}</strong>
<Tag>{org.type}</Tag>
<div style={{ fontSize: '12px', color: '#666' }}>
: {org.member_count} | : {org.power_level}
</div>
</Space>
</Card>
))}
</Space>
</Card>
</div>
{/* 右侧:组织详情和成员 */}
<div style={{ minHeight: isMobile ? 'auto' : undefined }}>
{selectedOrg ? (
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Card title="组织详情" size="small">
<Descriptions column={isMobile ? 1 : 2} size="small">
<Descriptions.Item label="组织名称">{selectedOrg.name}</Descriptions.Item>
<Descriptions.Item label="类型">{selectedOrg.type}</Descriptions.Item>
<Descriptions.Item label="成员数量">{selectedOrg.member_count}</Descriptions.Item>
<Descriptions.Item label="势力等级">{selectedOrg.power_level}</Descriptions.Item>
{selectedOrg.location && (
<Descriptions.Item label="所在地">{selectedOrg.location}</Descriptions.Item>
)}
{selectedOrg.motto && (
<Descriptions.Item label="宗旨" span={2}>{selectedOrg.motto}</Descriptions.Item>
)}
<Descriptions.Item label="目标/宗旨" span={2}>
{selectedOrg.purpose}
</Descriptions.Item>
</Descriptions>
</Card>
<Card
title={`组织成员 (${members.length})`}
extra={
<Button
type="primary"
size="small"
icon={<PlusOutlined />}
onClick={() => setIsAddMemberModalOpen(true)}
disabled={availableCharacters.length === 0}
>
</Button>
}
>
<Table
columns={memberColumns}
dataSource={members}
rowKey="id"
pagination={isMobile ? { simple: true, pageSize: 10 } : false}
size="small"
scroll={isMobile ? { x: 'max-content', y: 400 } : undefined}
/>
</Card>
</Space>
) : (
<Card>
<div style={{ textAlign: 'center', padding: '40px', color: '#999' }}>
</div>
</Card>
)}
</div>
</div>
</Card>
{/* 添加成员模态框 */}
<Modal
title="添加组织成员"
open={isAddMemberModalOpen}
onCancel={() => {
setIsAddMemberModalOpen(false);
form.resetFields();
}}
footer={null}
centered={!isMobile}
width={isMobile ? '100%' : 500}
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
styles={isMobile ? { body: { maxHeight: 'calc(100vh - 110px)', overflowY: 'auto' } } : undefined}
>
<Form
form={form}
layout="vertical"
onFinish={handleAddMember}
>
<Form.Item
name="character_id"
label="选择角色"
rules={[{ required: true, message: '请选择角色' }]}
>
<Select
placeholder="选择要加入的角色"
showSearch
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={availableCharacters.map(c => ({
label: c.name,
value: c.id
}))}
/>
</Form.Item>
<Form.Item
name="position"
label="职位"
rules={[{ required: true, message: '请输入职位' }]}
>
<Input placeholder="如:掌门、长老、弟子" />
</Form.Item>
<Form.Item
name="rank"
label="职位等级"
initialValue={5}
tooltip="数字越大等级越高"
>
<InputNumber min={0} max={10} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="loyalty"
label="初始忠诚度"
initialValue={50}
>
<InputNumber min={0} max={100} style={{ width: '100%' }} addonAfter="%" />
</Form.Item>
<Form.Item
name="status"
label="状态"
initialValue="active"
>
<Select>
<Select.Option value="active"></Select.Option>
<Select.Option value="retired">退</Select.Option>
<Select.Option value="expelled"></Select.Option>
</Select>
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setIsAddMemberModalOpen(false)}></Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
}
+480
View File
@@ -0,0 +1,480 @@
import { useState, useEffect } from 'react';
import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag } from 'antd';
import { EditOutlined, DeleteOutlined, ThunderboltOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { useOutlineSync } from '../store/hooks';
import { cardStyles } from '../components/CardStyles';
const { TextArea } = Input;
export default function Outline() {
const { currentProject, outlines } = useStore();
const [isGenerating, setIsGenerating] = useState(false);
const [editForm] = Form.useForm();
const [generateForm] = Form.useForm();
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth <= 768);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// 使用同步 hooks(移除createOutline
const {
refreshOutlines,
updateOutline,
deleteOutline,
reorderOutlines,
generateOutlines
} = useOutlineSync();
// 初始加载大纲列表
useEffect(() => {
if (currentProject?.id) {
refreshOutlines();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentProject?.id]); // 只依赖 ID,不依赖函数
// 移除事件监听,避免无限循环
// Hook 内部已经更新了 store,不需要再次刷新
if (!currentProject) return null;
// 确保大纲按 order_index 排序
const sortedOutlines = [...outlines].sort((a, b) => a.order_index - b.order_index);
const handleOpenEditModal = (id: string) => {
const outline = outlines.find(o => o.id === id);
if (outline) {
editForm.setFieldsValue(outline);
Modal.confirm({
title: '编辑大纲',
width: 600,
centered: true,
content: (
<Form
form={editForm}
layout="vertical"
style={{ marginTop: 16 }}
>
<Form.Item
label="标题"
name="title"
rules={[{ required: true, message: '请输入标题' }]}
>
<Input placeholder="输入大纲标题" />
</Form.Item>
<Form.Item
label="内容"
name="content"
rules={[{ required: true, message: '请输入内容' }]}
>
<TextArea rows={6} placeholder="输入大纲内容..." />
</Form.Item>
</Form>
),
okText: '更新',
cancelText: '取消',
onOk: async () => {
const values = await editForm.validateFields();
try {
await updateOutline(id, values);
message.success('大纲更新成功');
} catch {
message.error('更新失败');
}
},
});
}
};
const handleDeleteOutline = async (id: string) => {
try {
await deleteOutline(id);
message.success('删除成功');
} catch {
message.error('删除失败');
}
};
const handleMoveUp = async (index: number) => {
if (index === 0) return;
const items = Array.from(sortedOutlines);
[items[index - 1], items[index]] = [items[index], items[index - 1]];
const newOrders = items.map((item, idx) => ({
id: item.id,
order_index: idx + 1
}));
try {
await reorderOutlines(newOrders);
message.success('上移成功');
} catch (error) {
message.error('调整失败');
console.error('重排序失败:', error);
}
};
const handleMoveDown = async (index: number) => {
if (index === sortedOutlines.length - 1) return;
const items = Array.from(sortedOutlines);
[items[index], items[index + 1]] = [items[index + 1], items[index]];
const newOrders = items.map((item, idx) => ({
id: item.id,
order_index: idx + 1
}));
try {
await reorderOutlines(newOrders);
message.success('下移成功');
} catch (error) {
message.error('调整失败');
console.error('重排序失败:', error);
}
};
interface GenerateFormValues {
theme?: string;
chapter_count?: number;
narrative_perspective?: string;
requirements?: string;
provider?: string;
model?: string;
mode?: 'auto' | 'new' | 'continue';
story_direction?: string;
plot_stage?: 'development' | 'climax' | 'ending';
keep_existing?: boolean;
}
const handleGenerate = async (values: GenerateFormValues) => {
try {
setIsGenerating(true);
// 如果是全新生成模式,keep_existing应该为false
const isNewMode = values.mode === 'new';
const result = await generateOutlines({
project_id: currentProject.id,
genre: currentProject.genre || '通用',
theme: values.theme || currentProject.theme || '',
chapter_count: values.chapter_count || 5,
narrative_perspective: values.narrative_perspective || currentProject.narrative_perspective || '第三人称',
target_words: currentProject.target_words || 100000,
requirements: values.requirements,
// 续写参数
mode: values.mode || 'auto',
story_direction: values.story_direction,
plot_stage: values.plot_stage || 'development',
keep_existing: !isNewMode, // 全新生成模式下不保留旧大纲
});
message.success(`成功生成 ${result.length} 条大纲`);
Modal.destroyAll();
// 刷新大纲列表,确保显示最新数据
await refreshOutlines();
} catch (error) {
console.error('AI生成失败:', error);
message.error('AI生成失败');
} finally {
setIsGenerating(false);
}
};
const showGenerateModal = () => {
const hasOutlines = outlines.length > 0;
const initialMode = hasOutlines ? 'continue' : 'new';
Modal.confirm({
title: hasOutlines ? (
<Space>
<span>AI生成/</span>
<Tag color="blue"> {outlines.length} </Tag>
</Space>
) : 'AI生成大纲',
width: 700,
centered: true,
content: (
<Form
form={generateForm}
layout="vertical"
style={{ marginTop: 16 }}
initialValues={{
mode: initialMode,
chapter_count: 5,
narrative_perspective: currentProject.narrative_perspective || '第三人称',
plot_stage: 'development',
keep_existing: true,
theme: currentProject.theme || '',
}}
>
{hasOutlines && (
<Form.Item
label="生成模式"
name="mode"
tooltip="自动判断:根据是否有大纲自动选择;全新生成:删除旧大纲重新生成;续写模式:基于已有大纲继续创作"
>
<Radio.Group buttonStyle="solid">
<Radio.Button value="auto"></Radio.Button>
<Radio.Button value="new"></Radio.Button>
<Radio.Button value="continue"></Radio.Button>
</Radio.Group>
</Form.Item>
)}
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.mode !== currentValues.mode}
>
{({ getFieldValue }) => {
const mode = getFieldValue('mode');
const isContinue = mode === 'continue' || (mode === 'auto' && hasOutlines);
// 续写模式不显示主题输入,使用项目原有主题
if (isContinue) {
return null;
}
// 全新生成模式需要输入主题
return (
<Form.Item
label="故事主题"
name="theme"
rules={[{ required: true, message: '请输入故事主题' }]}
>
<TextArea rows={3} placeholder="描述你的故事主题、核心设定和主要情节..." />
</Form.Item>
);
}}
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(prevValues, currentValues) => prevValues.mode !== currentValues.mode}
>
{({ getFieldValue }) => {
const mode = getFieldValue('mode');
const isContinue = mode === 'continue' || (mode === 'auto' && hasOutlines);
return (
<>
{isContinue && (
<>
<Form.Item
label="故事发展方向"
name="story_direction"
tooltip="告诉AI你希望故事接下来如何发展"
>
<TextArea
rows={3}
placeholder="例如:主角遇到新的挑战、引入新角色、揭示关键秘密等..."
/>
</Form.Item>
<Form.Item
label="情节阶段"
name="plot_stage"
tooltip="帮助AI理解当前故事所处的阶段"
>
<Select>
<Select.Option value="development"> - </Select.Option>
<Select.Option value="climax"> - </Select.Option>
<Select.Option value="ending"> - </Select.Option>
</Select>
</Form.Item>
</>
)}
<Form.Item
label={isContinue ? "续写章节数" : "章节数量"}
name="chapter_count"
rules={[{ required: true, message: '请输入章节数量' }]}
>
<Input
type="number"
min={1}
max={50}
placeholder={isContinue ? "建议5-10章" : "如:30"}
/>
</Form.Item>
<Form.Item
label="叙事视角"
name="narrative_perspective"
rules={[{ required: true, message: '请选择叙事视角' }]}
>
<Select>
<Select.Option value="第一人称"></Select.Option>
<Select.Option value="第三人称"></Select.Option>
<Select.Option value="全知视角"></Select.Option>
</Select>
</Form.Item>
<Form.Item label="其他要求" name="requirements">
<TextArea rows={2} placeholder="其他特殊要求(可选)" />
</Form.Item>
</>
);
}}
</Form.Item>
</Form>
),
okText: hasOutlines ? '开始续写' : '开始生成',
cancelText: '取消',
onOk: async () => {
const values = await generateForm.validateFields();
await handleGenerate(values);
},
});
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* 固定头部 */}
<div style={{
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: '#fff',
padding: isMobile ? '12px 0' : '16px 0',
marginBottom: isMobile ? 12 : 16,
borderBottom: '1px solid #f0f0f0',
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
gap: isMobile ? 12 : 0,
justifyContent: 'space-between',
alignItems: isMobile ? 'stretch' : 'center'
}}>
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}></h2>
<Button
type="primary"
icon={<ThunderboltOutlined />}
onClick={showGenerateModal}
loading={isGenerating}
block={isMobile}
>
{isMobile ? 'AI生成/续写' : 'AI生成/续写大纲'}
</Button>
</div>
{/* 可滚动内容区域 */}
<div style={{ flex: 1, overflowY: 'auto' }}>
{outlines.length === 0 ? (
<Empty description="还没有大纲,开始创建吧!" />
) : (
<Card style={cardStyles.base}>
<List
dataSource={sortedOutlines}
renderItem={(item, index) => (
<List.Item
style={{
padding: '16px 0',
borderRadius: 8,
transition: 'background 0.3s ease',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'flex-start' : 'center'
}}
actions={isMobile ? undefined : [
<Button
type="text"
icon={<ArrowUpOutlined />}
onClick={() => handleMoveUp(index)}
disabled={index === 0}
title="上移"
>
</Button>,
<Button
type="text"
icon={<ArrowDownOutlined />}
onClick={() => handleMoveDown(index)}
disabled={index === sortedOutlines.length - 1}
title="下移"
>
</Button>,
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleOpenEditModal(item.id)}
>
</Button>,
<Popconfirm
title="确定删除这条大纲吗?"
onConfirm={() => handleDeleteOutline(item.id)}
okText="确定"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>,
]}
>
<div style={{ width: '100%' }}>
<List.Item.Meta
title={
<span style={{ fontSize: isMobile ? 14 : 16 }}>
<span style={{ color: '#1890ff', marginRight: 8, fontWeight: 'bold' }}>
{item.order_index || '?'}
</span>
{item.title}
</span>
}
description={
<div style={{ fontSize: isMobile ? 12 : 14 }}>
{item.content}
</div>
}
/>
{/* 移动端:按钮显示在内容下方 */}
{isMobile && (
<Space style={{ marginTop: 12, width: '100%', justifyContent: 'flex-end' }} wrap>
<Button
type="text"
icon={<ArrowUpOutlined />}
onClick={() => handleMoveUp(index)}
disabled={index === 0}
size="small"
/>
<Button
type="text"
icon={<ArrowDownOutlined />}
onClick={() => handleMoveDown(index)}
disabled={index === sortedOutlines.length - 1}
size="small"
/>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleOpenEditModal(item.id)}
size="small"
/>
<Popconfirm
title="确定删除这条大纲吗?"
onConfirm={() => handleDeleteOutline(item.id)}
okText="确定"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />} size="small" />
</Popconfirm>
</Space>
)}
</div>
</List.Item>
)}
/>
</Card>
)}
</div>
</div>
);
}
+76
View File
@@ -0,0 +1,76 @@
import { useState } from 'react';
import { Card, Input, Button, message, Space } from 'antd';
import { ThunderboltOutlined } from '@ant-design/icons';
import { polishApi } from '../services/api';
const { TextArea } = Input;
export default function Polish() {
const [originalText, setOriginalText] = useState('');
const [polishedText, setPolishedText] = useState('');
const [loading, setLoading] = useState(false);
const handlePolish = async () => {
if (!originalText.trim()) {
message.warning('请输入要去味的文本');
return;
}
try {
setLoading(true);
const result = await polishApi.polishText({ text: originalText });
setPolishedText(result.polished_text);
message.success('AI去味完成');
} catch {
message.error('AI去味失败');
} finally {
setLoading(false);
}
};
const handleCopy = () => {
navigator.clipboard.writeText(polishedText);
message.success('已复制到剪贴板');
};
return (
<div>
<h2 style={{ marginBottom: 16 }}>AI去味工具</h2>
<p style={{ color: 'rgba(0,0,0,0.45)', marginBottom: 24 }}>
AI生成的文本变得更自然
</p>
<Space direction="vertical" style={{ width: '100%' }} size="large">
<Card title="原始文本" extra={
<Button
type="primary"
icon={<ThunderboltOutlined />}
onClick={handlePolish}
loading={loading}
>
</Button>
}>
<TextArea
rows={10}
placeholder="粘贴或输入需要去味的文本..."
value={originalText}
onChange={(e) => setOriginalText(e.target.value)}
/>
</Card>
{polishedText && (
<Card title="去味后文本" extra={
<Button onClick={handleCopy}></Button>
}>
<TextArea
rows={10}
value={polishedText}
readOnly
/>
</Card>
)}
</Space>
</div>
);
}
+417
View File
@@ -0,0 +1,417 @@
import { useEffect, useMemo, useState } from 'react';
import { useParams, useNavigate, Outlet, Link, useLocation } from 'react-router-dom';
import { Layout, Menu, Spin, Button, Statistic, Row, Col, Card, Drawer } from 'antd';
import {
ArrowLeftOutlined,
FileTextOutlined,
TeamOutlined,
BookOutlined,
// ToolOutlined,
GlobalOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
ApartmentOutlined,
BankOutlined,
} from '@ant-design/icons';
import { useStore } from '../store';
import { useCharacterSync, useOutlineSync, useChapterSync } from '../store/hooks';
import { projectApi } from '../services/api';
const { Header, Sider, Content } = Layout;
// 判断是否为移动端
const isMobile = () => window.innerWidth <= 768;
export default function ProjectDetail() {
const { projectId } = useParams<{ projectId: string }>();
const navigate = useNavigate();
const location = useLocation();
const [collapsed, setCollapsed] = useState(false);
const [drawerVisible, setDrawerVisible] = useState(false);
const [mobile, setMobile] = useState(isMobile());
// 监听窗口大小变化
useEffect(() => {
const handleResize = () => {
setMobile(isMobile());
if (!isMobile()) {
setDrawerVisible(false);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
const {
currentProject,
setCurrentProject,
clearProjectData,
loading,
setLoading,
outlines,
characters,
chapters,
} = useStore();
// 使用同步 hooks
const { refreshCharacters } = useCharacterSync();
const { refreshOutlines } = useOutlineSync();
const { refreshChapters } = useChapterSync();
useEffect(() => {
const loadProjectData = async (id: string) => {
try {
setLoading(true);
// 加载项目基本信息
const project = await projectApi.getProject(id);
setCurrentProject(project);
// 并行加载其他数据
await Promise.all([
refreshOutlines(id),
refreshCharacters(id),
refreshChapters(id),
]);
} catch (error) {
console.error('加载项目数据失败:', error);
} finally {
setLoading(false);
}
};
if (projectId) {
loadProjectData(projectId);
}
return () => {
clearProjectData();
};
}, [projectId, clearProjectData, setLoading, setCurrentProject, refreshOutlines, refreshCharacters, refreshChapters]);
// 移除事件监听,避免无限循环
// Hook 内部已经更新了 store,不需要再次刷新
const menuItems = [
{
key: 'world-setting',
icon: <GlobalOutlined />,
label: <Link to={`/project/${projectId}/world-setting`}></Link>,
},
{
key: 'characters',
icon: <TeamOutlined />,
label: <Link to={`/project/${projectId}/characters`}></Link>,
},
{
key: 'relationships',
icon: <ApartmentOutlined />,
label: <Link to={`/project/${projectId}/relationships`}></Link>,
},
{
key: 'organizations',
icon: <BankOutlined />,
label: <Link to={`/project/${projectId}/organizations`}></Link>,
},
{
key: 'outline',
icon: <FileTextOutlined />,
label: <Link to={`/project/${projectId}/outline`}></Link>,
},
{
key: 'chapters',
icon: <BookOutlined />,
label: <Link to={`/project/${projectId}/chapters`}></Link>,
},
// {
// key: 'polish',
// icon: <ToolOutlined />,
// label: <Link to={`/project/${projectId}/polish`}>AI去味</Link>,
// },
];
// 根据当前路径动态确定选中的菜单项
const selectedKey = useMemo(() => {
const path = location.pathname;
if (path.includes('/world-setting')) return 'world-setting';
if (path.includes('/relationships')) return 'relationships';
if (path.includes('/organizations')) return 'organizations';
if (path.includes('/outline')) return 'outline';
if (path.includes('/characters')) return 'characters';
if (path.includes('/chapters')) return 'chapters';
// if (path.includes('/polish')) return 'polish';
return 'world-setting'; // 默认选中世界设定
}, [location.pathname]);
if (loading || !currentProject) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh' }}>
<Spin size="large" />
</div>
);
}
// 渲染菜单内容
const renderMenu = () => (
<div style={{
flex: 1,
overflowY: 'auto',
overflowX: 'hidden'
}}>
<Menu
mode="inline"
selectedKeys={[selectedKey]}
style={{
borderRight: 0,
paddingTop: '16px'
}}
items={menuItems}
onClick={() => mobile && setDrawerVisible(false)}
/>
</div>
);
return (
<Layout style={{ minHeight: '100vh', height: '100vh', overflow: 'hidden' }}>
<Header style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: mobile ? '0 12px' : '0 24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 1000,
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
height: mobile ? 56 : 70
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', zIndex: 1 }}>
<Button
type="text"
icon={mobile ? <MenuUnfoldOutlined /> : (collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />)}
onClick={() => mobile ? setDrawerVisible(true) : setCollapsed(!collapsed)}
style={{
fontSize: mobile ? '18px' : '20px',
color: '#fff',
width: mobile ? '36px' : '40px',
height: mobile ? '36px' : '40px'
}}
/>
{!mobile && (
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/')}
style={{
fontSize: '16px',
color: '#fff',
height: '40px',
padding: '0 16px'
}}
>
</Button>
)}
</div>
<h2 style={{
margin: 0,
color: '#fff',
fontSize: mobile ? '16px' : '24px',
fontWeight: 600,
textShadow: '0 2px 4px rgba(0,0,0,0.1)',
position: mobile ? 'static' : 'absolute',
left: mobile ? 'auto' : '50%',
transform: mobile ? 'none' : 'translateX(-50%)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
flex: mobile ? 1 : 'none',
textAlign: mobile ? 'center' : 'left',
paddingLeft: mobile ? '8px' : '0',
paddingRight: mobile ? '8px' : '0'
}}>
{currentProject.title}
</h2>
{mobile && (
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/')}
style={{
fontSize: '14px',
color: '#fff',
height: '36px',
padding: '0 8px',
zIndex: 1
}}
>
</Button>
)}
{!mobile && (
<Row gutter={12} style={{ width: '450px', justifyContent: 'flex-end', zIndex: 1 }}>
<Col>
<Card
size="small"
style={{
background: 'rgba(255,255,255,0.95)',
borderRadius: '6px',
border: 'none',
minWidth: '80px',
textAlign: 'center',
padding: '4px 8px'
}}
styles={{ body: { padding: '8px' } }}
>
<Statistic
title={<span style={{ fontSize: '11px', color: '#666' }}></span>}
value={outlines.length}
suffix="条"
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#667eea' }}
/>
</Card>
</Col>
<Col>
<Card
size="small"
style={{
background: 'rgba(255,255,255,0.95)',
borderRadius: '6px',
border: 'none',
minWidth: '80px',
textAlign: 'center',
padding: '4px 8px'
}}
styles={{ body: { padding: '8px' } }}
>
<Statistic
title={<span style={{ fontSize: '11px', color: '#666' }}></span>}
value={characters.length}
suffix="个"
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#52c41a' }}
/>
</Card>
</Col>
<Col>
<Card
size="small"
style={{
background: 'rgba(255,255,255,0.95)',
borderRadius: '6px',
border: 'none',
minWidth: '80px',
textAlign: 'center',
padding: '4px 8px'
}}
styles={{ body: { padding: '8px' } }}
>
<Statistic
title={<span style={{ fontSize: '11px', color: '#666' }}></span>}
value={chapters.length}
suffix="章"
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#1890ff' }}
/>
</Card>
</Col>
<Col>
<Card
size="small"
style={{
background: 'rgba(255,255,255,0.95)',
borderRadius: '6px',
border: 'none',
minWidth: '80px',
textAlign: 'center',
padding: '4px 8px'
}}
styles={{ body: { padding: '8px' } }}
>
<Statistic
title={<span style={{ fontSize: '11px', color: '#666' }}></span>}
value={currentProject.current_words}
suffix="字"
valueStyle={{ fontSize: '16px', fontWeight: 600, color: '#fa8c16' }}
/>
</Card>
</Col>
</Row>
)}
</Header>
<Layout style={{ marginTop: mobile ? 56 : 70 }}>
{mobile ? (
<Drawer
title="导航菜单"
placement="left"
onClose={() => setDrawerVisible(false)}
open={drawerVisible}
width={280}
styles={{ body: { padding: 0, display: 'flex', flexDirection: 'column' } }}
>
{renderMenu()}
</Drawer>
) : (
<Sider
collapsible
collapsed={collapsed}
onCollapse={setCollapsed}
trigger={null}
width={220}
collapsedWidth={60}
style={{
background: '#fff',
position: 'fixed',
left: 0,
top: 70,
bottom: 0,
overflow: 'hidden',
boxShadow: '2px 0 12px rgba(0,0,0,0.08)',
transition: 'all 0.2s',
height: 'calc(100vh - 70px)'
}}
>
<div style={{
height: '100%',
display: 'flex',
flexDirection: 'column'
}}>
{renderMenu()}
</div>
</Sider>
)}
<Layout style={{
marginLeft: mobile ? 0 : (collapsed ? 60 : 220),
transition: 'all 0.2s'
}}>
<Content
style={{
background: '#f5f7fa',
padding: mobile ? 12 : 24,
height: mobile ? 'calc(100vh - 56px)' : 'calc(100vh - 70px)',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}
>
<div style={{
background: '#fff',
padding: mobile ? 12 : 24,
borderRadius: mobile ? '8px' : '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
height: '100%',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column'
}}>
<Outlet />
</div>
</Content>
</Layout>
</Layout>
</Layout>
);
}
+398
View File
@@ -0,0 +1,398 @@
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, Button, Empty, Modal, message, Spin, Row, Col, Statistic, Space, Tag, Progress, Typography, Tooltip, Badge } from 'antd';
import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { useProjectSync } from '../store/hooks';
import type { ReactNode } from 'react';
import { cardStyles, cardHoverHandlers, gridConfig } from '../components/CardStyles';
import UserMenu from '../components/UserMenu';
const { Title, Text, Paragraph } = Typography;
export default function ProjectList() {
const navigate = useNavigate();
const { projects, loading } = useStore();
const { refreshProjects, deleteProject } = useProjectSync();
useEffect(() => {
refreshProjects();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const handleVisibilityChange = () => {
if (!document.hidden) {
refreshProjects();
}
};
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleDelete = (id: string) => {
const isMobile = window.innerWidth <= 768;
Modal.confirm({
title: '确认删除',
content: '删除项目将同时删除所有相关数据,此操作不可恢复。确定要删除吗?',
okText: '确定',
cancelText: '取消',
okType: 'danger',
centered: true,
...(isMobile && {
style: { top: 'auto' }
}),
onOk: async () => {
try {
await deleteProject(id);
message.success('项目删除成功');
} catch {
message.error('删除项目失败');
}
},
});
};
const handleEnterProject = (id: string) => {
const project = projects.find(p => p.id === id);
if (project) {
console.log('项目信息:', {
id: project.id,
title: project.title,
wizard_status: project.wizard_status,
wizard_step: project.wizard_step
});
if (project.wizard_status === 'incomplete' || !project.wizard_status) {
console.log('向导未完成,跳转到向导页面');
navigate(`/wizard?projectId=${id}&step=${project.wizard_step || 0}`);
} else {
console.log('向导已完成,进入项目管理界面');
navigate(`/project/${id}`);
}
}
};
const getStatusTag = (status: string) => {
const statusConfig: Record<string, { color: string; text: string; icon: ReactNode }> = {
planning: { color: 'blue', text: '规划中', icon: <CalendarOutlined /> },
writing: { color: 'green', text: '创作中', icon: <EditOutlined /> },
revising: { color: 'orange', text: '修改中', icon: <FileTextOutlined /> },
completed: { color: 'purple', text: '已完成', icon: <TrophyOutlined /> },
};
const config = statusConfig[status] || statusConfig.planning;
return (
<Tag color={config.color} icon={config.icon}>
{config.text}
</Tag>
);
};
const getProgress = (current: number, target: number) => {
if (!target) return 0;
return Math.min(Math.round((current / target) * 100), 100);
};
const getProgressColor = (progress: number) => {
if (progress >= 80) return '#52c41a';
if (progress >= 50) return '#1890ff';
if (progress >= 20) return '#faad14';
return '#ff4d4f';
};
const formatDate = (dateString: string) => {
const date = new Date(dateString);
const now = new Date();
const diff = now.getTime() - date.getTime();
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (days === 0) return '今天';
if (days === 1) return '昨天';
if (days < 7) return `${days}天前`;
if (days < 30) return `${Math.floor(days / 7)}周前`;
return date.toLocaleDateString('zh-CN');
};
const totalWords = projects.reduce((sum, p) => sum + (p.current_words || 0), 0);
const activeProjects = projects.filter(p => p.status === 'writing').length;
return (
<div style={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: window.innerWidth <= 768 ? '20px 16px' : '40px 24px'
}}>
<div style={{
maxWidth: 1400,
margin: '0 auto',
marginBottom: window.innerWidth <= 768 ? 20 : 40
}}>
<Card
variant="borderless"
style={{
background: 'rgba(255, 255, 255, 0.95)',
borderRadius: window.innerWidth <= 768 ? 12 : 16,
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.1)',
}}
>
<Row align="middle" justify="space-between" gutter={[16, 16]}>
<Col xs={24} sm={12} md={10}>
<Space direction="vertical" size={4}>
<Title level={window.innerWidth <= 768 ? 3 : 2} style={{ margin: 0 }}>
<FireOutlined style={{ color: '#ff4d4f', marginRight: 8 }} />
</Title>
<Text type="secondary" style={{ fontSize: window.innerWidth <= 768 ? 12 : 14 }}>
</Text>
</Space>
</Col>
<Col xs={24} sm={12} md={14} style={{ display: 'flex', justifyContent: window.innerWidth <= 768 ? 'space-between' : 'flex-end', alignItems: 'center', gap: 16 }}>
<Button
type="primary"
size={window.innerWidth <= 768 ? 'middle' : 'large'}
icon={<RocketOutlined />}
onClick={() => navigate('/wizard')}
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none'
}}
>
</Button>
<UserMenu />
</Col>
</Row>
{projects.length > 0 && (
<Row gutter={[16, 16]} style={{ marginTop: window.innerWidth <= 768 ? 16 : 24 }}>
<Col xs={24} sm={8}>
<Card variant="borderless" style={{ background: '#f0f5ff', borderRadius: 12 }}>
<Statistic
title={<span style={{ fontSize: window.innerWidth <= 768 ? 12 : 14, color: '#595959' }}></span>}
value={projects.length}
prefix={<BookOutlined style={{ color: '#1890ff' }} />}
suffix="个"
valueStyle={{ color: '#1890ff', fontSize: window.innerWidth <= 768 ? 20 : 28, fontWeight: 'bold' }}
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card variant="borderless" style={{ background: '#f6ffed', borderRadius: 12 }}>
<Statistic
title={<span style={{ fontSize: window.innerWidth <= 768 ? 12 : 14, color: '#595959' }}></span>}
value={activeProjects}
prefix={<EditOutlined style={{ color: '#52c41a' }} />}
suffix="个"
valueStyle={{ color: '#52c41a', fontSize: window.innerWidth <= 768 ? 20 : 28, fontWeight: 'bold' }}
/>
</Card>
</Col>
<Col xs={24} sm={8}>
<Card variant="borderless" style={{ background: '#fff7e6', borderRadius: 12 }}>
<Statistic
title={<span style={{ fontSize: window.innerWidth <= 768 ? 12 : 14, color: '#595959' }}></span>}
value={totalWords}
prefix={<FileTextOutlined style={{ color: '#faad14' }} />}
suffix="字"
valueStyle={{ color: '#faad14', fontSize: window.innerWidth <= 768 ? 20 : 28, fontWeight: 'bold' }}
/>
</Card>
</Col>
</Row>
)}
</Card>
</div>
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
<Spin spinning={loading}>
{!Array.isArray(projects) || projects.length === 0 ? (
<Card
variant="borderless"
style={{
background: 'rgba(255, 255, 255, 0.95)',
borderRadius: 16,
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.1)',
}}
>
<Empty
description={
<Space direction="vertical" size={16}>
<Text style={{ fontSize: 16, color: '#8c8c8c' }}>
</Text>
<Button
type="primary"
size="large"
icon={<RocketOutlined />}
onClick={() => navigate('/wizard')}
>
</Button>
</Space>
}
style={{ padding: '80px 0' }}
/>
</Card>
) : (
<Row gutter={[16, 16]}>
{projects.map((project) => {
const progress = getProgress(project.current_words, project.target_words || 0);
const isWizardComplete = project.wizard_status === 'completed';
return (
<Col {...gridConfig} key={project.id}>
<Badge.Ribbon
text={isWizardComplete ? getStatusTag(project.status) : <Tag color="orange" icon={<RocketOutlined />}></Tag>}
color="transparent"
style={{ top: 12, right: 12 }}
>
<Card
hoverable
variant="borderless"
onClick={() => handleEnterProject(project.id)}
style={cardStyles.project}
styles={{ body: { padding: 0, overflow: 'hidden' } }}
{...cardHoverHandlers}
>
<div style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: window.innerWidth <= 768 ? '16px' : '24px',
position: 'relative'
}}>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: window.innerWidth <= 768 ? 8 : 12 }}>
<BookOutlined style={{ fontSize: window.innerWidth <= 768 ? 20 : 28, color: '#fff' }} />
<Title level={window.innerWidth <= 768 ? 5 : 4} style={{ margin: 0, color: '#fff', flex: 1 }} ellipsis>
{project.title}
</Title>
</div>
{project.genre && (
<Tag color="rgba(255,255,255,0.3)" style={{ color: '#fff', border: 'none' }}>
{project.genre}
</Tag>
)}
</Space>
</div>
<div style={{ padding: window.innerWidth <= 768 ? '16px' : '20px' }}>
<Paragraph
ellipsis={{ rows: 2 }}
style={{
color: 'rgba(0,0,0,0.65)',
minHeight: 44,
marginBottom: 16
}}
>
{project.description || '暂无描述'}
</Paragraph>
{isWizardComplete ? (
<>
{project.target_words && project.target_words > 0 && (
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
<Text strong style={{ fontSize: 12 }}>{progress}%</Text>
</div>
<Progress
percent={progress}
strokeColor={getProgressColor(progress)}
showInfo={false}
size={{ height: 8 }}
/>
</div>
)}
<Row gutter={12}>
<Col span={12}>
<div style={{
textAlign: 'center',
padding: '12px 0',
background: '#f5f5f5',
borderRadius: 8
}}>
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#1890ff' }}>
{(project.current_words / 1000).toFixed(1)}K
</div>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
</div>
</Col>
<Col span={12}>
<div style={{
textAlign: 'center',
padding: '12px 0',
background: '#f5f5f5',
borderRadius: 8
}}>
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#52c41a' }}>
{project.target_words ? (project.target_words / 1000).toFixed(0) + 'K' : '--'}
</div>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
</div>
</Col>
</Row>
</>
) : (
<div style={{
textAlign: 'center',
padding: '24px 0',
background: '#f5f5f5',
borderRadius: 8
}}>
<RocketOutlined style={{ fontSize: 32, color: '#faad14', marginBottom: 12 }} />
<div style={{ color: '#faad14', fontWeight: 'bold', marginBottom: 4 }}>
</div>
<Text type="secondary" style={{ fontSize: 12 }}>
</Text>
</div>
)}
<div style={{
marginTop: 16,
paddingTop: 16,
borderTop: '1px solid #f0f0f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<Text type="secondary" style={{ fontSize: 12 }}>
<CalendarOutlined style={{ marginRight: 4 }} />
{formatDate(project.updated_at)}
</Text>
<Space size={8}>
<Tooltip title="删除">
<Button
type="text"
size="small"
danger
icon={<DeleteOutlined />}
onClick={(e) => {
e.stopPropagation();
handleDelete(project.id);
}}
/>
</Tooltip>
</Space>
</div>
</div>
</Card>
</Badge.Ribbon>
</Col>
);
})}
</Row>
)}
</Spin>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
+455
View File
@@ -0,0 +1,455 @@
import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { Card, Table, Tag, Button, Space, message, Modal, Form, Select, Slider, Input, Tabs } from 'antd';
import { PlusOutlined, TeamOutlined, UserOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import axios from 'axios';
const { TextArea } = Input;
interface Relationship {
id: string;
character_from_id: string;
character_to_id: string;
relationship_name: string;
intimacy_level: number;
status: string;
description?: string;
source: string;
}
interface RelationshipType {
id: number;
name: string;
category: string;
reverse_name?: string;
icon?: string;
}
interface Character {
id: string;
name: string;
is_organization: boolean;
}
export default function Relationships() {
const { projectId } = useParams<{ projectId: string }>();
const { currentProject } = useStore();
const [relationships, setRelationships] = useState<Relationship[]>([]);
const [relationshipTypes, setRelationshipTypes] = useState<RelationshipType[]>([]);
const [characters, setCharacters] = useState<Character[]>([]);
const [loading, setLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const [form] = Form.useForm();
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
const [pageSize, setPageSize] = useState(10);
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth <= 768);
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
useEffect(() => {
if (projectId) {
loadData();
}
}, [projectId]);
const loadData = async () => {
setLoading(true);
try {
const [relsRes, typesRes, charsRes] = await Promise.all([
axios.get(`/api/relationships/project/${projectId}`),
axios.get('/api/relationships/types'),
axios.get(`/api/characters?project_id=${projectId}`)
]);
setRelationships(relsRes.data);
setRelationshipTypes(typesRes.data);
setCharacters(charsRes.data.items || []);
} catch (error) {
message.error('加载数据失败');
console.error(error);
} finally {
setLoading(false);
}
};
const handleCreateRelationship = async (values: {
character_from_id: string;
character_to_id: string;
relationship_name: string;
intimacy_level: number;
status: string;
description?: string;
}) => {
try {
await axios.post('/api/relationships/', {
project_id: projectId,
...values
});
message.success('关系创建成功');
setIsModalOpen(false);
form.resetFields();
loadData();
} catch (error) {
message.error('创建关系失败');
console.error(error);
}
};
const handleDeleteRelationship = async (id: string) => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这条关系吗?',
centered: true,
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await axios.delete(`/api/relationships/${id}`);
message.success('关系删除成功');
loadData();
} catch (error) {
message.error('删除失败');
console.error(error);
}
}
});
};
const getCharacterName = (id: string) => {
const char = characters.find(c => c.id === id);
return char?.name || '未知';
};
const getIntimacyColor = (level: number) => {
if (level >= 75) return 'green';
if (level >= 50) return 'blue';
if (level >= 25) return 'orange';
return 'red';
};
const getStatusColor = (status: string) => {
const colors: Record<string, string> = {
active: 'green',
broken: 'red',
past: 'default',
complicated: 'orange'
};
return colors[status] || 'default';
};
const getCategoryColor = (category: string) => {
const colors: Record<string, string> = {
family: 'magenta',
social: 'blue',
hostile: 'red',
professional: 'cyan'
};
return colors[category] || 'default';
};
const columns = [
{
title: '角色A',
dataIndex: 'character_from_id',
key: 'from',
render: (id: string) => (
<Tag icon={<UserOutlined />} color="blue">
{getCharacterName(id)}
</Tag>
),
width: 120,
},
{
title: '关系',
dataIndex: 'relationship_name',
key: 'relationship',
render: (name: string) => <strong>{name}</strong>,
width: 120,
},
{
title: '角色B',
dataIndex: 'character_to_id',
key: 'to',
render: (id: string) => (
<Tag icon={<UserOutlined />} color="purple">
{getCharacterName(id)}
</Tag>
),
width: 120,
},
{
title: '亲密度',
dataIndex: 'intimacy_level',
key: 'intimacy',
render: (level: number) => (
<Tag color={getIntimacyColor(level)}>{level}</Tag>
),
width: 80,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
render: (status: string) => (
<Tag color={getStatusColor(status)}>{status}</Tag>
),
width: 80,
},
{
title: '来源',
dataIndex: 'source',
key: 'source',
render: (source: string) => (
<Tag>{source === 'ai' ? 'AI生成' : '手动创建'}</Tag>
),
width: 100,
},
{
title: '操作',
key: 'action',
render: (_: unknown, record: Relationship) => (
<Button
type="link"
danger
size="small"
onClick={() => handleDeleteRelationship(record.id)}
>
</Button>
),
width: 80,
fixed: isMobile ? ('right' as const) : undefined,
},
];
// 按类别分组关系类型
const groupedTypes = relationshipTypes.reduce((acc, type) => {
if (!acc[type.category]) {
acc[type.category] = [];
}
acc[type.category].push(type);
return acc;
}, {} as Record<string, RelationshipType[]>);
const categoryLabels: Record<string, string> = {
family: '家族关系',
social: '社交关系',
professional: '职业关系',
hostile: '敌对关系'
};
return (
<div>
<Card
title={
<Space wrap>
<TeamOutlined />
<span style={{ fontSize: isMobile ? 14 : 16 }}></span>
{!isMobile && <Tag color="blue">{currentProject?.title}</Tag>}
</Space>
}
extra={
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => setIsModalOpen(true)}
size={isMobile ? 'small' : 'middle'}
>
{isMobile ? '添加' : '添加关系'}
</Button>
}
>
<Tabs
items={[
{
key: 'list',
label: `关系列表 (${relationships.length})`,
children: (
<Table
columns={columns}
dataSource={relationships}
rowKey="id"
loading={loading}
pagination={{
current: currentPage,
pageSize: isMobile ? 10 : pageSize,
pageSizeOptions: ['10', '20', '50', '100'],
position: ['bottomCenter'],
showSizeChanger: !isMobile,
showQuickJumper: !isMobile,
showTotal: (total) => `${total}`,
simple: isMobile,
onChange: (page, size) => {
setCurrentPage(page);
if (size !== pageSize) {
setPageSize(size);
setCurrentPage(1); // 切换每页条数时重置到第一页
}
},
onShowSizeChange: (_, size) => {
setPageSize(size);
setCurrentPage(1);
}
}}
scroll={{
x: 700,
y: isMobile ? 'calc(100vh - 360px)' : 'calc(100vh - 440px)'
}}
size={isMobile ? 'small' : 'middle'}
/>
),
},
{
key: 'types',
label: `关系类型 (${relationshipTypes.length})`,
children: (
<div style={{
display: 'grid',
gridTemplateColumns: isMobile ? '1fr' : 'repeat(auto-fill, minmax(200px, 1fr))',
gap: isMobile ? '12px' : '16px',
maxHeight: isMobile ? 'calc(100vh - 400px)' : 'calc(100vh - 350px)',
overflow: 'auto'
}}>
{Object.entries(groupedTypes).map(([category, types]) => (
<Card
key={category}
size="small"
title={categoryLabels[category] || category}
headStyle={{ backgroundColor: '#f5f5f5' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{types.map(type => (
<Tag key={type.id} color={getCategoryColor(category)}>
{type.icon} {type.name}
{type.reverse_name && `${type.reverse_name}`}
</Tag>
))}
</Space>
</Card>
))}
</div>
),
},
]}
/>
</Card>
<Modal
title="添加关系"
open={isModalOpen}
onCancel={() => {
setIsModalOpen(false);
form.resetFields();
}}
footer={null}
centered={!isMobile}
width={isMobile ? '100%' : 600}
style={isMobile ? { top: 0, paddingBottom: 0, maxWidth: '100vw' } : undefined}
styles={isMobile ? { body: { maxHeight: 'calc(100vh - 110px)', overflowY: 'auto' } } : undefined}
>
<Form
form={form}
layout="vertical"
onFinish={handleCreateRelationship}
>
<Form.Item
name="character_from_id"
label="角色A"
rules={[{ required: true, message: '请选择角色A' }]}
>
<Select
placeholder="选择角色"
showSearch
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={characters
.filter(c => !c.is_organization)
.map(c => ({ label: c.name, value: c.id }))}
/>
</Form.Item>
<Form.Item
name="relationship_name"
label="关系类型"
rules={[{ required: true, message: '请选择或输入关系类型' }]}
>
<Select
placeholder="选择预定义类型或输入自定义"
showSearch
allowClear
options={relationshipTypes.map(t => ({
label: `${t.icon || ''} ${t.name} (${categoryLabels[t.category]})`,
value: t.name
}))}
/>
</Form.Item>
<Form.Item
name="character_to_id"
label="角色B"
rules={[{ required: true, message: '请选择角色B' }]}
>
<Select
placeholder="选择角色"
showSearch
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={characters
.filter(c => !c.is_organization)
.map(c => ({ label: c.name, value: c.id }))}
/>
</Form.Item>
<Form.Item
name="intimacy_level"
label="亲密度"
initialValue={50}
>
<Slider
min={0}
max={100}
marks={{ 0: '0', 25: '25', 50: '50', 75: '75', 100: '100' }}
/>
</Form.Item>
<Form.Item
name="status"
label="状态"
initialValue="active"
>
<Select>
<Select.Option value="active"></Select.Option>
<Select.Option value="broken"></Select.Option>
<Select.Option value="past"></Select.Option>
<Select.Option value="complicated"></Select.Option>
</Select>
</Form.Item>
<Form.Item name="description" label="关系描述">
<TextArea rows={3} placeholder="描述这段关系的细节..." />
</Form.Item>
<Form.Item>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setIsModalOpen(false)}></Button>
<Button type="primary" htmlType="submit">
</Button>
</Space>
</Form.Item>
</Form>
</Modal>
</div>
);
}
+187
View File
@@ -0,0 +1,187 @@
import { Card, Descriptions, Empty, Typography } from 'antd';
import { GlobalOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { cardStyles } from '../components/CardStyles';
const { Title, Paragraph } = Typography;
export default function WorldSetting() {
const { currentProject } = useStore();
if (!currentProject) return null;
// 检查是否有世界设定信息
const hasWorldSetting = currentProject.world_time_period ||
currentProject.world_location ||
currentProject.world_atmosphere ||
currentProject.world_rules;
if (!hasWorldSetting) {
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* 固定头部 */}
<div style={{
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: '#fff',
padding: '16px 0',
marginBottom: 16,
borderBottom: '1px solid #f0f0f0',
display: 'flex',
alignItems: 'center'
}}>
<GlobalOutlined style={{ fontSize: 24, marginRight: 12, color: '#1890ff' }} />
<h2 style={{ margin: 0 }}></h2>
</div>
{/* 可滚动内容区域 */}
<div style={{ flex: 1, overflowY: 'auto' }}>
<Empty
description="暂无世界设定信息"
style={{ marginTop: 60 }}
>
<Paragraph type="secondary">
</Paragraph>
</Empty>
</div>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* 固定头部 */}
<div style={{
position: 'sticky',
top: 0,
zIndex: 10,
backgroundColor: '#fff',
padding: '16px 0',
marginBottom: 24,
borderBottom: '1px solid #f0f0f0',
display: 'flex',
alignItems: 'center'
}}>
<GlobalOutlined style={{ fontSize: 24, marginRight: 12, color: '#1890ff' }} />
<h2 style={{ margin: 0 }}></h2>
</div>
{/* 可滚动内容区域 */}
<div style={{ flex: 1, overflowY: 'auto' }}>
<Card
style={{
...cardStyles.base,
marginBottom: 16
}}
title={
<span style={{ fontSize: 18, fontWeight: 500 }}>
</span>
}
>
<Descriptions bordered column={1} styles={{ label: { width: 120, fontWeight: 500 } }}>
<Descriptions.Item label="小说名称">{currentProject.title}</Descriptions.Item>
{currentProject.description && (
<Descriptions.Item label="小说简介">{currentProject.description}</Descriptions.Item>
)}
<Descriptions.Item label="小说主题">{currentProject.theme || '未设定'}</Descriptions.Item>
<Descriptions.Item label="小说类型">{currentProject.genre || '未设定'}</Descriptions.Item>
<Descriptions.Item label="叙事视角">{currentProject.narrative_perspective || '未设定'}</Descriptions.Item>
<Descriptions.Item label="目标字数">
{currentProject.target_words ? `${currentProject.target_words.toLocaleString()}` : '未设定'}
</Descriptions.Item>
</Descriptions>
</Card>
<Card
style={{
...cardStyles.base,
marginBottom: 16
}}
title={
<span style={{ fontSize: 18, fontWeight: 500 }}>
<GlobalOutlined style={{ marginRight: 8 }} />
</span>
}
>
<div style={{ padding: '16px 0' }}>
{currentProject.world_time_period && (
<div style={{ marginBottom: 24 }}>
<Title level={5} style={{ color: '#1890ff', marginBottom: 12 }}>
</Title>
<Paragraph style={{
fontSize: 15,
lineHeight: 1.8,
padding: 16,
background: '#f5f5f5',
borderRadius: 8,
borderLeft: '4px solid #1890ff'
}}>
{currentProject.world_time_period}
</Paragraph>
</div>
)}
{currentProject.world_location && (
<div style={{ marginBottom: 24 }}>
<Title level={5} style={{ color: '#52c41a', marginBottom: 12 }}>
</Title>
<Paragraph style={{
fontSize: 15,
lineHeight: 1.8,
padding: 16,
background: '#f5f5f5',
borderRadius: 8,
borderLeft: '4px solid #52c41a'
}}>
{currentProject.world_location}
</Paragraph>
</div>
)}
{currentProject.world_atmosphere && (
<div style={{ marginBottom: 24 }}>
<Title level={5} style={{ color: '#faad14', marginBottom: 12 }}>
</Title>
<Paragraph style={{
fontSize: 15,
lineHeight: 1.8,
padding: 16,
background: '#f5f5f5',
borderRadius: 8,
borderLeft: '4px solid #faad14'
}}>
{currentProject.world_atmosphere}
</Paragraph>
</div>
)}
{currentProject.world_rules && (
<div style={{ marginBottom: 0 }}>
<Title level={5} style={{ color: '#f5222d', marginBottom: 12 }}>
</Title>
<Paragraph style={{
fontSize: 15,
lineHeight: 1.8,
padding: 16,
background: '#f5f5f5',
borderRadius: 8,
borderLeft: '4px solid #f5222d'
}}>
{currentProject.world_rules}
</Paragraph>
</div>
)}
</div>
</Card>
</div>
</div>
);
}
+301
View File
@@ -0,0 +1,301 @@
import axios from 'axios';
import { message } from 'antd';
import { ssePost } from '../utils/sseClient';
import type { SSEClientOptions } from '../utils/sseClient';
import type {
User,
AuthUrlResponse,
Project,
ProjectCreate,
ProjectUpdate,
WorldBuildingResponse,
Outline,
OutlineCreate,
OutlineUpdate,
OutlineReorderRequest,
Character,
CharacterUpdate,
Chapter,
ChapterCreate,
ChapterUpdate,
GenerateOutlineRequest,
GenerateCharacterRequest,
PolishTextRequest,
GenerateCharactersResponse,
GenerateOutlineResponse,
} from '../types';
const api = axios.create({
baseURL: '/api',
timeout: 120000,
headers: {
'Content-Type': 'application/json',
},
withCredentials: true,
});
api.interceptors.request.use(
(config) => {
return config;
},
(error) => {
return Promise.reject(error);
}
);
api.interceptors.response.use(
(response) => {
return response.data;
},
(error) => {
let errorMessage = '请求失败';
if (error.response) {
const status = error.response.status;
const data = error.response.data;
switch (status) {
case 400:
errorMessage = data?.detail || '请求参数错误';
break;
case 401:
errorMessage = '未授权,请先登录';
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
break;
case 403:
errorMessage = '没有权限访问';
break;
case 404:
errorMessage = data?.detail || '请求的资源不存在';
break;
case 422:
errorMessage = data?.detail || '请求参数验证失败';
if (data?.errors) {
console.error('验证错误详情:', data.errors);
}
break;
case 500:
errorMessage = data?.detail || '服务器内部错误';
break;
case 503:
errorMessage = '服务暂时不可用,请稍后重试';
break;
default:
errorMessage = data?.detail || data?.message || `请求失败 (${status})`;
}
} else if (error.request) {
errorMessage = '网络错误,请检查网络连接';
} else {
errorMessage = error.message || '请求失败';
}
message.error(errorMessage);
console.error('API Error:', errorMessage, error);
return Promise.reject(error);
}
);
export const authApi = {
getAuthConfig: () => api.get<unknown, { local_auth_enabled: boolean; linuxdo_enabled: boolean }>('/auth/config'),
localLogin: (username: string, password: string) =>
api.post<unknown, { success: boolean; message: string; user: User }>('/auth/local/login', { username, password }),
getLinuxDOAuthUrl: () => api.get<unknown, AuthUrlResponse>('/auth/linuxdo/url'),
getCurrentUser: () => api.get<unknown, User>('/auth/user'),
logout: () => api.post('/auth/logout'),
};
export const userApi = {
getCurrentUser: () => api.get<unknown, User>('/users/current'),
listUsers: () => api.get<unknown, User[]>('/users'),
setAdmin: (userId: string, isAdmin: boolean) =>
api.post('/users/set-admin', { user_id: userId, is_admin: isAdmin }),
deleteUser: (userId: string) => api.delete(`/users/${userId}`),
getUser: (userId: string) => api.get<unknown, User>(`/users/${userId}`),
};
export const projectApi = {
getProjects: () => api.get<unknown, Project[]>('/projects'),
getProject: (id: string) => api.get<unknown, Project>(`/projects/${id}`),
createProject: (data: ProjectCreate) => api.post<unknown, Project>('/projects', data),
updateProject: (id: string, data: ProjectUpdate) =>
api.put<unknown, Project>(`/projects/${id}`, data),
deleteProject: (id: string) => api.delete(`/projects/${id}`),
exportProject: (id: string) => {
window.open(`/api/projects/${id}/export`, '_blank');
},
};
export const outlineApi = {
getOutlines: (projectId: string) =>
api.get<unknown, { total: number; items: Outline[] }>(`/outlines/project/${projectId}`).then(res => res.items),
getOutline: (id: string) => api.get<unknown, Outline>(`/outlines/${id}`),
createOutline: (data: OutlineCreate) => api.post<unknown, Outline>('/outlines', data),
updateOutline: (id: string, data: OutlineUpdate) =>
api.put<unknown, Outline>(`/outlines/${id}`, data),
deleteOutline: (id: string) => api.delete(`/outlines/${id}`),
reorderOutlines: (data: OutlineReorderRequest) =>
api.post<unknown, { message: string; updated_outlines: number; updated_chapters: number }>('/outlines/reorder', data),
generateOutline: (data: GenerateOutlineRequest) =>
api.post<unknown, { total: number; items: Outline[] }>('/outlines/generate', data).then(res => res.items),
};
export const characterApi = {
getCharacters: (projectId: string) =>
api.get<unknown, Character[]>(`/characters/project/${projectId}`),
getCharacter: (id: string) => api.get<unknown, Character>(`/characters/${id}`),
updateCharacter: (id: string, data: CharacterUpdate) =>
api.put<unknown, Character>(`/characters/${id}`, data),
deleteCharacter: (id: string) => api.delete(`/characters/${id}`),
generateCharacter: (data: GenerateCharacterRequest) =>
api.post<unknown, Character>('/characters/generate', data),
};
export const chapterApi = {
getChapters: (projectId: string) =>
api.get<unknown, Chapter[]>(`/chapters/project/${projectId}`),
getChapter: (id: string) => api.get<unknown, Chapter>(`/chapters/${id}`),
createChapter: (data: ChapterCreate) => api.post<unknown, Chapter>('/chapters', data),
updateChapter: (id: string, data: ChapterUpdate) =>
api.put<unknown, Chapter>(`/chapters/${id}`, data),
deleteChapter: (id: string) => api.delete(`/chapters/${id}`),
checkCanGenerate: (chapterId: string) =>
api.get<unknown, import('../types').ChapterCanGenerateResponse>(`/chapters/${chapterId}/can-generate`),
generateChapterContent: (chapterId: string) =>
api.post<unknown, { content: string }>(`/chapters/${chapterId}/generate`, {}),
};
export const polishApi = {
polishText: (data: PolishTextRequest) =>
api.post<unknown, { polished_text: string }>('/polish', data),
polishBatch: (texts: string[]) =>
api.post<unknown, { polished_texts: string[] }>('/polish/batch', { texts }),
};
export default api;
export const wizardStreamApi = {
generateWorldBuildingStream: (
data: {
title: string;
description: string;
theme: string;
genre: string | string[];
narrative_perspective?: string;
target_words?: number;
chapter_count?: number;
character_count?: number;
provider?: string;
model?: string;
},
options?: SSEClientOptions
) => ssePost<WorldBuildingResponse>(
'/api/wizard-stream/world-building',
data,
options
),
generateCharactersStream: (
data: {
project_id: string;
count?: number;
world_context?: Record<string, string>;
theme?: string;
genre?: string;
requirements?: string;
provider?: string;
model?: string;
},
options?: SSEClientOptions
) => ssePost<GenerateCharactersResponse>(
'/api/wizard-stream/characters',
data,
options
),
generateCompleteOutlineStream: (
data: {
project_id: string;
chapter_count: number;
narrative_perspective: string;
target_words?: number;
requirements?: string;
provider?: string;
model?: string;
},
options?: SSEClientOptions
) => ssePost<GenerateOutlineResponse>(
'/api/wizard-stream/outline',
data,
options
),
updateWorldBuildingStream: (
projectId: string,
data: {
time_period?: string;
location?: string;
atmosphere?: string;
rules?: string;
},
options?: SSEClientOptions
) => ssePost<WorldBuildingResponse>(
`/api/wizard-stream/world-building/${projectId}`,
data,
options
),
regenerateWorldBuildingStream: (
projectId: string,
data?: {
provider?: string;
model?: string;
},
options?: SSEClientOptions
) => ssePost<WorldBuildingResponse>(
`/api/wizard-stream/world-building/${projectId}/regenerate`,
data || {},
options
),
cleanupWizardDataStream: (
projectId: string,
options?: SSEClientOptions
) => ssePost<{ message: string; deleted: { characters: number; outlines: number; chapters: number } }>(
`/api/wizard-stream/cleanup/${projectId}`,
{},
options
),
};
+115
View File
@@ -0,0 +1,115 @@
/**
* 事件总线 - 用于跨组件/页面的数据同步通信
*
* 使用方式:
* - eventBus.on('eventName', callback) - 监听事件
* - eventBus.off('eventName', callback) - 取消监听
* - eventBus.emit('eventName', data) - 触发事件
* - eventBus.once('eventName', callback) - 一次性监听
*/
type EventCallback = (data?: unknown) => void;
class EventBus {
private events: Map<string, EventCallback[]> = new Map();
/**
* 监听事件
*/
on(event: string, callback: EventCallback): void {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event)!.push(callback);
}
/**
* 取消监听事件
*/
off(event: string, callback: EventCallback): void {
const callbacks = this.events.get(event);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
}
/**
* 触发事件
*/
emit(event: string, data?: unknown): void {
const callbacks = this.events.get(event);
if (callbacks) {
callbacks.forEach(cb => {
try {
cb(data);
} catch (error) {
console.error(`事件处理器执行失败 [${event}]:`, error);
}
});
}
}
/**
* 一次性监听事件
*/
once(event: string, callback: EventCallback): void {
const onceCallback: EventCallback = (data) => {
callback(data);
this.off(event, onceCallback);
};
this.on(event, onceCallback);
}
/**
* 移除某个事件的所有监听器
*/
removeAllListeners(event?: string): void {
if (event) {
this.events.delete(event);
} else {
this.events.clear();
}
}
/**
* 获取事件的监听器数量
*/
listenerCount(event: string): number {
return this.events.get(event)?.length || 0;
}
}
// 导出单例
export const eventBus = new EventBus();
// 导出事件名称常量,避免字符串拼写错误
export const EventNames = {
// 项目相关事件
PROJECT_CREATED: 'project:created',
PROJECT_UPDATED: 'project:updated',
PROJECT_DELETED: 'project:deleted',
PROJECT_NEEDS_REFRESH: 'project:needsRefresh',
// 角色相关事件
CHARACTER_CREATED: 'character:created',
CHARACTER_UPDATED: 'character:updated',
CHARACTER_DELETED: 'character:deleted',
CHARACTER_NEEDS_REFRESH: 'character:needsRefresh',
// 大纲相关事件
OUTLINE_CREATED: 'outline:created',
OUTLINE_UPDATED: 'outline:updated',
OUTLINE_DELETED: 'outline:deleted',
OUTLINE_REORDERED: 'outline:reordered',
OUTLINE_GENERATED: 'outline:generated',
OUTLINE_NEEDS_REFRESH: 'outline:needsRefresh',
// 章节相关事件
CHAPTER_CREATED: 'chapter:created',
CHAPTER_UPDATED: 'chapter:updated',
CHAPTER_DELETED: 'chapter:deleted',
CHAPTER_NEEDS_REFRESH: 'chapter:needsRefresh',
} as const;
+400
View File
@@ -0,0 +1,400 @@
/**
* Store Hooks - 提供数据获取和自动同步功能
* 这些 hooks 封装了数据获取逻辑,并自动更新 store
*/
import { useCallback } from 'react';
import { message } from 'antd';
import { useStore } from './index';
import { projectApi, outlineApi, characterApi, chapterApi } from '../services/api';
import type {
PaginationResponse,
Outline,
Character,
Chapter,
Project,
ProjectCreate,
ProjectUpdate,
OutlineCreate,
OutlineUpdate,
ChapterCreate,
ChapterUpdate,
GenerateOutlineRequest,
GenerateCharacterRequest
} from '../types';
/**
* 项目数据同步 Hook
*/
export function useProjectSync() {
const { setProjects, setLoading, addProject, updateProject, removeProject } = useStore();
// 刷新项目列表
const refreshProjects = useCallback(async () => {
try {
setLoading(true);
const data = await projectApi.getProjects();
const projects = Array.isArray(data) ? data : (data as PaginationResponse<Project>).items || [];
setProjects(projects);
return projects;
} catch (error) {
console.error('刷新项目列表失败:', error);
message.error('刷新项目列表失败');
return [];
} finally {
setLoading(false);
}
}, [setProjects, setLoading]);
// 创建项目(带同步)
const createProject = useCallback(async (data: ProjectCreate) => {
try {
const created = await projectApi.createProject(data);
addProject(created);
return created;
} catch (error) {
console.error('创建项目失败:', error);
throw error;
}
}, [addProject]);
// 更新项目(带同步)
const updateProjectSync = useCallback(async (id: string, data: ProjectUpdate) => {
try {
const updated = await projectApi.updateProject(id, data);
updateProject(id, updated);
return updated;
} catch (error) {
console.error('更新项目失败:', error);
throw error;
}
}, [updateProject]);
// 删除项目(带同步)
const deleteProject = useCallback(async (id: string) => {
try {
await projectApi.deleteProject(id);
removeProject(id);
} catch (error) {
console.error('删除项目失败:', error);
throw error;
}
}, [removeProject]);
return {
refreshProjects,
createProject,
updateProject: updateProjectSync,
deleteProject,
};
}
/**
* 角色数据同步 Hook
*/
export function useCharacterSync() {
const { currentProject, setCharacters, addCharacter, removeCharacter } = useStore();
// 刷新角色列表
const refreshCharacters = useCallback(async (projectId?: string) => {
const id = projectId || currentProject?.id;
if (!id) return [];
try {
const data = await characterApi.getCharacters(id);
const characters = Array.isArray(data) ? data : (data as PaginationResponse<Character>).items || [];
setCharacters(characters);
return characters;
} catch (error) {
console.error('刷新角色列表失败:', error);
message.error('刷新角色列表失败');
return [];
}
}, [currentProject?.id, setCharacters]);
// 删除角色(带同步)
const deleteCharacter = useCallback(async (id: string) => {
try {
await characterApi.deleteCharacter(id);
removeCharacter(id);
} catch (error) {
console.error('删除角色失败:', error);
throw error;
}
}, [removeCharacter]);
// AI生成角色(带同步)
const generateCharacter = useCallback(async (data: GenerateCharacterRequest) => {
try {
const generated = await characterApi.generateCharacter(data);
addCharacter(generated);
return generated;
} catch (error) {
console.error('AI生成角色失败:', error);
throw error;
}
}, [addCharacter]);
return {
refreshCharacters,
deleteCharacter,
generateCharacter,
};
}
/**
* 大纲数据同步 Hook
*/
export function useOutlineSync() {
const { currentProject, setOutlines, addOutline, updateOutline, removeOutline } = useStore();
// 刷新大纲列表
const refreshOutlines = useCallback(async (projectId?: string) => {
const id = projectId || currentProject?.id;
if (!id) return [];
try {
const data = await outlineApi.getOutlines(id);
const outlines = Array.isArray(data) ? data : (data as PaginationResponse<Outline>).items || [];
setOutlines(outlines);
return outlines;
} catch (error) {
console.error('刷新大纲列表失败:', error);
message.error('刷新大纲列表失败');
return [];
}
}, [currentProject?.id, setOutlines]); // 添加 currentProject?.id 到依赖数组
// 创建大纲(带同步)
const createOutline = useCallback(async (data: OutlineCreate) => {
try {
const created = await outlineApi.createOutline(data);
addOutline(created);
return created;
} catch (error) {
console.error('创建大纲失败:', error);
throw error;
}
}, [addOutline]);
// 更新大纲(带同步)
const updateOutlineSync = useCallback(async (id: string, data: OutlineUpdate) => {
try {
const updated = await outlineApi.updateOutline(id, data);
updateOutline(id, updated);
return updated;
} catch (error) {
console.error('更新大纲失败:', error);
throw error;
}
}, [updateOutline]);
// 删除大纲(带同步)
const deleteOutline = useCallback(async (id: string) => {
try {
await outlineApi.deleteOutline(id);
removeOutline(id);
} catch (error) {
console.error('删除大纲失败:', error);
throw error;
}
}, [removeOutline]);
// 重排序大纲(带同步)
const reorderOutlines = useCallback(async (orders: Array<{ id: string; order_index: number }>, projectId?: string) => {
try {
await outlineApi.reorderOutlines({ orders });
// 重新获取完整列表以确保顺序正确
const id = projectId || currentProject?.id;
if (id) {
const data = await outlineApi.getOutlines(id);
const outlines = Array.isArray(data) ? data : (data as PaginationResponse<Outline>).items || [];
setOutlines(outlines);
}
} catch (error) {
console.error('重排序大纲失败:', error);
throw error;
}
}, [currentProject?.id, setOutlines]); // 添加 currentProject?.id 到依赖数组
// AI生成大纲(带同步)
const generateOutlines = useCallback(async (data: GenerateOutlineRequest) => {
try {
const result = await outlineApi.generateOutline(data);
const outlines = Array.isArray(result) ? result : (result as PaginationResponse<Outline>).items || [];
outlines.forEach((outline: Outline) => addOutline(outline));
return outlines;
} catch (error) {
console.error('AI生成大纲失败:', error);
throw error;
}
}, [addOutline]);
return {
refreshOutlines,
createOutline,
updateOutline: updateOutlineSync,
deleteOutline,
reorderOutlines,
generateOutlines,
};
}
/**
* 章节数据同步 Hook
*/
export function useChapterSync() {
const { currentProject, setChapters, addChapter, updateChapter, removeChapter } = useStore();
// 刷新章节列表
const refreshChapters = useCallback(async (projectId?: string) => {
const id = projectId || currentProject?.id;
if (!id) return [];
try {
const data = await chapterApi.getChapters(id);
const chapters = Array.isArray(data) ? data : (data as PaginationResponse<Chapter>).items || [];
setChapters(chapters);
return chapters;
} catch (error) {
console.error('刷新章节列表失败:', error);
message.error('刷新章节列表失败');
return [];
}
}, [currentProject?.id, setChapters]); // 添加 currentProject?.id 到依赖数组
// 创建章节(带同步)
const createChapter = useCallback(async (data: ChapterCreate) => {
try {
const created = await chapterApi.createChapter(data);
addChapter(created);
return created;
} catch (error) {
console.error('创建章节失败:', error);
throw error;
}
}, [addChapter]);
// 更新章节(带同步)
const updateChapterSync = useCallback(async (id: string, data: ChapterUpdate) => {
try {
const updated = await chapterApi.updateChapter(id, data);
updateChapter(id, updated);
return updated;
} catch (error) {
console.error('更新章节失败:', error);
throw error;
}
}, [updateChapter]);
// 删除章节(带同步)
const deleteChapter = useCallback(async (id: string) => {
try {
await chapterApi.deleteChapter(id);
removeChapter(id);
} catch (error) {
console.error('删除章节失败:', error);
throw error;
}
}, [removeChapter]);
// AI生成章节内容(带同步)
const generateChapterContent = useCallback(async (chapterId: string) => {
try {
const result = await chapterApi.generateChapterContent(chapterId);
// 直接调用 API 更新
const updated = await chapterApi.updateChapter(chapterId, { content: result.content });
updateChapter(chapterId, updated);
return result;
} catch (error) {
console.error('AI生成章节内容失败:', error);
throw error;
}
}, [updateChapter]);
// AI流式生成章节内容(带同步)
const generateChapterContentStream = useCallback(async (
chapterId: string,
onProgress?: (content: string) => void
) => {
try {
// 使用fetch处理流式响应
const response = await fetch(`/api/chapters/${chapterId}/generate-stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) {
throw new Error('无法获取响应流');
}
let buffer = '';
let fullContent = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
// 处理缓冲区中的完整消息
const lines = buffer.split('\n\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim() === '' || line.startsWith(':')) {
continue;
}
try {
const dataMatch = line.match(/^data: (.+)$/m);
if (dataMatch) {
const message = JSON.parse(dataMatch[1]);
if (message.type === 'content' && message.content) {
fullContent += message.content;
if (onProgress) {
onProgress(fullContent);
}
} else if (message.type === 'error') {
throw new Error(message.error || '生成失败');
} else if (message.type === 'done') {
// 生成完成,刷新章节数据
await refreshChapters();
return { content: fullContent, word_count: message.word_count };
}
}
} catch (error) {
console.error('解析SSE消息失败:', error);
}
}
}
return { content: fullContent };
} catch (error) {
console.error('AI流式生成章节内容失败:', error);
throw error;
}
}, [refreshChapters]);
return {
refreshChapters,
createChapter,
updateChapter: updateChapterSync,
deleteChapter,
generateChapterContent,
generateChapterContentStream,
};
}
+137
View File
@@ -0,0 +1,137 @@
import { create } from 'zustand';
import type { Project, Outline, Character, Chapter } from '../types';
interface AppState {
currentProject: Project | null;
setCurrentProject: (project: Project | null) => void;
projects: Project[];
setProjects: (projects: Project[]) => void;
addProject: (project: Project) => void;
updateProject: (id: string, project: Partial<Project>) => void;
removeProject: (id: string) => void;
outlines: Outline[];
setOutlines: (outlines: Outline[]) => void;
addOutline: (outline: Outline) => void;
updateOutline: (id: string, outline: Partial<Outline>) => void;
removeOutline: (id: string) => void;
characters: Character[];
setCharacters: (characters: Character[]) => void;
addCharacter: (character: Character) => void;
updateCharacter: (id: string, character: Partial<Character>) => void;
removeCharacter: (id: string) => void;
chapters: Chapter[];
setChapters: (chapters: Chapter[]) => void;
addChapter: (chapter: Chapter) => void;
updateChapter: (id: string, chapter: Partial<Chapter>) => void;
removeChapter: (id: string) => void;
currentChapter: Chapter | null;
setCurrentChapter: (chapter: Chapter | null) => void;
loading: boolean;
setLoading: (loading: boolean) => void;
lastUpdated: {
projects?: number;
outlines?: number;
characters?: number;
chapters?: number;
};
markUpdated: (key: 'projects' | 'outlines' | 'characters' | 'chapters') => void;
clearProjectData: () => void;
}
export const useStore = create<AppState>((set) => ({
currentProject: null,
setCurrentProject: (project) => set({ currentProject: project }),
projects: [],
setProjects: (projects) => set({ projects }),
addProject: (project) => set((state) => ({
projects: [...state.projects, project]
})),
updateProject: (id, updatedProject) => set((state) => ({
projects: state.projects.map((p) =>
p.id === id ? { ...p, ...updatedProject } : p
),
currentProject: state.currentProject?.id === id
? { ...state.currentProject, ...updatedProject }
: state.currentProject,
})),
removeProject: (id) => set((state) => ({
projects: state.projects.filter((p) => p.id !== id),
currentProject: state.currentProject?.id === id ? null : state.currentProject,
})),
outlines: [],
setOutlines: (outlines) => set({ outlines }),
addOutline: (outline) => set((state) => ({
outlines: [...state.outlines, outline]
})),
updateOutline: (id, updatedOutline) => set((state) => ({
outlines: state.outlines.map((o) =>
o.id === id ? { ...o, ...updatedOutline } : o
),
})),
removeOutline: (id) => set((state) => ({
outlines: state.outlines.filter((o) => o.id !== id),
})),
characters: [],
setCharacters: (characters) => set({ characters }),
addCharacter: (character) => set((state) => ({
characters: [...state.characters, character]
})),
updateCharacter: (id, updatedCharacter) => set((state) => ({
characters: state.characters.map((c) =>
c.id === id ? { ...c, ...updatedCharacter } : c
),
})),
removeCharacter: (id) => set((state) => ({
characters: state.characters.filter((c) => c.id !== id),
})),
chapters: [],
setChapters: (chapters) => set({ chapters }),
addChapter: (chapter) => set((state) => ({
chapters: [...state.chapters, chapter]
})),
updateChapter: (id, updatedChapter) => set((state) => ({
chapters: state.chapters.map((c) =>
c.id === id ? { ...c, ...updatedChapter } : c
),
currentChapter: state.currentChapter?.id === id
? { ...state.currentChapter, ...updatedChapter }
: state.currentChapter,
})),
removeChapter: (id) => set((state) => ({
chapters: state.chapters.filter((c) => c.id !== id),
currentChapter: state.currentChapter?.id === id ? null : state.currentChapter,
})),
currentChapter: null,
setCurrentChapter: (chapter) => set({ currentChapter: chapter }),
loading: false,
setLoading: (loading) => set({ loading }),
lastUpdated: {},
markUpdated: (key) => set((state) => ({
lastUpdated: {
...state.lastUpdated,
[key]: Date.now(),
},
})),
clearProjectData: () => set({
outlines: [],
characters: [],
chapters: [],
currentChapter: null,
}),
}));
+304
View File
@@ -0,0 +1,304 @@
// 用户类型定义
export interface User {
user_id: string;
username: string;
display_name: string;
avatar_url?: string;
trust_level: number;
is_admin: boolean;
linuxdo_id: string;
created_at: string;
last_login: string;
}
// LinuxDO 授权 URL 响应
export interface AuthUrlResponse {
auth_url: string;
state: string;
}
// 项目类型定义
export interface Project {
id: string; // UUID字符串
title: string;
description?: string;
theme?: string;
genre?: string;
target_words?: number;
current_words: number;
status: 'planning' | 'writing' | 'revising' | 'completed';
wizard_status?: 'incomplete' | 'completed';
wizard_step?: number;
world_time_period?: string;
world_location?: string;
world_atmosphere?: string;
world_rules?: string;
chapter_count?: number;
narrative_perspective?: string;
character_count?: number;
created_at: string;
updated_at: string;
}
export interface ProjectCreate {
title: string;
description?: string;
theme?: string;
genre?: string;
target_words?: number;
wizard_status?: 'incomplete' | 'completed';
wizard_step?: number;
world_time_period?: string;
world_location?: string;
world_atmosphere?: string;
world_rules?: string;
}
export interface ProjectUpdate {
title?: string;
description?: string;
theme?: string;
genre?: string;
target_words?: number;
status?: 'planning' | 'writing' | 'revising' | 'completed';
world_time_period?: string;
world_location?: string;
world_atmosphere?: string;
world_rules?: string;
chapter_count?: number;
narrative_perspective?: string;
character_count?: number;
// current_words 由章节内容自动计算,不在此接口中
}
// 向导专用的项目更新接口,包含向导流程控制字段
export interface ProjectWizardUpdate extends ProjectUpdate {
wizard_status?: 'incomplete' | 'completed';
wizard_step?: number;
}
// 项目创建向导
export interface ProjectWizardRequest {
title: string;
theme: string;
genre?: string;
chapter_count: number;
narrative_perspective: string;
character_count?: number;
target_words?: number;
world_building?: {
time_period: string;
location: string;
atmosphere: string;
rules: string;
};
}
export interface WorldBuildingResponse {
project_id: string;
time_period: string;
location: string;
atmosphere: string;
rules: string;
}
// 大纲类型定义
export interface Outline {
id: string;
project_id: string;
title: string;
content: string;
structure?: string;
order_index: number;
created_at: string;
updated_at: string;
}
export interface OutlineCreate {
project_id: string;
title: string;
content: string;
structure?: string;
order_index: number;
}
export interface OutlineUpdate {
title?: string;
content?: string;
// structure 暂不支持修改
// order_index 只能通过 reorder 接口批量调整
}
// 角色类型定义
export interface Character {
id: string;
project_id: string;
name: string;
age?: string;
gender?: string;
is_organization: boolean;
role_type?: string;
personality?: string;
background?: string;
appearance?: string;
relationships?: string;
organization_type?: string;
organization_purpose?: string;
organization_members?: string;
traits?: string;
avatar_url?: string;
created_at: string;
updated_at: string;
}
export interface CharacterUpdate {
name?: string;
age?: string;
gender?: string;
is_organization?: boolean;
role_type?: string;
personality?: string;
background?: string;
appearance?: string;
relationships?: string;
organization_type?: string;
organization_purpose?: string;
organization_members?: string;
traits?: string;
}
// 章节类型定义
export interface Chapter {
id: string;
project_id: string;
title: string;
content?: string;
summary?: string;
chapter_number: number;
word_count: number;
status: 'draft' | 'writing' | 'completed';
created_at: string;
updated_at: string;
}
export interface ChapterCreate {
project_id: string;
title: string;
chapter_number: number;
content?: string;
summary?: string;
status?: 'draft' | 'writing' | 'completed';
}
export interface ChapterUpdate {
title?: string;
content?: string;
// chapter_number 不允许修改,由大纲顺序决定
summary?: string;
// word_count 自动计算,不允许手动修改
status?: 'draft' | 'writing' | 'completed';
}
// 章节生成检查响应
export interface ChapterCanGenerateResponse {
can_generate: boolean;
reason: string;
previous_chapters: {
id: string;
chapter_number: number;
title: string;
has_content: boolean;
word_count: number;
}[];
chapter_number: number;
}
// AI生成请求类型
export interface GenerateOutlineRequest {
project_id: string;
genre?: string;
theme: string;
chapter_count: number;
narrative_perspective: string;
world_context?: Record<string, unknown>;
characters_context?: Character[];
target_words?: number;
requirements?: string;
provider?: string;
model?: string;
// 续写功能新增字段
mode?: 'auto' | 'new' | 'continue';
story_direction?: string;
plot_stage?: 'development' | 'climax' | 'ending';
keep_existing?: boolean;
}
// 大纲重排序请求类型
export interface OutlineReorderItem {
id: string;
order_index: number;
}
export interface OutlineReorderRequest {
orders: OutlineReorderItem[];
}
export interface GenerateCharacterRequest {
project_id: string;
name?: string;
role_type?: string;
background?: string;
requirements?: string;
provider?: string;
model?: string;
}
export interface PolishTextRequest {
text: string;
style?: string;
}
// 向导API响应类型
export interface GenerateCharactersResponse {
characters: Character[];
}
export interface GenerateOutlineResponse {
outlines: Outline[];
}
// API响应类型
export interface ApiResponse<T> {
data: T;
message?: string;
}
export interface PaginationResponse<T> {
items: T[];
total: number;
page: number;
page_size: number;
total_pages: number;
}
// 向导表单数据类型
export interface WizardBasicInfo {
title: string;
description: string;
theme: string;
genre: string | string[];
chapter_count: number;
narrative_perspective: string;
character_count?: number;
target_words?: number;
}
// API 错误响应类型
export interface ApiError {
response?: {
data?: {
detail?: string;
};
};
message?: string;
}
+269
View File
@@ -0,0 +1,269 @@
export interface SSEMessage {
type: 'progress' | 'chunk' | 'result' | 'error' | 'done';
message?: string;
progress?: number;
status?: 'processing' | 'success' | 'error' | 'warning';
content?: string;
data?: any;
error?: string;
code?: number;
}
export interface SSEClientOptions {
onProgress?: (message: string, progress: number, status: string) => void;
onChunk?: (content: string) => void;
onResult?: (data: any) => void;
onError?: (error: string, code?: number) => void;
onComplete?: () => void;
onConnectionError?: (error: Event) => void;
}
export class SSEClient {
private eventSource: EventSource | null = null;
private url: string;
private options: SSEClientOptions;
private accumulatedContent: string = '';
constructor(url: string, options: SSEClientOptions = {}) {
this.url = url;
this.options = options;
}
connect(): Promise<any> {
return new Promise((resolve, reject) => {
try {
this.eventSource = new EventSource(this.url);
this.eventSource.onmessage = (event) => {
try {
const message: SSEMessage = JSON.parse(event.data);
this.handleMessage(message, resolve, reject);
} catch (error) {
console.error('解析SSE消息失败:', error);
}
};
this.eventSource.onerror = (error) => {
console.error('SSE连接错误:', error);
if (this.options.onConnectionError) {
this.options.onConnectionError(error);
}
this.close();
reject(new Error('SSE连接失败'));
};
} catch (error) {
reject(error);
}
});
}
private handleMessage(message: SSEMessage, resolve: Function, reject: Function) {
switch (message.type) {
case 'progress':
if (this.options.onProgress && message.message && message.progress !== undefined) {
this.options.onProgress(message.message, message.progress, message.status || 'processing');
}
break;
case 'chunk':
if (message.content) {
this.accumulatedContent += message.content;
if (this.options.onChunk) {
this.options.onChunk(message.content);
}
}
break;
case 'result':
if (this.options.onResult && message.data) {
this.options.onResult(message.data);
}
break;
case 'error':
if (this.options.onError) {
this.options.onError(message.error || '未知错误', message.code);
}
this.close();
reject(new Error(message.error || '未知错误'));
break;
case 'done':
if (this.options.onComplete) {
this.options.onComplete();
}
this.close();
if (!this.options.onResult && this.accumulatedContent) {
resolve({ content: this.accumulatedContent });
} else {
resolve(true);
}
break;
}
}
close() {
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
}
getAccumulatedContent(): string {
return this.accumulatedContent;
}
}
export class SSEPostClient {
private url: string;
private data: any;
private options: SSEClientOptions;
private abortController: AbortController | null = null;
private accumulatedContent: string = '';
constructor(url: string, data: any, options: SSEClientOptions = {}) {
this.url = url;
this.data = data;
this.options = options;
}
async connect(): Promise<any> {
return new Promise(async (resolve, reject) => {
try {
this.abortController = new AbortController();
const response = await fetch(this.url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(this.data),
signal: this.abortController.signal,
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) {
throw new Error('无法获取响应流');
}
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim() === '' || line.startsWith(':')) {
continue;
}
try {
const dataMatch = line.match(/^data: (.+)$/m);
if (dataMatch) {
const message: SSEMessage = JSON.parse(dataMatch[1]);
await this.handleMessage(message, resolve, reject);
}
} catch (error) {
console.error('解析SSE消息失败:', error, line);
}
}
}
} catch (error: any) {
if (error.name === 'AbortError') {
console.log('请求已取消');
} else {
console.error('SSE POST请求失败:', error);
if (this.options.onError) {
this.options.onError(error.message || '请求失败');
}
reject(error);
}
}
});
}
private async handleMessage(message: SSEMessage, resolve: Function, reject: Function) {
switch (message.type) {
case 'progress':
if (this.options.onProgress && message.message && message.progress !== undefined) {
this.options.onProgress(message.message, message.progress, message.status || 'processing');
}
break;
case 'chunk':
if (message.content) {
this.accumulatedContent += message.content;
if (this.options.onChunk) {
this.options.onChunk(message.content);
}
}
break;
case 'result':
if (this.options.onResult && message.data) {
this.options.onResult(message.data);
}
(this as any).resultData = message.data;
break;
case 'error':
if (this.options.onError) {
this.options.onError(message.error || '未知错误', message.code);
}
reject(new Error(message.error || '未知错误'));
break;
case 'done':
if (this.options.onComplete) {
this.options.onComplete();
}
if ((this as any).resultData) {
resolve((this as any).resultData);
} else if (this.accumulatedContent) {
resolve({ content: this.accumulatedContent });
} else {
resolve(true);
}
break;
}
}
abort() {
if (this.abortController) {
this.abortController.abort();
}
}
getAccumulatedContent(): string {
return this.accumulatedContent;
}
}
export async function ssePost<T = any>(
url: string,
data: any,
options: SSEClientOptions = {}
): Promise<T> {
const client = new SSEPostClient(url, data, options);
try {
return await client.connect();
} finally {
client.abort();
}
}
+28
View File
@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
+19
View File
@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
build: {
outDir: '../backend/static',
emptyOutDir: true,
},
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
}
}
}
})