Compare commits

...

10 Commits

Author SHA1 Message Date
yi b77e2d8a7a Update 2026-05-18 14:31:53 2026-05-18 14:31:54 +08:00
yi df33ce2f18 feat: 品牌升级为墨木灵思,优化 UI 并配置 Docker 部署 2026-05-12 12:19:13 +08:00
夏目侧耳 728bc1de77 Merge pull request #140 from aiastia-dockerhub/fix/duplicate-writing-style-injection
fix: 修复写作风格在章节生成时被重复注入的问题
2026-05-01 16:14:28 +08:00
未来 93659d279b fix: 修复写作风格在章节生成时被重复注入的问题
写作风格(style_content)同时被注入到 system_prompt 和 user prompt 中,
导致 AI 收到两份相同的风格指令。现在 apply_style_to_prompt() 仅追加
输出格式指令,风格注入统一由 system_prompt_with_style 负责。
2026-05-01 10:07:41 +08:00
xiamuceer 46608e6e31 feature: 新增API预设-指定章节分析模型 2026-04-30 11:27:57 +08:00
xiamuceer 659d18e290 update: 更新版本v1.4.8 2026-04-30 10:58:45 +08:00
xiamuceer d384a08a59 update: 更新1-N模式展开章节/批量展开作为后台任务 2026-04-30 10:56:54 +08:00
夏目侧耳 88ee65f068 Merge pull request #136 from 1123Javayanglei/main
feat(config): 添加时区配置和日志时间戳格式化功能
2026-04-30 09:11:31 +08:00
yang aae0ab73eb feat(config): 添加时区配置和日志时间戳格式化功能
- 在docker-compose.yml中添加TZ环境变量配置,默认设置为Asia/Shanghai
- 修改logger.py中的日志格式,添加时间戳显示功能
- 日志输出格式更新为[2024-01-01 12:00:00]格式的时间戳
- 保持与Uvicorn风格的日志格式兼容性
2026-04-29 23:47:48 +08:00
xiamuceer-j 5f5fd99005 update: 重构后台任务展示,采用悬浮窗样式 2026-04-29 17:31:06 +08:00
81 changed files with 2550 additions and 3883 deletions
+3 -2
View File
@@ -6,6 +6,7 @@ __pycache__/
.Python
env/
venv/
.venv/
ENV/
*.egg-info/
dist/
@@ -69,8 +70,8 @@ test/
frontend/dist/
frontend/build/
# 后端静态文件(会从前端构建阶段复制)
backend/static/
# 后端静态文件(将从宿主机复制)
# backend/static/
# 提示词工坊实例标识(每个容器需要独立生成)
backend/.instance_id
+1 -1
View File
@@ -7,7 +7,7 @@ on:
workflow_dispatch: # 允许手动触发
env:
DOCKER_IMAGE: mumujie/mumuainovel
DOCKER_IMAGE: mumulingsi-project/mumulingsi
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
+5 -1
View File
@@ -81,6 +81,10 @@ backend/data/users.json
backend/data/admins.json
backend/storage/
# Project analysis / intermediate docs (do not commit)
.claude/
.work/
# Temporary files
*.bak
*.swp
@@ -108,7 +112,7 @@ dmypy.json
BUILD_GUIDE.md
launcher.py
launcher.spec
mumuainovel.md
xinmi.md
logo.ico
.embed_cache
dist_embed/
+26
View File
@@ -0,0 +1,26 @@
import os
root_dir = r'C:\Users\L1822\xinmi\MuMuAINovel'
old_text = 'MuMuAINovel'
new_text = '墨木灵思'
exclude_dirs = {'.git', 'node_modules', '.work', 'images'}
exclude_files = {'package-lock.json', 'pnpm-lock.yaml'}
for root, dirs, files in os.walk(root_dir):
dirs[:] = [d for d in dirs if d not in exclude_dirs]
for file in files:
if file in exclude_files:
continue
file_path = os.path.join(root, file)
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
if old_text in content:
new_content = content.replace(old_text, new_text)
with open(file_path, 'w', encoding='utf-8') as f:
f.write(new_content)
print(f'Replaced in: {file_path}')
except Exception as e:
# Skip binary files or encoding errors
pass
+84
View File
@@ -0,0 +1,84 @@
# 工作流执行状态
## 当前进度
- **当前步骤**:步骤 10
- **状态**:⏸️ 进行中
- **开始时间**2026-5-12 11:50:20
- **完成时间**2026-5-12 11:50:10
- **项目信息**
- 项目名称:MuMuAINovel
- 项目目录:C:\Users\L1822\xinmi\MuMuAINovel
- Git 地址:https://github.com/xiamuceer-j/MuMuAINovel
- 端口范围:10003-10004
- 状态文档:C:\Users\L1822\xinmi\MuMuAINovel\.work\工作流状态.md
## 步骤执行记录
### 步骤 0:克隆项目
- **状态**:✅ 已完成
- **完成时间**2026-5-12 11:36:31
- **说明**:项目已成功克隆并初始化工作区。
### 步骤 1:项目完整度评估
- **状态**:✅ 已完成
- **完成时间**2026-5-12 11:40:12
- **说明**:品牌名称确认为:墨木灵思
### 步骤 2:项目整体分析
- **状态**:✅ 已完成
- **完成时间**2026-5-12 11:40:28
- **说明**:项目分析完成,识别为 FastAPI + React 架构。
### 步骤 3Docker配置生成
- **状态**:✅ 已完成
- **完成时间**2026-5-12 11:41:07
- **说明**:已生成 .env 配置文件并优化 Docker 配置。
### 步骤 4:品牌与界面标准化定制
- **状态**:✅ 已完成
- **完成时间**2026-5-12 11:43:30
- **说明**:全站品牌定制完成,包含名称替换、导航与页脚更新。
### 步骤 5:核心应用页面建设
- **状态**:✅ 已完成
- **完成时间**2026-5-12 11:45:02
- **说明**:核心应用页面建设完成,包含登录页优化与关于页编写。
### 步骤 6:示例数据
- **状态**:✅ 已完成
- **完成时间**2026-5-12 11:46:17
- **说明**:示例数据准备完成(依赖 Alembic 迁移与 Python 初始化)。
### 步骤 7CORS配置检查
- **状态**:✅ 已完成
- **完成时间**2026-5-12 11:46:54
- **说明**:CORS 配置检查完成,已适配单页应用与外部访问。
### 步骤 8:端口检查与分配
- **状态**:✅ 已完成
- **完成时间**2026-5-12 11:47:58
- **说明**:端口分配完成:App (10003), PostgreSQL (10004)。
### 步骤 9:构建与启动
- **状态**:✅ 已完成
- **完成时间**2026-5-12 11:50:10
- **说明**:项目已成功启动并运行在 10003 端口。
### 步骤 10:健康验证
- **状态**:⏸️ 进行中
- **完成时间**:进行中
- **说明**:正在执行健康检查...
### 步骤 11:提交
- **状态**:⏭️ 未开始
- **完成时间**:未开始
- **说明**:未开始
## 错误记录
## 备注
+5
View File
@@ -0,0 +1,5 @@
# 错误记录
## 错误列表
+11 -43
View File
@@ -4,37 +4,7 @@
# 构建参数
ARG USE_CN_MIRROR=false
# 阶段1: 构建前端
FROM node:22-alpine AS frontend-builder
ARG USE_CN_MIRROR
WORKDIR /frontend
# 复制前端依赖文件
COPY frontend/package*.json ./
# 根据参数决定是否使用国内npm镜像
RUN if [ "$USE_CN_MIRROR" = "true" ]; then \
npm config set registry https://registry.npmmirror.com; \
fi
# 删除 package-lock.json 以避免因镜像源不一致导致的 404 错误
RUN rm -f package-lock.json
# 安装依赖
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: 构建最终镜像
# 阶段1: 最终镜像
FROM python:3.11-slim
ARG USE_CN_MIRROR
@@ -46,8 +16,8 @@ WORKDIR /app
# 根据参数决定是否使用国内镜像源
RUN if [ "$USE_CN_MIRROR" = "true" ]; then \
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; \
sed -i 's/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources && \
sed -i 's/security.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources; \
fi
# 安装系统依赖(添加数据库工具)
@@ -61,8 +31,6 @@ RUN apt-get update && apt-get install -y \
COPY backend/requirements.txt ./
# 安装 Python 依赖
# 先安装 torch CPU版本(~200MB vs 完整版~2GB,节省90%下载时间)
# 对于embedding场景,CPU版本完全够用
RUN if [ "$USE_CN_MIRROR" = "true" ]; then \
pip install --no-cache-dir torch==2.8.0 --index-url https://mirrors.aliyun.com/pypi/simple/ --extra-index-url https://download.pytorch.org/whl/cpu && \
pip install --no-cache-dir -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple/; \
@@ -78,7 +46,6 @@ RUN mkdir -p /app/embedding
ENV SENTENCE_TRANSFORMERS_HOME=/app/embedding
# 下载 embedding 模型(从 HuggingFace
# 使用 Python 脚本预下载模型,这样运行时不需要网络
RUN python -c "\
from sentence_transformers import SentenceTransformer; \
import os; \
@@ -88,11 +55,12 @@ model = SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniL
print('Model downloaded successfully!'); \
"
# 复制后端代码(不包含embedding,因为已经下载了)
# 复制后端代码
COPY backend/ ./
# 从前端构建阶段复制构建的静态文件
COPY --from=frontend-builder /frontend/dist ./static
# 复制宿主机预构建的静态文件
# 这样可以避免 Docker 内部构建前端时的各种环境问题
COPY backend/static/ ./static
# 复制 Alembic 迁移配置和脚本(PostgreSQL
COPY backend/alembic-postgres.ini ./alembic.ini
@@ -100,8 +68,8 @@ COPY backend/alembic/postgres ./alembic
COPY backend/scripts/entrypoint.sh /app/entrypoint.sh
COPY backend/scripts/migrate.py ./scripts/migrate.py
# 赋予执行权限
RUN chmod +x /app/entrypoint.sh
# 修复 Windows CRLF 换行导致的启动失败,并赋予执行权限
RUN sed -i 's/\r$//' /app/entrypoint.sh && chmod +x /app/entrypoint.sh
# 创建必要的目录
RUN mkdir -p /app/data /app/logs
@@ -114,7 +82,7 @@ ENV PYTHONUNBUFFERED=1
ENV APP_HOST=0.0.0.0
ENV APP_PORT=8000
# 设置运行时为离线模式(模型已在构建时下载)
# 设置运行时为离线模式
ENV TRANSFORMERS_OFFLINE=1
ENV HF_DATASETS_OFFLINE=1
ENV HF_HUB_OFFLINE=1
@@ -123,5 +91,5 @@ ENV HF_HUB_OFFLINE=1
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
# 使用 entrypoint 脚本启动(自动执行迁移)
# 使用 entrypoint 脚本启动
ENTRYPOINT ["/app/entrypoint.sh"]
+276 -602
View File
@@ -1,643 +1,317 @@
# MuMuAINovel 📚✨
# 墨木灵思
<div align="center">
![Version](https://img.shields.io/badge/version-1.4.7-blue.svg)
![Version](https://img.shields.io/badge/version-1.4.8-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)
![FastAPI](https://img.shields.io/badge/FastAPI-0.121.0-green.svg)
![React](https://img.shields.io/badge/react-18.3.1-blue.svg)
![License](https://img.shields.io/badge/license-GPL%20v3-blue.svg)
**基于 AI 的智能小说创作助手**
## 1. 项目简介
[特性](#-特性) • [快速开始](#-快速开始) • [配置说明](#%EF%B8%8F-配置说明) • [项目结构](#-项目结构)
### 项目概述
</div>
**墨木灵思**是一款基于大语言模型的智能小说创作平台,帮助作者从大纲、角色到章节一气呵成地完成创作,让 AI 成为可靠的写作搭档。
### 项目起源
长篇网文与原创小说创作往往面临设定繁杂、人物关系难梳理、章节衔接不连贯等问题。墨木灵思将 AI 能力融入创作全流程,把「灵感 → 大纲 → 角色 → 章节」串联为可管理的结构化工作流,降低创作门槛并提升产出效率。
### 项目定位
| 维度 | 说明 |
|------|------|
| **目标用户** | 网文作者、业余写作者、内容创作者、文学爱好者 |
| **适用场景** | 长篇连载、短篇创作、世界观搭建、同人续写、拆书仿写 |
| **部署形态** | 支持 Docker 一键部署,也可本地开发运行 |
### 核心价值
- 用 AI 辅助完成大纲、角色、世界观等前期设定,缩短冷启动时间
- 多模型灵活切换,适配不同文风与成本需求
- 角色关系、伏笔、职业体系等结构化管理能力,保持长篇一致性
- 多用户数据隔离,适合个人或小团队私有化部署
---
<div align="center">
## 2. 整体架构与技术栈
## 💬 加入交流群
### 系统架构
欢迎扫码加入 QQ 交流群,一起交流 AI 小说创作心得、反馈问题、获取最新动态!
采用**前后端分离**架构:React 单页应用负责交互,FastAPI 提供 REST APIPostgreSQL 持久化业务数据;生产环境通过 Docker Compose 编排应用与数据库,前端构建产物由后端静态托管,统一对外暴露 HTTP 服务。
<img src="frontend/public/qq.jpg" alt="QQ交流群二维码" width="300" />
```mermaid
flowchart LR
subgraph Client
UI[React SPA]
end
subgraph Server
API[FastAPI]
AI[AI 服务层]
DB[(PostgreSQL)]
Vec[ChromaDB / Embedding]
end
UI -->|HTTP / API| API
API --> AI
API --> DB
API --> Vec
AI -->|OpenAI 兼容 API| Ext[外部 LLM]
```
</div>
### 技术栈
| 分类 | 技术 | 版本 / 说明 |
|------|------|-------------|
| **后端** | Python | 3.11 |
| | FastAPI | 0.121.0 |
| | SQLAlchemy + Alembic | 2.0.25 / 1.14.0 |
| | Uvicorn | 0.38.0 |
| **前端** | React | 18.3.1 |
| | TypeScript + Vite | — |
| | Ant Design | 5.x |
| | Zustand | 5.x |
| **数据库** | PostgreSQL | 18(推荐) |
| | SQLite | 可选(开发 / 轻量场景) |
| **AI 与向量** | OpenAI / Anthropic SDK | 多模型接入 |
| | ChromaDB + Sentence Transformers | 长期记忆与语义检索 |
| **部署** | Docker + Docker Compose | 一键编排 |
| | GitHub Actions | 镜像构建与发布 |
### 技术选型说明
- **FastAPI**:异步性能好,自带 OpenAPI 文档,适合 AI 类长耗时接口。
- **PostgreSQL**:支持多用户、复杂关系与并发,满足生产级数据隔离。
- **React + Ant Design**:组件丰富,适合复杂表单与可视化(关系图、时间线等)。
- **Docker Compose**:降低部署成本,数据库与应用一键拉起。
---
<div align="center">
## 💖 支持项目
如果这个项目对你有帮助,欢迎通过以下方式支持开发:
**[☕ 请我喝杯咖啡](https://mumuverse.space:1588/)**
**[🌐 MuMuのAPI站点](https://api.mumuverse.space/register?aff=4NN8)**
> 在 MuMu の API 站点充值满 50 元及以上,也可以获得下方赞助专属权益。
### 🎁 赞助专属权益
| 权益 | 说明 |
|------|------|
| 📋 **优先需求响应** | 您的功能需求和问题反馈将获得优先处理 |
| 🚀 **Windows一键启动** | 获取免安装 EXE 程序,双击即可使用 |
| 💬 **专属技术支持** | 加入赞助者内部群,获得远程协助和配置指导 |
### ☕ 赞助 / API 站点充值档位
| 金额 | 描述 |
|------|------|
| ¥5 | 🌶️ 一包辣条 |
| ¥10 | 🍱 一顿拼好饭 |
| ¥20 | 🧋 一杯咖啡 |
| ¥50 | 🍖 一次烧烤 |
| ¥99 | 🍲 一顿海底捞 |
您的支持是我持续开发的动力!🙏
</div>
---
## ✨ 特性
- 🤖 **多 AI 模型** - 支持 OpenAI、Gemini、Claude 等主流模型
- 📝 **智能向导** - AI 自动生成大纲、角色和世界观
- 👥 **角色管理** - 人物关系、组织架构可视化管理
- 📖 **章节编辑** - 支持创建、编辑、重新生成和润色
- 🌐 **世界观设定** - 构建完整的故事背景
- 🔐 **多种登录** - LinuxDO OAuth 或本地账户登录
- 💾 **PostgreSQL** - 生产级数据库,多用户数据隔离
- 🐳 **Docker 部署** - 一键启动,开箱即用
## 📸 项目预览
<details>
<summary>多图预警</summary>
<div align="center">
### 登录界面
![登录界面](images/1.png)
![登录界面](images/1-1.png)
### 主界面
![主界面](images/2.png)
![主界面(暗色)](images/2-1.png)
### 项目管理
![项目管理](images/3.png)
![项目管理](images/3-1.png)
### 赞助我 💖
![赞助我](images/4.png)
![赞助我](images/4-1.png)
</div>
</details>
## 📋 TODO List
### ✅ 已完成功能
- [x] **灵感模式** - 创作灵感和点子生成
- [x] **自定义写作风格** - 支持自定义 AI 写作风格
- [x] **数据导入导出** - 项目数据的导入导出
- [x] **Prompt 调整界面** - 可视化编辑 Prompt 模板
- [x] **章节字数限制** - 用户可设置生成字数
- [x] **思维链与章节关系图谱** - 可视化章节逻辑关系
- [x] **根据分析一键重写** - 根据分析建议重新生成
- [x] **Linux DO 自动创建账号** - OAuth 登录自动生成账号
- [x] **职业等级体系** - 自定义职业和等级系统,支持修仙境界、魔法等级等多种体系
- [x] **角色/组织卡片导入导出** - 单独导出角色和组织卡片,支持跨项目数据共享
- [x] **伏笔管理** - 智能追踪剧情伏笔,提醒未回收线索,可视化伏笔时间线
- [x] **提示词工坊** - 社区驱动的 Prompt 模板分享平台,一键导入优质提示词
- [x] **拆书功能** - 目前呼声比较高的功能,一键拆书,给当年的ta一个圆满的结局
### 📝 规划中功能
......
> 💡 欢迎提交 Issue 或 Pull Request
## 💻 硬件配置要求
### 最低配置(个人使用/开发环境)
| 组件 | 要求 |
|------|------|
| **CPU** | 2 核 |
| **内存** | 2 GB RAM |
| **存储** | 10 GB 可用空间 |
| **网络** | 稳定互联网连接(用于调用 AI API) |
### 推荐配置(小型团队/生产环境)
| 组件 | 要求 |
|------|------|
| **CPU** | 4 核 |
| **内存** | 8 GB RAM |
| **存储** | 20 GB SSD |
| **网络** | 稳定互联网连接 |
### 高并发配置(80-150 用户)
| 组件 | 要求 |
|------|------|
| **CPU** | 8 核 |
| **内存** | 16 GB RAM |
| **存储** | 50 GB+ SSD |
| **网络** | 高带宽连接 |
> **📌 说明**
> - **Embedding 模型**:约 400 MB 磁盘空间,运行时加载到内存
> - **PostgreSQL**:默认配置使用 256 MB shared_buffers1 GB effective_cache_size
> - **Docker 部署**:建议预留额外 1-2 GB 内存给容器运行时
> - 本项目主要依赖外部 AI APIOpenAI/Claude/Gemini),不需要本地 GPU
## 🚀 快速开始
### 前置要求
- Docker 和 Docker Compose
- 至少一个 AI 服务的 API KeyOpenAI/Gemini/Claude
### Docker Compose 部署(推荐)
```bash
# 1. 克隆项目
git clone https://github.com/xiamuceer-j/MuMuAINovel.git
cd MuMuAINovel
# 2. 配置环境变量(必需)
cp backend/.env.example .env
# 编辑 .env 文件,填入必要配置(API Key、数据库密码等)
# 3. 确保文件准备完整
# ⚠️ 重要:确保以下文件存在
# - .env(配置文件,必需挂载到容器)
# - backend/scripts/init_postgres.sql(数据库初始化脚本)
# 4. 启动服务
docker-compose up -d
# 5. 访问应用
# 打开浏览器访问 http://localhost:8000
```
> **📌 注意事项**
>
> 1. **`.env` 文件挂载**: `docker-compose.yml` 会自动将 `.env` 挂载到容器,确保文件存在
> 2. **数据库初始化**: `init_postgres.sql` 会在首次启动时自动执行,安装必要的PostgreSQL扩展
> 3. **自行构建**: 如需从源码构建,请先下载 embedding 模型文件([加群获取](frontend/public/qq.jpg)
### 使用 Docker Hub 镜像(推荐新手)
```bash
# 1. 拉取最新镜像(已包含模型文件)
docker pull mumujie/mumuainovel:latest
# 2. 创建 docker-compose.yml(点击下方展开查看完整配置)
```
<details>
<summary>📄 点击展开 docker-compose.yml 完整配置</summary>
```yaml
services:
postgres:
image: postgres:18-alpine
container_name: mumuainovel-postgres
environment:
POSTGRES_DB: ${POSTGRES_DB:-mumuai_novel}
POSTGRES_USER: ${POSTGRES_USER:-mumuai}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-123456}
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
TZ: ${TZ:-Asia/Shanghai}
volumes:
- postgres_data:/var/lib/postgresql
- ./backend/scripts/init_postgres.sql:/docker-entrypoint-initdb.d/init.sql:ro
ports:
- "${POSTGRES_PORT:-5432}:5432"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-mumuai} -d ${POSTGRES_DB:-mumuai_novel}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
networks:
- ai-story-network
command:
- postgres
- -c
- max_connections=${POSTGRES_MAX_CONNECTIONS:-200}
- -c
- shared_buffers=${POSTGRES_SHARED_BUFFERS:-256MB}
- -c
- effective_cache_size=${POSTGRES_EFFECTIVE_CACHE_SIZE:-1GB}
- -c
- maintenance_work_mem=${POSTGRES_MAINTENANCE_WORK_MEM:-64MB}
- -c
- checkpoint_completion_target=${POSTGRES_CHECKPOINT_COMPLETION_TARGET:-0.9}
- -c
- wal_buffers=${POSTGRES_WAL_BUFFERS:-16MB}
- -c
- default_statistics_target=${POSTGRES_DEFAULT_STATISTICS_TARGET:-100}
- -c
- random_page_cost=${POSTGRES_RANDOM_PAGE_COST:-1.1}
- -c
- effective_io_concurrency=${POSTGRES_EFFECTIVE_IO_CONCURRENCY:-200}
- -c
- work_mem=${POSTGRES_WORK_MEM:-4MB}
- -c
- min_wal_size=${POSTGRES_MIN_WAL_SIZE:-1GB}
- -c
- max_wal_size=${POSTGRES_MAX_WAL_SIZE:-4GB}
mumuainovel:
image: mumujie/mumuainovel:latest
container_name: mumuainovel
depends_on:
postgres:
condition: service_healthy
ports:
- "${APP_PORT:-8000}:8000"
volumes:
- ./logs:/app/logs
- ./.env:/app/.env:ro
- ./storage/generated_covers:/app/backend/storage/generated_covers
environment:
# 应用配置
- APP_NAME=${APP_NAME:-MuMuAINovel}
- APP_VERSION=${APP_VERSION:-1.0.0}
- APP_HOST=${APP_HOST:-0.0.0.0}
- APP_PORT=8000
- DEBUG=${DEBUG:-false}
# 数据库配置
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-mumuai}:${POSTGRES_PASSWORD:-123456}@postgres:5432/${POSTGRES_DB:-mumuai_novel}
- DB_HOST=postgres
- DB_PORT=5432
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-123456}
# PostgreSQL 连接池配置
- DATABASE_POOL_SIZE=${DATABASE_POOL_SIZE:-30}
- DATABASE_MAX_OVERFLOW=${DATABASE_MAX_OVERFLOW:-20}
- DATABASE_POOL_TIMEOUT=${DATABASE_POOL_TIMEOUT:-60}
- DATABASE_POOL_RECYCLE=${DATABASE_POOL_RECYCLE:-1800}
- DATABASE_POOL_PRE_PING=${DATABASE_POOL_PRE_PING:-True}
- DATABASE_POOL_USE_LIFO=${DATABASE_POOL_USE_LIFO:-True}
# 代理配置(可选)
- HTTP_PROXY=${HTTP_PROXY:-}
- HTTPS_PROXY=${HTTPS_PROXY:-}
- NO_PROXY=${NO_PROXY:-localhost,127.0.0.1}
# AI 服务配置
- OPENAI_API_KEY=${OPENAI_API_KEY:-}
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-https://api.openai.com/v1}
- GEMINI_API_KEY=${GEMINI_API_KEY:-}
- GEMINI_BASE_URL=${GEMINI_BASE_URL:-}
- ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-}
- ANTHROPIC_BASE_URL=${ANTHROPIC_BASE_URL:-}
- DEFAULT_AI_PROVIDER=${DEFAULT_AI_PROVIDER:-openai}
- DEFAULT_MODEL=${DEFAULT_MODEL:-gpt-4o-mini}
- DEFAULT_TEMPERATURE=${DEFAULT_TEMPERATURE:-0.7}
- DEFAULT_MAX_TOKENS=${DEFAULT_MAX_TOKENS:-32000}
# LinuxDO OAuth 配置
- LINUXDO_CLIENT_ID=${LINUXDO_CLIENT_ID:-11111}
- LINUXDO_CLIENT_SECRET=${LINUXDO_CLIENT_SECRET:-11111}
- LINUXDO_REDIRECT_URI=${LINUXDO_REDIRECT_URI:-http://localhost:8000/api/auth/linuxdo/callback}
- FRONTEND_URL=${FRONTEND_URL:-http://localhost:8000}
# 本地账户登录配置
- LOCAL_AUTH_ENABLED=${LOCAL_AUTH_ENABLED:-true}
- LOCAL_AUTH_USERNAME=${LOCAL_AUTH_USERNAME:-admin}
- LOCAL_AUTH_PASSWORD=${LOCAL_AUTH_PASSWORD:-admin123}
- LOCAL_AUTH_DISPLAY_NAME=${LOCAL_AUTH_DISPLAY_NAME:-本地管理员}
# 会话配置
- SESSION_EXPIRE_MINUTES=${SESSION_EXPIRE_MINUTES:-120}
- SESSION_REFRESH_THRESHOLD_MINUTES=${SESSION_REFRESH_THRESHOLD_MINUTES:-30}
- SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-true}
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: 30s
networks:
- ai-story-network
volumes:
postgres_data:
driver: local
networks:
ai-story-network:
driver: bridge
```
</details>
```bash
# 3. 启动服务
docker-compose up -d
# 4. 查看日志
docker-compose logs -f
# 5. 更新到最新版本
docker-compose pull
docker-compose up -d
```
> **💡 提示**: Docker Hub 镜像已包含所有依赖和模型文件,无需额外下载
### 本地开发 / 从源码构建
#### 前置准备
```bash
# ⚠️ 重要:如果从源码构建,需要先下载 embedding 模型文件
# 模型文件较大(约 400MB),需放置到以下目录:
# backend/embedding/models--sentence-transformers--paraphrase-multilingual-MiniLM-L12-v2/
#
# 📥 获取方式:
# - 加入项目 QQ 群或 Linux DO 讨论区获取下载链接
# - 群号:见项目主页
# - Linux DOhttps://linux.do/t/topic/1100112
```
#### 后端
```bash
cd backend
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
# 配置 .env 文件
cp .env.example .env
# 编辑 .env 填入必要配置
# 启动 PostgreSQL(可使用 Docker
docker run -d --name postgres \
-e POSTGRES_PASSWORD=your_password \
-e POSTGRES_DB=mumuai_novel \
-p 5432:5432 \
postgres:18-alpine
# 启动后端
python -m uvicorn app.main:app --host localhost --port 8000 --reload
```
#### 前端
```bash
cd frontend
npm install
npm run dev # 开发模式
npm run build # 生产构建
```
## ⚙️ 配置说明
### 必需配置
创建 `.env` 文件:
```bash
# PostgreSQL 数据库(必需)
DATABASE_URL=postgresql+asyncpg://mumuai:your_password@postgres:5432/mumuai_novel
POSTGRES_PASSWORD=your_secure_password
# AI 服务
OPENAI_API_KEY=your_openai_key
OPENAI_BASE_URL=https://api.openai.com/v1
DEFAULT_AI_PROVIDER=openai
DEFAULT_MODEL=gpt-4o-mini
# 本地账户登录
LOCAL_AUTH_ENABLED=true
LOCAL_AUTH_USERNAME=admin
LOCAL_AUTH_PASSWORD=your_password
```
### 可选配置
```bash
# LinuxDO OAuth
LINUXDO_CLIENT_ID=your_client_id
LINUXDO_CLIENT_SECRET=your_client_secret
LINUXDO_REDIRECT_URI=http://localhost:8000/api/auth/callback
# PostgreSQL 连接池(高并发优化)
DATABASE_POOL_SIZE=30
DATABASE_MAX_OVERFLOW=20
# 会话 Cookie Secure 标记
# 默认 true,适合 HTTPS 部署;如果使用 HTTP 访问并且浏览器不保存登录 Cookie,可设为 false
SESSION_COOKIE_SECURE=true
```
> **🔐 Cookie Secure 说明**
>
> - HTTPS 部署:建议保持 `SESSION_COOKIE_SECURE=true`,浏览器只会通过 HTTPS 发送登录 Cookie。
> - HTTP 部署:如果登录后浏览器没有保存 Cookie,请在 `.env` 中设置 `SESSION_COOKIE_SECURE=false`,然后重启后端或 Docker 容器。
> - Docker Compose 示例默认使用 `SESSION_COOKIE_SECURE=${SESSION_COOKIE_SECURE:-true}`,如需关闭必须在 `.env` 中显式配置。
### 中转 API 配置
支持所有 OpenAI 兼容格式的中转服务:
```bash
# New API 示例
OPENAI_API_KEY=sk-xxxxxxxx
OPENAI_BASE_URL=https://api.new-api.com/v1
# 其他中转服务
OPENAI_BASE_URL=https://your-proxy-service.com/v1
```
## 🐳 Docker 部署详情
### 服务架构
- **postgres**: PostgreSQL 18 数据库
- 端口: 5432
- 数据持久化: `postgres_data` volume
- 初始化脚本: `backend/scripts/init_postgres.sql`(自动挂载)
- 优化配置: 支持 80-150 并发用户
- **mumuainovel**: 主应用服务
- 端口: 8000
- 日志目录: `./logs`
- 配置挂载: `.env` 文件
- 自动等待数据库就绪
- 健康检查: 每 30 秒检测一次
### 重要文件说明
| 文件 | 说明 | 是否必需 |
|------|------|---------|
| `.env` | 环境配置(API Key、数据库密码等) | ✅ 必需 |
| `docker-compose.yml` | 服务编排配置 | ✅ 必需 |
| `backend/scripts/init_postgres.sql` | PostgreSQL 扩展安装脚本 | ✅ 自动挂载 |
| `backend/embedding/models--*/` | Embedding 模型文件 | ⚠️ 自建需要 |
> **注意**: 使用 Docker Hub 镜像时,模型文件已包含在镜像中,无需额外下载
### 常用命令
```bash
# 启动服务
docker-compose up -d
# 查看状态
docker-compose ps
# 查看日志
docker-compose logs -f
# 停止服务
docker-compose down
# 重启服务
docker-compose restart
# 查看资源使用
docker stats
```
### 数据持久化
- `./postgres_data` - PostgreSQL 数据库文件
- `./logs` - 应用日志文件
### 端口配置
修改 `docker-compose.yml` 中的端口映射:
```yaml
ports:
- "8800:8000" # 宿主机:容器
```
## 📁 项目结构
## 3. 项目目录概览
```
MuMuAINovel/
├── backend/ # 后端服务
│ ├── app/
│ │ ├── api/ # API 路由
│ │ ├── api/ # REST API 路由
│ │ ├── models/ # 数据模型
│ │ ├── services/ # 业务逻辑
│ │ ├── services/ # 业务逻辑(含 AI 调用)
│ │ ├── middleware/ # 中间件
│ │ ├── database.py # 数据库连接
│ │ ├── mcp/ # MCP 插件集成
│ │ └── main.py # 应用入口
│ ├── scripts/ # 工具脚本
── requirements.txt # Python 依赖
│ ├── alembic/ # 数据库迁移
── scripts/ # 初始化与运维脚本
│ ├── static/ # 前端构建产物(生产)
│ └── requirements.txt
├── frontend/ # 前端应用
│ ├── src/
│ │ ├── pages/ # 页面组件
│ │ ├── pages/ # 页面
│ │ ├── components/ # 通用组件
│ │ ├── services/ # API 服务
│ │ ── store/ # 状态管理
│ │ ├── services/ # API 封装
│ │ ── store/ # 状态管理
│ │ └── theme/ # 主题配置
│ └── package.json
├── docker-compose.yml # Docker Compose 配置
├── Dockerfile # Docker 镜像构建
├── images/ # 文档与截图资源
├── storage/ # 用户生成资源(如封面)
├── logs/ # 运行日志
├── docker-compose.yml # 容器编排
├── Dockerfile # 镜像构建
└── README.md
```
## 🛠️ 技术栈
**后端**: FastAPI • PostgreSQL • SQLAlchemy • OpenAI/Claude/Gemini SDK
**前端**: React 18 • TypeScript • Ant Design • Zustand • Vite
## 📖 使用指南
1. **登录系统** - 使用本地账户或 LinuxDO 账户
2. **创建项目** - 选择"使用向导创建"
3. **AI 生成** - 输入基本信息,AI 自动生成大纲和角色
4. **编辑完善** - 管理角色关系,生成和编辑章节
### API 文档
- Swagger UI: `http://localhost:8000/docs`
- ReDoc: `http://localhost:8000/redoc`
## 🤝 贡献
欢迎提交 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
### 贡献者
感谢所有为本项目做出贡献的开发者!
<a href="https://github.com/xiamuceer-j/MuMuAINovel/graphs/contributors">
<img src="https://contrib.rocks/image?repo=xiamuceer-j/MuMuAINovel" />
</a>
## 📝 许可证
本项目采用 [GNU General Public License v3.0](LICENSE)
**GPL v3 意味着:**
- ✅ 可自由使用、修改和分发
- ✅ 可用于商业目的
- 📝 必须开源修改版本
- 📝 必须保留原作者版权
- 📝 衍生作品必须使用 GPL v3 协议
## 🙏 致谢
- [FastAPI](https://fastapi.tiangolo.com/) - Python Web 框架
- [React](https://react.dev/) - 前端框架
- [Ant Design](https://ant.design/) - UI 组件库
- [PostgreSQL](https://www.postgresql.org/) - 数据库
## 📧 联系方式
- 提交 [Issue](https://github.com/xiamuceer-j/MuMuAINovel/issues)
- Linux DO [讨论](https://linux.do/t/topic/1106333)
- 加入QQ群 [QQ群](frontend/public/qq.jpg)
- 加入WX群 [WX群](frontend/public/WX.png)
| 目录 | 作用 |
|------|------|
| `backend/app/api/` | 项目、章节、角色、提示词等业务接口 |
| `backend/app/services/` | AI 生成、润色、向量记忆等核心逻辑 |
| `frontend/src/pages/` | 书架、项目详情、设置、提示词模板等页面 |
| `backend/alembic/` | PostgreSQL / SQLite schema 迁移 |
| `backend/scripts/` | 数据库初始化、入口脚本 |
---
<div align="center">
## 4. 核心业务功能
**如果这个项目对你有帮助,请给个 ⭐️ Star!**
- **智能创作向导**:根据题材与设定,AI 自动生成大纲、角色档案与世界观框架。
- **多 AI 模型支持**:接入 OpenAI、Claude、Gemini 等,支持自定义 Base URL(兼容中转 API)。
- **项目管理与书架**:多项目并行,支持导入导出,便于备份与迁移。
- **角色与组织管理**:人物卡片、关系图谱、组织架构可视化编辑。
- **职业等级体系**:可自定义修仙境界、魔法等级等成长体系。
- **章节创作与润色**:章节生成、续写、重写、字数控制及 diff 对比。
- **世界观与大纲**:结构化维护故事背景与情节脉络。
- **伏笔管理**:追踪未回收伏笔,可视化时间线提醒。
- **灵感模式**:快速生成创作点子与情节灵感。
- **提示词工坊**:浏览、导入社区 Prompt 模板,可视化编辑自有模板。
- **拆书功能**:分析既有作品结构,辅助仿写与续写。
- **封面生成**:基于项目信息 AI 生成封面图。
- **长期记忆**:基于 Embedding 的语义记忆,保持跨章节一致性。
- **用户与认证**:本地账户、邮箱验证、LinuxDO OAuth;多用户数据隔离。
- **系统设置**:SMTP、AI 密钥、主题等可在线配置。
Made with ❤️
---
</div>
## 5. 实际应用场景示例
## Star History
### 适用行业与领域
<a href="https://www.star-history.com/#xiamuceer-j/MuMuAINovel&type=date&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=xiamuceer-j/MuMuAINovel&type=date&theme=dark&legend=top-left" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=xiamuceer-j/MuMuAINovel&type=date&legend=top-left" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=xiamuceer-j/MuMuAINovel&type=date&legend=top-left" />
</picture>
</a>
- 网络文学与自媒体连载
- 游戏、动漫、影视衍生文案
- 教育培训中的创意写作练习
- 个人 IP 与世界观孵化
## History
### 典型场景
![Alt](https://repobeats.axiom.co/api/embed/ee7141a5f269c64759302e067abe23b46796bafe.svg "Repobeats analytics image")
**场景一:新人作者快速开书**
输入题材与基调后,由向导生成大纲与主要角色,再逐章扩写;适合从零起步、需要结构指引的作者。
**场景二:长篇连载一致性维护**
在数百章规模下,通过角色关系图、伏笔管理与向量记忆,减少人设崩坏与情节穿帮。
**场景三:拆书仿写与风格学习**
导入参考作品或章节,分析结构后结合自身设定续写,用于练笔或同人向创作。
---
## 6. 帮助解决的核心问题
- **创作冷启动难**:自动生成大纲、角色与世界观,减少空白页焦虑。
- **设定易混乱**:关系图、职业体系、伏笔追踪让长篇设定可检索、可维护。
- **AI 调用分散**:统一配置多模型与 Prompt,降低切换成本。
- **协作与部署复杂**:Docker 私有化部署,数据留在自有环境。
- **章节质量不稳定**:润色、重写、分析建议一键应用,提升成稿效率。
- **提示词难以沉淀**:模板工坊与可视化编辑,复用优质 Prompt。
---
## 7. 快速开始
### 环境要求
| 方式 | 要求 |
|------|------|
| **Docker 部署(推荐)** | Docker 20.10+、Docker Compose 2.0+;至少一个 LLM API Key |
| **本地开发** | Python 3.11、Node.js 18+、PostgreSQL 18(或 SQLite);稳定网络(调用外部 AI API) |
**硬件建议(个人使用)**2 核 CPU、2 GB 内存、10 GB 磁盘;生产或小团队建议 4 核 / 8 GB 内存。项目依赖外部 AI API,无需本地 GPU。
### 安装步骤(Docker
```bash
# 1. 获取项目代码
git clone <你的仓库地址>
cd MuMuAINovel
# 2. 配置环境变量
cp backend/.env.example .env
# 编辑 .env:至少配置 AI API Key、数据库密码等
# 3. 确认必要文件存在
# - .env
# - backend/scripts/init_postgres.sql
```
### 运行步骤
```bash
# 启动全部服务
docker compose up -d
# 查看状态与日志
docker compose ps
docker compose logs -f
```
### 访问地址
| 服务 | 默认地址 |
|------|----------|
| **Web 应用** | http://localhost:8000 |
| **API 文档(Swagger** | http://localhost:8000/docs |
| **API 文档(ReDoc** | http://localhost:8000/redoc |
| **PostgreSQL** | localhost:5432(容器内通过服务名 `postgres` 访问) |
> 若修改 `.env` 中 `APP_PORT`(例如 `10003`),访问地址为 `http://localhost:10003`。
### 默认账号
`.env` 中启用本地登录时,默认配置示例:
| 项 | 默认值 |
|----|--------|
| 用户名 | `admin` |
| 密码 | `admin123` |
**请在生产环境中立即修改默认密码。**
### 本地开发(可选)
**后端**
```bash
cd backend
python -m venv .venv
# Windows: .venv\Scripts\activate
# Linux/macOS: source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
# 配置 DATABASE_URL 与 AI Key 后启动
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`,产物由后端 `backend/static` 托管。
### 必需配置示例
```bash
# 数据库(Docker 场景由 compose 注入,本地需自行填写)
POSTGRES_PASSWORD=your_secure_password
# AI 服务(至少配置一项)
OPENAI_API_KEY=your_key
OPENAI_BASE_URL=https://api.openai.com/v1
DEFAULT_AI_PROVIDER=openai
DEFAULT_MODEL=gpt-4o-mini
# 本地登录
LOCAL_AUTH_ENABLED=true
LOCAL_AUTH_USERNAME=admin
LOCAL_AUTH_PASSWORD=your_password
```
### 常见问题
| 问题 | 解决方案 |
|------|----------|
| 启动后无法访问 | 检查 `APP_PORT` 映射与防火墙;`docker compose ps` 确认容器健康 |
| 登录后 Cookie 未保存 | HTTP 部署时将 `SESSION_COOKIE_SECURE=false` 写入 `.env` 并重启 |
| AI 调用失败 | 核对 API Key、Base URL 及网络代理(`HTTP_PROXY` / `HTTPS_PROXY` |
| 数据库连接失败 | 确认 PostgreSQL 容器已 healthy;检查 `DATABASE_URL` 用户名密码 |
| 自建镜像缺少 Embedding | 使用官方镜像或按文档将模型放入 `backend/embedding/` 目录 |
---
## 8. 运行示例截图
![image-20260518142402652](images/image-20260518142402652.png)
![image-20260518142411086](images/image-20260518142411086.png)
![image-20260518142620791](images/image-20260518142620791.png)
![image-20260518142641345](images/image-20260518142641345.png)
![image-20260518143108652](images/image-20260518143108652.png)
![image-20260518143113951](images/image-20260518143113951.png)
![image-20260518143117651](images/image-20260518143117651.png)
![image-20260518143121175](images/image-20260518143121175.png)
![image-20260518143125197](images/image-20260518143125197.png)
![image-20260518143134898](images/image-20260518143134898.png)
+10 -10
View File
@@ -1,5 +1,5 @@
# ==========================================
# MuMuAINovel 配置文件示例
# 墨木灵思 配置文件示例
# ==========================================
# 复制此文件为 .env 并修改配置值
# cp .env.example .env
@@ -7,8 +7,8 @@
# ==========================================
# 应用配置
# ==========================================
APP_NAME=MuMuAINovel
APP_VERSION=1.4.7
APP_NAME=墨木灵思
APP_VERSION=1.4.8
APP_HOST=0.0.0.0
APP_PORT=8000
DEBUG=false
@@ -19,13 +19,13 @@ TZ=Asia/Shanghai
# ==========================================
# PostgreSQL 连接信息
POSTGRES_DB=mumuai_novel
POSTGRES_USER=mumuai
POSTGRES_DB=mumulingsi_novel
POSTGRES_USER=mumulingsi
POSTGRES_PASSWORD=123456
POSTGRES_PORT=5432
# 数据库连接 URL(Docker 部署时自动生成)
# DATABASE_URL=postgresql+asyncpg://mumuai:123456@localhost:5432/mumuai_novel
# DATABASE_URL=postgresql+asyncpg://mumulingsi:123456@localhost:5432/mumulingsi_novel
# ==========================================
# SQLite 数据库配置
@@ -45,7 +45,7 @@ LOG_BACKUP_COUNT=30
# ==========================================
# CORS 配置
# ==========================================
CORS_ORIGINS=["http://localhost:8000","http://127.0.0.1:8000"]
CORS_ORIGINS=["http://localhost:3000","http://127.0.0.1:8000"]
# ==========================================
# 代理配置(可选)
@@ -106,7 +106,7 @@ SMTP_PASSWORD=your-qq-smtp-auth-code
SMTP_USE_TLS=false
SMTP_USE_SSL=true
SMTP_FROM_EMAIL=your-email@qq.com
SMTP_FROM_NAME=MuMuAINovel
SMTP_FROM_NAME=墨木灵思
EMAIL_AUTH_ENABLED=true
EMAIL_REGISTER_ENABLED=true
EMAIL_VERIFICATION_CODE_TTL_MINUTES=10
@@ -116,11 +116,11 @@ EMAIL_VERIFICATION_RESEND_INTERVAL_SECONDS=60
# 提示词工坊配置
# ==========================================
# 运行模式:client(本地部署)或 server(云端服务器)
# 只有 mumuverse.space:1566 需要设置为 server
# 云端服务配置示例
WORKSHOP_MODE=client
# 云端服务地址(client 模式使用)
WORKSHOP_CLOUD_URL=https://mumuverse.space:1566
WORKSHOP_CLOUD_URL=
# 云端 API 请求超时时间(秒)
WORKSHOP_API_TIMEOUT=30
+2 -2
View File
@@ -1,5 +1,5 @@
# Alembic Database Migration Profile - PostgreSQL
# Database version management for the MuMuAINovel project
# Database version management for the mumulingsi project
[alembic]
# Migration Script storage directory (PostgreSQL)
@@ -10,7 +10,7 @@ file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)
# Database connection string
# Note: The actual connection string is read from the environment variable in env.py
# sqlalchemy.url = postgresql+asyncpg://mumuai:password@localhost:5432/mumuai_novel
# sqlalchemy.url = postgresql+asyncpg://mumulingsi:password@localhost:5432/mumulingsi_novel
# Log Configuration
[loggers]
+1 -1
View File
@@ -1,5 +1,5 @@
# Alembic Database Migration Profile - SQLite
# Database version management for the MuMuAINovel project
# Database version management for the mumulingsi project
[alembic]
# Migration Script storage directory (SQLite)
+2 -2
View File
@@ -59,7 +59,7 @@ alembic -c alembic-postgres.ini current
#### 配置环境变量
```bash
# .env 文件
DATABASE_URL=sqlite+aiosqlite:///./data/mumuai.db
DATABASE_URL=sqlite+aiosqlite:///./data/mumulingsi.db
```
#### 生成迁移脚本
@@ -115,7 +115,7 @@ alembic -c alembic-sqlite.ini current
```bash
# 切换到 SQLite
DATABASE_URL=sqlite+aiosqlite:///./data/mumuai.db
DATABASE_URL=sqlite+aiosqlite:///./data/mumulingsi.db
alembic -c alembic-sqlite.ini upgrade head
# 切换到 PostgreSQL
+2 -1
View File
@@ -25,7 +25,8 @@ from app.models import (
Settings, WritingStyle, ProjectDefaultStyle,
RelationshipType, CharacterRelationship, Organization, OrganizationMember,
StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask,
RegenerationTask, Career, CharacterCareer, User, MCPPlugin, PromptTemplate
RegenerationTask, Career, CharacterCareer, User, MCPPlugin, PromptTemplate,
BackgroundTask
)
# Alembic Config 对象
@@ -28,7 +28,7 @@ def upgrade() -> None:
op.add_column('settings', sa.Column('smtp_use_tls', sa.Boolean(), server_default='0', nullable=False, comment='是否启用 TLS'))
op.add_column('settings', sa.Column('smtp_use_ssl', sa.Boolean(), server_default='1', nullable=False, comment='是否启用 SSL'))
op.add_column('settings', sa.Column('smtp_from_email', sa.String(length=255), nullable=True, comment='发件人邮箱'))
op.add_column('settings', sa.Column('smtp_from_name', sa.String(length=255), server_default='MuMuAINovel', nullable=False, comment='发件人名称'))
op.add_column('settings', sa.Column('smtp_from_name', sa.String(length=255), server_default='墨木灵思', nullable=False, comment='发件人名称'))
op.add_column('settings', sa.Column('email_auth_enabled', sa.Boolean(), server_default='1', nullable=False, comment='是否启用邮箱认证'))
op.add_column('settings', sa.Column('email_register_enabled', sa.Boolean(), server_default='1', nullable=False, comment='是否启用邮箱注册'))
op.add_column('settings', sa.Column('verification_code_ttl_minutes', sa.Integer(), server_default='10', nullable=False, comment='验证码有效期(分钟)'))
+2 -1
View File
@@ -25,7 +25,8 @@ from app.models import (
Settings, WritingStyle, ProjectDefaultStyle,
RelationshipType, CharacterRelationship, Organization, OrganizationMember,
StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask,
RegenerationTask, Career, CharacterCareer, User, MCPPlugin, PromptTemplate
RegenerationTask, Career, CharacterCareer, User, MCPPlugin, PromptTemplate,
BackgroundTask
)
# Alembic Config 对象
@@ -35,7 +35,7 @@ def upgrade() -> None:
batch_op.add_column(sa.Column('smtp_use_tls', sa.Boolean(), server_default='0', nullable=False, comment='是否启用 TLS'))
batch_op.add_column(sa.Column('smtp_use_ssl', sa.Boolean(), server_default='1', nullable=False, comment='是否启用 SSL'))
batch_op.add_column(sa.Column('smtp_from_email', sa.String(length=255), nullable=True, comment='发件人邮箱'))
batch_op.add_column(sa.Column('smtp_from_name', sa.String(length=255), server_default='MuMuAINovel', nullable=False, comment='发件人名称'))
batch_op.add_column(sa.Column('smtp_from_name', sa.String(length=255), server_default='墨木灵思', nullable=False, comment='发件人名称'))
batch_op.add_column(sa.Column('email_auth_enabled', sa.Boolean(), server_default='1', nullable=False, comment='是否启用邮箱认证'))
batch_op.add_column(sa.Column('email_register_enabled', sa.Boolean(), server_default='1', nullable=False, comment='是否启用邮箱注册'))
batch_op.add_column(sa.Column('verification_code_ttl_minutes', sa.Integer(), server_default='10', nullable=False, comment='验证码有效期(分钟)'))
@@ -28,7 +28,7 @@ def upgrade() -> None:
sa.Column('status_message', sa.String(), nullable=True),
sa.Column('progress_details', sa.Text(), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('task_params', sa.Text(), nullable=True),
sa.Column('task_input', sa.Text(), nullable=True),
sa.Column('task_result', sa.Text(), nullable=True),
sa.Column('cancel_requested', sa.Boolean(), default=False),
sa.Column('retry_count', sa.Integer(), default=0),
+5 -5
View File
@@ -291,14 +291,14 @@ def _build_verification_mail_content(scene: str, code: str, ttl_minutes: int) ->
"reset_password": "重置密码验证码",
}
scene_desc_map = {
"register": "欢迎注册 MuMuAINovel",
"login": "你正在使用邮箱验证码登录 MuMuAINovel",
"reset_password": "你正在重置 MuMuAINovel 账号密码。",
"register": "欢迎注册 墨木灵思",
"login": "你正在使用邮箱验证码登录 墨木灵思",
"reset_password": "你正在重置 墨木灵思 账号密码。",
}
scene_title = scene_title_map.get(scene, "邮箱验证码")
scene_desc = scene_desc_map.get(scene, "你正在进行邮箱身份验证。")
subject = f"MuMuAINovel {scene_title}"
subject = f"墨木灵思 {scene_title}"
text_body = (
f"{scene_desc}\n\n"
f"你的验证码是:{code}\n"
@@ -307,7 +307,7 @@ def _build_verification_mail_content(scene: str, code: str, ttl_minutes: int) ->
)
html_body = f"""
<div style="font-family: Arial, PingFang SC, Microsoft YaHei, sans-serif; line-height: 1.8; color: #1f2937;">
<h2 style="margin-bottom: 16px;">MuMuAINovel {scene_title}</h2>
<h2 style="margin-bottom: 16px;">墨木灵思 {scene_title}</h2>
<p>{scene_desc}</p>
<p>你的验证码为:</p>
<div style="display: inline-block; padding: 10px 18px; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; font-size: 28px; font-weight: 700; letter-spacing: 4px; color: #2563eb;">
+3 -3
View File
@@ -21,8 +21,8 @@ def require_login(request: Request):
# GitHub API配置
GITHUB_API_BASE = "https://api.github.com"
REPO_OWNER = "xiamuceer-j"
REPO_NAME = "MuMuAINovel"
REPO_OWNER = "mumulingsi-project"
REPO_NAME = "mumulingsi"
# 缓存配置
_cache = {
@@ -88,7 +88,7 @@ async def fetch_github_commits(page: int = 1, per_page: int = 30) -> List[dict]:
headers = {
"Accept": "application/vnd.github.v3+json",
"User-Agent": "MuMuAINovel-App"
"User-Agent": "mumulingsi-App"
}
try:
+16 -15
View File
@@ -57,7 +57,7 @@ from app.services.memory_service import memory_service
from app.services.foreshadow_service import foreshadow_service
from app.services.chapter_regenerator import ChapterRegenerator
from app.logger import get_logger
from app.api.settings import get_user_ai_service
from app.api.settings import get_user_ai_service, get_user_ai_service_from_db_by_usage
from app.utils.sse_response import SSEResponse, create_sse_response
router = APIRouter(prefix="/chapters", tags=["章节管理"])
@@ -797,7 +797,7 @@ async def analyze_chapter_background(
user_id: str,
project_id: str,
task_id: str,
ai_service: AIService
ai_service: Optional[AIService] = None
) -> bool:
"""
后台异步分析章节(支持并发,使用锁保护数据库写入)
@@ -865,6 +865,13 @@ async def analyze_chapter_background(
task.progress = 20
await db_session.commit()
if ai_service is None:
ai_service = await get_user_ai_service_from_db_by_usage(
user_id=user_id,
db=db_session,
usage="chapter_analysis"
)
# 获取已埋入的伏笔列表(用于回收匹配,传入当前章节号以启用智能标记)
existing_foreshadows = await foreshadow_service.get_planted_foreshadows_for_analysis(
db=db_session,
@@ -2229,8 +2236,7 @@ async def _run_chapter_generation_bg(
chapter_id=chapter_id,
user_id=user_id,
project_id=current_chapter.project_id,
task_id=analysis_task.id,
ai_service=ai_service
task_id=analysis_task.id
)
)
@@ -2441,7 +2447,7 @@ async def _run_batch_analysis_in_sequence(
tasks_queue: list[dict[str, int | str]],
user_id: str,
project_id: str,
ai_service: AIService
ai_service: Optional[AIService] = None
) -> None:
"""按章节顺序逐个执行分析任务。"""
for index, task_item in enumerate(tasks_queue, start=1):
@@ -2477,8 +2483,7 @@ async def batch_analyze_unanalyzed_chapters(
project_id: str,
payload: BatchAnalyzeUnanalyzedRequest,
request: Request,
db: AsyncSession = Depends(get_db),
user_ai_service: AIService = Depends(get_user_ai_service)
db: AsyncSession = Depends(get_db)
):
"""自动识别项目中未完成分析的章节,并按章节顺序逐个启动分析。"""
user_id = getattr(request.state, "user_id", None)
@@ -2585,8 +2590,7 @@ async def batch_analyze_unanalyzed_chapters(
_run_batch_analysis_in_sequence(
tasks_queue=tasks_queue,
user_id=user_id,
project_id=project_id,
ai_service=user_ai_service
project_id=project_id
)
)
@@ -2818,8 +2822,7 @@ async def trigger_chapter_analysis(
chapter_id: str,
request: Request,
background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db),
user_ai_service: AIService = Depends(get_user_ai_service)
db: AsyncSession = Depends(get_db)
):
"""
手动触发章节分析(用于重新分析或分析旧章节)
@@ -2876,8 +2879,7 @@ async def trigger_chapter_analysis(
chapter_id=chapter_id,
user_id=user_id,
project_id=project.id,
task_id=task_id,
ai_service=user_ai_service
task_id=task_id
)
return {
@@ -3274,8 +3276,7 @@ async def execute_batch_generation_in_order(
chapter_id=chapter_id,
user_id=user_id,
project_id=task.project_id,
task_id=analysis_task.id,
ai_service=ai_service
task_id=analysis_task.id
)
# 直接根据返回值判断
+355
View File
@@ -2451,6 +2451,284 @@ async def expand_outline_generator(
yield await tracker.error(f"展开失败: {str(e)}")
async def _save_background_task_result(db: AsyncSession, task_id: str, result_data: Dict[str, Any]) -> None:
"""保存后台任务结果到 background_tasks.task_result。"""
from app.models.background_task import BackgroundTask
task_result = await db.execute(select(BackgroundTask).where(BackgroundTask.id == task_id))
task = task_result.scalar_one_or_none()
if task:
task.task_result = result_data
await db.commit()
async def _run_outline_expansion_background(
task_id: str,
user_id: str,
outline_id: str,
data: Dict[str, Any]
):
"""后台执行单个大纲展开并可直接创建章节。"""
from app.database import get_engine
from app.api.settings import get_user_ai_service_from_db
from app.services.background_task_service import TaskProgressTracker
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession as BgAsyncSession
engine = await get_engine(user_id)
AsyncSessionLocal = async_sessionmaker(engine, class_=BgAsyncSession, expire_on_commit=False)
async with AsyncSessionLocal() as bg_db:
tracker = TaskProgressTracker(task_id, user_id, "大纲展开")
try:
await tracker.start("开始大纲展开任务...")
target_chapter_count = int(data.get("target_chapter_count", 3))
expansion_strategy = data.get("expansion_strategy", "balanced")
enable_scene_analysis = data.get("enable_scene_analysis", True)
auto_create_chapters = data.get("auto_create_chapters", True)
batch_size = int(data.get("batch_size", 5))
await tracker.loading("加载大纲信息...", 0.3)
outline_result = await bg_db.execute(select(Outline).where(Outline.id == outline_id))
outline = outline_result.scalar_one_or_none()
if not outline:
raise ValueError("大纲不存在")
await tracker.loading("加载项目信息...", 0.7)
project_result = await bg_db.execute(select(Project).where(Project.id == outline.project_id))
project = project_result.scalar_one_or_none()
if not project:
raise ValueError("项目不存在")
if await tracker.check_cancelled():
return
await tracker.preparing(f"准备展开《{outline.title}》为 {target_chapter_count} 章...")
bg_ai_service = await get_user_ai_service_from_db(user_id, bg_db)
expansion_service = PlotExpansionService(bg_ai_service)
await tracker.generating(
current_chars=0,
estimated_total=target_chapter_count * 500,
message=f"AI分析大纲《{outline.title}》,生成章节规划..."
)
chapter_plans = await expansion_service.analyze_outline_for_chapters(
outline=outline,
project=project,
db=bg_db,
target_chapter_count=target_chapter_count,
expansion_strategy=expansion_strategy,
enable_scene_analysis=enable_scene_analysis,
provider=data.get("provider"),
model=data.get("model"),
batch_size=batch_size,
progress_callback=None
)
if await tracker.check_cancelled():
return
if not chapter_plans:
raise ValueError("AI分析失败,未能生成章节规划")
await tracker.parsing(f"规划生成完成,共 {len(chapter_plans)} 个章节")
created_chapters = None
if auto_create_chapters:
await tracker.saving("创建章节记录...", 0.3)
created_chapters = await expansion_service.create_chapters_from_plans(
outline_id=outline_id,
chapter_plans=chapter_plans,
project_id=outline.project_id,
db=bg_db,
start_chapter_number=None
)
await tracker.saving(f"成功创建 {len(created_chapters)} 个章节记录", 0.8)
result_data = {
"outline_id": outline_id,
"outline_title": outline.title,
"target_chapter_count": target_chapter_count,
"actual_chapter_count": len(chapter_plans),
"expansion_strategy": expansion_strategy,
"chapter_plans": chapter_plans,
"created_chapters": [
{
"id": ch.id,
"chapter_number": ch.chapter_number,
"title": ch.title,
"summary": ch.summary,
"outline_id": ch.outline_id,
"sub_index": ch.sub_index,
"status": ch.status
}
for ch in created_chapters
] if created_chapters else None
}
await _save_background_task_result(bg_db, task_id, result_data)
await tracker.complete(f"{outline.title}》展开完成")
except Exception as e:
logger.error(f"后台大纲展开失败: {str(e)}", exc_info=True)
try:
if bg_db.in_transaction():
await bg_db.rollback()
except Exception:
pass
await tracker.error(str(e))
async def _run_batch_outline_expansion_background(
task_id: str,
user_id: str,
data: Dict[str, Any]
):
"""后台执行批量大纲展开并可直接创建章节。"""
from app.database import get_engine
from app.api.settings import get_user_ai_service_from_db
from app.services.background_task_service import TaskProgressTracker
from sqlalchemy.ext.asyncio import async_sessionmaker, AsyncSession as BgAsyncSession
engine = await get_engine(user_id)
AsyncSessionLocal = async_sessionmaker(engine, class_=BgAsyncSession, expire_on_commit=False)
async with AsyncSessionLocal() as bg_db:
tracker = TaskProgressTracker(task_id, user_id, "批量大纲展开")
try:
await tracker.start("开始批量大纲展开任务...")
project_id = data.get("project_id")
chapters_per_outline = int(data.get("chapters_per_outline", 3))
expansion_strategy = data.get("expansion_strategy", "balanced")
auto_create_chapters = data.get("auto_create_chapters", True)
outline_ids = data.get("outline_ids")
await tracker.loading("加载项目信息...", 0.4)
project_result = await bg_db.execute(select(Project).where(Project.id == project_id))
project = project_result.scalar_one_or_none()
if not project:
raise ValueError("项目不存在")
await tracker.loading("获取大纲列表...", 0.8)
if outline_ids:
outlines_result = await bg_db.execute(
select(Outline)
.where(Outline.project_id == project_id, Outline.id.in_(outline_ids))
.order_by(Outline.order_index)
)
else:
outlines_result = await bg_db.execute(
select(Outline)
.where(Outline.project_id == project_id)
.order_by(Outline.order_index)
)
outlines = outlines_result.scalars().all()
if not outlines:
raise ValueError("没有找到要展开的大纲")
total_outlines = len(outlines)
await tracker.preparing(f"共找到 {total_outlines} 个大纲,准备批量展开...")
bg_ai_service = await get_user_ai_service_from_db(user_id, bg_db)
expansion_service = PlotExpansionService(bg_ai_service)
expansion_results = []
skipped_outlines = []
total_chapters_created = 0
for idx, outline in enumerate(outlines):
if await tracker.check_cancelled():
return
await tracker.generating(
current_chars=idx * chapters_per_outline * 500,
estimated_total=total_outlines * chapters_per_outline * 500,
message=f"处理第 {idx + 1}/{total_outlines} 个大纲:《{outline.title}"
)
existing_chapters_result = await bg_db.execute(
select(Chapter).where(Chapter.outline_id == outline.id).limit(1)
)
existing_chapter = existing_chapters_result.scalar_one_or_none()
if existing_chapter:
skipped_outlines.append({
"outline_id": outline.id,
"outline_title": outline.title,
"reason": "已展开"
})
await tracker.warning(f"{outline.title}》已展开过,已跳过")
continue
chapter_plans = await expansion_service.analyze_outline_for_chapters(
outline=outline,
project=project,
db=bg_db,
target_chapter_count=chapters_per_outline,
expansion_strategy=expansion_strategy,
enable_scene_analysis=data.get("enable_scene_analysis", True),
provider=data.get("provider"),
model=data.get("model")
)
created_chapters = None
if auto_create_chapters:
created_chapters = await expansion_service.create_chapters_from_plans(
outline_id=outline.id,
chapter_plans=chapter_plans,
project_id=outline.project_id,
db=bg_db,
start_chapter_number=None
)
total_chapters_created += len(created_chapters)
expansion_results.append({
"outline_id": outline.id,
"outline_title": outline.title,
"target_chapter_count": chapters_per_outline,
"actual_chapter_count": len(chapter_plans),
"expansion_strategy": expansion_strategy,
"chapter_plans": chapter_plans,
"created_chapters": [
{
"id": ch.id,
"chapter_number": ch.chapter_number,
"title": ch.title,
"summary": ch.summary,
"outline_id": ch.outline_id,
"sub_index": ch.sub_index,
"status": ch.status
}
for ch in created_chapters
] if created_chapters else None
})
await tracker.generating(
current_chars=(idx + 1) * chapters_per_outline * 500,
estimated_total=total_outlines * chapters_per_outline * 500,
message=f"{outline.title}》展开完成 ({len(chapter_plans)} 章)"
)
await tracker.parsing("整理批量展开结果...")
result_data = {
"project_id": project_id,
"total_outlines_expanded": len(expansion_results),
"total_chapters_created": total_chapters_created,
"skipped_count": len(skipped_outlines),
"skipped_outlines": skipped_outlines,
"expansion_results": expansion_results
}
await _save_background_task_result(bg_db, task_id, result_data)
await tracker.complete(f"批量展开完成,共创建 {total_chapters_created} 个章节")
except Exception as e:
logger.error(f"后台批量大纲展开失败: {str(e)}", exc_info=True)
try:
if bg_db.in_transaction():
await bg_db.rollback()
except Exception:
pass
await tracker.error(str(e))
@router.post("/{outline_id}/create-single-chapter", summary="一对一创建章节(传统模式)")
async def create_single_chapter_from_outline(
outline_id: str,
@@ -2549,6 +2827,48 @@ async def create_single_chapter_from_outline(
raise HTTPException(status_code=500, detail=f"创建章节失败: {str(e)}")
@router.post("/{outline_id}/expand-background", summary="后台展开单个大纲为多章")
async def expand_outline_to_chapters_background(
outline_id: str,
data: Dict[str, Any],
request: Request,
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="大纲不存在")
user_id = getattr(request.state, 'user_id', None)
await verify_project_access(outline.project_id, user_id, db)
from app.services.background_task_service import background_task_service
task_input = dict(data or {})
task_input["outline_id"] = outline_id
task_input.setdefault("auto_create_chapters", True)
task = await background_task_service.create_task(
user_id=user_id,
project_id=outline.project_id,
task_type="outline_expand",
task_input=task_input,
db=db
)
await background_task_service.spawn_background_task(
task.id, user_id, _run_outline_expansion_background, outline_id, task_input
)
return {
"task_id": task.id,
"task_type": "outline_expand",
"status": "pending",
"message": "大纲展开任务已创建,请通过后台任务面板查看进度"
}
@router.post("/{outline_id}/expand-stream", summary="展开单个大纲为多章(SSE流式)")
async def expand_outline_to_chapters_stream(
outline_id: str,
@@ -2901,6 +3221,41 @@ async def batch_expand_outlines_generator(
yield await SSEResponse.send_error(f"批量展开失败: {str(e)}")
@router.post("/batch-expand-background", summary="后台批量展开大纲为多章")
async def batch_expand_outlines_background(
data: Dict[str, Any],
request: Request,
db: AsyncSession = Depends(get_db)
):
"""创建后台任务批量展开大纲,任务完成后可在右下角后台任务面板查看结果。"""
user_id = getattr(request.state, 'user_id', None)
project = await verify_project_access(data.get("project_id"), user_id, db)
from app.services.background_task_service import background_task_service
task_input = dict(data or {})
task_input.setdefault("auto_create_chapters", True)
task = await background_task_service.create_task(
user_id=user_id,
project_id=project.id,
task_type="outline_batch_expand",
task_input=task_input,
db=db
)
await background_task_service.spawn_background_task(
task.id, user_id, _run_batch_outline_expansion_background, task_input
)
return {
"task_id": task.id,
"task_type": "outline_batch_expand",
"status": "pending",
"message": "批量大纲展开任务已创建,请通过后台任务面板查看进度"
}
@router.post("/batch-expand-stream", summary="批量展开大纲为多章(SSE流式)")
async def batch_expand_outlines_stream(
data: Dict[str, Any],
+121 -6
View File
@@ -19,6 +19,7 @@ from app.schemas.settings import (
SettingsCreate, SettingsUpdate, SettingsResponse,
APIKeyPreset, APIKeyPresetConfig, PresetCreateRequest,
PresetUpdateRequest, PresetResponse, PresetListResponse,
ChapterAnalysisPresetSelectionRequest,
SystemSMTPSettingsResponse, SystemSMTPSettingsUpdate, SMTPTestRequest
)
from app.user_manager import User
@@ -52,6 +53,53 @@ def read_env_defaults() -> Dict[str, Any]:
}
def _safe_load_preferences(raw_preferences: Optional[str]) -> Dict[str, Any]:
"""安全解析用户偏好设置。"""
try:
return json.loads(raw_preferences or '{}')
except (json.JSONDecodeError, TypeError):
return {}
def _get_api_presets_payload(prefs: Dict[str, Any]) -> Dict[str, Any]:
"""获取API预设偏好结构。"""
api_presets = prefs.get('api_presets')
if not isinstance(api_presets, dict):
api_presets = {'presets': [], 'version': '1.0'}
if not isinstance(api_presets.get('presets'), list):
api_presets['presets'] = []
api_presets.setdefault('version', '1.0')
return api_presets
def _get_chapter_analysis_preset_id(prefs: Dict[str, Any]) -> Optional[str]:
"""读取章节内容分析专用API预设ID。"""
preset_id = prefs.get('chapter_analysis_preset_id')
return preset_id if isinstance(preset_id, str) and preset_id.strip() else None
def _build_ai_service_from_config(
*,
config: Dict[str, Any],
user_id: str,
db: AsyncSession,
enable_mcp: bool,
) -> AIService:
"""基于指定配置创建AI服务。"""
return create_user_ai_service_with_mcp(
api_provider=normalize_provider(config.get('api_provider')),
api_key=config.get('api_key') or "",
api_base_url=config.get('api_base_url') or "",
model_name=config.get('llm_model') or app_settings.default_model,
temperature=config.get('temperature') if config.get('temperature') is not None else app_settings.default_temperature,
max_tokens=config.get('max_tokens') if config.get('max_tokens') is not None else app_settings.default_max_tokens,
user_id=user_id,
db_session=db,
system_prompt=config.get('system_prompt'),
enable_mcp=enable_mcp,
)
def require_login(request: Request):
"""依赖:要求用户已登录"""
if not hasattr(request.state, "user") or not request.state.user:
@@ -164,6 +212,15 @@ async def get_user_ai_service_from_db(user_id: str, db: AsyncSession) -> AIServi
"""
从数据库直接创建用户AI服务实例用于后台任务不依赖FastAPI的Depends
"""
return await get_user_ai_service_from_db_by_usage(user_id, db, usage="default")
async def get_user_ai_service_from_db_by_usage(
user_id: str,
db: AsyncSession,
usage: str = "default"
) -> AIService:
"""按用途创建用户AI服务实例。"""
from app.models.mcp_plugin import MCPPlugin
result = await db.execute(
@@ -184,6 +241,23 @@ async def get_user_ai_service_from_db(user_id: str, db: AsyncSession) -> AIServi
mcp_plugins = mcp_result.scalars().all()
enable_mcp = any(plugin.enabled for plugin in mcp_plugins) if mcp_plugins else False
if usage == "chapter_analysis":
prefs = _safe_load_preferences(settings.preferences)
api_presets = _get_api_presets_payload(prefs)
presets = api_presets.get('presets', [])
preset_id = _get_chapter_analysis_preset_id(prefs)
if preset_id:
target_preset = next((p for p in presets if p.get('id') == preset_id), None)
if target_preset and isinstance(target_preset.get('config'), dict):
logger.info(f"用户 {user_id} 使用章节内容分析专用API预设: {target_preset.get('name')}")
return _build_ai_service_from_config(
config=target_preset['config'],
user_id=user_id,
db=db,
enable_mcp=enable_mcp,
)
logger.warning(f"用户 {user_id} 配置的章节内容分析预设不存在,回退默认API配置: {preset_id}")
return create_user_ai_service_with_mcp(
api_provider=settings.api_provider,
api_key=settings.api_key,
@@ -310,9 +384,9 @@ async def test_system_smtp_settings(
if not from_email:
raise HTTPException(status_code=400, detail="请先配置发件人邮箱或 SMTP 用户名")
subject = "MuMuAINovel SMTP 测试邮件"
subject = "墨木灵思 SMTP 测试邮件"
text_body = (
"这是一封来自 MuMuAINovel 系统设置页面的 SMTP 测试邮件。\n\n"
"这是一封来自 墨木灵思 系统设置页面的 SMTP 测试邮件。\n\n"
f"发送时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
f"SMTP 服务商:{settings.smtp_provider}\n"
f"SMTP 主机:{settings.smtp_host}:{settings.smtp_port}\n"
@@ -320,7 +394,7 @@ async def test_system_smtp_settings(
)
html_body = f"""
<div style=\"font-family: Arial, sans-serif; line-height: 1.7; color: #1f1f1f;\">
<h2 style=\"margin-bottom: 12px;\">MuMuAINovel SMTP 测试邮件</h2>
<h2 style=\"margin-bottom: 12px;\">墨木灵思 SMTP 测试邮件</h2>
<p>这是一封来自系统设置页面的 SMTP 测试邮件</p>
<ul>
<li><strong>发送时间</strong>{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</li>
@@ -1043,8 +1117,11 @@ async def get_presets(
logger.warning(f"用户 {user.user_id} 的preferences字段JSON格式错误,重置为空")
prefs = {}
api_presets = prefs.get('api_presets', {'presets': [], 'version': '1.0'})
api_presets = _get_api_presets_payload(prefs)
presets = api_presets.get('presets', [])
chapter_analysis_preset_id = _get_chapter_analysis_preset_id(prefs)
if chapter_analysis_preset_id and not any(p.get('id') == chapter_analysis_preset_id for p in presets):
chapter_analysis_preset_id = None
# 找到激活的预设
active_preset_id = next(
@@ -1057,7 +1134,8 @@ async def get_presets(
return {
"presets": presets,
"total": len(presets),
"active_preset_id": active_preset_id
"active_preset_id": active_preset_id,
"chapter_analysis_preset_id": chapter_analysis_preset_id
}
@@ -1177,7 +1255,7 @@ async def delete_preset(
except json.JSONDecodeError:
raise HTTPException(status_code=500, detail="配置数据格式错误")
api_presets = prefs.get('api_presets', {'presets': [], 'version': '1.0'})
api_presets = _get_api_presets_payload(prefs)
presets = api_presets.get('presets', [])
# 找到预设
@@ -1191,6 +1269,8 @@ async def delete_preset(
# 删除预设
presets = [p for p in presets if p['id'] != preset_id]
if prefs.get('chapter_analysis_preset_id') == preset_id:
prefs.pop('chapter_analysis_preset_id', None)
# 保存回preferences
api_presets['presets'] = presets
@@ -1258,6 +1338,41 @@ async def activate_preset(
}
@router.put("/presets/usage/chapter-analysis")
async def set_chapter_analysis_preset_selection(
data: ChapterAnalysisPresetSelectionRequest,
user: User = Depends(require_login),
db: AsyncSession = Depends(get_db)
):
"""设置章节内容分析专用API预设;为空则使用默认API配置。"""
settings = await get_user_settings(user.user_id, db)
prefs = _safe_load_preferences(settings.preferences)
api_presets = _get_api_presets_payload(prefs)
presets = api_presets.get('presets', [])
preset_id = data.preset_id.strip() if data.preset_id else None
preset_name = None
if preset_id:
target_preset = next((p for p in presets if p.get('id') == preset_id), None)
if not target_preset:
raise HTTPException(status_code=404, detail="预设不存在")
prefs['chapter_analysis_preset_id'] = preset_id
preset_name = target_preset.get('name')
else:
prefs.pop('chapter_analysis_preset_id', None)
prefs['api_presets'] = api_presets
settings.preferences = json.dumps(prefs, ensure_ascii=False)
await db.commit()
logger.info(f"用户 {user.user_id} 设置章节内容分析API预设: {preset_id or '默认配置'}")
return {
"message": "章节内容分析API配置已更新",
"chapter_analysis_preset_id": preset_id,
"preset_name": preset_name
}
@router.post("/presets/{preset_id}/test")
async def test_preset(
preset_id: str,
+109 -11
View File
@@ -1,10 +1,12 @@
"""后台任务API - 查询状态、取消任务"""
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional
from app.database import get_db
from app.models.background_task import BackgroundTask
from app.models.batch_generation_task import BatchGenerationTask
from app.services.background_task_service import background_task_service
from app.logger import get_logger
@@ -54,17 +56,17 @@ async def get_tasks(
limit: int = 20,
db: AsyncSession = Depends(get_db)
):
"""获取项目的后台任务列表"""
"""获取项目的后台任务列表(合并 BackgroundTask 和 BatchGenerationTask"""
user_id = getattr(request.state, 'user_id', None)
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
tasks = await background_task_service.get_project_tasks(
# 查询 BackgroundTask
bg_tasks = await background_task_service.get_project_tasks(
project_id, user_id, db, task_type=task_type, limit=limit
)
return {
"items": [
items = [
{
"id": t.id,
"task_type": t.task_type,
@@ -76,9 +78,53 @@ async def get_tasks(
"created_at": t.created_at.isoformat() if t.created_at else None,
"completed_at": t.completed_at.isoformat() if t.completed_at else None,
}
for t in tasks
for t in bg_tasks
]
}
# 查询 BatchGenerationTask(不按 task_type 过滤,或过滤 chapter_batch 时才查)
if not task_type or task_type == 'chapter_batch':
batch_result = await db.execute(
select(BatchGenerationTask)
.where(
BatchGenerationTask.project_id == project_id,
BatchGenerationTask.user_id == user_id
)
.order_by(BatchGenerationTask.created_at.desc())
.limit(limit)
)
batch_tasks = batch_result.scalars().all()
for bt in batch_tasks:
progress = bt.total_chapters * 100 if bt.total_chapters > 0 else 0
if bt.total_chapters > 0 and bt.status in ('pending', 'running'):
progress = int((bt.completed_chapters / bt.total_chapters) * 100)
elif bt.status == 'completed':
progress = 100
status_message = None
if bt.status == 'running' and bt.current_chapter_number:
status_message = f"正在生成第 {bt.current_chapter_number} 章 ({bt.completed_chapters}/{bt.total_chapters})"
elif bt.status == 'completed':
status_message = f"已完成 {bt.completed_chapters}"
elif bt.status == 'pending':
status_message = f"等待中,共 {bt.total_chapters}"
items.append({
"id": bt.id,
"task_type": "chapter_batch",
"status": bt.status,
"progress": progress,
"status_message": status_message,
"progress_details": None,
"error_message": bt.error_message,
"created_at": bt.created_at.isoformat() if bt.created_at else None,
"completed_at": bt.completed_at.isoformat() if bt.completed_at else None,
})
# 按创建时间降序排序
items.sort(key=lambda x: x.get("created_at") or "", reverse=True)
return {"items": items[:limit]}
@router.post("/{task_id}/cancel", summary="取消任务")
@@ -105,18 +151,70 @@ async def delete_task(
request: Request,
db: AsyncSession = Depends(get_db)
):
"""删除已完成/失败的任务记录"""
"""删除已完成/失败的任务记录(支持 BackgroundTask 和 BatchGenerationTask"""
user_id = getattr(request.state, 'user_id', None)
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
# 先尝试从 BackgroundTask 查找
task = await background_task_service.get_task(task_id, user_id, db)
if not task:
raise HTTPException(status_code=404, detail="任务不存在")
if task:
if task.status in ("pending", "running"):
raise HTTPException(status_code=400, detail="无法删除进行中的任务,请先取消")
await db.delete(task)
await db.commit()
return {"message": "任务记录已删除"}
# 再尝试从 BatchGenerationTask 查找
result = await db.execute(
select(BatchGenerationTask).where(
BatchGenerationTask.id == task_id,
BatchGenerationTask.user_id == user_id
)
)
batch_task = result.scalar_one_or_none()
if batch_task:
if batch_task.status in ("pending", "running"):
raise HTTPException(status_code=400, detail="无法删除进行中的任务,请先取消")
await db.delete(batch_task)
await db.commit()
return {"message": "任务记录已删除"}
raise HTTPException(status_code=404, detail="任务不存在")
@router.delete("/project/{project_id}/clear", summary="清理项目已结束的任务记录")
async def clear_project_tasks(
project_id: str,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""清理项目中已完成/失败/已取消的任务记录"""
user_id = getattr(request.state, 'user_id', None)
if not user_id:
raise HTTPException(status_code=401, detail="未登录")
from sqlalchemy import delete as sql_delete
# 清理 BackgroundTask
bg_result = await db.execute(
sql_delete(BackgroundTask).where(
BackgroundTask.project_id == project_id,
BackgroundTask.user_id == user_id,
BackgroundTask.status.in_(["completed", "failed", "cancelled"])
)
)
# 清理 BatchGenerationTask
batch_result = await db.execute(
sql_delete(BatchGenerationTask).where(
BatchGenerationTask.project_id == project_id,
BatchGenerationTask.user_id == user_id,
BatchGenerationTask.status.in_(["completed", "failed", "cancelled"])
)
)
await db.commit()
total = (bg_result.rowcount or 0) + (batch_result.rowcount or 0)
return {"message": f"已清理 {total} 条任务记录", "deleted_count": total}
+5 -5
View File
@@ -16,7 +16,7 @@ config_logger = logging.getLogger(__name__)
# 数据库配置:PostgreSQL
# 从环境变量获取数据库URL
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://mumuai:password@localhost:5432/mumuai_novel")
DATABASE_URL = os.getenv("DATABASE_URL", "postgresql+asyncpg://mumulingsi:password@localhost:5432/mumulingsi_novel")
config_logger.debug(f"数据库类型: PostgreSQL")
config_logger.debug(f"数据库URL: {DATABASE_URL}")
@@ -25,7 +25,7 @@ class Settings(BaseSettings):
"""应用配置"""
# 应用配置
app_name: str = "MuMuAINovel"
app_name: str = "墨木灵思"
app_version: str = "1.0.0"
app_host: str = "0.0.0.0"
app_port: int = 8000
@@ -39,7 +39,7 @@ class Settings(BaseSettings):
log_backup_count: int = 30 # 保留30个备份文件
# CORS配置
cors_origins: list[str] = ["http://localhost:8000", "http://127.0.0.1:8000"]
cors_origins: list[str] = ["http://localhost:3000", "http://127.0.0.1:8000"]
# 数据库配置 - PostgreSQL
database_url: str = DATABASE_URL
@@ -118,7 +118,7 @@ class Settings(BaseSettings):
SMTP_USE_TLS: bool = False
SMTP_USE_SSL: bool = True
SMTP_FROM_EMAIL: Optional[str] = None
SMTP_FROM_NAME: str = "MuMuAINovel"
SMTP_FROM_NAME: str = "墨木灵思"
EMAIL_AUTH_ENABLED: bool = True
EMAIL_REGISTER_ENABLED: bool = True
EMAIL_VERIFICATION_CODE_TTL_MINUTES: int = 10
@@ -126,7 +126,7 @@ class Settings(BaseSettings):
# 提示词工坊配置
WORKSHOP_MODE: str = "client" # client: 本地部署实例, server: 云端中央服务器
WORKSHOP_CLOUD_URL: str = "https://mumuverse.space:1566" # 云端服务地址
WORKSHOP_CLOUD_URL: str = "" # 云端服务地址
WORKSHOP_API_TIMEOUT: int = 30 # 云端API请求超时时间(秒)
class Config:
+5 -2
View File
@@ -44,9 +44,12 @@ class UvicornFormatter(logging.Formatter):
request_id = getattr(record, 'request_id', None)
request_id_str = f" [{request_id}]" if request_id else ""
# Uvicorn风格格式: INFO: module_name - message [request_id]
# 格式化时间戳 (YYYY-MM-DD HH:MM:SS)
timestamp = self.formatTime(record, self.datefmt)
# Uvicorn风格格式: INFO: [2024-01-01 12:00:00] module_name - message [request_id]
# 注意:INFO后面有5个空格,保持对齐
return f"{colored_level}: {record.name}{request_id_str} - {record.getMessage()}"
return f"{colored_level}: [{timestamp}] {record.name}{request_id_str} - {record.getMessage()}"
# 全局标志,防止重复初始化
+7 -1
View File
@@ -141,6 +141,12 @@ async def db_session_stats(request: Request):
}
@app.get("/nanobot/sessions")
async def nanobot_sessions():
"""兼容层:为环境监控工具提供空的会话列表"""
return []
from app.api import (
projects, outlines, characters, chapters,
wizard_stream, relationships, organizations,
@@ -220,7 +226,7 @@ else:
@app.get("/")
async def root():
return {
"message": "欢迎使用MuMuAINovel",
"message": "欢迎使用墨木灵思",
"version": config_settings.app_version,
"docs": "/docs",
"notice": "请先构建前端: cd frontend && npm run build"
+1 -1
View File
@@ -35,7 +35,7 @@ class Settings(Base):
smtp_use_tls = Column(Boolean, default=False, server_default="0", nullable=False, comment="是否启用 TLS")
smtp_use_ssl = Column(Boolean, default=True, server_default="1", nullable=False, comment="是否启用 SSL")
smtp_from_email = Column(String(255), comment="发件人邮箱")
smtp_from_name = Column(String(255), default="MuMuAINovel", server_default="MuMuAINovel", nullable=False, comment="发件人名称")
smtp_from_name = Column(String(255), default="墨木灵思", server_default="墨木灵思", nullable=False, comment="发件人名称")
email_auth_enabled = Column(Boolean, default=True, server_default="1", nullable=False, comment="是否启用邮箱认证")
email_register_enabled = Column(Boolean, default=True, server_default="1", nullable=False, comment="是否启用邮箱注册")
verification_code_ttl_minutes = Column(Integer, default=10, server_default="10", nullable=False, comment="验证码有效期(分钟)")
+9 -1
View File
@@ -55,7 +55,7 @@ class SystemSMTPSettingsBase(BaseModel):
smtp_use_tls: bool = Field(default=False, description="是否启用 TLS")
smtp_use_ssl: bool = Field(default=True, description="是否启用 SSL")
smtp_from_email: Optional[str] = Field(default=None, description="发件人邮箱")
smtp_from_name: str = Field(default="MuMuAINovel", description="发件人名称")
smtp_from_name: str = Field(default="墨木灵思", description="发件人名称")
email_auth_enabled: bool = Field(default=True, description="是否启用邮箱认证")
email_register_enabled: bool = Field(default=True, description="是否启用邮箱注册")
verification_code_ttl_minutes: int = Field(default=10, ge=1, le=120, description="验证码有效期(分钟)")
@@ -141,3 +141,11 @@ class PresetListResponse(BaseModel):
presets: List[PresetResponse] = Field(..., description="预设列表")
total: int = Field(..., description="总数")
active_preset_id: Optional[str] = Field(None, description="当前激活的预设ID")
chapter_analysis_preset_id: Optional[str] = Field(None, description="章节内容分析使用的预设ID,为空则使用默认API配置")
class ChapterAnalysisPresetSelectionRequest(BaseModel):
"""章节内容分析预设选择请求"""
model_config = ConfigDict(protected_namespaces=())
preset_id: Optional[str] = Field(None, description="章节内容分析使用的预设ID;为空则使用默认API配置")
+1 -1
View File
@@ -24,7 +24,7 @@ def _session_secret() -> bytes:
or settings.openai_api_key
)
if not secret:
secret = "mumuainovel-development-session-secret"
secret = "mumulingsi-development-session-secret"
return str(secret).encode("utf-8")
+1 -1
View File
@@ -29,7 +29,7 @@ logger = get_logger(__name__)
def normalize_provider(provider: Optional[str]) -> Optional[str]:
"""标准化 provider 名称,兼容渠道别名。"""
if provider == "mumu":
if provider == "xinmi":
return "openai"
return provider
@@ -225,11 +225,11 @@ class CoverGenerationService:
return GeminiCoverProvider(api_key=api_key, base_url=normalized_base_url)
if provider_value == "grok":
return GrokCoverProvider(api_key=api_key, base_url=normalized_base_url)
if provider_value == "mumu":
if provider_value == "xinmi":
if normalized_base_url.endswith("/v1beta"):
return GeminiCoverProvider(api_key=api_key, base_url=normalized_base_url)
return GrokCoverProvider(api_key=api_key, base_url=normalized_base_url or "https://api.mumuverse.space/v1")
raise HTTPException(status_code=400, detail="当前版本仅支持 Gemini、Grok 或 MuMuのAPI 作为封面图片 Provider")
return GrokCoverProvider(api_key=api_key, base_url=normalized_base_url or "v1")
raise HTTPException(status_code=400, detail="当前版本仅支持 Gemini、Grok 或 墨木灵思 API 作为封面图片 Provider")
def _save_cover_file(
self,
+7 -4
View File
@@ -11,15 +11,18 @@ class WritingStyleManager:
"""
将写作风格应用到基础提示词中
注意写作风格已通过 system_prompt 注入system_prompt_with_style
此方法仅追加输出指令不再重复注入 style_content避免风格信息被注入两次
Args:
base_prompt: 基础提示词
style_content: 风格要求内容
style_content: 风格要求内容已通过 system_prompt 注入此处不使用
Returns:
组合后的提示词
追加输出指令后的提示词
"""
# 在基础提示词末尾添加风格要求
return f"{base_prompt}\n\n{style_content}\n\n请直接输出章节正文内容,不要包含章节标题和其他说明文字。"
# 写作风格已在 system_prompt 中注入,此处只追加输出格式指令
return f"{base_prompt}\n\n请直接输出章节正文内容,不要包含章节标题和其他说明文字。"
class PromptService:
+4 -4
View File
@@ -1,4 +1,4 @@
#!/bin/bash
#!/bin/sh
# Docker 容器启动入口脚本
# 功能:等待数据库就绪,执行迁移,启动应用
@@ -17,7 +17,7 @@ if [ -z "$APP_NAME" ]; then
if [ -f "/app/.env.example" ]; then
APP_NAME=$(grep "^APP_NAME=" /app/.env.example | cut -d '=' -f2)
fi
APP_NAME="${APP_NAME:-MuMuAINovel}"
APP_NAME="${APP_NAME:-墨木灵思}"
fi
BUILD_TIME=$(date '+%Y-%m-%d %H:%M:%S')
@@ -31,8 +31,8 @@ echo "================================================"
# 数据库配置(从环境变量读取)
DB_HOST="${DB_HOST:-postgres}"
DB_PORT="${DB_PORT:-5432}"
DB_USER="${POSTGRES_USER:-mumuai}"
DB_NAME="${POSTGRES_DB:-mumuai_novel}"
DB_USER="${POSTGRES_USER:-mumulingsi}"
DB_NAME="${POSTGRES_DB:-mumulingsi_novel}"
# 等待数据库就绪
echo "⏳ 等待数据库启动..."
+1 -1
View File
@@ -8,7 +8,7 @@ CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- 模糊搜索和全文检索支
DO $$
BEGIN
RAISE NOTICE '==================================================';
RAISE NOTICE 'MuMuAINovel PostgreSQL 扩展安装完成';
RAISE NOTICE 'mumulingsi PostgreSQL 扩展安装完成';
RAISE NOTICE '已安装扩展:';
RAISE NOTICE ' - uuid-ossp: UUID生成支持';
RAISE NOTICE ' - pg_trgm: 模糊搜索和全文检索支持';
+5 -5
View File
@@ -56,8 +56,8 @@ class PostgreSQLSetup:
port: int = 5432,
admin_user: str = "postgres",
admin_password: str = None,
db_name: str = "mumuai_novel",
db_user: str = "mumuai",
db_name: str = "xinmi_novel",
db_user: str = "mumulingsi",
db_password: str = "123456"
):
"""
@@ -374,9 +374,9 @@ async def main():
admin_password = getpass(f"管理员密码: ")
print("\n请输入要创建的数据库信息:\n")
db_name = input("数据库名 [mumuai_novel]: ").strip() or "mumuai_novel"
db_user = input("数据库用户名 [mumuai]: ").strip() or "mumuai"
db_password = getpass("数据库用户密码 [mumuai123]: ") or "mumuai123"
db_name = input("数据库名 [xinmi_novel]: ").strip() or "xinmi_novel"
db_user = input("数据库用户名 [mumulingsi]: ").strip() or "mumulingsi"
db_password = getpass("数据库用户密码 [xinmi123]: ") or "xinmi123"
print(f"\n{'='*60}")
print(f"配置摘要:")
+17 -10
View File
@@ -1,10 +1,10 @@
services:
postgres:
image: postgres:18-alpine
container_name: mumuainovel-postgres
container_name: mumulingsi-postgres
environment:
POSTGRES_DB: ${POSTGRES_DB:-mumuai_novel}
POSTGRES_USER: ${POSTGRES_USER:-mumuai}
POSTGRES_DB: ${POSTGRES_DB:-mumulingsi_novel}
POSTGRES_USER: ${POSTGRES_USER:-mumulingsi}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-123456}
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
TZ: ${TZ:-Asia/Shanghai}
@@ -15,7 +15,7 @@ services:
- "${POSTGRES_PORT:-5432}:5432"
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-mumuai} -d ${POSTGRES_DB:-mumuai_novel}"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-mumulingsi} -d ${POSTGRES_DB:-mumulingsi_novel}"]
interval: 10s
timeout: 5s
retries: 5
@@ -49,12 +49,14 @@ services:
- -c
- max_wal_size=${POSTGRES_MAX_WAL_SIZE:-4GB}
mumuainovel:
mumulingsi:
build:
context: .
dockerfile: Dockerfile
image: mumujie/mumuainovel:latest
container_name: mumuainovel
args:
- USE_CN_MIRROR=${USE_CN_MIRROR:-false}
image: mumulingsi-project/mumulingsi:latest
container_name: mumulingsi
depends_on:
postgres:
condition: service_healthy
@@ -65,19 +67,24 @@ services:
- ./storage/generated_covers:/app/storage/generated_covers
- ./.env:/app/.env:ro
environment:
# 时区配置
- TZ=${TZ:-Asia/Shanghai}
# 应用配置
- APP_NAME=${APP_NAME:-MuMuAINovel}
- APP_NAME=${APP_NAME:-墨木灵思}
- APP_VERSION=${APP_VERSION:-1.0.0}
- APP_HOST=${APP_HOST:-0.0.0.0}
- APP_PORT=8000
- DEBUG=${DEBUG:-false}
# 数据库配置
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-mumuai}:${POSTGRES_PASSWORD:-123456}@postgres:5432/${POSTGRES_DB:-mumuai_novel}
- DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER:-mumulingsi}:${POSTGRES_PASSWORD:-123456}@postgres:5432/${POSTGRES_DB:-mumulingsi_novel}
# 数据库连接信息(用于 entrypoint.sh
# 数据库连接信息(用于 entrypoint.sh,须与 postgres 服务一致
- DB_HOST=postgres
- DB_PORT=5432
- POSTGRES_USER=${POSTGRES_USER:-mumulingsi}
- POSTGRES_DB=${POSTGRES_DB:-mumulingsi_novel}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-123456}
# PostgreSQL 连接池配置
+2
View File
@@ -0,0 +1,2 @@
# XinMi API 文档地址(默认 https://api.xinmi.cloud,一般无需修改)
# VITE_OFFICIAL_API_DOC_URL=https://api.xinmi.cloud
+4 -1
View File
@@ -4,7 +4,10 @@
<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>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Noto+Serif+SC:wght@500;600;700&family=Source+Sans+3:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet" />
<title>墨木灵思</title>
</head>
<body>
<div id="root"></div>
+37 -34
View File
@@ -1,12 +1,12 @@
{
"name": "frontend",
"version": "1.4.4",
"version": "1.4.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "frontend",
"version": "1.4.4",
"version": "1.4.8",
"dependencies": {
"@ant-design/icons": "^5.6.1",
"@dnd-kit/core": "^6.3.1",
@@ -2165,9 +2165,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2461,14 +2461,14 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
"integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
"proxy-from-env": "^2.1.0"
}
},
"node_modules/babel-plugin-macros": {
@@ -2504,9 +2504,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2708,9 +2708,9 @@
}
},
"node_modules/cosmiconfig/node_modules/yaml": {
"version": "1.10.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==",
"version": "1.10.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
"integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"license": "ISC",
"engines": {
"node": ">= 6"
@@ -3313,9 +3313,9 @@
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
@@ -3729,9 +3729,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.merge": {
@@ -3976,9 +3976,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true,
"license": "MIT",
"engines": {
@@ -3989,9 +3989,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"dev": true,
"funding": [
{
@@ -4045,10 +4045,13 @@
"license": "MIT"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
@@ -5125,9 +5128,9 @@
}
},
"node_modules/vite": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true,
"license": "MIT",
"dependencies": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.4.7",
"version": "1.4.8",
"type": "module",
"scripts": {
"dev": "vite",
+2 -3
View File
@@ -21,19 +21,17 @@ import MCPPlugins from './pages/MCPPlugins';
import UserManagement from './pages/UserManagement';
import PromptTemplates from './pages/PromptTemplates';
import Sponsor from './pages/Sponsor';
import About from './pages/About';
// import Polish from './pages/Polish';
import Login from './pages/Login';
import AuthCallback from './pages/AuthCallback';
import ProtectedRoute from './components/ProtectedRoute';
import AppFooter from './components/AppFooter';
import SpringFestival from './components/SpringFestival';
import './App.css';
function App() {
return (
<>
{/* 🧧 春节喜庆装饰 */}
<SpringFestival />
<BrowserRouter
future={{
v7_startTransition: true,
@@ -68,6 +66,7 @@ function App() {
<Route path="writing-styles" element={<WritingStyles />} />
<Route path="prompt-workshop" element={<PromptWorkshop />} />
<Route path="sponsor" element={<Sponsor />} />
<Route path="about" element={<About />} />
{/* <Route path="polish" element={<Polish />} /> */}
</Route>
</Routes>
@@ -1,246 +0,0 @@
import { Modal, Button, Space, theme } from 'antd';
import { useEffect, useState } from 'react';
interface AnnouncementModalProps {
visible: boolean;
onClose: () => void;
onDoNotShowToday: () => void;
onNeverShow: () => void;
}
export default function AnnouncementModal({ visible, onClose, onDoNotShowToday, onNeverShow }: AnnouncementModalProps) {
const [qqImageError, setQqImageError] = useState(false);
const [wxImageError, setWxImageError] = useState(false);
const { token } = theme.useToken();
const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`;
useEffect(() => {
if (visible) {
setQqImageError(false);
setWxImageError(false);
}
}, [visible]);
const handleDoNotShowToday = () => {
onDoNotShowToday();
onClose();
};
const handleNeverShow = () => {
onNeverShow();
onClose();
};
return (
<Modal
title={
<div style={{
fontSize: '20px',
fontWeight: 600,
color: token.colorPrimary,
textAlign: 'center',
}}>
🎉 使 AI小说创作助手
</div>
}
open={visible}
onCancel={onClose}
footer={
<Space style={{ width: '100%', justifyContent: 'center' }}>
<Button
onClick={handleDoNotShowToday}
size="large"
style={{
borderRadius: '8px',
height: '40px',
fontSize: '14px',
}}
>
</Button>
<Button
type="primary"
onClick={handleNeverShow}
size="large"
style={{
borderRadius: '8px',
height: '40px',
fontSize: '14px',
background: token.colorPrimary,
borderColor: token.colorPrimary,
boxShadow: `0 8px 20px ${alphaColor(token.colorPrimary, 0.32)}`,
}}
>
</Button>
</Space>
}
width={700}
centered
styles={{
body: {
padding: '20px',
background: token.colorBgContainer,
},
header: {
background: `linear-gradient(135deg, ${alphaColor(token.colorPrimary, 0.1)} 0%, ${alphaColor(token.colorBgContainer, 0.98)} 100%)`,
borderBottom: `1px solid ${token.colorBorderSecondary}`,
padding: '16px 24px',
},
footer: {
background: token.colorBgContainer,
borderTop: `1px solid ${token.colorBorderSecondary}`,
padding: '16px 24px',
},
}}
>
<div style={{ textAlign: 'center' }}>
<div style={{
marginBottom: '12px',
fontSize: '15px',
color: token.colorTextSecondary,
lineHeight: '1.5',
}}>
<p style={{ marginBottom: '8px' }}>👋 </p>
<ul style={{
textAlign: 'left',
marginLeft: '40px',
marginTop: '0',
marginBottom: '12px',
}}>
<li>💬 </li>
<li>💡 使</li>
<li>🐛 </li>
<li>📚 </li>
</ul>
<p style={{ fontWeight: 600, color: token.colorText, marginBottom: '12px' }}>
</p>
</div>
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
gap: '24px',
padding: '16px',
background: token.colorBgLayout,
borderRadius: '8px',
flexWrap: 'wrap',
}}>
{/* QQ 二维码 */}
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
minWidth: '200px',
}}>
<p style={{ fontWeight: 600, color: token.colorText, marginBottom: '8px', fontSize: '14px' }}>
QQ交流群
</p>
{!qqImageError ? (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: token.colorBgContainer,
borderRadius: '8px',
padding: '6px',
boxShadow: `0 2px 8px ${alphaColor(token.colorText, 0.12)}`,
}}>
<img
src="/qq.jpg"
alt="QQ交流群二维码"
style={{
maxWidth: '180px',
maxHeight: '180px',
width: 'auto',
height: 'auto',
display: 'block',
objectFit: 'contain',
}}
onError={() => setQqImageError(true)}
/>
</div>
) : (
<div style={{
width: '180px',
height: '180px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: token.colorBgContainer,
borderRadius: '8px',
color: token.colorTextTertiary,
}}>
<p></p>
</div>
)}
</div>
{/* 微信二维码 */}
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
minWidth: '200px',
}}>
<p style={{ fontWeight: 600, color: token.colorText, marginBottom: '8px', fontSize: '14px' }}>
</p>
{!wxImageError ? (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: token.colorBgContainer,
borderRadius: '8px',
padding: '6px',
boxShadow: `0 2px 8px ${alphaColor(token.colorText, 0.12)}`,
}}>
<img
src="/WX.png"
alt="微信交流群二维码"
style={{
maxWidth: '180px',
maxHeight: '180px',
width: 'auto',
height: 'auto',
display: 'block',
objectFit: 'contain',
}}
onError={() => setWxImageError(true)}
/>
</div>
) : (
<div style={{
width: '180px',
height: '180px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: token.colorBgContainer,
borderRadius: '8px',
color: token.colorTextTertiary,
}}>
<p></p>
</div>
)}
</div>
</div>
<div style={{
marginTop: '16px',
padding: '10px',
background: token.colorWarningBg,
borderRadius: '8px',
border: `1px solid ${token.colorWarningBorder}`,
fontSize: '13px',
color: token.colorWarning,
}}>
💡 "今日内不再展示""永不再展示"
</div>
</div>
</Modal>
);
}
+12 -179
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Typography, Space, Divider, Badge, Button, Grid, theme } from 'antd';
import { GithubOutlined, CopyrightOutlined, HeartFilled, ClockCircleOutlined, GiftOutlined } from '@ant-design/icons';
import { Typography, Divider, Badge, Grid, theme } from 'antd';
import { ClockCircleOutlined, CopyrightOutlined } from '@ant-design/icons';
import { VERSION_INFO, getVersionString } from '../config/version';
import { checkLatestVersion } from '../services/versionService';
@@ -21,7 +21,6 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`;
useEffect(() => {
// 检查版本更新(每次都重新检查)
const checkVersion = async () => {
try {
const result = await checkLatestVersion();
@@ -33,19 +32,16 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
}
};
// 延迟3秒后检查,避免影响首次加载
const timer = setTimeout(checkVersion, 3000);
return () => clearTimeout(timer);
}, []);
// 点击版本号查看更新
const handleVersionClick = () => {
if (hasUpdate && releaseUrl) {
window.open(releaseUrl, '_blank');
}
};
// 计算左边距:桌面端有侧边栏时需要偏移
const leftOffset = isMobile ? 0 : sidebarWidth;
return (
@@ -61,8 +57,8 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
padding: isMobile ? '8px 12px' : '10px 16px',
zIndex: 100,
boxShadow: `0 -2px 16px ${alphaColor(token.colorText, 0.08)}`,
backgroundColor: alphaColor(token.colorBgContainer, 0.82), // 半透明背景以支持 backdrop-filter
transition: 'left 0.3s ease', // 平滑过渡
backgroundColor: alphaColor(token.colorBgContainer, 0.82),
transition: 'left 0.3s ease',
}}
>
<div
@@ -70,87 +66,11 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
maxWidth: 1400,
margin: '0 auto',
textAlign: 'center',
}}
>
{isMobile ? (
// 移动端:紧凑单行布局
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: 8,
gap: isMobile ? 8 : 16,
flexWrap: 'wrap'
}}>
<Badge dot={hasUpdate} offset={[-8, 2]}>
<Text
onClick={handleVersionClick}
style={{
fontSize: 11,
display: 'flex',
alignItems: 'center',
gap: 4,
color: token.colorPrimary,
cursor: hasUpdate ? 'pointer' : 'default',
}}
title={hasUpdate ? `发现新版本 v${latestVersion},点击查看` : '当前版本'}
>
<strong style={{ color: token.colorText }}>{VERSION_INFO.projectName}</strong>
<span>{getVersionString()}</span>
</Text>
</Badge>
<Divider type="vertical" style={{ margin: '0 4px', borderColor: token.colorBorder }} />
<Button
type="text"
size="small"
icon={<GiftOutlined />}
onClick={() => window.open('https://mumuverse.space:1588/', '_blank')}
style={{
color: token.colorTextSecondary,
fontSize: 11,
height: 24,
padding: '0 4px',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
</Button>
<Divider type="vertical" style={{ margin: '0 4px', borderColor: token.colorBorder }} />
<Link
href={VERSION_INFO.githubUrl}
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: 11,
display: 'flex',
alignItems: 'center',
gap: 4,
color: token.colorTextSecondary,
}}
>
<GithubOutlined style={{ fontSize: 12 }} />
</Link>
<Text
style={{
fontSize: 10,
color: token.colorTextTertiary,
}}
>
<ClockCircleOutlined style={{ fontSize: 10, marginRight: 4 }} />
{VERSION_INFO.buildTime}
</Text>
</div>
) : (
// PC端:完整布局
<Space
direction="horizontal"
size={12}
split={<Divider type="vertical" style={{ borderColor: token.colorBorder }} />}
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}
>
{/* 版本信息 */}
@@ -158,24 +78,12 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
<Text
onClick={handleVersionClick}
style={{
fontSize: 12,
fontSize: isMobile ? 11 : 12,
display: 'flex',
alignItems: 'center',
gap: 6,
color: token.colorTextSecondary,
textShadow: 'none',
cursor: hasUpdate ? 'pointer' : 'default',
transition: 'all 0.3s',
}}
onMouseEnter={(e) => {
if (hasUpdate) {
e.currentTarget.style.transform = 'scale(1.05)';
}
}}
onMouseLeave={(e) => {
if (hasUpdate) {
e.currentTarget.style.transform = 'scale(1)';
}
}}
title={hasUpdate ? `发现新版本 v${latestVersion},点击查看` : '当前版本'}
>
@@ -184,65 +92,7 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
</Text>
</Badge>
{/* GitHub 链接 */}
<Link
href={VERSION_INFO.githubUrl}
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 6,
color: token.colorTextSecondary,
}}
>
<GithubOutlined style={{ fontSize: 13 }} />
<span>GitHub</span>
</Link>
{/* LinuxDO 社区 */}
<Link
href={VERSION_INFO.linuxDoUrl}
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: 12,
color: token.colorTextSecondary,
}}
>
LinuxDO
</Link>
{/* 赞助按钮 */}
<Button
type="primary"
icon={<GiftOutlined style={{ fontSize: 14 }} />}
onClick={() => window.open('https://mumuverse.space:1588/', '_blank')}
style={{
background: token.colorPrimary,
border: 'none',
boxShadow: `0 4px 12px ${alphaColor(token.colorPrimary, 0.35)}`,
fontSize: 13,
height: 32,
padding: '0 20px',
display: 'flex',
alignItems: 'center',
gap: 6,
fontWeight: 600,
transition: 'all 0.3s',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = `0 6px 16px ${alphaColor(token.colorPrimary, 0.5)}`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = `0 4px 12px ${alphaColor(token.colorPrimary, 0.35)}`;
}}
>
</Button>
<Divider type="vertical" style={{ borderColor: token.colorBorder }} />
{/* 许可证 */}
<Link
@@ -250,7 +100,7 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
target="_blank"
rel="noopener noreferrer"
style={{
fontSize: 12,
fontSize: isMobile ? 11 : 12,
display: 'flex',
alignItems: 'center',
gap: 6,
@@ -261,39 +111,22 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
<span>{VERSION_INFO.license}</span>
</Link>
<Divider type="vertical" style={{ borderColor: token.colorBorder }} />
{/* 更新时间 */}
<Text
style={{
fontSize: 12,
fontSize: isMobile ? 10 : 12,
display: 'flex',
alignItems: 'center',
gap: 4,
color: token.colorTextTertiary,
}}
>
<ClockCircleOutlined style={{ fontSize: 12 }} />
<ClockCircleOutlined style={{ fontSize: isMobile ? 10 : 12 }} />
<span>{VERSION_INFO.buildTime}</span>
</Text>
{/* 致谢信息 */}
<Text
style={{
fontSize: 12,
display: 'flex',
alignItems: 'center',
gap: 4,
color: token.colorTextSecondary,
textShadow: `0 1px 3px ${alphaColor(token.colorText, 0.08)}`,
}}
>
<span>Made with</span>
<HeartFilled style={{ color: token.colorError, fontSize: 11 }} />
<span>by {VERSION_INFO.author}</span>
</Text>
</Space>
)}
</div>
</div>
);
}
+8 -14
View File
@@ -20,10 +20,7 @@ const bookshelfNewHoverShadow = `
inset 0 1px 0 color-mix(in srgb, var(--ant-color-bg-container) 90%, transparent)
`;
const promptTemplateBaseShadow = `
0 6px 16px color-mix(in srgb, var(--ant-color-text) 11%, transparent),
0 1px 0 color-mix(in srgb, var(--ant-color-white) 42%, transparent) inset
`;
const promptTemplateBaseShadow = '4px 4px 0 color-mix(in srgb, var(--ant-color-text) 10%, transparent)';
// BookshelfPage 样式(书架/书本卡片)
export const bookshelfCardStyles = {
@@ -107,28 +104,25 @@ export const bookshelfCardHoverHandlers = {
export const promptTemplateCardStyles = {
templateCard: {
height: '100%',
borderRadius: 14,
borderRadius: 2,
overflow: 'hidden',
border: '1px solid color-mix(in srgb, var(--ant-color-text) 8%, transparent)',
background: 'linear-gradient(180deg, color-mix(in srgb, var(--ant-color-bg-container) 97%, var(--ant-color-primary) 3%) 0%, var(--ant-color-bg-container) 100%)',
border: '1px solid color-mix(in srgb, var(--ant-color-text) 14%, transparent)',
background: 'var(--ant-color-bg-container)',
boxShadow: promptTemplateBaseShadow,
transition: 'transform 0.28s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.28s cubic-bezier(0.22, 1, 0.36, 1), border-color 0.28s ease',
transition: 'transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease',
} as CSSProperties,
};
export const promptTemplateCardHoverHandlers = {
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.currentTarget;
target.style.transform = 'translateY(-6px)';
target.style.boxShadow = `
0 14px 24px color-mix(in srgb, var(--ant-color-text) 16%, transparent),
0 1px 0 color-mix(in srgb, var(--ant-color-white) 48%, transparent) inset
`;
target.style.transform = 'translate(-2px, -2px)';
target.style.boxShadow = '6px 6px 0 color-mix(in srgb, var(--ant-color-primary) 25%, transparent)';
target.style.borderColor = 'color-mix(in srgb, var(--ant-color-primary) 24%, transparent)';
},
onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.currentTarget;
target.style.transform = 'translateY(0)';
target.style.transform = 'translate(0, 0)';
target.style.boxShadow = promptTemplateBaseShadow;
target.style.borderColor = 'color-mix(in srgb, var(--ant-color-text) 8%, transparent)';
},
@@ -1,38 +0,0 @@
import { useState } from 'react';
import { FloatButton, Grid } from 'antd';
import { FileTextOutlined } from '@ant-design/icons';
import ChangelogModal from './ChangelogModal';
const { useBreakpoint } = Grid;
export default function ChangelogFloatingButton() {
const [showChangelog, setShowChangelog] = useState(false);
const screens = useBreakpoint();
const isMobile = !screens.md;
return (
<>
<FloatButton
icon={<FileTextOutlined />}
type="primary"
tooltip="查看更新日志"
style={{
// 桌面端时,确保按钮在主内容区域内(侧边栏右侧)
right: 24,
bottom: 100,
// 移动端无侧边栏,不需要额外处理
...(isMobile ? {} : {
// 确保 zIndex 低于侧边栏但高于内容
zIndex: 999,
}),
}}
onClick={() => setShowChangelog(true)}
/>
<ChangelogModal
visible={showChangelog}
onClose={() => setShowChangelog(false)}
/>
</>
);
}
-306
View File
@@ -1,306 +0,0 @@
import { Modal, Timeline, Tag, Avatar, Empty, Spin, Button, Space } from 'antd';
import { useState, useEffect } from 'react';
import {
BugOutlined,
StarOutlined,
FileTextOutlined,
BgColorsOutlined,
ThunderboltOutlined,
ExperimentOutlined,
ToolOutlined,
QuestionCircleOutlined,
GithubOutlined,
ReloadOutlined,
ClockCircleOutlined,
SyncOutlined,
} from '@ant-design/icons';
import {
fetchChangelog,
groupChangelogByDate,
cacheChangelog,
clearChangelogCache,
type ChangelogEntry,
} from '../services/changelogService';
interface ChangelogModalProps {
visible: boolean;
onClose: () => void;
}
// 提交类型图标和颜色配置
const typeConfig: Record<ChangelogEntry['type'], { icon: React.ReactNode; color: string; label: string }> = {
feature: { icon: <StarOutlined />, color: 'green', label: '新功能' },
update: { icon: <SyncOutlined />, color: 'geekblue', label: '更新' },
fix: { icon: <BugOutlined />, color: 'red', label: '修复' },
docs: { icon: <FileTextOutlined />, color: 'blue', label: '文档' },
style: { icon: <BgColorsOutlined />, color: 'purple', label: '样式' },
refactor: { icon: <ThunderboltOutlined />, color: 'orange', label: '重构' },
perf: { icon: <ThunderboltOutlined />, color: 'gold', label: '性能' },
test: { icon: <ExperimentOutlined />, color: 'cyan', label: '测试' },
chore: { icon: <ToolOutlined />, color: 'default', label: '杂项' },
other: { icon: <QuestionCircleOutlined />, color: 'default', label: '其他' },
};
export default function ChangelogModal({ visible, onClose }: ChangelogModalProps) {
const [changelog, setChangelog] = useState<ChangelogEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
// 加载更新日志
// 每次用户打开窗口时才同步获取最新数据,不自动刷新
const loadChangelog = async (pageNum: number = 1, append: boolean = false) => {
setLoading(true);
setError(null);
try {
// 每次打开都从网络获取最新数据
const entries = await fetchChangelog(pageNum, 30);
if (entries.length === 0) {
setHasMore(false);
} else {
if (append) {
setChangelog(prev => [...prev, ...entries]);
} else {
setChangelog(entries);
// 缓存第一页数据(用于分页加载时的数据持久化)
if (pageNum === 1) {
cacheChangelog(entries);
}
}
}
} catch (err) {
setError(err instanceof Error ? err.message : '获取更新日志失败');
} finally {
setLoading(false);
}
};
// 初始加载
useEffect(() => {
if (visible) {
loadChangelog(1, false);
setPage(1);
setHasMore(true);
}
}, [visible]);
// 加载更多
const handleLoadMore = () => {
const nextPage = page + 1;
setPage(nextPage);
loadChangelog(nextPage, true);
};
// 刷新(清除缓存并重新加载)
const handleRefresh = () => {
clearChangelogCache();
setPage(1);
setHasMore(true);
loadChangelog(1, false);
};
// 按日期分组
const groupedChangelog = groupChangelogByDate(changelog);
const sortedDates = Array.from(groupedChangelog.keys()).sort((a, b) => b.localeCompare(a));
// 格式化日期
const formatDate = (dateStr: string) => {
const date = new Date(dateStr);
const now = new Date();
const diffDays = Math.floor((now.getTime() - date.getTime()) / (1000 * 60 * 60 * 24));
if (diffDays === 0) return '今天';
if (diffDays === 1) return '昨天';
if (diffDays < 7) return `${diffDays} 天前`;
return date.toLocaleDateString('zh-CN', { year: 'numeric', month: 'long', day: 'numeric' });
};
// 格式化时间
const formatTime = (dateStr: string) => {
return new Date(dateStr).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
};
return (
<Modal
title={
<Space>
<GithubOutlined />
<span></span>
<Button
type="text"
size="small"
icon={<ReloadOutlined />}
onClick={handleRefresh}
loading={loading}
title="刷新"
/>
</Space>
}
open={visible}
onCancel={onClose}
footer={null}
width={800}
centered
styles={{
body: {
maxHeight: '70vh',
overflowY: 'auto',
padding: '24px',
},
}}
>
{error && (
<div style={{
padding: '16px',
marginBottom: '16px',
background: 'var(--color-error-bg)',
border: '1px solid var(--color-error-border)',
borderRadius: '4px',
color: 'var(--color-error)',
}}>
{error}
</div>
)}
{loading && changelog.length === 0 ? (
<div style={{ textAlign: 'center', padding: '40px 0' }}>
<Spin size="large" tip="加载更新日志中..." />
</div>
) : changelog.length === 0 ? (
<Empty description="暂无更新日志" />
) : (
<>
{sortedDates.map(date => {
const entries = groupedChangelog.get(date) || [];
return (
<div key={date} style={{ marginBottom: '32px' }}>
<div style={{
fontSize: '16px',
fontWeight: 600,
color: 'var(--color-primary)',
marginBottom: '16px',
paddingBottom: '8px',
borderBottom: '2px solid var(--color-border-secondary)',
}}>
<ClockCircleOutlined style={{ marginRight: '8px' }} />
{formatDate(date)}
</div>
<Timeline>
{entries.map(entry => {
const config = typeConfig[entry.type] || typeConfig.other;
return (
<Timeline.Item
key={entry.id}
dot={
<div style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'var(--color-bg-container)',
border: `2px solid ${config.color === 'default' ? 'var(--color-border)' : config.color}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
}}>
{config.icon}
</div>
}
>
<div style={{ marginLeft: '8px' }}>
<Space size="small" wrap>
<Tag color={config.color} icon={config.icon}>
{config.label}
</Tag>
{entry.scope && (
<Tag color="blue">{entry.scope}</Tag>
)}
<span style={{ color: 'var(--color-text-tertiary)', fontSize: '12px' }}>
{formatTime(entry.date)}
</span>
</Space>
<div style={{
marginTop: '8px',
fontSize: '14px',
lineHeight: '1.6',
color: 'var(--color-text-primary)',
}}>
{entry.message}
</div>
<Space size="small" style={{ marginTop: '8px' }}>
{entry.author.avatar && (
<Avatar size="small" src={entry.author.avatar} />
)}
<span style={{ color: 'var(--color-text-secondary)', fontSize: '13px' }}>
{entry.author.username || entry.author.name}
</span>
<a
href={entry.commitUrl}
target="_blank"
rel="noopener noreferrer"
style={{ fontSize: '12px' }}
>
</a>
</Space>
</div>
</Timeline.Item>
);
})}
</Timeline>
</div>
);
})}
{
hasMore && (
<div style={{ textAlign: 'center', marginTop: '24px' }}>
<Button
type="default"
onClick={handleLoadMore}
loading={loading}
>
</Button>
</div>
)
}
{
!hasMore && changelog.length > 0 && (
<div style={{
textAlign: 'center',
color: 'var(--color-text-tertiary)',
padding: '16px 0',
fontSize: '14px',
}}>
</div>
)
}
</>
)}
<div style={{
marginTop: '24px',
padding: '12px',
background: 'var(--color-info-bg)',
borderRadius: '4px',
border: '1px solid var(--color-info-border)',
fontSize: '13px',
color: 'var(--color-primary)',
}}>
💡 GitHub
</div>
</Modal >
);
}
@@ -6,7 +6,7 @@ import axios from 'axios';
const { TextArea } = Input;
const { Text, Paragraph } = Typography;
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000';
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
interface CareerDetail {
id: string;
@@ -0,0 +1,343 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Card, List, Button, Space, Badge, Tag, Progress, Popconfirm, Empty, theme, Tooltip, message } from 'antd';
import {
ClockCircleOutlined,
LoadingOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
ReloadOutlined,
DeleteOutlined,
UpOutlined,
DownOutlined,
ClearOutlined,
} from '@ant-design/icons';
import { getProjectTasks, cancelTask, cancelBatchTask, deleteTask, clearProjectTasks, type TaskStatus } from '../services/backgroundTaskService';
import { eventBus } from '../store/eventBus';
interface FloatingTaskPanelProps {
projectId: string;
autoRefreshInterval?: number; // 自动刷新间隔(毫秒),默认3000
}
/**
*
* /
*/
export const FloatingTaskPanel: React.FC<FloatingTaskPanelProps> = ({
projectId,
autoRefreshInterval = 3000,
}) => {
const [taskList, setTaskList] = useState<TaskStatus[]>([]);
const [loading, setLoading] = useState(false);
const [collapsed, setCollapsed] = useState(true); // 默认收起
const userCollapsedRef = useRef(false); // 用户手动收起标记
const { token } = theme.useToken();
// 加载任务列表
const loadTasks = useCallback(async () => {
if (!projectId) return;
setLoading(true);
try {
const result = await getProjectTasks(projectId);
setTaskList(result.items || []);
} catch (error) {
console.error('加载任务列表失败:', error);
} finally {
setLoading(false);
}
}, [projectId]);
// 初始加载
useEffect(() => {
loadTasks();
}, [loadTasks]);
// 监听后台任务创建事件,立即刷新列表并展开浮窗
useEffect(() => {
const handleTaskCreated = () => {
loadTasks();
// 创建新任务时自动展开(重置用户手动收起标记)
userCollapsedRef.current = false;
setCollapsed(false);
};
eventBus.on('background-task-created', handleTaskCreated);
return () => {
eventBus.off('background-task-created', handleTaskCreated);
};
}, [loadTasks]);
// 有活跃任务时自动展开(仅当用户没有手动收起时)
useEffect(() => {
const hasActiveTasks = taskList.some(
(t) => t.status === 'running' || t.status === 'pending'
);
if (hasActiveTasks && !userCollapsedRef.current) {
setCollapsed(false);
}
}, [taskList]);
// 自动刷新(仅当有运行中或等待中的任务时)
useEffect(() => {
const hasActiveTasks = taskList.some(
(t) => t.status === 'running' || t.status === 'pending'
);
if (!hasActiveTasks) return;
const timer = setInterval(loadTasks, autoRefreshInterval);
return () => clearInterval(timer);
}, [taskList, autoRefreshInterval, loadTasks]);
// 取消任务
const handleCancelTask = async (task: TaskStatus) => {
try {
if (task.task_type === 'chapter_batch') {
await cancelBatchTask(task.id);
} else {
await cancelTask(task.id);
}
loadTasks();
} catch (error) {
console.error('取消任务失败:', error);
}
};
// 删除任务记录
const handleDeleteTask = async (taskId: string) => {
try {
await deleteTask(taskId);
loadTasks();
} catch (error) {
console.error('删除任务记录失败:', error);
}
};
// 一键清理已结束的任务记录
const handleClearTasks = async () => {
try {
const result = await clearProjectTasks(projectId);
message.success(`已清理 ${result.deleted_count} 条任务记录`);
loadTasks();
} catch (error) {
console.error('清理任务记录失败:', error);
message.error('清理任务记录失败');
}
};
// 获取任务状态标签
const getTaskStatusTag = (status: TaskStatus['status']) => {
switch (status) {
case 'pending':
return <Tag icon={<ClockCircleOutlined />} color="default"></Tag>;
case 'running':
return <Tag icon={<LoadingOutlined />} color="processing"></Tag>;
case 'completed':
return <Tag icon={<CheckCircleOutlined />} color="success"></Tag>;
case 'failed':
return <Tag icon={<CloseCircleOutlined />} color="error"></Tag>;
case 'cancelled':
return <Tag icon={<CloseCircleOutlined />} color="default"></Tag>;
default:
return <Tag>{status}</Tag>;
}
};
// 获取任务类型标签
const getTaskTypeLabel = (taskType: string) => {
switch (taskType) {
case 'outline_new':
return '大纲生成';
case 'outline_continue':
return '大纲续写';
case 'outline_expand':
return '大纲展开';
case 'outline_batch_expand':
return '批量大纲展开';
case 'chapter_generate':
return '章节生成';
case 'chapter_batch':
return '批量章节生成';
case 'wizard':
return '向导创建';
default:
return taskType;
}
};
const activeTasks = taskList.filter((t) => t.status === 'running' || t.status === 'pending');
const hasActiveTasks = activeTasks.length > 0;
// 没有任务时不显示浮窗
if (taskList.length === 0) return null;
return (
<div
style={{
position: 'fixed',
bottom: 10,
right: 23,
width: collapsed ? 260 : 400,
maxHeight: collapsed ? 60 : 500,
zIndex: 1000,
boxShadow: token.boxShadowSecondary,
borderRadius: token.borderRadiusLG,
overflow: 'hidden',
transition: 'all 0.3s ease',
}}
>
<Card
size="small"
title={
<Space>
<ClockCircleOutlined />
<span></span>
{hasActiveTasks && <Badge count={activeTasks.length} />}
</Space>
}
extra={
<Space>
<Tooltip title="刷新">
<Button
type="text"
size="small"
icon={<ReloadOutlined />}
onClick={loadTasks}
loading={loading}
/>
</Tooltip>
{taskList.some(t => t.status === 'completed' || t.status === 'failed' || t.status === 'cancelled') && (
<Popconfirm
title="确认清理所有已结束的任务记录?"
onConfirm={handleClearTasks}
okText="确认"
cancelText="取消"
>
<Tooltip title="清理已结束任务">
<Button
type="text"
size="small"
icon={<ClearOutlined />}
/>
</Tooltip>
</Popconfirm>
)}
<Button
type="text"
size="small"
icon={collapsed ? <UpOutlined /> : <DownOutlined />}
onClick={() => {
const newCollapsed = !collapsed;
setCollapsed(newCollapsed);
// 记录用户手动收起,防止自动展开覆盖
userCollapsedRef.current = newCollapsed;
}}
/>
</Space>
}
bodyStyle={{
padding: collapsed ? 0 : 12,
maxHeight: collapsed ? 0 : 400,
overflowY: 'auto',
transition: 'all 0.3s ease',
}}
>
{!collapsed && (
<>
{taskList.length === 0 ? (
<Empty description="暂无任务" image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<List
size="small"
dataSource={taskList}
renderItem={(task: TaskStatus) => (
<List.Item
key={task.id}
style={{
padding: '8px 0',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
}}
>
<div style={{ width: '100%' }}>
<div style={{ marginBottom: 4 }}>
<Space size={4} wrap>
{getTaskStatusTag(task.status)}
<Tag color="blue">{getTaskTypeLabel(task.task_type)}</Tag>
</Space>
</div>
{task.status_message && (
<div
style={{
fontSize: 12,
color: token.colorTextSecondary,
marginBottom: 4,
}}
>
{task.status_message}
</div>
)}
{(task.status === 'running' || task.status === 'pending') && (
<Progress
percent={task.progress}
size="small"
status={task.status === 'running' ? 'active' : 'normal'}
style={{ marginBottom: 4 }}
/>
)}
{task.error_message && (
<div
style={{
fontSize: 12,
color: token.colorError,
marginBottom: 4,
}}
>
: {task.error_message}
</div>
)}
<div style={{ marginTop: 8 }}>
<Space size={4}>
{(task.status === 'running' || task.status === 'pending') && (
<Popconfirm
title="确认取消任务?"
onConfirm={() => handleCancelTask(task)}
okText="确认"
cancelText="取消"
>
<Button size="small" danger>
</Button>
</Popconfirm>
)}
{(task.status === 'completed' ||
task.status === 'failed' ||
task.status === 'cancelled') && (
<Popconfirm
title="确认删除任务记录?"
onConfirm={() => handleDeleteTask(task.id)}
okText="确认"
cancelText="取消"
>
<Button size="small" icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
)}
</Space>
</div>
</div>
</List.Item>
)}
/>
)}
</>
)}
</Card>
</div>
);
};
export default FloatingTaskPanel;
+9 -6
View File
@@ -13,19 +13,22 @@ export const VERSION_INFO = {
buildTime: import.meta.env.VITE_BUILD_TIME || new Date().toISOString().split('T')[0],
// 项目信息
projectName: 'MuMuAINovel',
projectFullName: 'MuMu AI 小说创作助手',
projectName: '墨木灵思',
projectFullName: '墨木灵思 - AI 智能小说创作助手',
// 链接信息
githubUrl: 'https://github.com/xiamuceer-j/MuMuAINovel',
linuxDoUrl: 'https://linux.do/t/topic/1106333',
// 链接信息(不在代码里写死 GitHub;需要时在 .env 里配置 Vite 变量)
linuxDoUrl: '',
/** XinMi API 官方地址(侧栏入口、设置页文档链接) */
xinmiApiBaseUrl: 'http://api.xinmi.cloud',
officialApiDocUrl:
String(import.meta.env.VITE_OFFICIAL_API_DOC_URL ?? '').trim() || 'http://api.xinmi.cloud',
// 许可证
license: 'GPL v3.0',
licenseUrl: 'https://www.gnu.org/licenses/gpl-3.0.html',
// 作者信息
author: 'xiamuceer-j',
author: '墨木灵思团队',
};
/**
+45 -3
View File
@@ -5,7 +5,14 @@ body,
}
:root {
font-family: "PingFang SC", "Microsoft YaHei", "Heiti SC", Inter, system-ui, sans-serif;
--app-font-serif: 'Noto Serif SC', 'Songti SC', 'SimSun', serif;
--app-font-sans: 'Source Sans 3', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--app-font-mono: 'IBM Plex Mono', 'Consolas', monospace;
--app-ink: #292524;
--app-parchment: #f5f0e6;
--app-copper: #b45309;
--app-gold: #f59e0b;
font-family: var(--app-font-sans);
line-height: 1.5715;
font-weight: 400;
font-synthesis: none;
@@ -16,6 +23,41 @@ body,
-ms-text-size-adjust: 100%;
}
[data-theme-resolved='dark'] {
--app-ink: #e7e5e4;
--app-parchment: #0c0a09;
--app-copper: #f59e0b;
--app-gold: #fbbf24;
}
.app-serif-title {
font-family: var(--app-font-serif);
letter-spacing: 0.02em;
}
.app-shell-sider .ant-menu-dark,
.app-shell-sider .ant-menu {
background: transparent !important;
}
.app-shell-sider .ant-menu-item-selected {
border-left: 3px solid var(--app-gold) !important;
}
.app-login-tabs .ant-tabs-nav::before {
border-bottom-color: color-mix(in srgb, var(--app-ink) 12%, transparent);
}
.app-login-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
color: var(--app-copper) !important;
font-weight: 700;
}
.app-prompt-header {
border-left: 4px solid var(--app-copper);
padding-left: 20px;
}
body {
margin: 0;
min-height: 100vh;
@@ -219,8 +261,8 @@ body {
}
.ant-tooltip .ant-tooltip-inner {
background: var(--app-tooltip-bg, #884d5c);
border-radius: 8px;
background: var(--app-tooltip-bg, #292524);
border-radius: 2px;
padding: 8px 16px;
font-weight: 500;
box-shadow: 0 4px 12px var(--app-tooltip-shadow, rgba(136, 77, 92, 0.3));
+160
View File
@@ -0,0 +1,160 @@
import { Typography, Card, Space, Divider, Row, Col, theme, List, Timeline } from 'antd';
import {
RocketOutlined,
BulbOutlined,
TeamOutlined,
BookOutlined,
ExperimentOutlined,
SmileOutlined
} from '@ant-design/icons';
import { VERSION_INFO } from '../config/version';
const { Title, Paragraph, Text } = Typography;
export default function About() {
const { token } = theme.useToken();
const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`;
const features = [
{
icon: <RocketOutlined style={{ fontSize: 24, color: token.colorPrimary }} />,
title: '智能向导',
description: 'AI 自动生成小说大纲、角色背景和世界观,为您开启创作之门。',
},
{
icon: <BulbOutlined style={{ fontSize: 24, color: '#fadb14' }} />,
title: '多模型支持',
description: '集成 OpenAI、Gemini、Claude 等顶级模型,灵活切换以满足不同创作需求。',
},
{
icon: <TeamOutlined style={{ fontSize: 24, color: '#52c41a' }} />,
title: '角色与关系管理',
description: '精细化的角色档案、复杂的人物关系网,让故事角色跃然纸上。',
},
{
icon: <BookOutlined style={{ fontSize: 24, color: '#1890ff' }} />,
title: '章节精雕细琢',
description: '支持章节自动生成、润色、续写,AI 辅助您完成每一段精彩故事。',
},
];
const techStack = [
{ label: '前端框架', value: 'React 18 + Vite' },
{ label: ' UI 组件库', value: 'Ant Design 5' },
{ label: '状态管理', value: 'Zustand' },
{ label: '后端框架', value: 'FastAPI (Python 3.11)' },
{ label: '数据库', value: 'PostgreSQL + Alembic' },
{ label: '部署技术', value: 'Docker + Docker Compose' },
];
return (
<div style={{ height: '100%', overflowY: 'auto', padding: '24px' }}>
<div style={{ maxWidth: 1000, margin: '0 auto' }}>
<div style={{ textAlign: 'center', marginBottom: 48 }}>
<Title level={1}> </Title>
<Paragraph style={{ fontSize: 18, color: token.colorTextSecondary }}>
</Paragraph>
</div>
<Row gutter={[24, 24]}>
<Col xs={24} md={12}>
<Card
title={<Space><SmileOutlined /> </Space>}
bordered={false}
style={{ height: '100%', boxShadow: `0 4px 12px ${alphaColor(token.colorText, 0.05)}` }}
>
<Paragraph>
AI
</Paragraph>
<Paragraph>
</Paragraph>
</Card>
</Col>
<Col xs={24} md={12}>
<Card
title={<Space><ExperimentOutlined /> </Space>}
bordered={false}
style={{ height: '100%', boxShadow: `0 4px 12px ${alphaColor(token.colorText, 0.05)}` }}
>
<List
dataSource={features}
renderItem={(item) => (
<List.Item style={{ padding: '12px 0' }}>
<List.Item.Meta
avatar={item.icon}
title={item.title}
description={item.description}
/>
</List.Item>
)}
/>
</Card>
</Col>
</Row>
<Divider />
<Title level={2} style={{ textAlign: 'center', marginBottom: 32 }}></Title>
<Row gutter={[24, 24]}>
<Col span={24}>
<Card bordered={false} style={{ background: alphaColor(token.colorPrimary, 0.02) }}>
<Row gutter={[16, 16]}>
{techStack.map((tech, index) => (
<Col xs={12} sm={8} key={index}>
<div style={{ textAlign: 'center', padding: '16px' }}>
<Text type="secondary" style={{ display: 'block', marginBottom: 4 }}>{tech.label}</Text>
<Text strong style={{ fontSize: 16 }}>{tech.value}</Text>
</div>
</Col>
))}
</Row>
</Card>
</Col>
</Row>
<Divider />
<Title level={2} style={{ textAlign: 'center', marginBottom: 32 }}>线</Title>
<div style={{ padding: '0 24px' }}>
<Timeline
items={[
{
color: 'green',
children: '2024 - 墨木灵思基础版发布,支持核心 AI 创作流程',
},
{
color: 'blue',
children: '2025 - 引入更强大的多模型协同引擎,完善角色关系拓扑图',
},
{
children: '2026 - 墨木灵思品牌升级,全站 UI/UX 深度优化',
},
{
color: 'gray',
children: '未来 - 探索小说到多媒体(漫画、短剧)的 AI 转化路径',
},
]}
/>
</div>
<div style={{ textAlign: 'center', marginTop: 64, marginBottom: 32 }}>
<Paragraph type="secondary">
© 2026
{VERSION_INFO.officialApiDocUrl ? (
<>
{' '}
|{' '}
<a href={VERSION_INFO.officialApiDocUrl} target="_blank" rel="noopener noreferrer">
</a>
</>
) : null}
</Paragraph>
</div>
</div>
</div>
);
}
+21 -153
View File
@@ -2,16 +2,15 @@ import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Spin, Result, Button, Modal, Input, message, theme } from 'antd';
import { authApi } from '../services/api';
import AnnouncementModal from '../components/AnnouncementModal';
export default function AuthCallback() {
const navigate = useNavigate();
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [errorMessage, setErrorMessage] = useState('');
const [showAnnouncement, setShowAnnouncement] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false);
const { token } = theme.useToken();
const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`;
interface PasswordStatus {
has_password: boolean;
has_custom_password: boolean;
@@ -26,64 +25,33 @@ export default function AuthCallback() {
useEffect(() => {
const handleCallback = async () => {
try {
// 后端会通过 Cookie 自动设置认证信息
// 这里只需要验证登录状态
const currentUser = await authApi.getCurrentUser();
// 检查是否是首次登录(通过 Cookie 标记)
const isFirstLogin = document.cookie.includes('first_login=true');
setStatus('success');
if (isFirstLogin) {
// 首次登录:生成默认密码并显示提示
const defaultPassword = `${currentUser.username}@666`;
const pwdStatus = {
setPasswordStatus({
has_password: false,
has_custom_password: false,
username: currentUser.username,
default_password: defaultPassword
};
setPasswordStatus(pwdStatus);
// 清除首次登录标记 Cookie
});
document.cookie = 'first_login=; path=/; max-age=0';
// 显示密码初始化弹窗
setTimeout(() => {
setShowPasswordModal(true);
}, 1000);
setTimeout(() => setShowPasswordModal(true), 1000);
return;
}
// 非首次登录:正常流程
// 从 sessionStorage 获取重定向地址
const redirect = sessionStorage.getItem('login_redirect') || '/';
sessionStorage.removeItem('login_redirect');
// 检查是否永久隐藏公告或今日已隐藏
const hideForever = localStorage.getItem('announcement_hide_forever');
const hideToday = localStorage.getItem('announcement_hide_today');
const today = new Date().toDateString();
if (hideForever === 'true' || hideToday === today) {
// 延迟一下再跳转,让用户看到成功提示
setTimeout(() => {
navigate(redirect);
}, 1000);
} else {
// 延迟一下再显示公告,让用户看到成功提示
setTimeout(() => {
setShowAnnouncement(true);
}, 1000);
}
setTimeout(() => navigate(redirect), 1000);
} catch (error) {
console.error('登录失败:', error);
setStatus('error');
setErrorMessage('登录失败,请重试');
}
};
handleCallback();
}, [navigate]);
@@ -98,9 +66,7 @@ export default function AuthCallback() {
}}>
<div style={{ textAlign: 'center' }}>
<Spin size="large" />
<div style={{ marginTop: 20, color: token.colorWhite, fontSize: 16 }}>
...
</div>
<div style={{ marginTop: 20, color: token.colorWhite, fontSize: 16 }}>...</div>
</div>
</div>
);
@@ -119,55 +85,21 @@ export default function AuthCallback() {
status="error"
title="登录失败"
subTitle={errorMessage}
extra={
<Button type="primary" onClick={() => navigate('/login')}>
</Button>
}
extra={<Button type="primary" onClick={() => navigate('/login')}></Button>}
style={{ background: token.colorBgContainer, padding: 40, borderRadius: 8 }}
/>
</div>
);
}
const handleAnnouncementClose = () => {
setShowAnnouncement(false);
const redirect = sessionStorage.getItem('login_redirect') || '/';
sessionStorage.removeItem('login_redirect');
navigate(redirect);
};
const handleDoNotShowToday = () => {
// 设置今日不再显示
const today = new Date().toDateString();
localStorage.setItem('announcement_hide_today', today);
};
const handleNeverShow = () => {
// 设置永久不再显示
localStorage.setItem('announcement_hide_forever', 'true');
};
const handleSetPassword = async () => {
// 如果没有输入新密码,使用默认密码
const passwordToSet = newPassword || passwordStatus?.default_password;
if (!passwordToSet) {
message.error('输入密码');
return;
}
if (passwordToSet.length < 6) {
message.error('密码长度至少为6个字符');
return;
}
if (newPassword && newPassword !== confirmPassword) {
message.error('两次输入的密码不一致');
return;
}
if (!passwordToSet) { message.error('请输入新密码'); return; }
if (passwordToSet.length < 6) { message.error('密码长度至少为6个字符'); return; }
if (newPassword && newPassword !== confirmPassword) { message.error('两次输入密码不一致'); return; }
setSettingPassword(true);
try {
// 首次登录使用初始化接口,后续使用修改接口
const isFirstLogin = !passwordStatus?.has_password;
if (isFirstLogin) {
await authApi.initializePassword(passwordToSet);
@@ -177,24 +109,9 @@ export default function AuthCallback() {
message.success('密码设置成功');
}
setShowPasswordModal(false);
// 继续后续流程
const redirect = sessionStorage.getItem('login_redirect') || '/';
sessionStorage.removeItem('login_redirect');
const hideForever = localStorage.getItem('announcement_hide_forever');
const hideToday = localStorage.getItem('announcement_hide_today');
const today = new Date().toDateString();
if (hideForever === 'true' || hideToday === today) {
setTimeout(() => {
navigate(redirect);
}, 500);
} else {
setTimeout(() => {
setShowAnnouncement(true);
}, 500);
}
setTimeout(() => navigate(redirect), 500);
} catch {
message.error('密码设置失败,请重试');
} finally {
@@ -203,46 +120,19 @@ export default function AuthCallback() {
};
const handleSkipPasswordSetting = async () => {
// 首次登录时,如果跳过设置,使用默认密码初始化
const isFirstLogin = !passwordStatus?.has_password;
if (isFirstLogin && passwordStatus?.default_password) {
try {
await authApi.initializePassword(passwordStatus.default_password);
} catch (error) {
console.error('初始化默认密码失败:', error);
try { await authApi.initializePassword(passwordStatus.default_password); }
catch (error) { console.error('初始化默认密码失败:', error); }
}
}
setShowPasswordModal(false);
// 继续后续流程
const redirect = sessionStorage.getItem('login_redirect') || '/';
sessionStorage.removeItem('login_redirect');
const hideForever = localStorage.getItem('announcement_hide_forever');
const hideToday = localStorage.getItem('announcement_hide_today');
const today = new Date().toDateString();
if (hideForever === 'true' || hideToday === today) {
setTimeout(() => {
navigate(redirect);
}, 500);
} else {
setTimeout(() => {
setShowAnnouncement(true);
}, 500);
}
setTimeout(() => navigate(redirect), 500);
};
return (
<>
<AnnouncementModal
visible={showAnnouncement}
onClose={handleAnnouncementClose}
onDoNotShowToday={handleDoNotShowToday}
onNeverShow={handleNeverShow}
/>
<Modal
title="设置账号密码"
open={showPasswordModal}
@@ -255,45 +145,23 @@ export default function AuthCallback() {
width={500}
>
<div style={{ marginBottom: 20 }}>
<p> Linux DO </p>
<p>使</p>
<p></p>
<p>使</p>
{passwordStatus?.default_password && (
<div style={{
background: token.colorFillTertiary,
padding: 12,
borderRadius: 4,
marginTop: 12
}}>
<div style={{ background: token.colorFillTertiary, padding: 12, borderRadius: 4, marginTop: 12 }}>
<strong></strong>{passwordStatus.username}<br />
<strong></strong><code style={{
background: token.colorBgContainer,
padding: '2px 8px',
borderRadius: 3,
color: token.colorPrimary,
fontSize: 14
}}>{passwordStatus.default_password}</code>
<strong></strong><code style={{ background: token.colorBgContainer, padding: '2px 8px', borderRadius: 3, color: token.colorPrimary, fontSize: 14 }}>{passwordStatus.default_password}</code>
</div>
)}
</div>
<div style={{ marginTop: 20 }}>
<div style={{ marginBottom: 12 }}>
<label>6</label>
<Input.Password
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="请输入新密码"
style={{ marginTop: 4 }}
/>
<Input.Password value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="请输入新密码" style={{ marginTop: 4 }} />
</div>
<div>
<label></label>
<Input.Password
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="请再次输入密码"
style={{ marginTop: 4 }}
/>
<Input.Password value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} placeholder="请再次输入密码" style={{ marginTop: 4 }} />
</div>
</div>
</Modal>
@@ -308,7 +176,7 @@ export default function AuthCallback() {
<Result
status="success"
title="登录成功"
subTitle={showPasswordModal ? "请设置账号密码..." : (showAnnouncement ? "欢迎使用..." : "正在跳转...")}
subTitle={showPasswordModal ? "请设置账号密码..." : "正在跳转..."}
style={{ background: alphaColor(token.colorBgContainer, 0.96), padding: 40, borderRadius: 8 }}
/>
</div>
+78 -28
View File
@@ -1,10 +1,11 @@
import { Card, Button, Spin, Space, Tag, Typography, Alert, theme } from 'antd';
import { BookOutlined, RocketOutlined, BulbOutlined, UploadOutlined, DownloadOutlined, LoadingOutlined, CalendarOutlined, DeleteOutlined, CheckCircleOutlined, EditOutlined, PauseCircleOutlined, PictureOutlined, SwapOutlined, ReloadOutlined } from '@ant-design/icons';
import { Card, Button, Spin, Space, Tag, Typography, theme } from 'antd';
import { BookOutlined, RocketOutlined, BulbOutlined, UploadOutlined, DownloadOutlined, LoadingOutlined, CalendarOutlined, DeleteOutlined, CheckCircleOutlined, EditOutlined, PauseCircleOutlined, PictureOutlined, SwapOutlined, ReloadOutlined, InfoCircleOutlined, CloseOutlined } from '@ant-design/icons';
import { useState } from 'react';
import type { ReactNode } from 'react';
import type { Project } from '../types';
import { bookshelfCardStyles, bookshelfCardHoverHandlers } from '../components/CardStyles';
import { useThemeMode } from '../theme/useThemeMode';
import { VERSION_INFO } from '../config/version';
const { Paragraph } = Typography;
@@ -215,42 +216,91 @@ export default function BookshelfPage({
</Button>
</Space>
</div>
</Card>
{showApiTip && projects.length === 0 && (
<Alert
message="欢迎使用 MuMuAINovel"
description={
<div style={{
<div
style={{
marginTop: isMobile ? 12 : 14,
paddingTop: isMobile ? 12 : 14,
borderTop: `1px solid ${alphaColor(token.colorWhite, 0.28)}`,
position: 'relative',
zIndex: 1,
}}
>
<div
style={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'flex-start' : 'center',
alignItems: isMobile ? 'stretch' : 'center',
gap: isMobile ? 12 : 16,
justifyContent: 'space-between'
}}>
<span style={{ fontSize: isMobile ? 12 : 14 }}>
AI接口 OpenAI / Anthropic
</span>
<Button
size="small"
type="primary"
onClick={onGoSettings}
style={{ flexShrink: 0 }}
justifyContent: 'space-between',
}}
>
</Button>
</div>
}
type="info"
showIcon
closable
onClose={() => setShowApiTip(false)}
<div
style={{
marginBottom: isMobile ? 16 : 24,
borderRadius: 12
display: 'flex',
alignItems: 'flex-start',
gap: 10,
flex: 1,
minWidth: 0,
}}
>
<InfoCircleOutlined
style={{
fontSize: isMobile ? 18 : 20,
color: token.colorWhite,
marginTop: 2,
flexShrink: 0,
opacity: 0.95,
}}
/>
<div style={{ minWidth: 0 }}>
<div
style={{
fontSize: isMobile ? 14 : 15,
fontWeight: 600,
color: token.colorWhite,
marginBottom: 4,
lineHeight: 1.35,
}}
>
使 {VERSION_INFO.projectName}
</div>
<div
style={{
fontSize: isMobile ? 12 : 13,
color: alphaColor(token.colorWhite, 0.9),
lineHeight: 1.5,
}}
>
AI OpenAI Gemini
</div>
</div>
</div>
<Space
wrap
style={{
flexShrink: 0,
justifyContent: isMobile ? 'flex-end' : 'flex-end',
width: isMobile ? '100%' : 'auto',
}}
>
<Button size="small" type="primary" ghost onClick={onGoSettings}>
</Button>
<Button
size="small"
type="text"
icon={<CloseOutlined />}
onClick={() => setShowApiTip(false)}
style={{ color: alphaColor(token.colorWhite, 0.92) }}
aria-label="关闭提示"
/>
</Space>
</div>
</div>
)}
</Card>
<Spin spinning={loading}>
<div style={{
+22 -346
View File
@@ -1,9 +1,10 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Progress, Card, InputNumber, Alert, Radio, Descriptions, Collapse, Popconfirm, Pagination, theme } from 'antd';
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined, DeleteOutlined, BookOutlined, FormOutlined, PlusOutlined, ReadOutlined, ClockCircleOutlined, LoadingOutlined } from '@ant-design/icons';
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, InputNumber, Alert, Radio, Descriptions, Collapse, Popconfirm, Pagination, theme } from 'antd';
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined, DeleteOutlined, BookOutlined, FormOutlined, PlusOutlined, ReadOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { eventBus } from '../store/eventBus';
import { useChapterSync } from '../store/hooks';
import { generateChapterBackground, getProjectTasks, cancelTask, deleteTask, type TaskStatus as BgTaskStatus } from '../services/backgroundTaskService';
import { generateChapterBackground } from '../services/backgroundTaskService';
import { projectApi, writingStyleApi, chapterApi } from '../services/api';
import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask, ExpansionPlanData } from '../types';
import type { TextAreaRef } from 'antd/es/input/TextArea';
@@ -97,111 +98,6 @@ export default function Chapters() {
const [singleChapterProgress, setSingleChapterProgress] = useState(0);
const [singleChapterProgressMessage, setSingleChapterProgressMessage] = useState('');
// 后台生成任务状态
const [bgTaskVisible, setBgTaskVisible] = useState(false);
const [bgTaskProgress, setBgTaskProgress] = useState(0);
const [bgTaskMessage, setBgTaskMessage] = useState('');
const [bgTaskRunning, setBgTaskRunning] = useState(false);
const bgTaskCancelRef = useRef<(() => void) | null>(null);
const [projectBgTasks, setProjectBgTasks] = useState<BgTaskStatus[]>([]);
const bgPollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 后台任务列表 Modal 状态
const [taskListVisible, setTaskListVisible] = useState(false);
const [taskList, setTaskList] = useState<BgTaskStatus[]>([]);
const [taskListLoading, setTaskListLoading] = useState(false);
// 轮询项目后台任务
useEffect(() => {
if (!currentProject) return;
const pollBgTasks = async () => {
try {
const resp = await getProjectTasks(currentProject.id, 'chapter_generate', 10);
const active = resp.items.filter(t => t.status === 'pending' || t.status === 'running');
setProjectBgTasks(active);
// 如果有活跃任务,继续轮询
if (active.length > 0) {
bgPollTimerRef.current = setTimeout(pollBgTasks, 3000);
}
} catch {}
};
pollBgTasks();
return () => { if (bgPollTimerRef.current) clearTimeout(bgPollTimerRef.current); };
}, [currentProject]);
// 加载并显示后台任务列表
const showTaskListModal = async () => {
if (!currentProject?.id) return;
setTaskListVisible(true);
setTaskListLoading(true);
try {
const result = await getProjectTasks(currentProject.id);
setTaskList(result.items || []);
} catch (error) {
message.error('加载任务列表失败');
} finally {
setTaskListLoading(false);
}
};
// 刷新任务列表
const refreshTaskList = async () => {
if (!currentProject?.id) return;
setTaskListLoading(true);
try {
const result = await getProjectTasks(currentProject.id);
setTaskList(result.items || []);
const active = (result.items || []).filter(t => t.status === 'pending' || t.status === 'running');
setProjectBgTasks(active);
} catch (error) {
console.error('刷新任务列表失败:', error);
} finally {
setTaskListLoading(false);
}
};
// 获取任务状态标签
const getTaskStatusTag = (status: BgTaskStatus['status']) => {
switch (status) {
case 'pending': return <Tag icon={<ClockCircleOutlined />} color="default"></Tag>;
case 'running': return <Tag icon={<LoadingOutlined />} color="processing"></Tag>;
case 'completed': return <Tag icon={<CheckCircleOutlined />} color="success"></Tag>;
case 'failed': return <Tag icon={<CloseCircleOutlined />} color="error"></Tag>;
case 'cancelled': return <Tag icon={<CloseCircleOutlined />} color="default"></Tag>;
default: return <Tag>{status}</Tag>;
}
};
// 获取任务类型标签
const getTaskTypeLabel = (taskType: string) => {
switch (taskType) {
case 'chapter_generate': return '章节生成';
case 'outline_new': return '大纲生成';
case 'outline_continue': return '大纲续写';
default: return taskType;
}
};
// 处理取消后台任务
const handleCancelBgTask = async (taskId: string) => {
try {
await cancelTask(taskId);
message.success('任务已取消');
refreshTaskList();
} catch (error) {
message.error('取消任务失败');
}
};
// 处理删除任务记录
const handleDeleteBgTask = async (taskId: string) => {
try {
await deleteTask(taskId);
message.success('任务记录已删除');
refreshTaskList();
} catch (error) {
message.error('删除任务记录失败');
}
};
// 批量生成相关状态
const [batchGenerateVisible, setBatchGenerateVisible] = useState(false);
@@ -643,7 +539,7 @@ export default function Chapters() {
// 启动轮询
startBatchPolling(task.batch_id);
message.info('检测到未完成的批量生成任务,已在顶部显示进度');
message.info('检测到未完成的批量生成任务,请查看任务列表');
}
} catch (error) {
console.error('检查批量生成任务失败:', error);
@@ -1079,6 +975,7 @@ export default function Chapters() {
// 后台生成章节(关闭浏览器也不影响)
// 不再强制显示进度弹窗,任务进度在右下角悬浮任务框中显示
const handleBackgroundGenerate = async () => {
if (!editingId) return;
if (!selectedStyleId) {
@@ -1087,12 +984,7 @@ export default function Chapters() {
}
try {
setBgTaskVisible(true);
setBgTaskRunning(true);
setBgTaskProgress(0);
setBgTaskMessage("正在创建后台任务...");
const cancelFn = await generateChapterBackground(
await generateChapterBackground(
editingId,
{
style_id: selectedStyleId,
@@ -1100,14 +992,10 @@ export default function Chapters() {
model: selectedModel,
narrative_perspective: temporaryNarrativePerspective,
},
(status) => {
setBgTaskProgress(status.progress || 0);
setBgTaskMessage(status.status_message || "处理中...");
() => {
// 进度更新由悬浮任务框处理,无需额外操作
},
(_) => {
setBgTaskProgress(100);
setBgTaskMessage("生成完成!");
setBgTaskRunning(false);
message.success("后台章节生成完成!");
refreshChapters();
if (currentProject) {
@@ -1116,17 +1004,15 @@ export default function Chapters() {
loadAnalysisTasks();
},
(error) => {
setBgTaskRunning(false);
setBgTaskMessage("失败: " + error);
message.error("后台生成失败: " + error);
}
);
bgTaskCancelRef.current = cancelFn;
message.info("已提交后台生成任务,可以关闭此页面");
message.info("章节生成任务已提交,可在右下角任务面板查看进度");
// 通知悬浮任务框刷新
eventBus.emit('background-task-created');
} catch (error) {
message.error("创建后台任务失败");
setBgTaskRunning(false);
}
};
const getStatusColor = (status: string) => {
@@ -1243,7 +1129,7 @@ export default function Chapters() {
try {
setBatchGenerating(true);
setBatchGenerateVisible(false); // 关闭配置对话框,避免遮挡进度弹窗
setBatchGenerateVisible(false); // 关闭配置对话框,任务进度在悬浮任务框中显示
const requestBody: {
start_chapter_number: number;
@@ -1293,7 +1179,9 @@ export default function Chapters() {
estimated_time_minutes: result.estimated_time_minutes,
});
message.success(`批量生成任务已创建,预计需要 ${result.estimated_time_minutes} 分钟`);
message.success(`批量生成任务已创建,预计需要 ${result.estimated_time_minutes} 分钟,可在右下角任务面板查看进度`);
// 通知悬浮任务框刷新
eventBus.emit('background-task-created');
// 🔔 触发浏览器通知(任务开始)
showBrowserNotification(
@@ -2092,23 +1980,17 @@ export default function Chapters() {
>
{batchAnalyzableChapterCount > 0 ? ` (${batchAnalyzableChapterCount})` : ''}
</Button>
<Button
icon={<ClockCircleOutlined />}
onClick={showTaskListModal}
>
{projectBgTasks.length > 0 && <Badge count={projectBgTasks.length} size="small" style={{ marginLeft: 4 }} />}
</Button>
<Button
type="primary"
icon={<RocketOutlined />}
onClick={handleOpenBatchGenerate}
disabled={chapters.length === 0}
disabled={chapters.length === 0 || batchGenerating}
loading={batchGenerating}
block={isMobile}
size={isMobile ? 'middle' : 'middle'}
style={{ background: token.colorInfo, borderColor: token.colorInfo }}
style={batchGenerating ? {} : { background: token.colorInfo, borderColor: token.colorInfo }}
>
{batchGenerating ? '生成中...' : '批量生成'}
</Button>
<Button
type="default"
@@ -2123,102 +2005,6 @@ export default function Chapters() {
</Space>
</div>
{/* 后台生成任务进度 */}
{(projectBgTasks.length > 0 || (batchGenerating && batchProgress)) && (
<div style={{
marginBottom: 16,
padding: '12px 16px',
background: token.colorInfoBg,
borderRadius: token.borderRadius,
border: `1px solid ${token.colorInfoBorder}`
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 8 }}>
<RocketOutlined style={{ color: token.colorInfo }} spin />
<span style={{ fontWeight: 600, color: token.colorInfo }}>
</span>
<span style={{ fontSize: 12, color: token.colorTextSecondary }}>
</span>
</div>
{/* 批量生成进度 */}
{batchGenerating && batchProgress && (
<div style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '8px 0',
borderBottom: `1px solid ${token.colorBorderSecondary}`
}}>
<Tag color="processing" style={{ minWidth: 60, textAlign: 'center' }}>
</Tag>
<div style={{ flex: 1 }}>
<div style={{ fontSize: 12, marginBottom: 4, color: token.colorText }}>
{batchProgress.current_chapter_number
? `正在生成第 ${batchProgress.current_chapter_number}`
: '批量生成中...'} ({batchProgress.completed}/{batchProgress.total})
</div>
<div style={{
background: token.colorBgLayout, borderRadius: 4,
height: 8, overflow: 'hidden'
}}>
<div style={{
background: token.colorInfo, height: '100%',
width: (batchProgress.total > 0 ? Math.round((batchProgress.completed / batchProgress.total) * 100) : 0) + '%',
transition: 'width 0.3s'
}} />
</div>
</div>
<span style={{ fontSize: 13, fontWeight: 600, color: token.colorInfo, minWidth: 40, textAlign: 'right' }}>
{batchProgress.total > 0 ? Math.round((batchProgress.completed / batchProgress.total) * 100) : 0}%
</span>
<Button size="small" danger onClick={() => {
modal.confirm({
title: '确认取消',
content: '确定要取消批量生成吗?已生成的章节将保留。',
okText: '确定取消',
cancelText: '继续生成',
okButtonProps: { danger: true },
centered: true,
onOk: handleCancelBatchGenerate,
});
}}>
</Button>
</div>
)}
{/* 单章节后台生成进度 */}
{projectBgTasks.map(task => (
<div key={task.id} style={{
display: 'flex', alignItems: 'center', gap: 12,
padding: '6px 0',
borderBottom: `1px solid ${token.colorBorderSecondary}`
}}>
<Tag color={task.status === 'running' ? 'processing' : 'default'}
style={{ minWidth: 60, textAlign: 'center' }}>
{task.status === 'running' ? '生成中' : '排队中'}
</Tag>
<div style={{ flex: 1 }}>
<div style={{
background: token.colorBgLayout, borderRadius: 4,
height: 6, overflow: 'hidden'
}}>
<div style={{
background: token.colorInfo, height: '100%',
width: (task.progress || 0) + '%',
transition: 'width 0.3s'
}} />
</div>
</div>
<span style={{ fontSize: 12, color: token.colorTextSecondary, minWidth: 40, textAlign: 'right' }}>
{task.progress || 0}%
</span>
<span style={{ fontSize: 12, color: token.colorTextSecondary }}>
{task.status_message || ''}
</span>
</div>
))}
</div>
)}
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{chapters.length === 0 ? (
@@ -2769,7 +2555,7 @@ export default function Chapters() {
icon={canGenerate ? <ThunderboltOutlined /> : <LockOutlined />}
onClick={() => currentChapter && showGenerateModal(currentChapter)}
loading={isContinuing}
disabled={!canGenerate || bgTaskRunning}
disabled={!canGenerate}
danger={!canGenerate}
style={{ fontWeight: 'bold' }}
title={!canGenerate ? disabledReason : '根据大纲和前置章节内容创作(流式)'}
@@ -2779,8 +2565,7 @@ export default function Chapters() {
<Button
icon={<RocketOutlined />}
onClick={handleBackgroundGenerate}
disabled={!canGenerate || bgTaskRunning || isContinuing}
loading={bgTaskRunning}
disabled={!canGenerate || isContinuing}
style={{ fontWeight: 'bold' }}
title={!canGenerate ? disabledReason : '后台生成:关闭浏览器也不影响,完成后自动保存'}
>
@@ -2792,26 +2577,6 @@ export default function Chapters() {
</Space.Compact>
</Form.Item>
{/* 后台生成进度 */}
{bgTaskVisible && (
<Alert
message={bgTaskRunning ? '后台生成进行中...' : '后台生成完成'}
description={
<div>
<div style={{ marginBottom: 8 }}>{bgTaskMessage}</div>
<div style={{ background: '#f0f0f0', borderRadius: 4, height: 8, overflow: 'hidden' }}>
<div style={{ background: '#1890ff', height: '100%', width: bgTaskProgress + '%', transition: 'width 0.3s' }} />
</div>
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>{bgTaskProgress}%</div>
</div>
}
type={bgTaskRunning ? 'info' : (bgTaskProgress >= 100 ? 'success' : 'error')}
showIcon
style={{ marginBottom: 12 }}
closable={!bgTaskRunning}
onClose={() => setBgTaskVisible(false)}
/>
)}
{/* 第一行:写作风格 + 叙事角度 */}
<div style={{
@@ -3233,95 +2998,6 @@ export default function Chapters() {
message={singleChapterProgressMessage}
/>
{/* 后台任务列表 Modal */}
<Modal
title={
<Space>
<ClockCircleOutlined />
<span></span>
{taskList.filter(t => t.status === 'running' || t.status === 'pending').length > 0 && (
<Badge count={taskList.filter(t => t.status === 'running' || t.status === 'pending').length} />
)}
</Space>
}
open={taskListVisible}
onCancel={() => setTaskListVisible(false)}
width={isMobile ? '95%' : 700}
centered
footer={
<Space>
<Button icon={<SyncOutlined />} onClick={refreshTaskList} loading={taskListLoading}>
</Button>
<Button onClick={() => setTaskListVisible(false)}>
</Button>
</Space>
}
>
{taskListLoading && taskList.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<LoadingOutlined style={{ fontSize: 24 }} />
<div style={{ marginTop: 12, color: token.colorTextSecondary }}>...</div>
</div>
) : taskList.length === 0 ? (
<Empty description="暂无后台任务" />
) : (
<List
dataSource={taskList}
renderItem={(task) => (
<List.Item
key={task.id}
actions={[
...(task.status === 'running' || task.status === 'pending'
? [<Button key="cancel" size="small" danger onClick={() => handleCancelBgTask(task.id)}></Button>]
: []
),
...(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled'
? [<Button key="delete" size="small" type="link" danger onClick={() => handleDeleteBgTask(task.id)}></Button>]
: []
),
].filter(Boolean)}
>
<List.Item.Meta
title={
<Space>
{getTaskStatusTag(task.status)}
<span>{getTaskTypeLabel(task.task_type)}</span>
{task.status === 'running' || task.status === 'pending' ? (
<Progress percent={task.progress} size="small" style={{ width: 120 }} />
) : null}
</Space>
}
description={
<div>
<div style={{ fontSize: 12, color: token.colorTextSecondary }}>
{task.status_message || '无状态信息'}
</div>
<div style={{ fontSize: 11, color: token.colorTextTertiary, marginTop: 4 }}>
: {task.created_at ? new Date(task.created_at).toLocaleString() : '-'}
{task.completed_at && ' | 完成: ' + new Date(task.completed_at).toLocaleString()}
</div>
{task.error_message && (
<div style={{ fontSize: 12, color: token.colorError, marginTop: 4 }}>
{'❌ ' + task.error_message}
</div>
)}
{task.task_result && task.status === 'completed' && (
<div style={{ fontSize: 12, color: token.colorSuccess, marginTop: 4 }}>
{'✅ ' + ((task.task_result as Record<string, unknown>).message as string || '任务完成')}
</div>
)}
</div>
}
/>
</List.Item>
)}
/>
)}
</Modal>
{/* 章节阅读器 */}
{readingChapter && (
<ChapterReader
+93 -478
View File
@@ -29,7 +29,6 @@ import {
} from '@ant-design/icons';
import { authApi } from '../services/api';
import { useNavigate, useSearchParams } from 'react-router-dom';
import AnnouncementModal from '../components/AnnouncementModal';
import ThemeSwitch from '../components/ThemeSwitch';
const { Title, Paragraph, Text } = Typography;
@@ -83,19 +82,26 @@ export default function Login() {
const [resetPasswordForm] = Form.useForm<ResetPasswordValues>();
const { token } = theme.useToken();
const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`;
const primaryButtonShadow = `0 8px 20px ${alphaColor(token.colorPrimary, 0.28)}`;
const hoverButtonShadow = `0 12px 28px ${alphaColor(token.colorPrimary, 0.36)}`;
const [showAnnouncement, setShowAnnouncement] = useState(false);
const [loginCodeSending, setLoginCodeSending] = useState(false);
const [registerCodeSending, setRegisterCodeSending] = useState(false);
const [resetCodeSending, setResetCodeSending] = useState(false);
const primaryButtonStyle = {
height: 46,
fontSize: 15,
fontWeight: 700,
borderRadius: 2,
letterSpacing: '0.06em',
textTransform: 'uppercase' as const,
};
const [loginCountdown, setLoginCountdown] = useState(0);
const [registerCountdown, setRegisterCountdown] = useState(0);
const [resetCountdown, setResetCountdown] = useState(0);
const [loginCodeSending, setLoginCodeSending] = useState(false);
const [registerCodeSending, setRegisterCodeSending] = useState(false);
const [resetCodeSending, setResetCodeSending] = useState(false);
const [showResetPassword, setShowResetPassword] = useState(false);
const localAuthEnabled = authConfig.local_auth_enabled;
const linuxdoEnabled = authConfig.linuxdo_enabled;
const emailAuthEnabled = authConfig.email_auth_enabled;
const emailRegisterEnabled = authConfig.email_register_enabled;
@@ -105,27 +111,13 @@ export default function Login() {
{ value: registerCountdown, setter: setRegisterCountdown },
{ value: resetCountdown, setter: setResetCountdown },
].map(({ value, setter }) => {
if (value <= 0) {
return null;
}
if (value <= 0) return null;
return window.setInterval(() => {
setter((prev) => {
if (prev <= 1) {
return 0;
}
return prev - 1;
});
setter((prev) => (prev <= 1 ? 0 : prev - 1));
}, 1000);
});
return () => {
timers.forEach((timer) => {
if (timer) {
window.clearInterval(timer);
}
});
};
return () => timers.forEach((timer) => timer && window.clearInterval(timer));
}, [loginCountdown, registerCountdown, resetCountdown]);
useEffect(() => {
@@ -141,8 +133,8 @@ export default function Login() {
} catch (error) {
console.error('获取认证配置失败:', error);
setAuthConfig({
local_auth_enabled: false,
linuxdo_enabled: true,
local_auth_enabled: true, // 默认开启本地,防止全关
linuxdo_enabled: false,
email_auth_enabled: false,
email_register_enabled: false,
});
@@ -155,17 +147,8 @@ export default function Login() {
const handleLoginSuccess = () => {
message.success('登录成功!');
const hideForever = localStorage.getItem('announcement_hide_forever');
const hideToday = localStorage.getItem('announcement_hide_today');
const today = new Date().toDateString();
if (hideForever === 'true' || hideToday === today) {
const redirect = searchParams.get('redirect') || '/';
navigate(redirect);
} else {
setShowAnnouncement(true);
}
};
const handleLocalLogin = async (values: LocalLoginValues) => {
@@ -282,52 +265,14 @@ export default function Login() {
}
};
const handleLinuxDOLogin = async () => {
try {
setLoading(true);
const response = await authApi.getLinuxDOAuthUrl();
const redirect = searchParams.get('redirect');
if (redirect) {
sessionStorage.setItem('login_redirect', redirect);
}
window.location.href = response.auth_url;
} catch (error) {
console.error('获取授权地址失败:', error);
message.error('获取授权地址失败,请稍后重试');
setLoading(false);
}
};
const handleAnnouncementClose = () => {
setShowAnnouncement(false);
const redirect = searchParams.get('redirect') || '/';
navigate(redirect);
};
const handleDoNotShowToday = () => {
const today = new Date().toDateString();
localStorage.setItem('announcement_hide_today', today);
};
const handleNeverShow = () => {
localStorage.setItem('announcement_hide_forever', 'true');
};
const loginTips = useMemo(() => {
const tips = [
'首次 LinuxDO 登录会自动创建账号。',
];
const tips = [];
if (localAuthEnabled) {
tips.unshift('本地登录默认账号:admin / admin123');
tips.push('本地登录默认账号:admin / admin123');
}
if (emailAuthEnabled) {
tips.push('邮箱注册用户支持通过邮箱验证码重置密码。');
}
return tips;
}, [emailAuthEnabled, localAuthEnabled]);
@@ -355,7 +300,6 @@ export default function Login() {
];
const renderLocalLogin = () => (
<>
<Form
form={localForm}
layout="vertical"
@@ -372,7 +316,7 @@ export default function Login() {
prefix={<UserOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入管理账号/邮箱"
autoComplete="username"
style={{ height: 46, borderRadius: 12 }}
style={{ height: 46, borderRadius: 2 }}
/>
</Form.Item>
<Form.Item
@@ -384,7 +328,7 @@ export default function Login() {
prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入访问密钥"
autoComplete="current-password"
style={{ height: 46, borderRadius: 12 }}
style={{ height: 46, borderRadius: 2 }}
/>
</Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
@@ -393,28 +337,12 @@ export default function Login() {
htmlType="submit"
loading={loading}
block
style={{
height: 46,
fontSize: 16,
fontWeight: 600,
background: `linear-gradient(90deg, ${token.colorPrimary} 0%, ${alphaColor(token.colorPrimary, 0.86)} 100%)`,
border: 'none',
borderRadius: '12px',
boxShadow: primaryButtonShadow,
}}
style={primaryButtonStyle}
>
</Button>
</Form.Item>
</Form>
{linuxdoEnabled ? (
<>
<Divider style={{ margin: '18px 0 16px' }}></Divider>
{renderLinuxDOLogin()}
</>
) : null}
</>
);
const renderEmailLogin = () => {
@@ -429,13 +357,8 @@ export default function Login() {
</Button>
</Space>
<Card size="small" bordered={false} style={{ borderRadius: 12, background: token.colorFillAlter }}>
<Form
form={resetPasswordForm}
layout="vertical"
onFinish={handleResetPassword}
size="middle"
>
<Card size="small" bordered={false} style={{ borderRadius: 2, background: token.colorFillAlter, border: `1px solid ${token.colorBorder}` }}>
<Form form={resetPasswordForm} layout="vertical" onFinish={handleResetPassword} size="middle">
<Form.Item
name="email"
label="注册邮箱"
@@ -458,11 +381,7 @@ export default function Login() {
>
<Input placeholder="请输入重置验证码" maxLength={6} />
</Form.Item>
<Button
onClick={sendResetCode}
loading={resetCodeSending}
disabled={resetCountdown > 0}
>
<Button onClick={sendResetCode} loading={resetCodeSending} disabled={resetCountdown > 0}>
{resetCountdown > 0 ? `${resetCountdown}s 后重发` : '发送验证码'}
</Button>
</Space.Compact>
@@ -470,10 +389,7 @@ export default function Login() {
<Form.Item
name="new_password"
label="新密码"
rules={[
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度至少为 6 个字符' },
]}
rules={[{ required: true, message: '请输入新密码' }, { min: 6, message: '密码长度至少为 6 个字符' }]}
>
<Input.Password prefix={<LockOutlined />} placeholder="请输入新密码" />
</Form.Item>
@@ -485,9 +401,7 @@ export default function Login() {
{ required: true, message: '请再次输入新密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('new_password') === value) {
return Promise.resolve();
}
if (!value || getFieldValue('new_password') === value) return Promise.resolve();
return Promise.reject(new Error('两次输入的新密码不一致'));
},
}),
@@ -525,7 +439,7 @@ export default function Login() {
prefix={<MailOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入已注册邮箱"
autoComplete="email"
style={{ height: 46, borderRadius: 12 }}
style={{ height: 46, borderRadius: 2 }}
/>
</Form.Item>
@@ -543,15 +457,10 @@ export default function Login() {
prefix={<SafetyCertificateOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入 6 位登录验证码"
maxLength={6}
style={{ height: 46, borderRadius: '12px 0 0 12px' }}
style={{ height: 46, borderRadius: '2px 0 0 2px' }}
/>
</Form.Item>
<Button
style={{ height: 46 }}
onClick={sendLoginCode}
loading={loginCodeSending}
disabled={loginCountdown > 0}
>
<Button style={{ height: 46 }} onClick={sendLoginCode} loading={loginCodeSending} disabled={loginCountdown > 0}>
{loginCountdown > 0 ? `${loginCountdown}s 后重发` : '发送验证码'}
</Button>
</Space.Compact>
@@ -563,15 +472,7 @@ export default function Login() {
htmlType="submit"
loading={loading}
block
style={{
height: 46,
fontSize: 16,
fontWeight: 600,
background: `linear-gradient(90deg, ${token.colorPrimary} 0%, ${alphaColor(token.colorPrimary, 0.86)} 100%)`,
border: 'none',
borderRadius: '12px',
boxShadow: primaryButtonShadow,
}}
style={primaryButtonStyle}
>
</Button>
@@ -606,7 +507,7 @@ export default function Login() {
prefix={<MailOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入注册邮箱"
autoComplete="email"
style={{ height: 46, borderRadius: 12 }}
style={{ height: 46, borderRadius: 2 }}
/>
</Form.Item>
@@ -624,46 +525,34 @@ export default function Login() {
prefix={<SafetyCertificateOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入 6 位验证码"
maxLength={6}
style={{ height: 46, borderRadius: '12px 0 0 12px' }}
style={{ height: 46, borderRadius: '2px 0 0 2px' }}
/>
</Form.Item>
<Button
style={{ height: 46 }}
onClick={sendRegisterCode}
loading={registerCodeSending}
disabled={registerCountdown > 0}
>
<Button style={{ height: 46 }} onClick={sendRegisterCode} loading={registerCodeSending} disabled={registerCountdown > 0}>
{registerCountdown > 0 ? `${registerCountdown}s 后重发` : '发送验证码'}
</Button>
</Space.Compact>
</Form.Item>
<Form.Item
name="display_name"
label="昵称"
rules={[{ max: 50, message: '昵称长度不能超过 50 个字符' }]}
>
<Form.Item name="display_name" label="昵称" rules={[{ max: 50, message: '昵称长度不能超过 50 个字符' }]}>
<Input
prefix={<UserOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="选填,默认使用邮箱前缀"
autoComplete="nickname"
style={{ height: 46, borderRadius: 12 }}
style={{ height: 46, borderRadius: 2 }}
/>
</Form.Item>
<Form.Item
name="password"
label="登录密码"
rules={[
{ required: true, message: '请输入登录密码' },
{ min: 6, message: '密码长度至少为 6 个字符' },
]}
rules={[{ required: true, message: '请输入登录密码' }, { min: 6, message: '密码长度至少为 6 个字符' }]}
>
<Input.Password
prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入登录密码"
autoComplete="new-password"
style={{ height: 46, borderRadius: 12 }}
style={{ height: 46, borderRadius: 2 }}
/>
</Form.Item>
@@ -675,9 +564,7 @@ export default function Login() {
{ required: true, message: '请再次输入登录密码' },
({ getFieldValue }) => ({
validator(_, value) {
if (!value || getFieldValue('password') === value) {
return Promise.resolve();
}
if (!value || getFieldValue('password') === value) return Promise.resolve();
return Promise.reject(new Error('两次输入的密码不一致'));
},
}),
@@ -687,7 +574,7 @@ export default function Login() {
prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请再次输入登录密码"
autoComplete="new-password"
style={{ height: 46, borderRadius: 12 }}
style={{ height: 46, borderRadius: 2 }}
/>
</Form.Item>
@@ -697,361 +584,89 @@ export default function Login() {
htmlType="submit"
loading={loading}
block
style={{
height: 46,
fontSize: 16,
fontWeight: 600,
background: `linear-gradient(90deg, ${token.colorPrimary} 0%, ${alphaColor(token.colorPrimary, 0.86)} 100%)`,
border: 'none',
borderRadius: '12px',
boxShadow: primaryButtonShadow,
}}
style={primaryButtonStyle}
>
</Button>
</Form.Item>
<Text type="secondary" style={{ marginTop: 12, display: 'block' }}>
</Text>
</Form>
);
const renderLinuxDOLogin = () => (
<div>
<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: 46,
fontSize: 16,
fontWeight: 600,
background: `linear-gradient(90deg, ${token.colorPrimary} 0%, ${alphaColor(token.colorPrimary, 0.86)} 100%)`,
border: 'none',
borderRadius: '12px',
boxShadow: primaryButtonShadow,
transition: 'all 0.3s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px)';
e.currentTarget.style.boxShadow = hoverButtonShadow;
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = primaryButtonShadow;
}}
>
使 LinuxDO OAuth
</Button>
</div>
);
const authTabs = [
...(localAuthEnabled
? [
{
key: 'local-login',
label: '本地登录',
children: renderLocalLogin(),
},
]
: []),
...(emailAuthEnabled
? [
{
key: 'email-login',
label: '邮箱登录',
children: renderEmailLogin(),
},
]
: []),
...(emailAuthEnabled && emailRegisterEnabled
? [
{
key: 'email-register',
label: '邮箱注册',
children: renderEmailRegister(),
},
]
: []),
...(localAuthEnabled ? [{ key: 'local-login', label: '本地登录', children: renderLocalLogin() }] : []),
...(emailAuthEnabled ? [{ key: 'email-login', label: '邮箱登录', children: renderEmailLogin() }] : []),
...(emailAuthEnabled && emailRegisterEnabled ? [{ key: 'email-register', label: '邮箱注册', children: renderEmailRegister() }] : []),
];
if (checking) {
return (
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
background: token.colorBgLayout,
}}
>
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh', background: token.colorBgLayout }}>
<Spin size="large" style={{ color: token.colorPrimary }} />
</div>
);
}
const pageTexture = `repeating-linear-gradient(-12deg, transparent, transparent 31px, ${alphaColor(token.colorText, 0.035)} 31px, ${alphaColor(token.colorText, 0.035)} 32px)`;
return (
<>
<AnnouncementModal
visible={showAnnouncement}
onClose={handleAnnouncementClose}
onDoNotShowToday={handleDoNotShowToday}
onNeverShow={handleNeverShow}
/>
<Layout style={{ minHeight: '100vh', background: token.colorBgLayout }}>
<div
style={{
position: 'fixed',
top: 20,
right: 20,
zIndex: 10,
padding: '8px 10px',
borderRadius: 12,
background: alphaColor(token.colorBgContainer, 0.9),
border: `1px solid ${token.colorBorderSecondary}`,
backdropFilter: 'blur(6px)',
}}
>
<Layout style={{ minHeight: '100vh', background: token.colorBgLayout, backgroundImage: pageTexture }}>
<div style={{ position: 'fixed', top: 16, right: 16, zIndex: 10, padding: '6px 8px', borderRadius: 2, background: token.colorBgContainer, border: `1px solid ${token.colorBorder}` }}>
<ThemeSwitch size="small" />
</div>
<Row style={{ minHeight: '100vh' }}>
<Col xs={0} lg={11}>
<section
style={{
height: '100%',
padding: '44px 64px 88px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
position: 'relative',
overflow: 'hidden',
backgroundColor: alphaColor(token.colorBgContainer, 0.78),
backgroundImage: `linear-gradient(${alphaColor(token.colorTextSecondary, 0.06)} 1px, transparent 1px), linear-gradient(90deg, ${alphaColor(token.colorTextSecondary, 0.06)} 1px, transparent 1px)`,
backgroundSize: '68px 68px',
}}
>
<div
style={{
position: 'absolute',
inset: 0,
background: `radial-gradient(circle at 25% 20%, ${alphaColor(token.colorPrimary, 0.12)} 0%, transparent 50%)`,
pointerEvents: 'none',
}}
/>
<div
style={{
position: 'relative',
zIndex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
gap: 34,
width: '100%',
}}
>
<Space align="center" size={14}>
<div
style={{
width: 46,
height: 46,
borderRadius: 14,
background: `linear-gradient(135deg, ${token.colorPrimary} 0%, ${alphaColor(token.colorPrimary, 0.7)} 100%)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: primaryButtonShadow,
}}
>
<img
src="/logo.svg"
alt="MuMuAINovel"
style={{ width: 26, height: 26, filter: 'brightness(0) invert(1)' }}
/>
<div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 20px 64px', width: '100%' }}>
<header style={{ width: '100%', maxWidth: 1080, marginBottom: 32, textAlign: 'center' }}>
<Space align="center" size={12} style={{ justifyContent: 'center', marginBottom: 12 }}>
<div style={{ width: 44, height: 44, border: `2px solid ${token.colorPrimary}`, display: 'flex', alignItems: 'center', justifyContent: 'center', background: token.colorBgContainer }}>
<img src="/logo.svg" alt="墨木灵思" style={{ width: 24, height: 24 }} />
</div>
<Title level={3} style={{ margin: 0, color: token.colorText }}>
MuMuAINovel
</Title>
<Title level={3} className="app-serif-title" style={{ margin: 0, fontWeight: 700 }}></Title>
</Space>
<Space direction="vertical" size={32} style={{ width: '100%' }}>
<div style={{ maxWidth: 'min(860px, 100%)' }}>
<Title
level={1}
style={{
marginBottom: 22,
color: token.colorText,
lineHeight: 1.12,
fontWeight: 800,
fontSize: 'clamp(52px, 3vw, 78px)',
}}
>
AI
<br />
<span
style={{
backgroundImage: `linear-gradient(90deg, ${token.colorPrimary} 0%, #d946ef 100%)`,
WebkitBackgroundClip: 'text',
backgroundClip: 'text',
WebkitTextFillColor: 'transparent',
color: token.colorPrimary,
}}
>
</span>
</Title>
<Paragraph
style={{
fontSize: 'clamp(18px, 1vw, 22px)',
lineHeight: 1.85,
color: token.colorTextSecondary,
marginBottom: 0,
maxWidth: 800,
}}
>
稿
</Paragraph>
<Title level={1} className="app-serif-title" style={{ margin: '0 0 10px', fontSize: 'clamp(26px, 4vw, 40px)', fontWeight: 700, lineHeight: 1.25 }}> AI</Title>
<Paragraph style={{ margin: 0, color: token.colorTextSecondary, fontSize: 15, maxWidth: 520, marginInline: 'auto' }}> · · · </Paragraph>
</header>
<Row style={{ width: '100%', maxWidth: 1080, flex: 1 }} gutter={[28, 28]} align="stretch">
<Col xs={24} lg={13}>
<div>
{featureItems.map((item, index) => (
<div key={item.title} style={{ padding: '18px 0 18px 18px', borderLeft: `3px solid ${index % 2 === 0 ? token.colorPrimary : alphaColor(token.colorPrimary, 0.4)}`, borderBottom: `1px solid ${token.colorBorderSecondary}` }}>
<Space align="start" size={12}>
<span style={{ color: token.colorPrimary, fontSize: 18, marginTop: 2 }}>{item.icon}</span>
<div>
<Text strong style={{ display: 'block', marginBottom: 4, fontSize: 15 }}>{item.title}</Text>
<Paragraph style={{ margin: 0, color: token.colorTextSecondary, fontSize: 14, lineHeight: 1.65 }}>{item.description}</Paragraph>
</div>
<Row gutter={[20, 20]} style={{ width: '100%', maxWidth: 'min(920px, 100%)' }}>
{featureItems.map((item) => (
<Col span={12} key={item.title}>
<Card
size="small"
bordered={false}
style={{
height: '100%',
minHeight: 120,
borderRadius: 16,
background: alphaColor(token.colorBgContainer, 0.9),
}}
bodyStyle={{ padding: 16 }}
>
<Space direction="vertical" size={8}>
<Space size={10} style={{ color: token.colorPrimary, fontWeight: 700, fontSize: 15 }}>
{item.icon}
<span>{item.title}</span>
</Space>
<Paragraph style={{ marginBottom: 0, color: token.colorTextSecondary, fontSize: 14, lineHeight: 1.65 }}>
{item.description}
</Paragraph>
</div>
))}
<Space size={[8, 8]} wrap style={{ marginTop: 16 }}>
{['OpenAI', 'Gemini', 'Claude', 'Docker', 'PostgreSQL'].map((label) => (
<Tag key={label} style={{ margin: 0, borderRadius: 2, background: token.colorFillSecondary, border: `1px solid ${token.colorBorder}`, color: token.colorTextSecondary }}>{label}</Tag>
))}
</Space>
</div>
</Col>
<Col xs={24} lg={11}>
<Card bordered style={{ borderRadius: 2, border: `2px solid ${token.colorText}`, boxShadow: `8px 8px 0 ${alphaColor(token.colorText, 0.12)}`, background: token.colorBgContainer }} styles={{ body: { padding: '28px 28px 24px' } }}>
<Space direction="vertical" size={4} style={{ width: '100%', marginBottom: 8 }}>
<Title level={3} className="app-serif-title" style={{ margin: 0, fontWeight: 700 }}></Title>
<Text type="secondary"></Text>
</Space>
<div className="app-login-tabs" style={{ marginTop: 8 }}>
{authTabs.length > 0 ? <Tabs defaultActiveKey={authTabs[0].key} items={authTabs} /> : null}
{authTabs.length === 0 ? <Alert type="warning" showIcon message="当前未启用可用登录方式" description="请联系管理员在系统配置中启用本地登录或邮箱认证。" /> : null}
{emailAuthEnabled && !emailRegisterEnabled ? <Alert type="info" showIcon style={{ marginTop: 12, borderRadius: 2 }} message="邮箱注册暂未开放" description="当前仅开放邮箱验证码登录与找回密码,如需注册请联系管理员。" /> : null}
<Divider style={{ margin: '18px 0 12px', borderColor: token.colorBorderSecondary }} />
<Alert type="info" showIcon icon={<SafetyCertificateOutlined />} style={{ background: token.colorFillTertiary, border: `1px solid ${token.colorBorder}`, borderRadius: 2 }} message="登录说明" description={<ul style={{ margin: 0, paddingLeft: 18 }}>{loginTips.map((tip) => <li key={tip} style={{ marginBottom: 4 }}>{tip}</li>)}</ul>} />
</div>
</Card>
</Col>
))}
</Row>
</Space>
<Space size={[10, 14]} wrap style={{ maxWidth: 'min(860px, 100%)' }}>
<Tag color="blue">OpenAI</Tag>
<Tag color="geekblue">Gemini</Tag>
<Tag color="purple">Claude</Tag>
<Tag color="cyan">LinuxDO OAuth</Tag>
<Tag color="green">Docker Compose</Tag>
<Tag color="gold">PostgreSQL</Tag>
</Space>
<Paragraph style={{ marginTop: 32, marginBottom: 0, fontSize: 12, color: token.colorTextTertiary, letterSpacing: '0.08em' }}>© 2026 · GPLv3</Paragraph>
</div>
<Paragraph
style={{
marginBottom: 0,
fontSize: 12,
color: token.colorTextTertiary,
position: 'relative',
zIndex: 1,
letterSpacing: 0.4,
}}
>
© 2026 MuMuAINovel · GPLv3 License
</Paragraph>
</section>
</Col>
<Col xs={24} lg={13}>
<section
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '48px min(7vw, 72px)',
background: token.colorBgLayout,
}}
>
<div style={{ width: '100%', maxWidth: 520 }}>
<Space direction="vertical" size={4}>
<Title level={2} style={{ marginBottom: 0, fontWeight: 700, color: token.colorText }}>
</Title>
<Paragraph style={{ marginBottom: 0, color: token.colorTextSecondary }}>
MuMuAINovel
</Paragraph>
</Space>
<div style={{ marginTop: 22 }}>
{authTabs.length > 0 ? (
<Tabs defaultActiveKey={authTabs[0].key} items={authTabs} />
) : null}
{!localAuthEnabled && !linuxdoEnabled && !emailAuthEnabled ? (
<Alert
type="warning"
showIcon
message="当前未启用可用登录方式"
description="请联系管理员在系统配置中启用本地登录、邮箱认证或 LinuxDO OAuth 登录。"
/>
) : null}
{emailAuthEnabled && !emailRegisterEnabled ? (
<Alert
type="info"
showIcon
style={{ marginTop: 12, borderRadius: 12 }}
message="邮箱注册暂未开放"
description="当前仅开放邮箱验证码登录与找回密码,如需注册请联系管理员。"
/>
) : null}
<Divider style={{ margin: '20px 0 14px' }} />
<Alert
type="info"
showIcon
icon={<SafetyCertificateOutlined />}
style={{ background: alphaColor(token.colorPrimary, 0.06), borderRadius: 12 }}
message="登录说明"
description={(
<ul style={{ margin: 0, paddingLeft: 18 }}>
{loginTips.map((tip) => (
<li key={tip} style={{ marginBottom: 4 }}>
{tip}
</li>
))}
</ul>
)}
/>
</div>
</div>
</section>
</Col>
</Row>
</Layout>
</>
);
}
+63 -724
View File
@@ -1,13 +1,13 @@
import { useState, useEffect, useMemo, useRef } from 'react';
import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag, InputNumber, Tabs, Pagination, theme, Progress, Badge, Tooltip } from 'antd';
import { EditOutlined, DeleteOutlined, ThunderboltOutlined, BranchesOutlined, AppstoreAddOutlined, CheckCircleOutlined, ExclamationCircleOutlined, PlusOutlined, FileTextOutlined, ClockCircleOutlined, ReloadOutlined, CloseCircleOutlined, LoadingOutlined } from '@ant-design/icons';
import { useState, useEffect, useMemo } from 'react';
import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag, InputNumber, Tabs, Pagination, theme } from 'antd';
import { EditOutlined, DeleteOutlined, ThunderboltOutlined, BranchesOutlined, AppstoreAddOutlined, CheckCircleOutlined, ExclamationCircleOutlined, PlusOutlined, FileTextOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { eventBus } from '../store/eventBus';
import { getProjectTasks, type TaskStatus } from '../services/backgroundTaskService';
import { useOutlineSync } from '../store/hooks';
import { SSEPostClient } from '../utils/sseClient';
import { SSEProgressModal } from '../components/SSEProgressModal';
import { generateOutlineBackground, getProjectTasks, cancelTask, deleteTask, type TaskStatus } from '../services/backgroundTaskService';
import { generateOutlineBackground } from '../services/backgroundTaskService';
import { outlineApi, chapterApi, projectApi, characterApi } from '../services/api';
import type { OutlineExpansionResponse, BatchOutlineExpansionResponse, ChapterPlanItem, ApiError, Character } from '../types';
import type { ApiError, Character } from '../types';
// 大纲生成请求数据类型
interface OutlineGenerateRequestData {
@@ -25,20 +25,6 @@ interface OutlineGenerateRequestData {
provider?: string;
}
// 跳过的大纲信息类型
interface SkippedOutlineInfo {
outline_id: string;
outline_title: string;
reason: string;
}
// 场景类型
interface SceneInfo {
location: string;
characters: string[];
purpose: string;
}
// 角色/组织条目类型(新格式)
interface CharacterEntry {
name: string;
@@ -141,28 +127,6 @@ export default function Outline() {
// ✅ 新增:记录场景区域的展开/折叠状态
const [scenesExpandStatus, setScenesExpandStatus] = useState<Record<string, boolean>>({});
// 缓存批量展开的规划数据,避免重复AI调用
const [cachedBatchExpansionResponse, setCachedBatchExpansionResponse] = useState<BatchOutlineExpansionResponse | null>(null);
// 批量展开预览的状态
const [batchPreviewVisible, setBatchPreviewVisible] = useState(false);
const [batchPreviewData, setBatchPreviewData] = useState<BatchOutlineExpansionResponse | null>(null);
const [selectedOutlineIdx, setSelectedOutlineIdx] = useState(0);
const [selectedChapterIdx, setSelectedChapterIdx] = useState(0);
// SSE进度状态
const [sseProgress, setSSEProgress] = useState(0);
const [sseMessage, setSSEMessage] = useState('');
const [sseModalVisible, setSSEModalVisible] = useState(false);
// 后台任务取消函数引用
const cancelGenerateRef = useRef<(() => void) | null>(null);
// 后台任务列表状态
const [taskListVisible, setTaskListVisible] = useState(false);
const [taskList, setTaskList] = useState<TaskStatus[]>([]);
const [taskListLoading, setTaskListLoading] = useState(false);
useEffect(() => {
const handleResize = () => {
setIsMobile(window.innerWidth <= 768);
@@ -190,10 +154,26 @@ export default function Outline() {
refreshOutlines();
// 加载项目角色列表
loadProjectCharacters();
// 检查是否有活跃的大纲生成任务,恢复按钮禁用状态
checkActiveOutlineTasks();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentProject?.id]); // 只依赖 ID,不依赖函数
// 检查是否有活跃的大纲生成任务(页面切换后恢复状态)
const checkActiveOutlineTasks = async () => {
if (!currentProject?.id) return;
try {
const result = await getProjectTasks(currentProject.id, 'outline_new', 5);
const result2 = await getProjectTasks(currentProject.id, 'outline_continue', 5);
const allTasks = [...(result.items || []), ...(result2.items || [])];
const hasActive = allTasks.some((t: TaskStatus) => t.status === 'running' || t.status === 'pending');
setIsGenerating(hasActive);
} catch (error) {
console.error('检查活跃大纲任务失败:', error);
}
};
// 加载项目角色列表
const loadProjectCharacters = async () => {
if (!currentProject?.id) return;
@@ -546,11 +526,6 @@ export default function Outline() {
// 关闭生成表单Modal
Modal.destroyAll();
// 显示进度Modal
setSSEProgress(0);
setSSEMessage('正在连接AI服务...');
setSSEModalVisible(true);
// 准备请求数据
const requestData: OutlineGenerateRequestData = {
project_id: currentProject.id,
@@ -583,35 +558,30 @@ export default function Outline() {
console.log('=========================');
// 使用后台任务生成(不怕断连,关闭浏览器也继续运行)
setSSEMessage('正在创建后台任务...');
const cancelFn = await generateOutlineBackground(
// 不再强制显示进度弹窗,任务进度在右下角悬浮任务框中显示
await generateOutlineBackground(
requestData,
(status) => {
setSSEProgress(status.progress);
setSSEMessage(status.status_message || '处理中...');
() => {
// 进度更新由悬浮任务框处理,无需额外操作
},
(result) => {
message.success(result.task_result?.message as string || '大纲生成完成!');
setSSEModalVisible(false);
setIsGenerating(false);
cancelGenerateRef.current = null;
refreshOutlines();
},
(error) => {
message.error(`生成失败: ${error}`);
setSSEModalVisible(false);
setIsGenerating(false);
cancelGenerateRef.current = null;
}
);
cancelGenerateRef.current = cancelFn;
message.info('大纲生成任务已提交,可在右下角任务面板查看进度');
// 通知悬浮任务框刷新
eventBus.emit('background-task-created');
} catch (error) {
console.error('AI生成失败:', error);
message.error('AI生成失败');
setSSEModalVisible(false);
setIsGenerating(false);
}
};
@@ -921,7 +891,7 @@ export default function Outline() {
});
};
// 展开单个大纲为多章 - 使用SSE显示进度
// 展开单个大纲为多章 - 提交后台任务并在悬浮任务面板显示进度
const handleExpandOutline = async (outlineId: string, outlineTitle: string) => {
try {
setIsExpanding(true);
@@ -1042,60 +1012,39 @@ export default function Outline() {
</Form>
</div>
),
okText: '生成规划预览',
okText: '提交后台任务',
cancelText: '取消',
onOk: async () => {
try {
const values = await expansionForm.validateFields();
// 关闭配置表单
Modal.destroyAll();
// 显示SSE进度Modal
setSSEProgress(0);
setSSEMessage('正在准备展开大纲...');
setSSEModalVisible(true);
setIsExpanding(true);
// 准备请求数据
const requestData = {
...values,
auto_create_chapters: false, // 第一步:仅生成规划
auto_create_chapters: true,
enable_scene_analysis: true
};
// 使用SSE客户端调用新的流式端点
const apiUrl = `/api/outlines/${outlineId}/expand-stream`;
const client = new SSEPostClient(apiUrl, requestData, {
onProgress: (msg: string, progress: number) => {
setSSEMessage(msg);
setSSEProgress(progress);
},
onResult: (data: OutlineExpansionResponse) => {
console.log('展开完成,结果:', data);
// 关闭SSE进度Modal
setSSEModalVisible(false);
// 显示规划预览
showExpansionPreview(outlineId, data);
},
onError: (error: string) => {
message.error(`展开失败: ${error}`);
setSSEModalVisible(false);
setIsExpanding(false);
},
onComplete: () => {
setSSEModalVisible(false);
setIsExpanding(false);
}
const response = await fetch(`/api/outlines/${outlineId}/expand-background`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestData),
});
// 开始连接
client.connect();
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(err.detail || '创建大纲展开任务失败');
}
message.success('大纲展开任务已提交,可在右下角任务面板查看进度');
eventBus.emit('background-task-created');
setIsExpanding(false);
} catch (error) {
console.error('展开失败:', error);
message.error('展开失败');
setSSEModalVisible(false);
message.error(error instanceof Error ? error.message : '展开失败');
setIsExpanding(false);
}
},
@@ -1390,134 +1339,7 @@ export default function Outline() {
});
};
// 显示展开规划预览,并提供确认创建章节的选项
const showExpansionPreview = (outlineId: string, response: OutlineExpansionResponse) => {
// 缓存AI生成的规划数据
const cachedPlans = response.chapter_plans;
modalApi.confirm({
title: (
<Space>
<CheckCircleOutlined style={{ color: token.colorSuccess }} />
<span></span>
</Space>
),
width: 900,
centered: true,
okText: '确认并创建章节',
cancelText: '暂不创建',
content: (
<div>
<div style={{ marginBottom: 16 }}>
<Tag color="blue">: {response.expansion_strategy}</Tag>
<Tag color="green">: {response.actual_chapter_count}</Tag>
<Tag color="orange"></Tag>
</div>
<Tabs
defaultActiveKey="0"
type="card"
items={response.chapter_plans.map((plan, idx) => ({
key: idx.toString(),
label: (
<Space size="small">
<span style={{ fontWeight: 500 }}>{idx + 1}. {plan.title}</span>
</Space>
),
children: (
<div style={{ maxHeight: '500px', overflowY: 'auto', padding: '8px 0' }}>
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card size="small" title="基本信息">
<Space wrap>
<Tag color="blue">{plan.emotional_tone}</Tag>
<Tag color="orange">{plan.conflict_type}</Tag>
<Tag color="green">{plan.estimated_words}</Tag>
</Space>
</Card>
<Card size="small" title="情节概要">
{plan.plot_summary}
</Card>
<Card size="small" title="叙事目标">
{plan.narrative_goal}
</Card>
<Card size="small" title="关键事件">
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{plan.key_events.map((event, eventIdx) => (
<div key={eventIdx}> {event}</div>
))}
</Space>
</Card>
<Card size="small" title="涉及角色">
<Space wrap>
{plan.character_focus.map((char, charIdx) => (
<Tag key={charIdx} color="purple">{char}</Tag>
))}
</Space>
</Card>
{plan.scenes && plan.scenes.length > 0 && (
<Card size="small" title="场景">
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{plan.scenes.map((scene, sceneIdx) => (
<Card key={sceneIdx} size="small" style={{ backgroundColor: token.colorFillQuaternary }}>
<div><strong></strong>{scene.location}</div>
<div><strong></strong>{scene.characters.join('、')}</div>
<div><strong></strong>{scene.purpose}</div>
</Card>
))}
</Space>
</Card>
)}
</Space>
</div>
)
}))}
/>
</div>
),
onOk: async () => {
// 第二步:用户确认后,直接使用缓存的规划创建章节(避免重复调用AI)
await handleConfirmCreateChapters(outlineId, cachedPlans);
},
onCancel: () => {
message.info('已取消创建章节');
}
});
};
// 确认创建章节 - 使用缓存的规划数据,避免重复AI调用
const handleConfirmCreateChapters = async (
outlineId: string,
cachedPlans: ChapterPlanItem[]
) => {
try {
setIsExpanding(true);
// 使用新的API端点,直接传递缓存的规划数据
const response = await outlineApi.createChaptersFromPlans(outlineId, cachedPlans);
message.success(
`成功创建${response.chapters_created}个章节!`,
3
);
console.log('✅ 使用缓存的规划创建章节,避免了重复的AI调用');
// 刷新大纲和章节列表
refreshOutlines();
} catch (error) {
console.error('创建章节失败:', error);
message.error('创建章节失败');
} finally {
setIsExpanding(false);
}
};
// 批量展开所有大纲 - 使用SSE流式显示进度
// 批量展开所有大纲 - 提交后台任务并在悬浮任务面板显示进度
const handleBatchExpandOutlines = () => {
if (!currentProject?.id || outlines.length === 0) {
message.warning('没有可展开的大纲');
@@ -1583,524 +1405,50 @@ export default function Outline() {
</Form>
</div>
),
okText: '开始展开',
okText: '提交后台任务',
cancelText: '取消',
okButtonProps: { type: 'primary' },
onOk: async () => {
try {
const values = await batchExpansionForm.validateFields();
// 关闭配置表单
Modal.destroyAll();
// 显示SSE进度Modal
setSSEProgress(0);
setSSEMessage('正在准备批量展开...');
setSSEModalVisible(true);
setIsExpanding(true);
// 准备请求数据
const requestData = {
project_id: currentProject.id,
...values,
auto_create_chapters: false // 第一步:仅生成规划
auto_create_chapters: true,
enable_scene_analysis: true
};
// 使用SSE客户端
const apiUrl = `/api/outlines/batch-expand-stream`;
const client = new SSEPostClient(apiUrl, requestData, {
onProgress: (msg: string, progress: number) => {
setSSEMessage(msg);
setSSEProgress(progress);
},
onResult: (data: BatchOutlineExpansionResponse) => {
console.log('批量展开完成,结果:', data);
// 缓存AI生成的规划数据
setCachedBatchExpansionResponse(data);
setBatchPreviewData(data);
// 关闭SSE进度Modal
setSSEModalVisible(false);
// 重置选择状态
setSelectedOutlineIdx(0);
setSelectedChapterIdx(0);
// 显示批量预览Modal
setBatchPreviewVisible(true);
},
onError: (error: string) => {
message.error(`批量展开失败: ${error}`);
setSSEModalVisible(false);
setIsExpanding(false);
},
onComplete: () => {
setSSEModalVisible(false);
setIsExpanding(false);
}
const response = await fetch('/api/outlines/batch-expand-background', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestData),
});
// 开始连接
client.connect();
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: response.statusText }));
throw new Error(err.detail || '创建批量展开任务失败');
}
message.success('批量展开任务已提交,可在右下角任务面板查看进度');
eventBus.emit('background-task-created');
setIsExpanding(false);
} catch (error) {
console.error('批量展开失败:', error);
message.error('批量展开失败');
setSSEModalVisible(false);
message.error(error instanceof Error ? error.message : '批量展开失败');
setIsExpanding(false);
}
},
});
};
// 渲染批量展开预览 Modal 内容
const renderBatchPreviewContent = () => {
if (!batchPreviewData) return null;
return (
<div>
{/* 顶部统计信息 */}
<div style={{ marginBottom: 16 }}>
<Tag color="blue">: {batchPreviewData.total_outlines_expanded} </Tag>
<Tag color="green">: {batchPreviewData.expansion_results.reduce((sum: number, r: OutlineExpansionResponse) => sum + r.actual_chapter_count, 0)}</Tag>
<Tag color="orange"></Tag>
{batchPreviewData.skipped_outlines && batchPreviewData.skipped_outlines.length > 0 && (
<Tag color="warning">: {batchPreviewData.skipped_outlines.length} </Tag>
)}
</div>
{/* 显示跳过的大纲信息 */}
{batchPreviewData.skipped_outlines && batchPreviewData.skipped_outlines.length > 0 && (
<div style={{
marginBottom: 16,
padding: 12,
background: token.colorWarningBg,
borderRadius: token.borderRadius,
border: `1px solid ${token.colorWarningBorder}`
}}>
<div style={{ fontWeight: 500, marginBottom: 8, color: token.colorWarning }}>
</div>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{batchPreviewData.skipped_outlines.map((skipped: SkippedOutlineInfo, idx: number) => (
<div key={idx} style={{ fontSize: 13, color: token.colorTextSecondary }}>
{skipped.outline_title} <Tag color="default" style={{ fontSize: 11 }}>{skipped.reason}</Tag>
</div>
))}
</Space>
</div>
)}
{/* 水平三栏布局 */}
<div style={{ display: 'flex', gap: 16, height: 500 }}>
{/* 左栏:大纲列表 */}
<div style={{
width: 280,
borderRight: `1px solid ${token.colorBorderSecondary}`,
paddingRight: 12,
overflowY: 'auto'
}}>
<div style={{ fontWeight: 500, marginBottom: 8, color: token.colorTextSecondary }}></div>
<List
size="small"
dataSource={batchPreviewData.expansion_results}
renderItem={(result: OutlineExpansionResponse, idx: number) => (
<List.Item
key={idx}
onClick={() => {
setSelectedOutlineIdx(idx);
setSelectedChapterIdx(0);
}}
style={{
cursor: 'pointer',
padding: '8px 12px',
background: selectedOutlineIdx === idx ? token.colorPrimaryBg : 'transparent',
borderRadius: token.borderRadius,
marginBottom: 4,
border: selectedOutlineIdx === idx ? `1px solid ${token.colorPrimary}` : '1px solid transparent'
}}
>
<div style={{ width: '100%' }}>
<div style={{ fontWeight: 500, fontSize: 13, marginBottom: 4 }}>
{idx + 1}. {result.outline_title}
</div>
<Space size={4}>
<Tag color="blue" style={{ fontSize: 11, margin: 0 }}>{result.expansion_strategy}</Tag>
<Tag color="green" style={{ fontSize: 11, margin: 0 }}>{result.actual_chapter_count} </Tag>
</Space>
</div>
</List.Item>
)}
/>
</div>
{/* 中栏:章节列表 */}
<div style={{
width: 320,
borderRight: `1px solid ${token.colorBorderSecondary}`,
paddingRight: 12,
overflowY: 'auto'
}}>
<div style={{ fontWeight: 500, marginBottom: 8, color: token.colorTextSecondary }}>
({batchPreviewData.expansion_results[selectedOutlineIdx]?.actual_chapter_count || 0} )
</div>
{batchPreviewData.expansion_results[selectedOutlineIdx] && (
<List
size="small"
dataSource={batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans}
renderItem={(plan: ChapterPlanItem, idx: number) => (
<List.Item
key={idx}
onClick={() => setSelectedChapterIdx(idx)}
style={{
cursor: 'pointer',
padding: '8px 12px',
background: selectedChapterIdx === idx ? token.colorPrimaryBg : 'transparent',
borderRadius: token.borderRadius,
marginBottom: 4,
border: selectedChapterIdx === idx ? `1px solid ${token.colorPrimary}` : '1px solid transparent'
}}
>
<div style={{ width: '100%' }}>
<div style={{ fontWeight: 500, fontSize: 13, marginBottom: 4 }}>
{idx + 1}. {plan.title}
</div>
<Space size={4} wrap>
<Tag color="blue" style={{ fontSize: 11, margin: 0 }}>{plan.emotional_tone}</Tag>
<Tag color="orange" style={{ fontSize: 11, margin: 0 }}>{plan.conflict_type}</Tag>
<Tag color="green" style={{ fontSize: 11, margin: 0 }}>{plan.estimated_words}</Tag>
</Space>
</div>
</List.Item>
)}
/>
)}
</div>
{/* 右栏:章节详情 */}
<div style={{ flex: 1, overflowY: 'auto', paddingLeft: 12 }}>
<div style={{ fontWeight: 500, marginBottom: 12, color: token.colorTextSecondary }}></div>
{batchPreviewData.expansion_results[selectedOutlineIdx]?.chapter_plans[selectedChapterIdx] ? (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<Card size="small" title="情节概要" bordered={false}>
{batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].plot_summary}
</Card>
<Card size="small" title="叙事目标" bordered={false}>
{batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].narrative_goal}
</Card>
<Card size="small" title="关键事件" bordered={false}>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{(batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].key_events as string[]).map((event: string, eventIdx: number) => (
<div key={eventIdx}> {event}</div>
))}
</Space>
</Card>
<Card size="small" title="涉及角色" bordered={false}>
<Space wrap>
{(batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].character_focus as string[]).map((char: string, charIdx: number) => (
<Tag key={charIdx} color="purple">{char}</Tag>
))}
</Space>
</Card>
{batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].scenes && batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].scenes!.length > 0 && (
<Card size="small" title="场景" bordered={false}>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{batchPreviewData.expansion_results[selectedOutlineIdx].chapter_plans[selectedChapterIdx].scenes!.map((scene: SceneInfo, sceneIdx: number) => (
<Card key={sceneIdx} size="small" style={{ backgroundColor: token.colorFillQuaternary }}>
<div><strong></strong>{scene.location}</div>
<div><strong></strong>{scene.characters.join('、')}</div>
<div><strong></strong>{scene.purpose}</div>
</Card>
))}
</Space>
</Card>
)}
</Space>
) : (
<Empty description="请选择章节查看详情" />
)}
</div>
</div>
</div>
);
};
// 处理批量预览确认
const handleBatchPreviewOk = async () => {
setBatchPreviewVisible(false);
await handleConfirmBatchCreateChapters();
};
// 处理批量预览取消
const handleBatchPreviewCancel = () => {
setBatchPreviewVisible(false);
message.info('已取消创建章节,规划已保存');
};
// 确认批量创建章节 - 使用缓存的规划数据
const handleConfirmBatchCreateChapters = async () => {
try {
setIsExpanding(true);
// 使用缓存的规划数据,避免重复调用AI
if (!cachedBatchExpansionResponse) {
message.error('规划数据丢失,请重新展开');
return;
}
console.log('✅ 使用缓存的批量规划数据创建章节,避免重复AI调用');
// 逐个大纲创建章节
let totalCreated = 0;
const errors: string[] = [];
for (const result of cachedBatchExpansionResponse.expansion_results) {
try {
// 使用create-chapters-from-plans接口,直接传递缓存的规划
const response = await outlineApi.createChaptersFromPlans(
result.outline_id,
result.chapter_plans
);
totalCreated += response.chapters_created;
} catch (error: unknown) {
const apiError = error as ApiError;
const err = error as Error;
const errorMsg = apiError.response?.data?.detail || err.message || '未知错误';
errors.push(`${result.outline_title}: ${errorMsg}`);
console.error(`创建大纲 ${result.outline_title} 的章节失败:`, error);
}
}
// 显示结果
if (errors.length === 0) {
message.success(
`批量创建完成!共创建 ${totalCreated} 个章节`,
3
);
} else {
message.warning(
`部分完成:成功创建 ${totalCreated} 个章节,${errors.length} 个失败`,
5
);
console.error('失败详情:', errors);
}
// 清除缓存
setCachedBatchExpansionResponse(null);
// 刷新列表
refreshOutlines();
} catch (error) {
console.error('批量创建章节失败:', error);
message.error('批量创建章节失败');
} finally {
setIsExpanding(false);
}
};
// 加载并显示后台任务列表
const showTaskListModal = async () => {
if (!currentProject?.id) return;
setTaskListVisible(true);
setTaskListLoading(true);
try {
const result = await getProjectTasks(currentProject.id);
setTaskList(result.items || []);
} catch (error) {
message.error('加载任务列表失败');
} finally {
setTaskListLoading(false);
}
};
// 刷新任务列表
const refreshTaskList = async () => {
if (!currentProject?.id) return;
setTaskListLoading(true);
try {
const result = await getProjectTasks(currentProject.id);
setTaskList(result.items || []);
} catch (error) {
console.error('刷新任务列表失败:', error);
} finally {
setTaskListLoading(false);
}
};
// 获取任务状态标签
const getTaskStatusTag = (status: TaskStatus['status']) => {
switch (status) {
case 'pending': return <Tag icon={<ClockCircleOutlined />} color="default"></Tag>;
case 'running': return <Tag icon={<LoadingOutlined />} color="processing"></Tag>;
case 'completed': return <Tag icon={<CheckCircleOutlined />} color="success"></Tag>;
case 'failed': return <Tag icon={<CloseCircleOutlined />} color="error"></Tag>;
case 'cancelled': return <Tag icon={<CloseCircleOutlined />} color="default"></Tag>;
default: return <Tag>{status}</Tag>;
}
};
// 获取任务类型标签
const getTaskTypeLabel = (taskType: string) => {
switch (taskType) {
case 'outline_new': return '大纲生成';
case 'outline_continue': return '大纲续写';
default: return taskType;
}
};
// 处理取消后台任务
const handleCancelTask = async (taskId: string) => {
try {
await cancelTask(taskId);
message.success('任务已取消');
refreshTaskList();
} catch (error) {
message.error('取消任务失败');
}
};
// 处理删除任务记录
const handleDeleteTask = async (taskId: string) => {
try {
await deleteTask(taskId);
message.success('任务记录已删除');
refreshTaskList();
} catch (error) {
message.error('删除任务记录失败');
}
};
return (
<>
{/* 后台任务列表 Modal */}
<Modal
title={
<Space>
<ClockCircleOutlined />
<span></span>
{taskList.filter(t => t.status === 'running' || t.status === 'pending').length > 0 && (
<Badge count={taskList.filter(t => t.status === 'running' || t.status === 'pending').length} />
)}
</Space>
}
open={taskListVisible}
onCancel={() => setTaskListVisible(false)}
width={isMobile ? '95%' : 700}
centered
footer={
<Space>
<Button icon={<ReloadOutlined />} onClick={refreshTaskList} loading={taskListLoading}>
</Button>
<Button onClick={() => setTaskListVisible(false)}>
</Button>
</Space>
}
>
{taskListLoading && taskList.length === 0 ? (
<div style={{ textAlign: 'center', padding: 40 }}>
<LoadingOutlined style={{ fontSize: 24 }} />
<div style={{ marginTop: 12, color: token.colorTextSecondary }}>...</div>
</div>
) : taskList.length === 0 ? (
<Empty description="暂无后台任务" />
) : (
<List
dataSource={taskList}
renderItem={(task) => (
<List.Item
key={task.id}
actions={[
...(task.status === 'running' || task.status === 'pending'
? [<Button key="cancel" size="small" danger onClick={() => handleCancelTask(task.id)}></Button>]
: []
),
...(task.status === 'completed' || task.status === 'failed' || task.status === 'cancelled'
? [<Button key="delete" size="small" type="link" danger onClick={() => handleDeleteTask(task.id)}></Button>]
: []
),
].filter(Boolean)}
>
<List.Item.Meta
title={
<Space>
{getTaskStatusTag(task.status)}
<span>{getTaskTypeLabel(task.task_type)}</span>
{task.status === 'running' || task.status === 'pending' ? (
<Progress percent={task.progress} size="small" style={{ width: 120 }} />
) : null}
</Space>
}
description={
<div>
<div style={{ fontSize: 12, color: token.colorTextSecondary }}>
{task.status_message || '无状态信息'}
</div>
<div style={{ fontSize: 11, color: token.colorTextTertiary, marginTop: 4 }}>
: {task.created_at ? new Date(task.created_at).toLocaleString() : '-'}
{task.completed_at && ` | 完成: ${new Date(task.completed_at).toLocaleString()}`}
</div>
{task.error_message && (
<div style={{ fontSize: 12, color: token.colorError, marginTop: 4 }}>
{task.error_message}
</div>
)}
{task.task_result && task.status === 'completed' && (
<div style={{ fontSize: 12, color: token.colorSuccess, marginTop: 4 }}>
{(task.task_result as Record<string, unknown>).message as string || '任务完成'}
</div>
)}
</div>
}
/>
</List.Item>
)}
/>
)}
</Modal>
{/* 批量展开预览 Modal */}
<Modal
title={
<Space>
<CheckCircleOutlined style={{ color: token.colorSuccess }} />
<span></span>
</Space>
}
open={batchPreviewVisible}
onOk={handleBatchPreviewOk}
onCancel={handleBatchPreviewCancel}
width={1200}
centered
okText="确认并批量创建章节"
cancelText="暂不创建"
okButtonProps={{ danger: true }}
>
{renderBatchPreviewContent()}
</Modal>
{contextHolder}
{/* SSE进度Modal - 使用统一组件 */}
<SSEProgressModal
visible={sseModalVisible}
progress={sseProgress}
message={sseMessage}
title="AI生成中(后台运行,可关闭页面)..."
onCancel={() => {
if (cancelGenerateRef.current) {
cancelGenerateRef.current();
cancelGenerateRef.current = null;
}
setSSEModalVisible(false);
setIsGenerating(false);
message.info('已取消生成任务');
}}
/>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* 固定头部 */}
@@ -2153,15 +1501,6 @@ export default function Outline() {
>
{isMobile ? 'AI生成/续写' : 'AI生成/续写大纲'}
</Button>
<Tooltip title="查看后台任务进度">
<Button
icon={<ClockCircleOutlined />}
onClick={showTaskListModal}
block={isMobile}
>
{isMobile ? '任务' : '后台任务'}
</Button>
</Tooltip>
{outlines.length > 0 && currentProject?.outline_mode === 'one-to-many' && (
<Button
icon={<AppstoreAddOutlined />}
+34 -2
View File
@@ -23,9 +23,11 @@ import {
import { useStore } from '../store';
import { useCharacterSync, useOutlineSync, useChapterSync } from '../store/hooks';
import { projectApi } from '../services/api';
import { VERSION_INFO } from '../config/version';
import ThemeSwitch from '../components/ThemeSwitch';
import { useThemeMode } from '../theme/useThemeMode';
import { getStoredSidebarCollapsed, setStoredSidebarCollapsed } from '../utils/sidebarState';
import FloatingTaskPanel from '../components/FloatingTaskPanel';
const { Header, Sider, Content } = Layout;
@@ -118,6 +120,11 @@ export default function ProjectDetail() {
icon: <HeartOutlined />,
label: <Link to={`/project/${projectId}/sponsor`}></Link>,
},
{
key: 'about',
icon: <BulbOutlined />,
label: <Link to={`/project/${projectId}/about`}></Link>,
},
{
type: 'group' as const,
label: '创作管理',
@@ -185,6 +192,23 @@ export default function ProjectDetail() {
},
],
},
{
type: 'group' as const,
label: '官方资源',
children: [
{
key: 'source-code',
icon: <CloudOutlined />,
label: VERSION_INFO.officialApiDocUrl ? (
<a href={VERSION_INFO.officialApiDocUrl} target="_blank" rel="noopener noreferrer">
API
</a>
) : (
<span style={{ color: 'inherit' }}>API </span>
),
},
],
},
];
const menuItemsCollapsed = [
@@ -193,6 +217,11 @@ export default function ProjectDetail() {
icon: <HeartOutlined />,
label: <Link to={`/project/${projectId}/sponsor`}></Link>,
},
{
key: 'about',
icon: <BulbOutlined />,
label: <Link to={`/project/${projectId}/about`}></Link>,
},
{
key: 'world-setting',
icon: <GlobalOutlined />,
@@ -447,7 +476,7 @@ export default function ProjectDetail() {
}}>
<BookOutlined />
</div>
<span style={{ fontWeight: 600, fontSize: 16 }}>MuMuAINovel</span>
<span style={{ fontWeight: 600, fontSize: 16 }}></span>
</div>
}
placement="left"
@@ -543,7 +572,7 @@ export default function ProjectDetail() {
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
MuMuAINovel
</span>
</div>
<Button
@@ -656,6 +685,9 @@ export default function ProjectDetail() {
</Content>
</Layout>
</Layout>
{/* 悬浮任务框 */}
{projectId && <FloatingTaskPanel projectId={projectId} />}
</Layout>
);
}
+85 -63
View File
@@ -9,7 +9,6 @@ import { eventBus, EventNames } from '../store/eventBus';
import type { ReactNode } from 'react';
import type { Project, User } from '../types';
import UserMenu from '../components/UserMenu';
import ChangelogFloatingButton from '../components/ChangelogFloatingButton';
import ThemeSwitch from '../components/ThemeSwitch';
import { useThemeMode } from '../theme/useThemeMode';
import SettingsPage from './Settings';
@@ -19,6 +18,8 @@ import PromptTemplates from './PromptTemplates';
import BookImport from './BookImport';
import BookshelfPage from './BookshelfPage';
import { getStoredSidebarCollapsed, setStoredSidebarCollapsed } from '../utils/sidebarState';
import { VERSION_INFO } from '../config/version';
import { shellColors } from '../theme/themeConfig';
const { Text } = Typography;
@@ -386,10 +387,11 @@ export default function ProjectList() {
};
const isMobile = window.innerWidth <= 768;
const headerHeight = isMobile ? 56 : 70;
const expandedSiderWidth = 220;
const collapsedSiderWidth = 60;
const headerHeight = isMobile ? 56 : 64;
const expandedSiderWidth = 232;
const collapsedSiderWidth = 64;
const desktopSiderWidth = collapsed ? collapsedSiderWidth : expandedSiderWidth;
const shell = shellColors[resolvedMode];
const currentViewTitle = activeView === 'projects'
? '我的书架'
@@ -447,9 +449,9 @@ export default function ProjectList() {
label: '系统设置',
}] : []),
{
key: 'mumu-api',
key: 'xinmi-api',
icon: <ApiOutlined />,
label: 'MuMuのAPI',
label: 'XinMi API',
},
],
},
@@ -487,12 +489,16 @@ export default function ProjectList() {
label: '系统设置',
}] : []),
{
key: 'mumu-api',
key: 'xinmi-api',
icon: <ApiOutlined />,
label: 'MuMuのAPI',
label: 'XinMi API',
},
];
const openXinmiApi = () => {
window.open(VERSION_INFO.officialApiDocUrl, '_blank', 'noopener,noreferrer');
};
return (
<div style={{
height: '100vh',
@@ -505,10 +511,11 @@ export default function ProjectList() {
{!isMobile && (
<div
className="app-shell-sider"
style={{
width: desktopSiderWidth,
background: token.colorBgContainer,
borderRight: `1px solid ${token.colorBorderSecondary}`,
background: shell.siderBg,
borderRight: `1px solid ${shell.siderBorder}`,
display: 'flex',
flexDirection: 'column',
position: 'fixed',
@@ -518,15 +525,16 @@ export default function ProjectList() {
height: '100vh',
overflow: 'hidden',
transition: 'width 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
boxShadow: `4px 0 16px ${alphaColor(token.colorText, 0.06)}`,
boxShadow: 'none',
zIndex: 1000
}}>
<div style={{
height: 70,
height: 64,
display: 'flex',
alignItems: 'center',
padding: collapsed ? 0 : '0 12px',
background: token.colorPrimary,
padding: collapsed ? 0 : '0 14px',
background: shell.siderBg,
borderBottom: `1px solid ${shell.siderBorder}`,
flexShrink: 0,
justifyContent: collapsed ? 'center' : 'space-between',
gap: 8
@@ -537,7 +545,7 @@ export default function ProjectList() {
icon={<MenuUnfoldOutlined />}
onClick={() => setCollapsed(false)}
style={{
color: token.colorWhite,
color: shell.siderText,
width: '100%',
height: '100%',
padding: 0,
@@ -549,39 +557,53 @@ export default function ProjectList() {
/>
) : (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0, overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0, overflow: 'hidden', flex: 1 }}>
<div style={{
width: 30,
height: 30,
background: alphaColor(token.colorWhite, 0.2),
borderRadius: 8,
width: 32,
height: 32,
border: `1px solid ${shell.siderAccent}`,
borderRadius: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: token.colorWhite,
color: shell.siderAccent,
fontSize: 16,
backdropFilter: 'blur(4px)'
flexShrink: 0,
}}>
<BookOutlined />
</div>
<span style={{
color: token.colorWhite,
<div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
<span className="app-serif-title" style={{
color: shell.siderText,
fontWeight: 600,
fontSize: 15,
fontFamily: token.fontFamily,
lineHeight: 1.2,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
}}>
MuMuAINovel
{VERSION_INFO.projectName}
</span>
<span style={{
color: shell.siderMuted,
fontWeight: 400,
fontSize: 11,
lineHeight: 1.2,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
letterSpacing: '0.04em',
}}>
</span>
</div>
</div>
<Button
type="text"
icon={<MenuFoldOutlined />}
onClick={() => setCollapsed(true)}
style={{
color: token.colorWhite,
color: shell.siderMuted,
width: 32,
height: 32,
padding: 0,
@@ -594,13 +616,14 @@ export default function ProjectList() {
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}>
<Menu
theme="dark"
mode="inline"
inlineCollapsed={collapsed}
selectedKeys={[activeView]}
style={{ borderRight: 0, paddingTop: 12, width: '100%' }}
style={{ borderRight: 0, paddingTop: 12, width: '100%', background: 'transparent' }}
onClick={({ key }) => {
if (key === 'mumu-api') {
window.open('https://api.mumuverse.space/register?aff=4NN8', '_blank', 'noopener,noreferrer');
if (key === 'xinmi-api') {
openXinmiApi();
return;
}
changeView(key as ProjectListView);
@@ -611,7 +634,7 @@ export default function ProjectList() {
<div style={{
padding: collapsed ? '12px 8px' : 16,
borderTop: `1px solid ${token.colorBorderSecondary}`,
borderTop: `1px solid ${shell.siderBorder}`,
flexShrink: 0
}}>
{collapsed ? (
@@ -624,17 +647,17 @@ export default function ProjectList() {
style={{
width: 40,
height: 40,
borderRadius: 20,
background: alphaColor(token.colorBgContainer, 0.65),
border: `1px solid ${token.colorBorder}`,
color: token.colorTextSecondary,
borderRadius: 2,
background: alphaColor(shell.siderText, 0.08),
border: `1px solid ${shell.siderBorder}`,
color: shell.siderMuted,
}}
/>
<UserMenu compact />
</Space>
) : (
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 12, color: token.colorTextTertiary }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 12, color: shell.siderMuted }}>
<span></span>
<span>{resolvedMode === 'dark' ? '深色' : '浅色'}</span>
</div>
@@ -647,7 +670,8 @@ export default function ProjectList() {
)}
<div style={{
background: token.colorPrimary,
background: token.colorBgContainer,
borderBottom: `2px solid ${shell.headerBorder}`,
padding: isMobile ? '0 12px' : '0 24px',
display: 'flex',
alignItems: 'center',
@@ -657,7 +681,7 @@ export default function ProjectList() {
left: isMobile ? 0 : desktopSiderWidth,
right: 0,
zIndex: 1000,
boxShadow: `0 2px 10px ${alphaColor(token.colorText, 0.16)}`,
boxShadow: 'none',
height: headerHeight,
flexShrink: 0,
transition: 'left 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
@@ -672,19 +696,19 @@ export default function ProjectList() {
onClick={() => setDrawerVisible(true)}
style={{
fontSize: 18,
color: token.colorWhite,
color: token.colorText,
width: 36,
height: 36
}}
/>
</div>
<h2 style={{
<h2 className="app-serif-title" style={{
margin: 0,
color: token.colorWhite,
color: token.colorText,
fontSize: 16,
fontWeight: 600,
textShadow: `0 2px 4px ${alphaColor(token.colorText, 0.2)}`,
textShadow: 'none',
flex: 1,
textAlign: 'center',
whiteSpace: 'nowrap',
@@ -701,12 +725,12 @@ export default function ProjectList() {
<>
<div style={{ width: 40, zIndex: 1 }} />
<h2 style={{
<h2 className="app-serif-title" style={{
margin: 0,
color: token.colorWhite,
fontSize: '24px',
color: token.colorText,
fontSize: '22px',
fontWeight: 600,
textShadow: `0 2px 4px ${alphaColor(token.colorText, 0.2)}`,
textShadow: 'none',
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
@@ -735,29 +759,29 @@ export default function ProjectList() {
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
backdropFilter: 'blur(4px)',
borderRadius: '28px',
borderRadius: 2,
minWidth: '56px',
height: '56px',
height: '52px',
padding: '0 12px',
boxShadow: `inset 0 0 15px ${alphaColor(token.colorWhite, 0.15)}, 0 4px 10px ${alphaColor(token.colorText, 0.1)}`,
border: `1px solid ${token.colorBorder}`,
background: token.colorFillTertiary,
boxShadow: `3px 3px 0 ${alphaColor(token.colorText, 0.08)}`,
cursor: 'default',
transition: 'transform 0.3s ease, box-shadow 0.3s ease',
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-3px) scale(1.02)';
e.currentTarget.style.boxShadow = `inset 0 0 20px ${alphaColor(token.colorWhite, 0.25)}, 0 8px 16px ${alphaColor(token.colorText, 0.15)}`;
e.currentTarget.style.border = `1px solid ${alphaColor(token.colorWhite, 0.1)}`;
e.currentTarget.style.transform = 'translate(-1px, -1px)';
e.currentTarget.style.boxShadow = `4px 4px 0 ${alphaColor(token.colorPrimary, 0.2)}`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0) scale(1)';
e.currentTarget.style.boxShadow = `inset 0 0 15px ${alphaColor(token.colorWhite, 0.15)}, 0 4px 10px ${alphaColor(token.colorText, 0.1)}`;
e.currentTarget.style.transform = 'translate(0, 0)';
e.currentTarget.style.boxShadow = `3px 3px 0 ${alphaColor(token.colorText, 0.08)}`;
}}
>
<span style={{ fontSize: '11px', color: alphaColor(token.colorWhite, 0.9), marginBottom: '2px', lineHeight: 1 }}>
<span style={{ fontSize: '11px', color: token.colorTextSecondary, marginBottom: '2px', lineHeight: 1 }}>
{item.label}
</span>
<span style={{ fontSize: '15px', fontWeight: '600', color: token.colorWhite, lineHeight: 1, fontFamily: 'Monaco, monospace' }}>
<span style={{ fontSize: '15px', fontWeight: '600', color: token.colorText, lineHeight: 1, fontFamily: 'var(--app-font-mono)' }}>
{item.label === '总字数' ? formatWordCount(item.value) : item.value}
{item.unit && <span style={{ fontSize: '10px', marginLeft: '2px', opacity: 0.8 }}>{item.unit}</span>}
</span>
@@ -789,7 +813,7 @@ export default function ProjectList() {
}}>
<BookOutlined />
</div>
<span style={{ fontWeight: 600, fontSize: 16, fontFamily: token.fontFamily }}>MuMuAINovel</span>
<span style={{ fontWeight: 600, fontSize: 16, fontFamily: token.fontFamily }}></span>
</div>
}
placement="left"
@@ -804,8 +828,8 @@ export default function ProjectList() {
selectedKeys={[activeView]}
style={{ borderRight: 0, paddingTop: 8 }}
onClick={({ key }) => {
if (key === 'mumu-api') {
window.open('https://api.mumuverse.space/register?aff=4NN8', '_blank', 'noopener,noreferrer');
if (key === 'xinmi-api') {
openXinmiApi();
setDrawerVisible(false);
return;
}
@@ -887,8 +911,6 @@ export default function ProjectList() {
formatDate={formatDate}
/>
)}
<ChangelogFloatingButton />
</div>
</div>
+29 -65
View File
@@ -248,8 +248,7 @@ export default function PromptTemplates() {
};
const currentTemplates = getCurrentTemplates();
const pageBackground = `linear-gradient(180deg, ${token.colorBgLayout} 0%, ${token.colorFillSecondary} 100%)`;
const headerBackground = `linear-gradient(135deg, ${token.colorPrimary} 0%, ${token.colorPrimaryHover} 100%)`;
const pageBackground = token.colorBgLayout;
return (
<>
@@ -269,73 +268,37 @@ export default function PromptTemplates() {
display: 'flex',
flexDirection: 'column',
}}>
{/* 顶部导航卡片 */}
{/* 顶部标题区 */}
<Card
variant="borderless"
className="app-prompt-header"
style={{
background: headerBackground,
borderRadius: isMobile ? 16 : 24,
boxShadow: token.boxShadowSecondary,
background: token.colorBgContainer,
borderRadius: 2,
border: `1px solid ${token.colorBorder}`,
marginBottom: isMobile ? 20 : 24,
border: 'none',
position: 'relative',
overflow: 'hidden'
boxShadow: `6px 6px 0 ${token.colorFillSecondary}`,
}}
>
{/* 装饰性背景元素 */}
<div style={{ position: 'absolute', top: -60, right: -60, width: 200, height: 200, borderRadius: '50%', background: token.colorWhite, opacity: 0.08, pointerEvents: 'none' }} />
<div style={{ position: 'absolute', bottom: -40, left: '30%', width: 120, height: 120, borderRadius: '50%', background: token.colorWhite, opacity: 0.05, pointerEvents: 'none' }} />
<div style={{ position: 'absolute', top: '50%', right: '15%', width: 80, height: 80, borderRadius: '50%', background: token.colorWhite, opacity: 0.06, pointerEvents: 'none' }} />
<Row align="middle" justify="space-between" gutter={[16, 16]} style={{ position: 'relative', zIndex: 1 }}>
<Row align="middle" justify="space-between" gutter={[16, 16]}>
<Col xs={24} sm={12} md={14}>
<Space direction="vertical" size={4}>
<Title level={isMobile ? 3 : 2} style={{ margin: 0, color: token.colorWhite, textShadow: `0 2px 4px ${token.colorBgMask}` }}>
<FileSearchOutlined style={{ color: token.colorWhite, opacity: 0.9, marginRight: 8 }} />
<Title level={isMobile ? 3 : 2} className="app-serif-title" style={{ margin: 0, color: token.colorText }}>
<FileSearchOutlined style={{ color: token.colorPrimary, marginRight: 8 }} />
</Title>
<Text style={{ fontSize: isMobile ? 12 : 14, color: token.colorTextLightSolid, opacity: 0.85, marginLeft: isMobile ? 40 : 48 }}>
<Text style={{ fontSize: isMobile ? 12 : 14, color: token.colorTextSecondary }}>
AI
</Text>
</Space>
</Col>
<Col xs={24} sm={12} md={10}>
<Space wrap style={{ justifyContent: isMobile ? 'flex-start' : 'flex-end', width: '100%' }}>
<Button
icon={<DownloadOutlined />}
onClick={handleExport}
size={isMobile ? 'small' : 'middle'}
style={{
borderRadius: 12,
background: token.colorWhite,
border: `1px solid ${token.colorWhite}`,
boxShadow: token.boxShadow,
color: token.colorPrimary,
fontWeight: 600,
backdropFilter: 'blur(10px)',
transition: 'all 0.3s ease'
}}
>
<Button icon={<DownloadOutlined />} onClick={handleExport} size={isMobile ? 'small' : 'middle'}>
</Button>
<Upload
accept=".json"
showUploadList={false}
beforeUpload={handleImport}
>
<Button
icon={<UploadOutlined />}
size={isMobile ? 'small' : 'middle'}
style={{
borderRadius: 12,
background: token.colorWhite,
border: `1px solid ${token.colorWhite}`,
boxShadow: token.boxShadow,
color: token.colorPrimary,
fontWeight: 600,
backdropFilter: 'blur(10px)',
}}
>
<Upload accept=".json" showUploadList={false} beforeUpload={handleImport}>
<Button icon={<UploadOutlined />} size={isMobile ? 'small' : 'middle'}>
</Button>
</Upload>
@@ -343,7 +306,6 @@ export default function PromptTemplates() {
</Col>
</Row>
{/* 使用提示 */}
<Alert
message={
<Space align="center">
@@ -354,20 +316,20 @@ export default function PromptTemplates() {
description={
<div>
<Text style={{ fontSize: isMobile ? 12 : 13, display: 'block', marginBottom: 8 }}>
<strong></strong>"编辑"
<strong></strong>
</Text>
<Text style={{ fontSize: isMobile ? 12 : 13, display: 'block' }}>
<strong></strong>/使 <Text code>{'{variable_name}'}</Text> "重置"
<strong></strong>/使 <Text code>{'{variable_name}'}</Text>
</Text>
</div>
}
type="info"
showIcon={false}
style={{
marginTop: isMobile ? 16 : 24,
borderRadius: 12,
background: token.colorInfoBg,
border: `1px solid ${token.colorInfoBorder}`
marginTop: isMobile ? 16 : 20,
borderRadius: 2,
background: token.colorFillTertiary,
border: `1px solid ${token.colorBorder}`,
}}
/>
</Card>
@@ -381,8 +343,9 @@ export default function PromptTemplates() {
variant="borderless"
style={{
background: token.colorBgContainer,
borderRadius: isMobile ? 12 : 16,
boxShadow: token.boxShadowSecondary,
borderRadius: 2,
border: `1px solid ${token.colorBorder}`,
boxShadow: 'none',
marginBottom: isMobile ? 16 : 24
}}
styles={{ body: { padding: isMobile ? '12px' : '16px' } }}
@@ -407,8 +370,9 @@ export default function PromptTemplates() {
variant="borderless"
style={{
background: token.colorBgContainer,
borderRadius: isMobile ? 12 : 16,
boxShadow: token.boxShadowSecondary,
borderRadius: 2,
border: `1px solid ${token.colorBorder}`,
boxShadow: 'none',
}}
>
<Empty
@@ -430,7 +394,7 @@ export default function PromptTemplates() {
{/* 头部 */}
<div style={{
background: template.is_system_default
? token.colorFillTertiary
? token.colorFillSecondary
: token.colorPrimary,
padding: isMobile ? '16px' : '20px',
position: 'relative'
@@ -490,7 +454,7 @@ export default function PromptTemplates() {
icon={<EditOutlined />}
onClick={() => handleEdit(template)}
size={isMobile ? 'small' : 'middle'}
style={{ borderRadius: 6 }}
style={{ borderRadius: 2 }}
>
</Button>
@@ -498,7 +462,7 @@ export default function PromptTemplates() {
icon={<ReloadOutlined />}
onClick={() => handleReset(template.template_key)}
size={isMobile ? 'small' : 'middle'}
style={{ borderRadius: 6 }}
style={{ borderRadius: 2 }}
>
</Button>
+100 -42
View File
@@ -4,6 +4,7 @@ import { SaveOutlined, DeleteOutlined, ReloadOutlined, InfoCircleOutlined, Check
import { settingsApi, mcpPluginApi } from '../services/api';
import type { SettingsUpdate, APIKeyPreset, PresetCreateRequest, APIKeyPresetConfig } from '../types';
import { eventBus, EventNames } from '../store/eventBus';
import { VERSION_INFO } from '../config/version';
const { Title, Text } = Typography;
const { Option } = Select;
@@ -48,6 +49,8 @@ export default function SettingsPage() {
const [presets, setPresets] = useState<APIKeyPreset[]>([]);
const [presetsLoading, setPresetsLoading] = useState(false);
const [activePresetId, setActivePresetId] = useState<string | undefined>();
const [chapterAnalysisPresetId, setChapterAnalysisPresetId] = useState<string | undefined>();
const [savingChapterAnalysisPreset, setSavingChapterAnalysisPreset] = useState(false);
const [editingPreset, setEditingPreset] = useState<APIKeyPreset | null>(null);
const [isPresetModalVisible, setIsPresetModalVisible] = useState(false);
const [testingPresetId, setTestingPresetId] = useState<string | null>(null);
@@ -282,25 +285,29 @@ export default function SettingsPage() {
});
};
const mumuTextDefaultUrl = 'https://api.mumuverse.space/v1';
const mumuRegisterUrl = 'https://api.mumuverse.space/register?aff=4NN8';
const mumuCoverBaseUrlOptions = [
{ value: 'https://api.mumuverse.space/v1beta', label: 'https://api.mumuverse.space/v1beta', defaultModel: 'gemini-3.1-flash-image-preview' },
{ value: 'https://api.mumuverse.space/v1', label: 'https://api.mumuverse.space/v1', defaultModel: 'gpt-image-1.5' },
const xinmiApiHost = VERSION_INFO.xinmiApiBaseUrl.replace(/\/$/, '');
const xinmiTextDefaultUrl = `${xinmiApiHost}/v1`;
const officialApiDocUrl = VERSION_INFO.officialApiDocUrl;
const openOfficialApiDoc = () => {
window.open(officialApiDocUrl, '_blank', 'noopener,noreferrer');
};
const xinmiCoverBaseUrlOptions = [
{ value: `${xinmiApiHost}/v1beta`, label: 'v1beta', defaultModel: 'gemini-3.1-flash-image-preview' },
{ value: `${xinmiApiHost}/v1`, label: 'v1', defaultModel: 'gpt-image-1.5' },
];
const defaultCoverSettings = {
cover_enabled: false,
cover_api_provider: 'mumu',
cover_api_provider: 'xinmi',
cover_api_key: '',
cover_api_base_url: mumuCoverBaseUrlOptions[0].value,
cover_image_model: mumuCoverBaseUrlOptions[0].defaultModel,
cover_api_base_url: xinmiCoverBaseUrlOptions[0].value,
cover_image_model: xinmiCoverBaseUrlOptions[0].defaultModel,
};
const apiProviders = [
{
value: 'mumu',
label: 'MuMuのAPI',
defaultUrl: mumuTextDefaultUrl,
value: 'xinmi',
label: 'XinMi API',
defaultUrl: xinmiTextDefaultUrl,
defaultModel: 'gemini-3-flash-preview'
},
{ value: 'openai', label: 'OpenAI Compatible', defaultUrl: 'https://api.openai.com/v1' },
@@ -319,7 +326,7 @@ export default function SettingsPage() {
if (provider.defaultUrl) {
nextValues.api_base_url = provider.defaultUrl;
}
if (provider.value === 'mumu') {
if (provider.value === 'xinmi') {
nextValues.api_key = '';
nextValues.llm_model = provider.defaultModel || 'gemini-3-flash-preview';
}
@@ -332,10 +339,10 @@ export default function SettingsPage() {
const coverApiProviders = [
{
value: 'mumu',
label: 'MuMuのAPI',
defaultUrl: mumuCoverBaseUrlOptions[0].value,
defaultModel: mumuCoverBaseUrlOptions[0].defaultModel,
value: 'xinmi',
label: 'XinMi API',
defaultUrl: xinmiCoverBaseUrlOptions[0].value,
defaultModel: xinmiCoverBaseUrlOptions[0].defaultModel,
},
{ value: 'gemini', label: 'Google Gemini', defaultUrl: 'https://generativelanguage.googleapis.com/v1beta' },
{ value: 'grok', label: 'Grok', defaultUrl: 'https://api.x.ai/v1' },
@@ -352,20 +359,20 @@ export default function SettingsPage() {
if (provider.defaultUrl) {
nextValues.cover_api_base_url = provider.defaultUrl;
}
if (provider.value === 'mumu') {
if (provider.value === 'xinmi') {
nextValues.cover_api_key = '';
nextValues.cover_image_model = provider.defaultModel || mumuCoverBaseUrlOptions[0].defaultModel;
nextValues.cover_image_model = provider.defaultModel || xinmiCoverBaseUrlOptions[0].defaultModel;
}
form.setFieldsValue(nextValues);
setCoverTestResult(null);
};
const handleMumuCoverBaseUrlChange = (value: string) => {
const option = mumuCoverBaseUrlOptions.find(item => item.value === value);
const handleXinmiCoverBaseUrlChange = (value: string) => {
const option = xinmiCoverBaseUrlOptions.find(item => item.value === value);
form.setFieldsValue({
cover_api_base_url: value,
cover_image_model: option?.defaultModel || mumuCoverBaseUrlOptions[0].defaultModel,
cover_image_model: option?.defaultModel || xinmiCoverBaseUrlOptions[0].defaultModel,
});
setCoverTestResult(null);
};
@@ -511,6 +518,7 @@ export default function SettingsPage() {
const response = await settingsApi.getPresets();
setPresets(response.presets);
setActivePresetId(response.active_preset_id);
setChapterAnalysisPresetId(response.chapter_analysis_preset_id);
} catch (error) {
message.error('加载预设失败');
console.error(error);
@@ -608,7 +616,7 @@ export default function SettingsPage() {
if (provider.defaultUrl) {
nextValues.api_base_url = provider.defaultUrl;
}
if (provider.value === 'mumu') {
if (provider.value === 'xinmi') {
nextValues.api_key = '';
nextValues.llm_model = provider.defaultModel || 'gemini-3-flash-preview';
}
@@ -656,6 +664,23 @@ export default function SettingsPage() {
}
};
const handleChapterAnalysisPresetChange = async (presetId?: string) => {
setSavingChapterAnalysisPreset(true);
try {
const normalizedPresetId = presetId || undefined;
await settingsApi.setChapterAnalysisPresetSelection(normalizedPresetId);
setChapterAnalysisPresetId(normalizedPresetId);
message.success(normalizedPresetId ? '已设置章节内容分析专用API配置' : '章节内容分析已恢复使用默认API配置');
loadPresets();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
message.error(error.response?.data?.detail || '设置章节内容分析API配置失败');
console.error(error);
} finally {
setSavingChapterAnalysisPreset(false);
}
};
const handlePresetDelete = async (presetId: string) => {
try {
await settingsApi.deletePreset(presetId);
@@ -903,7 +928,7 @@ export default function SettingsPage() {
// return 'purple';
case 'gemini':
return 'green';
case 'mumu':
case 'xinmi':
return 'magenta';
default:
return 'default';
@@ -927,6 +952,38 @@ export default function SettingsPage() {
</Space>
</div>
<Card size="small" style={{ background: token.colorFillAlter, borderColor: token.colorBorderSecondary }}>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Space wrap align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
<Space direction="vertical" size={2}>
<Text strong> API </Text>
<Text type="secondary" style={{ fontSize: 12 }}>
使使
</Text>
</Space>
<Select
allowClear
placeholder="默认API配置"
value={chapterAnalysisPresetId}
loading={savingChapterAnalysisPreset}
disabled={presetsLoading || savingChapterAnalysisPreset}
style={{ minWidth: isMobile ? '100%' : 280 }}
onChange={(value) => handleChapterAnalysisPresetChange(value)}
options={presets.map((preset) => ({
value: preset.id,
label: `${preset.name} (${preset.config.llm_model})`,
}))}
/>
</Space>
<Alert
showIcon
type="info"
message={chapterAnalysisPresetId ? '章节内容分析将优先使用所选预设。' : '当前未指定章节内容分析预设,将使用默认API配置。'}
style={{ padding: '6px 10px' }}
/>
</Space>
</Card>
{presets.length === 0 ? (
<Empty
description="暂无预设配置"
@@ -1007,6 +1064,7 @@ export default function SettingsPage() {
<Space>
<span style={{ fontWeight: 'bold' }}>{preset.name}</span>
{isActive && <Tag color="success"></Tag>}
{preset.id === chapterAnalysisPresetId && <Tag color="processing"></Tag>}
</Space>
}
description={
@@ -1175,11 +1233,11 @@ export default function SettingsPage() {
</Select>
</Form.Item>
{selectedProvider === 'mumu' && (
{selectedProvider === 'xinmi' && (
<Alert
type="info"
showIcon
message="MuMuのAPI 专属供应商"
message="墨木灵思 API 专属供应商"
description={
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text>
@@ -1188,9 +1246,9 @@ export default function SettingsPage() {
<div>
<Button
type="primary"
onClick={() => window.open(mumuRegisterUrl, '_blank', 'noopener,noreferrer')}
onClick={openOfficialApiDoc}
>
MuMuのAPI
API
</Button>
</div>
</Space>
@@ -1687,22 +1745,22 @@ export default function SettingsPage() {
</Select>
</Form.Item>
{selectedCoverProvider === 'mumu' && (
{selectedCoverProvider === 'xinmi' && (
<Alert
type="info"
showIcon
message="MuMuのAPI 专属适配器"
message="墨木灵思 API 专属适配器"
description={
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text>
MuMuのAPI API Key MuMuのAPI
API API Key
</Text>
<div>
<Button
type="primary"
onClick={() => window.open(mumuRegisterUrl, '_blank', 'noopener,noreferrer')}
onClick={openOfficialApiDoc}
>
MuMuのAPI
API
</Button>
</div>
</Space>
@@ -1712,15 +1770,15 @@ export default function SettingsPage() {
)}
<Form.Item label="封面图片 API Key" name="cover_api_key" rules={[{ required: true, message: '请输入封面图片 API Key' }]}>
<Input.Password size={isMobile ? 'middle' : 'large'} placeholder={selectedCoverProvider === 'mumu' ? '请输入 MuMuのAPI Key' : '输入封面图片 API Key'} autoComplete="new-password" />
<Input.Password size={isMobile ? 'middle' : 'large'} placeholder={selectedCoverProvider === 'xinmi' ? '请输入墨木灵思 API Key' : '输入封面图片 API Key'} autoComplete="new-password" />
</Form.Item>
<Form.Item label="封面图片 API 地址" name="cover_api_base_url" rules={[{ type: 'url', message: '请输入有效的URL' }]}>
{selectedCoverProvider === 'mumu' ? (
{selectedCoverProvider === 'xinmi' ? (
<Select
size={isMobile ? 'middle' : 'large'}
onChange={handleMumuCoverBaseUrlChange}
options={mumuCoverBaseUrlOptions.map(option => ({
onChange={handleXinmiCoverBaseUrlChange}
options={xinmiCoverBaseUrlOptions.map(option => ({
value: option.value,
label: option.label,
}))}
@@ -1733,7 +1791,7 @@ export default function SettingsPage() {
<Form.Item label="封面图片模型" name="cover_image_model" rules={[{ required: true, message: '请输入封面图片模型名称' }]}>
<Input
size={isMobile ? 'middle' : 'large'}
placeholder={selectedCoverProvider === 'mumu'
placeholder={selectedCoverProvider === 'xinmi'
? '选择地址后自动填入推荐模型'
: selectedCoverProvider === 'grok'
? 'grok-2-image'
@@ -1825,17 +1883,17 @@ export default function SettingsPage() {
style={{ marginBottom: 16 }}
>
<Select placeholder="选择提供商" onChange={handlePresetProviderChange}>
<Select.Option value="mumu">MuMuのAPI</Select.Option>
<Select.Option value="xinmi"> API</Select.Option>
<Select.Option value="openai">OpenAI</Select.Option>
<Select.Option value="gemini">Google Gemini</Select.Option>
</Select>
</Form.Item>
{selectedPresetProvider === 'mumu' && (
{selectedPresetProvider === 'xinmi' && (
<Alert
type="info"
showIcon
message="MuMuのAPI 专属供应商"
message="墨木灵思 API 专属供应商"
description={
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text>
@@ -1844,9 +1902,9 @@ export default function SettingsPage() {
<div>
<Button
type="primary"
onClick={() => window.open(mumuRegisterUrl, '_blank', 'noopener,noreferrer')}
onClick={openOfficialApiDoc}
>
MuMuのAPI
API
</Button>
</div>
</Space>
+4 -4
View File
@@ -104,13 +104,13 @@ export default function Sponsor() {
color: token.colorWhite
}}>
<Title level={1} style={{ color: token.colorWhite, marginBottom: '8px', fontSize: 'clamp(24px, 5vw, 32px)', fontWeight: 'bold' }}>
MuMuAINovel
</Title>
<Text type="secondary" style={{ color: token.colorWhite, fontSize: 'clamp(11px, 2vw, 13px)', letterSpacing: '2px' }}>
SUPPORT MuMuAINovel
SUPPORT
</Text>
<Title level={4} style={{ color: token.colorWhite, marginTop: '8px', marginBottom: '8px' }}>
📚 MuMuAINovel - AI
📚 - AI
</Title>
</div>
</div>
@@ -222,7 +222,7 @@ export default function Sponsor() {
marginTop: 'auto'
}}>
<Title level={4} style={{ marginBottom: '12px', fontSize: 'clamp(16px, 3vw, 20px)' }}>
💖 MuMuAINovel
💖
</Title>
<Paragraph style={{ fontSize: 'clamp(12px, 2vw, 14px)', color: token.colorTextSecondary, marginBottom: '12px' }}>
AI小说创作体验!
+1 -1
View File
@@ -216,7 +216,7 @@ export default function SystemSettingsPage() {
</Col>
<Col xs={24} md={12}>
<Form.Item name="smtp_from_name" label="发件人名称" rules={[{ required: true, message: '请输入发件人名称' }]}>
<Input placeholder="MuMuAINovel" />
<Input placeholder="墨木灵思" />
</Form.Item>
</Col>
</Row>
+5
View File
@@ -315,6 +315,11 @@ export const settingsApi = {
suggestions?: string[];
}>(`/settings/presets/${presetId}/test`),
setChapterAnalysisPresetSelection: (presetId?: string) =>
api.put<unknown, { message: string; chapter_analysis_preset_id?: string; preset_name?: string }>('/settings/presets/usage/chapter-analysis', {
preset_id: presetId || null,
}),
createPresetFromCurrent: (name: string, description?: string) =>
api.post<unknown, APIKeyPreset>('/settings/presets/from-current', null, {
params: { name, description }
+52 -1
View File
@@ -13,9 +13,10 @@ export interface TaskStatus {
status_message: string | null;
progress_details: {
stage: string;
message: string;
message?: string;
current_chars?: number;
retry_count?: number;
queue_size?: number;
} | null;
error_message: string | null;
task_result: Record<string, unknown> | null;
@@ -31,6 +32,22 @@ export interface TaskListResponse {
items: TaskStatus[];
}
/**
*
*/
export interface BatchTaskStatus {
id: string;
project_id: string;
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
total_chapters: number;
completed_chapters: number;
current_chapter_number: number | null;
error_message: string | null;
created_at: string | null;
started_at: string | null;
completed_at: string | null;
}
/**
*
*/
@@ -59,6 +76,29 @@ export async function getProjectTasks(
return response.json();
}
/**
*
*/
export async function getActiveBatchTasks(projectId: string): Promise<BatchTaskStatus[]> {
const response = await fetch(`/api/chapters/project/${projectId}/batch-generate/active`);
if (!response.ok) {
throw new Error(`获取批量生成任务失败: ${response.statusText}`);
}
const data = await response.json();
// API 返回单个任务或空,统一转为数组
return data ? [data] : [];
}
/**
*
*/
export async function cancelBatchTask(batchId: string): Promise<void> {
const response = await fetch(`/api/chapters/batch-generate/${batchId}/cancel`, { method: 'POST' });
if (!response.ok) {
throw new Error(`取消批量生成任务失败: ${response.statusText}`);
}
}
/**
*
*/
@@ -69,6 +109,17 @@ export async function cancelTask(taskId: string): Promise<void> {
}
}
/**
*
*/
export async function clearProjectTasks(projectId: string): Promise<{ deleted_count: number }> {
const response = await fetch(`${API_BASE}/project/${projectId}/clear`, { method: 'DELETE' });
if (!response.ok) {
throw new Error(`清理任务记录失败: ${response.statusText}`);
}
return response.json();
}
/**
*
*/
-276
View File
@@ -1,276 +0,0 @@
/**
* GitHub
* GitHub API
*/
export interface GitHubCommit {
sha: string;
commit: {
author: {
name: string;
email: string;
date: string;
};
message: string;
};
html_url: string;
author: {
login: string;
avatar_url: string;
} | null;
}
export interface ChangelogEntry {
id: string;
date: string;
version?: string;
author: {
name: string;
avatar?: string;
username?: string;
};
message: string;
commitUrl: string;
type: 'feature' | 'fix' | 'docs' | 'style' | 'refactor' | 'perf' | 'test' | 'chore' | 'update' | 'other';
scope?: string;
}
const GITHUB_API_BASE = 'https://api.github.com';
const REPO_OWNER = 'xiamuceer-j';
const REPO_NAME = 'MuMuAINovel';
/**
*
*
*/
const TYPE_MAPPING: Record<string, ChangelogEntry['type']> = {
// 功能类
'feat': 'feature',
'feature': 'feature',
'update': 'update',
// 修复类
'fix': 'fix',
// 文档类
'docs': 'docs',
'doc': 'docs',
// 样式类
'style': 'style',
// 重构类
'refactor': 'refactor',
// 性能类
'perf': 'perf',
// 测试类
'test': 'test',
// 杂项类
'chore': 'chore',
};
/**
*
*
*
* 1. Conventional Commits 格式: type(scope): message type: message
* 2. : [type] message
* 3. 简单前缀格式: type: message
* 4.
*/
function parseCommitType(message: string): { type: ChangelogEntry['type']; scope?: string; cleanMessage: string } {
const lowerMessage = message.toLowerCase().trim();
// 优先级1:标准 Conventional Commits 格式 - type(scope): message 或 type: message
// 匹配所有支持的类型
const conventionalPattern = new RegExp(
`^(${Object.keys(TYPE_MAPPING).join('|')})(?:\\(([^)]+)\\))?\\s*[:\\:]\\s*(.+)`,
'i'
);
const conventionalMatch = message.match(conventionalPattern);
if (conventionalMatch) {
const typeStr = conventionalMatch[1].toLowerCase();
const mappedType = TYPE_MAPPING[typeStr] || 'other';
return {
type: mappedType,
scope: conventionalMatch[2],
cleanMessage: conventionalMatch[3].trim(),
};
}
// 优先级2:方括号格式 - [type] message
const bracketPattern = new RegExp(
`^\\[(${Object.keys(TYPE_MAPPING).join('|')})\\]\\s*(.+)`,
'i'
);
const bracketMatch = message.match(bracketPattern);
if (bracketMatch) {
const typeStr = bracketMatch[1].toLowerCase();
const mappedType = TYPE_MAPPING[typeStr] || 'other';
return {
type: mappedType,
cleanMessage: bracketMatch[2].trim(),
};
}
// 优先级3:简单前缀格式 - type: message(支持英文和中文冒号)
for (const [key, value] of Object.entries(TYPE_MAPPING)) {
const prefixPattern = new RegExp(`^${key}\\s*[:\\:]\\s*`, 'i');
if (prefixPattern.test(lowerMessage)) {
const cleanMsg = message.replace(prefixPattern, '').trim();
return { type: value, cleanMessage: cleanMsg };
}
}
// 优先级4:关键词模糊匹配(仅当前面都不匹配时)
const keywordMap: Array<{ keywords: string[]; type: ChangelogEntry['type'] }> = [
{ keywords: ['修复', 'fix'], type: 'fix' },
{ keywords: ['优化', 'perf'], type: 'perf' },
{ keywords: ['文档', 'document'], type: 'docs' },
{ keywords: ['新增', '添加', '增加', 'add'], type: 'feature' },
{ keywords: ['更新', 'update'], type: 'update' },
{ keywords: ['样式', 'style'], type: 'style' },
{ keywords: ['重构', 'refactor'], type: 'refactor' },
{ keywords: ['测试', 'test'], type: 'test' },
];
for (const { keywords, type } of keywordMap) {
if (keywords.some(keyword => lowerMessage.includes(keyword))) {
return { type, cleanMessage: message };
}
}
// 默认类型
return { type: 'other', cleanMessage: message };
}
/**
* GitHub提交历史
*/
export async function fetchGitHubCommits(page: number = 1, perPage: number = 30): Promise<GitHubCommit[]> {
try {
const url = `${GITHUB_API_BASE}/repos/${REPO_OWNER}/${REPO_NAME}/commits?author=${REPO_OWNER}&page=${page}&per_page=${perPage}`;
const response = await fetch(url, {
method: 'GET',
headers: {
'Accept': 'application/vnd.github.v3+json',
},
cache: 'no-cache',
});
if (!response.ok) {
throw new Error(`GitHub API 请求失败: ${response.status} ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('获取 GitHub 提交历史失败:', error);
throw error;
}
}
/**
* GitHub提交转换为更新日志条目
*/
export function convertCommitsToChangelog(commits: GitHubCommit[]): ChangelogEntry[] {
return commits.map(commit => {
const { type, scope, cleanMessage } = parseCommitType(commit.commit.message);
return {
id: commit.sha,
date: commit.commit.author.date,
author: {
name: commit.commit.author.name,
avatar: commit.author?.avatar_url,
username: commit.author?.login,
},
message: cleanMessage,
commitUrl: commit.html_url,
type,
scope,
};
});
}
/**
*
*/
export async function fetchChangelog(page: number = 1, perPage: number = 30): Promise<ChangelogEntry[]> {
const commits = await fetchGitHubCommits(page, perPage);
return convertCommitsToChangelog(commits);
}
/**
*
*/
export function groupChangelogByDate(entries: ChangelogEntry[]): Map<string, ChangelogEntry[]> {
const grouped = new Map<string, ChangelogEntry[]>();
entries.forEach(entry => {
const date = new Date(entry.date).toISOString().split('T')[0];
const existing = grouped.get(date) || [];
existing.push(entry);
grouped.set(date, existing);
});
return grouped;
}
/**
*
*/
export function shouldFetchChangelog(): boolean {
const lastFetch = localStorage.getItem('changelog_last_fetch');
if (!lastFetch) {
return true;
}
const lastFetchTime = new Date(lastFetch).getTime();
const now = Date.now();
const oneHourMs = 60 * 60 * 1000; // 1小时
return now - lastFetchTime >= oneHourMs;
}
/**
*
*/
export function markChangelogFetched(): void {
localStorage.setItem('changelog_last_fetch', new Date().toISOString());
}
/**
*
*/
export function getCachedChangelog(): ChangelogEntry[] | null {
const cached = localStorage.getItem('changelog_cache');
if (cached) {
try {
return JSON.parse(cached);
} catch {
return null;
}
}
return null;
}
/**
*
*/
export function cacheChangelog(entries: ChangelogEntry[]): void {
localStorage.setItem('changelog_cache', JSON.stringify(entries));
}
/**
*
*
*/
export function clearChangelogCache(): void {
localStorage.removeItem('changelog_cache');
localStorage.removeItem('changelog_last_fetch');
}
+3 -3
View File
@@ -32,7 +32,7 @@ function compareVersion(v1: string, v2: string): number {
export async function checkLatestVersion(): Promise<VersionCheckResult> {
try {
// 使用 shields.io 的 GitHub release badge API
const badgeUrl = 'https://img.shields.io/github/v/release/xiamuceer-j/MuMuAINovel';
const badgeUrl = 'https://img.shields.io/github/v/release/mumulingsi-project/mumulingsi';
const response = await fetch(badgeUrl, {
method: 'GET',
@@ -63,7 +63,7 @@ export async function checkLatestVersion(): Promise<VersionCheckResult> {
return {
hasUpdate,
latestVersion,
releaseUrl: `https://github.com/xiamuceer-j/MuMuAINovel/releases/tag/v${latestVersion}`,
releaseUrl: '',
};
}
}
@@ -74,7 +74,7 @@ export async function checkLatestVersion(): Promise<VersionCheckResult> {
return {
hasUpdate: false,
latestVersion: VERSION_INFO.version,
releaseUrl: VERSION_INFO.githubUrl,
releaseUrl: '',
};
}
}
+102 -19
View File
@@ -4,23 +4,48 @@ import type { ThemeMode } from './themeStorage';
export type ResolvedThemeMode = Exclude<ThemeMode, 'system'>;
/** 铜墨编辑部 — 暖赭石 + 纸感底色,与原先蓝紫圆角风区分 */
const sharedToken: ThemeConfig['token'] = {
colorPrimary: '#4D8088',
borderRadius: 8,
colorPrimary: '#B45309',
colorInfo: '#0D9488',
colorSuccess: '#15803D',
colorWarning: '#CA8A04',
colorError: '#B91C1C',
borderRadius: 2,
wireframe: false,
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif",
fontFamily: "'Source Sans 3', 'PingFang SC', 'Microsoft YaHei', sans-serif",
fontFamilyCode: "'IBM Plex Mono', 'Consolas', monospace",
};
const sharedComponents: ThemeConfig['components'] = {
Button: {
borderRadius: 8,
controlHeight: 36,
borderRadius: 2,
controlHeight: 40,
fontWeight: 600,
primaryShadow: 'none',
},
Card: {
borderRadiusLG: 12,
borderRadiusLG: 2,
boxShadowTertiary: 'none',
},
Menu: {
itemBorderRadius: 0,
itemHeight: 44,
iconSize: 16,
},
Tabs: {
inkBarColor: '#B45309',
titleFontSize: 14,
},
Input: {
borderRadius: 2,
controlHeight: 42,
},
Layout: {
headerHeight: 64,
},
Tooltip: {
colorBgSpotlight: sharedToken.colorPrimary,
colorBgSpotlight: '#292524',
},
};
@@ -28,17 +53,34 @@ const lightThemeConfig: ThemeConfig = {
algorithm: theme.defaultAlgorithm,
token: {
...sharedToken,
colorBgBase: '#F8F6F1',
colorTextBase: '#2B2B2B',
colorBgLayout: '#F8F6F1',
colorBgContainer: '#FFFFFF',
colorBgBase: '#F5F0E6',
colorTextBase: '#292524',
colorBgLayout: '#EDE8DC',
colorBgContainer: '#FFFCF7',
colorBorder: '#C9BFB0',
colorBorderSecondary: '#DDD5C8',
colorFillSecondary: '#E8E2D6',
colorFillTertiary: '#F0EBE1',
colorPrimaryBg: '#FEF3C7',
colorPrimaryBorder: '#D97706',
colorPrimaryHover: '#92400E',
colorLink: '#9A3412',
colorLinkHover: '#7C2D12',
},
components: {
...sharedComponents,
Layout: {
bodyBg: '#F8F6F1',
headerBg: '#FFFFFF',
siderBg: '#FFFFFF',
bodyBg: '#EDE8DC',
headerBg: '#FFFCF7',
siderBg: '#1C1917',
},
Menu: {
...sharedComponents.Menu,
darkItemBg: 'transparent',
darkItemColor: '#D6D3D1',
darkItemSelectedBg: 'rgba(180, 83, 9, 0.22)',
darkItemSelectedColor: '#FCD34D',
darkItemHoverBg: 'rgba(255, 255, 255, 0.06)',
},
},
};
@@ -47,15 +89,34 @@ const darkThemeConfig: ThemeConfig = {
algorithm: theme.darkAlgorithm,
token: {
...sharedToken,
colorBgBase: '#141414',
colorTextBase: '#f5f5f5',
colorPrimary: '#F59E0B',
colorBgBase: '#0C0A09',
colorTextBase: '#E7E5E4',
colorBgLayout: '#0C0A09',
colorBgContainer: '#1C1917',
colorBorder: '#44403C',
colorBorderSecondary: '#292524',
colorFillSecondary: '#292524',
colorFillTertiary: '#1C1917',
colorPrimaryBg: 'rgba(245, 158, 11, 0.12)',
colorPrimaryBorder: '#B45309',
colorLink: '#FBBF24',
colorLinkHover: '#FCD34D',
},
components: {
...sharedComponents,
Layout: {
bodyBg: '#0f1115',
headerBg: '#141414',
siderBg: '#141414',
bodyBg: '#0C0A09',
headerBg: '#1C1917',
siderBg: '#0C0A09',
},
Menu: {
...sharedComponents.Menu,
darkItemBg: 'transparent',
darkItemColor: '#A8A29E',
darkItemSelectedBg: 'rgba(245, 158, 11, 0.18)',
darkItemSelectedColor: '#FCD34D',
darkItemHoverBg: 'rgba(255, 255, 255, 0.05)',
},
},
};
@@ -63,3 +124,25 @@ const darkThemeConfig: ThemeConfig = {
export const getThemeConfig = (mode: ResolvedThemeMode): ThemeConfig => {
return mode === 'dark' ? darkThemeConfig : lightThemeConfig;
};
/** 侧栏等壳层用色(不依赖 ant token 的固定值) */
export const shellColors = {
light: {
siderBg: '#1C1917',
siderBorder: '#44403C',
siderAccent: '#F59E0B',
siderText: '#E7E5E4',
siderMuted: '#A8A29E',
headerBorder: '#C9BFB0',
inkPattern: 'rgba(28, 25, 23, 0.04)',
},
dark: {
siderBg: '#0C0A09',
siderBorder: '#292524',
siderAccent: '#FBBF24',
siderText: '#E7E5E4',
siderMuted: '#78716C',
headerBorder: '#44403C',
inkPattern: 'rgba(251, 191, 36, 0.06)',
},
} as const;
+14 -2
View File
@@ -1,6 +1,7 @@
export type ThemeMode = 'light' | 'dark' | 'system';
const THEME_MODE_STORAGE_KEY = 'mumu_theme_mode';
const THEME_MODE_STORAGE_KEY = 'mumulingsi_theme_mode';
const LEGACY_THEME_MODE_STORAGE_KEY = 'xinmi_theme_mode';
const isThemeMode = (value: string | null): value is ThemeMode => {
return value === 'light' || value === 'dark' || value === 'system';
@@ -8,7 +9,18 @@ const isThemeMode = (value: string | null): value is ThemeMode => {
export const getStoredThemeMode = (): ThemeMode => {
try {
const value = localStorage.getItem(THEME_MODE_STORAGE_KEY);
let value = localStorage.getItem(THEME_MODE_STORAGE_KEY);
if (!value) {
value = localStorage.getItem(LEGACY_THEME_MODE_STORAGE_KEY);
if (isThemeMode(value)) {
localStorage.setItem(THEME_MODE_STORAGE_KEY, value);
try {
localStorage.removeItem(LEGACY_THEME_MODE_STORAGE_KEY);
} catch {
// ignore
}
}
}
if (isThemeMode(value)) {
return value;
}
+1
View File
@@ -143,6 +143,7 @@ export interface PresetListResponse {
presets: APIKeyPreset[];
total: number;
active_preset_id?: string;
chapter_analysis_preset_id?: string;
}
// LinuxDO 授权 URL 响应
+17 -2
View File
@@ -1,4 +1,5 @@
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'mumu_sidebar_collapsed';
const SIDEBAR_COLLAPSED_STORAGE_KEY = 'mumulingsi_sidebar_collapsed';
const LEGACY_SIDEBAR_COLLAPSED_STORAGE_KEY = 'xinmi_sidebar_collapsed';
export const getStoredSidebarCollapsed = (): boolean => {
if (typeof window === 'undefined') {
@@ -6,7 +7,21 @@ export const getStoredSidebarCollapsed = (): boolean => {
}
try {
return localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY) === '1';
const v = localStorage.getItem(SIDEBAR_COLLAPSED_STORAGE_KEY);
if (v === '1' || v === '0') {
return v === '1';
}
const legacy = localStorage.getItem(LEGACY_SIDEBAR_COLLAPSED_STORAGE_KEY);
if (legacy === '1' || legacy === '0') {
localStorage.setItem(SIDEBAR_COLLAPSED_STORAGE_KEY, legacy);
try {
localStorage.removeItem(LEGACY_SIDEBAR_COLLAPSED_STORAGE_KEY);
} catch {
// ignore
}
return legacy === '1';
}
return false;
} catch (error) {
console.warn('读取侧边栏状态失败:', error);
return false;
Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 574 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 195 KiB

+32 -32
View File
@@ -1,16 +1,16 @@
#!/bin/bash
# =============================================================================
# MuMuAINovel Termux 一键安装脚本
# 墨木灵思 Termux 一键安装脚本
# =============================================================================
#
set -e
# ── 路径配置 ──────────────────────────────────────────────────────────────────
INSTALL_DIR="$HOME/MuMuAINovel" # 项目安装目录
DATA_DIR="$HOME/mumuainovel/data" # 数据库目录
LOG_DIR="$HOME/mumuainovel/logs" # 日志目录
REPO="https://ghfast.top/https://github.com/xiamuceer-j/MuMuAINovel.git" # GitHub 镜像
INSTALL_DIR="$HOME/mumulingsi" # 项目安装目录
DATA_DIR="$HOME/mumulingsi/data" # 数据库目录
LOG_DIR="$HOME/mumulingsi/logs" # 日志目录
REPO="https://ghfast.top/https://github.com/mumulingsi-project/mumulingsi.git" # GitHub 镜像
# ── 输出函数 ──────────────────────────────────────────────────────────────────
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; CYAN='\033[0;36m'; NC='\033[0m'
@@ -53,7 +53,7 @@ MIRROR="-i https://mirrors.aliyun.com/pypi/simple/ --trusted-host mirrors.aliyun
# =============================================================================
echo ""
echo -e "${CYAN}╔══════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ 📚 MuMuAINovel Termux 一键安装 ║${NC}"
echo -e "${CYAN}║ 📚 墨木灵思 Termux 一键安装 ║${NC}"
echo -e "${CYAN}╚══════════════════════════════════════════╝${NC}"
echo ""
@@ -118,7 +118,7 @@ LOG="$TMPDIR/patch.log"
# ── 4a. 修补 memory_service.py ──────────────────────────────────────────────
python3 << 'PYEOF'
import os
f = os.path.expanduser("~/MuMuAINovel/backend/app/services/memory_service.py")
f = os.path.expanduser("~/mumulingsi/backend/app/services/memory_service.py")
with open(f) as fh:
c = fh.read()
@@ -150,11 +150,11 @@ python3 << 'PYEOF'
import os
home = os.path.expanduser("~")
files = [
f"{home}/MuMuAINovel/backend/app/api/chapters.py",
f"{home}/MuMuAINovel/backend/app/api/memories.py",
f"{home}/MuMuAINovel/backend/app/api/outlines.py",
f"{home}/MuMuAINovel/backend/app/api/projects.py",
f"{home}/MuMuAINovel/backend/app/services/foreshadow_service.py",
f"{home}/mumulingsi/backend/app/api/chapters.py",
f"{home}/mumulingsi/backend/app/api/memories.py",
f"{home}/mumulingsi/backend/app/api/outlines.py",
f"{home}/mumulingsi/backend/app/api/projects.py",
f"{home}/mumulingsi/backend/app/services/foreshadow_service.py",
]
old = 'from app.services.memory_service import memory_service'
new = 'try:\n from app.services.memory_service import memory_service\nexcept ImportError:\n memory_service = None'
@@ -173,25 +173,25 @@ PYEOF
mkdir -p "$DATA_DIR" "$LOG_DIR"
if [ ! -f "$BACKEND/.env" ]; then
cat > "$BACKEND/.env" << 'ENVEOF'
# MuMuAINovel Termux 配置
APP_NAME=MuMuAINovel
# 墨木灵思 Termux 配置
APP_NAME=墨木灵思
APP_HOST=0.0.0.0
APP_PORT=8000
DEBUG=false
TZ=Asia/Shanghai
# SQLite 数据库(替代 PostgreSQL
DATABASE_URL=sqlite+aiosqlite:///data/data/com.termux/files/home/mumuainovel/data/ai_story.db
DATABASE_URL=sqlite+aiosqlite:///data/data/com.termux/files/home/mumulingsi/data/ai_story.db
# 日志
LOG_LEVEL=INFO
LOG_TO_FILE=true
LOG_FILE_PATH=/data/data/com.termux/files/home/mumuainovel/logs/app.log
LOG_FILE_PATH=/data/data/com.termux/files/home/mumulingsi/logs/app.log
LOG_MAX_BYTES=10485760
LOG_BACKUP_COUNT=5
# CORS
CORS_ORIGINS=["http://localhost:8000","http://127.0.0.1:8000"]
CORS_ORIGINS=["http://localhost:3000","http://127.0.0.1:8000"]
# ⚠️ 请填入你的 API Key
OPENAI_API_KEY=***
@@ -295,12 +295,12 @@ grep -E "built in" "$LOG" | sed 's/^/ /'
# =============================================================================
# 步骤 9: 创建启动脚本
# 说明: 生成 ~/mumuainovel-start.sh,支持前台/后台运行
# 说明: 生成 ~/mumulingsi-start.sh,支持前台/后台运行
# =============================================================================
step 9 $TOTAL "创建启动脚本"
cat > "$HOME/mumuainovel-start.sh" << STARTEOF
cat > "$HOME/mumulingsi-start.sh" << STARTEOF
#!/bin/bash
# MuMuAINovel Termux 启动脚本
# 墨木灵思 Termux 启动脚本
set -e
BACKEND="$BACKEND"
@@ -313,44 +313,44 @@ export DATABASE_URL="sqlite+aiosqlite:///\$DATA_DIR/ai_story.db"
cd "\$BACKEND"
if [ "\$1" = "--bg" ]; then
echo "🚀 后台启动 MuMuAINovel (端口 8000)..."
echo "🚀 后台启动 墨木灵思 (端口 8000)..."
nohup "\$PYTHON" -m uvicorn app.main:app --host 0.0.0.0 --port 8000 \\
> "\$LOG_DIR/app.log" 2>&1 &
echo \$! > "$HOME/mumuainovel.pid"
echo \$! > "$HOME/mumulingsi.pid"
sleep 2
if kill -0 \$(cat "$HOME/mumuainovel.pid") 2>/dev/null; then
echo "✅ 已启动, PID: \$(cat $HOME/mumuainovel.pid)"
if kill -0 \$(cat "$HOME/mumulingsi.pid") 2>/dev/null; then
echo "✅ 已启动, PID: \$(cat $HOME/mumulingsi.pid)"
else
echo "❌ 启动失败,查看日志: \$LOG_DIR/app.log"
exit 1
fi
else
echo "🚀 启动 MuMuAINovel (端口 8000, Ctrl+C 停止)..."
echo "🚀 启动 墨木灵思 (端口 8000, Ctrl+C 停止)..."
exec "\$PYTHON" -m uvicorn app.main:app --host 0.0.0.0 --port 8000
fi
STARTEOF
chmod +x "$HOME/mumuainovel-start.sh"
info "启动脚本已创建: ~/mumuainovel-start.sh"
chmod +x "$HOME/mumulingsi-start.sh"
info "启动脚本已创建: ~/mumulingsi-start.sh"
# =============================================================================
# 安装完成
# =============================================================================
echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ 🎉 MuMuAINovel 安装完成! ║${NC}"
echo -e "${GREEN}║ 🎉 墨木灵思 安装完成! ║${NC}"
echo -e "${GREEN}╠══════════════════════════════════════════════╣${NC}"
echo -e "${GREEN}║ ║${NC}"
echo -e "${GREEN}║ 前台运行(Ctrl+C 停止): ║${NC}"
echo -e "${GREEN}║ bash ~/mumuainovel-start.sh ║${NC}"
echo -e "${GREEN}║ bash ~/mumulingsi-start.sh ║${NC}"
echo -e "${GREEN}║ ║${NC}"
echo -e "${GREEN}║ 后台运行: ║${NC}"
echo -e "${GREEN}║ bash ~/mumuainovel-start.sh --bg ║${NC}"
echo -e "${GREEN}║ bash ~/mumulingsi-start.sh --bg ║${NC}"
echo -e "${GREEN}║ ║${NC}"
echo -e "${GREEN}║ 停止后台: ║${NC}"
echo -e "${GREEN}║ kill \$(cat ~/mumuainovel.pid) ║${NC}"
echo -e "${GREEN}║ kill \$(cat ~/mumulingsi.pid) ║${NC}"
echo -e "${GREEN}║ ║${NC}"
echo -e "${GREEN}║ 查看日志: ║${NC}"
echo -e "${GREEN}║ tail -f ~/mumuainovel/logs/app.log ║${NC}"
echo -e "${GREEN}║ tail -f ~/mumulingsi/logs/app.log ║${NC}"
echo -e "${GREEN}║ ║${NC}"
echo -e "${GREEN}║ 🌐 访问: http://127.0.0.1:8000 ║${NC}"
echo -e "${GREEN}║ 🔑 账号: admin / admin123 ║${NC}"