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 .Python
env/ env/
venv/ venv/
.venv/
ENV/ ENV/
*.egg-info/ *.egg-info/
dist/ dist/
@@ -69,8 +70,8 @@ test/
frontend/dist/ frontend/dist/
frontend/build/ frontend/build/
# 后端静态文件(会从前端构建阶段复制) # 后端静态文件(将从宿主机复制)
backend/static/ # backend/static/
# 提示词工坊实例标识(每个容器需要独立生成) # 提示词工坊实例标识(每个容器需要独立生成)
backend/.instance_id backend/.instance_id
+1 -1
View File
@@ -7,7 +7,7 @@ on:
workflow_dispatch: # 允许手动触发 workflow_dispatch: # 允许手动触发
env: env:
DOCKER_IMAGE: mumujie/mumuainovel DOCKER_IMAGE: mumulingsi-project/mumulingsi
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs: jobs:
+5 -1
View File
@@ -81,6 +81,10 @@ backend/data/users.json
backend/data/admins.json backend/data/admins.json
backend/storage/ backend/storage/
# Project analysis / intermediate docs (do not commit)
.claude/
.work/
# Temporary files # Temporary files
*.bak *.bak
*.swp *.swp
@@ -108,7 +112,7 @@ dmypy.json
BUILD_GUIDE.md BUILD_GUIDE.md
launcher.py launcher.py
launcher.spec launcher.spec
mumuainovel.md xinmi.md
logo.ico logo.ico
.embed_cache .embed_cache
dist_embed/ 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 ARG USE_CN_MIRROR=false
# 阶段1: 构建前端 # 阶段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: 构建最终镜像
FROM python:3.11-slim FROM python:3.11-slim
ARG USE_CN_MIRROR ARG USE_CN_MIRROR
@@ -46,8 +16,8 @@ WORKDIR /app
# 根据参数决定是否使用国内镜像源 # 根据参数决定是否使用国内镜像源
RUN if [ "$USE_CN_MIRROR" = "true" ]; then \ 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/deb.debian.org/mirrors.tuna.tsinghua.edu.cn/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/security.debian.org/mirrors.tuna.tsinghua.edu.cn/g' /etc/apt/sources.list.d/debian.sources; \
fi fi
# 安装系统依赖(添加数据库工具) # 安装系统依赖(添加数据库工具)
@@ -61,8 +31,6 @@ RUN apt-get update && apt-get install -y \
COPY backend/requirements.txt ./ COPY backend/requirements.txt ./
# 安装 Python 依赖 # 安装 Python 依赖
# 先安装 torch CPU版本(~200MB vs 完整版~2GB,节省90%下载时间)
# 对于embedding场景,CPU版本完全够用
RUN if [ "$USE_CN_MIRROR" = "true" ]; then \ 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 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/; \ 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 ENV SENTENCE_TRANSFORMERS_HOME=/app/embedding
# 下载 embedding 模型(从 HuggingFace # 下载 embedding 模型(从 HuggingFace
# 使用 Python 脚本预下载模型,这样运行时不需要网络
RUN python -c "\ RUN python -c "\
from sentence_transformers import SentenceTransformer; \ from sentence_transformers import SentenceTransformer; \
import os; \ import os; \
@@ -88,11 +55,12 @@ model = SentenceTransformer('sentence-transformers/paraphrase-multilingual-MiniL
print('Model downloaded successfully!'); \ print('Model downloaded successfully!'); \
" "
# 复制后端代码(不包含embedding,因为已经下载了) # 复制后端代码
COPY backend/ ./ COPY backend/ ./
# 从前端构建阶段复制构建的静态文件 # 复制宿主机预构建的静态文件
COPY --from=frontend-builder /frontend/dist ./static # 这样可以避免 Docker 内部构建前端时的各种环境问题
COPY backend/static/ ./static
# 复制 Alembic 迁移配置和脚本(PostgreSQL # 复制 Alembic 迁移配置和脚本(PostgreSQL
COPY backend/alembic-postgres.ini ./alembic.ini 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/entrypoint.sh /app/entrypoint.sh
COPY backend/scripts/migrate.py ./scripts/migrate.py COPY backend/scripts/migrate.py ./scripts/migrate.py
# 赋予执行权限 # 修复 Windows CRLF 换行导致的启动失败,并赋予执行权限
RUN chmod +x /app/entrypoint.sh RUN sed -i 's/\r$//' /app/entrypoint.sh && chmod +x /app/entrypoint.sh
# 创建必要的目录 # 创建必要的目录
RUN mkdir -p /app/data /app/logs RUN mkdir -p /app/data /app/logs
@@ -114,7 +82,7 @@ ENV PYTHONUNBUFFERED=1
ENV APP_HOST=0.0.0.0 ENV APP_HOST=0.0.0.0
ENV APP_PORT=8000 ENV APP_PORT=8000
# 设置运行时为离线模式(模型已在构建时下载) # 设置运行时为离线模式
ENV TRANSFORMERS_OFFLINE=1 ENV TRANSFORMERS_OFFLINE=1
ENV HF_DATASETS_OFFLINE=1 ENV HF_DATASETS_OFFLINE=1
ENV HF_HUB_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 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1 CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
# 使用 entrypoint 脚本启动(自动执行迁移) # 使用 entrypoint 脚本启动
ENTRYPOINT ["/app/entrypoint.sh"] 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.8-blue.svg)
![Version](https://img.shields.io/badge/version-1.4.7-blue.svg)
![Python](https://img.shields.io/badge/python-3.11-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) ![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"> ## 3. 项目目录概览
## 💖 支持项目
如果这个项目对你有帮助,欢迎通过以下方式支持开发:
**[☕ 请我喝杯咖啡](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" # 宿主机:容器
```
## 📁 项目结构
``` ```
MuMuAINovel/ MuMuAINovel/
├── backend/ # 后端服务 ├── backend/ # 后端服务
│ ├── app/ │ ├── app/
│ │ ├── api/ # API 路由 │ │ ├── api/ # REST API 路由
│ │ ├── models/ # 数据模型 │ │ ├── models/ # 数据模型
│ │ ├── services/ # 业务逻辑 │ │ ├── services/ # 业务逻辑(含 AI 调用)
│ │ ├── middleware/ # 中间件 │ │ ├── middleware/ # 中间件
│ │ ├── database.py # 数据库连接 │ │ ├── mcp/ # MCP 插件集成
│ │ └── main.py # 应用入口 │ │ └── main.py # 应用入口
│ ├── scripts/ # 工具脚本 │ ├── alembic/ # 数据库迁移
── requirements.txt # Python 依赖 ── scripts/ # 初始化与运维脚本
│ ├── static/ # 前端构建产物(生产)
│ └── requirements.txt
├── frontend/ # 前端应用 ├── frontend/ # 前端应用
│ ├── src/ │ ├── src/
│ │ ├── pages/ # 页面组件 │ │ ├── pages/ # 页面
│ │ ├── components/ # 通用组件 │ │ ├── components/ # 通用组件
│ │ ├── services/ # API 服务 │ │ ├── services/ # API 封装
│ │ ── store/ # 状态管理 │ │ ── store/ # 状态管理
│ │ └── theme/ # 主题配置
│ └── package.json │ └── package.json
├── docker-compose.yml # Docker Compose 配置 ├── images/ # 文档与截图资源
├── Dockerfile # Docker 镜像构建 ├── storage/ # 用户生成资源(如封面)
├── logs/ # 运行日志
├── docker-compose.yml # 容器编排
├── Dockerfile # 镜像构建
└── README.md └── README.md
``` ```
## 🛠️ 技术栈 | 目录 | 作用 |
|------|------|
**后端**: FastAPI • PostgreSQL • SQLAlchemy • OpenAI/Claude/Gemini SDK | `backend/app/api/` | 项目、章节、角色、提示词等业务接口 |
| `backend/app/services/` | AI 生成、润色、向量记忆等核心逻辑 |
**前端**: React 18 • TypeScript • Ant Design • Zustand • Vite | `frontend/src/pages/` | 书架、项目详情、设置、提示词模板等页面 |
| `backend/alembic/` | PostgreSQL / SQLite schema 迁移 |
## 📖 使用指南 | `backend/scripts/` | 数据库初始化、入口脚本 |
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)
--- ---
<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" /> - 个人 IP 与世界观孵化
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=xiamuceer-j/MuMuAINovel&type=date&legend=top-left" />
</picture>
</a>
## 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 并修改配置值 # 复制此文件为 .env 并修改配置值
# cp .env.example .env # cp .env.example .env
@@ -7,8 +7,8 @@
# ========================================== # ==========================================
# 应用配置 # 应用配置
# ========================================== # ==========================================
APP_NAME=MuMuAINovel APP_NAME=墨木灵思
APP_VERSION=1.4.7 APP_VERSION=1.4.8
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8000 APP_PORT=8000
DEBUG=false DEBUG=false
@@ -19,13 +19,13 @@ TZ=Asia/Shanghai
# ========================================== # ==========================================
# PostgreSQL 连接信息 # PostgreSQL 连接信息
POSTGRES_DB=mumuai_novel POSTGRES_DB=mumulingsi_novel
POSTGRES_USER=mumuai POSTGRES_USER=mumulingsi
POSTGRES_PASSWORD=123456 POSTGRES_PASSWORD=123456
POSTGRES_PORT=5432 POSTGRES_PORT=5432
# 数据库连接 URL(Docker 部署时自动生成) # 数据库连接 URL(Docker 部署时自动生成)
# DATABASE_URL=postgresql+asyncpg://mumuai:123456@localhost:5432/mumuai_novel # DATABASE_URL=postgresql+asyncpg://mumulingsi:123456@localhost:5432/mumulingsi_novel
# ========================================== # ==========================================
# SQLite 数据库配置 # SQLite 数据库配置
@@ -45,7 +45,7 @@ LOG_BACKUP_COUNT=30
# ========================================== # ==========================================
# CORS 配置 # 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_TLS=false
SMTP_USE_SSL=true SMTP_USE_SSL=true
SMTP_FROM_EMAIL=your-email@qq.com SMTP_FROM_EMAIL=your-email@qq.com
SMTP_FROM_NAME=MuMuAINovel SMTP_FROM_NAME=墨木灵思
EMAIL_AUTH_ENABLED=true EMAIL_AUTH_ENABLED=true
EMAIL_REGISTER_ENABLED=true EMAIL_REGISTER_ENABLED=true
EMAIL_VERIFICATION_CODE_TTL_MINUTES=10 EMAIL_VERIFICATION_CODE_TTL_MINUTES=10
@@ -116,11 +116,11 @@ EMAIL_VERIFICATION_RESEND_INTERVAL_SECONDS=60
# 提示词工坊配置 # 提示词工坊配置
# ========================================== # ==========================================
# 运行模式:client(本地部署)或 server(云端服务器) # 运行模式:client(本地部署)或 server(云端服务器)
# 只有 mumuverse.space:1566 需要设置为 server # 云端服务配置示例
WORKSHOP_MODE=client WORKSHOP_MODE=client
# 云端服务地址(client 模式使用) # 云端服务地址(client 模式使用)
WORKSHOP_CLOUD_URL=https://mumuverse.space:1566 WORKSHOP_CLOUD_URL=
# 云端 API 请求超时时间(秒) # 云端 API 请求超时时间(秒)
WORKSHOP_API_TIMEOUT=30 WORKSHOP_API_TIMEOUT=30
+2 -2
View File
@@ -1,5 +1,5 @@
# Alembic Database Migration Profile - PostgreSQL # Alembic Database Migration Profile - PostgreSQL
# Database version management for the MuMuAINovel project # Database version management for the mumulingsi project
[alembic] [alembic]
# Migration Script storage directory (PostgreSQL) # 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 # Database connection string
# Note: The actual connection string is read from the environment variable in env.py # 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 # Log Configuration
[loggers] [loggers]
+1 -1
View File
@@ -1,5 +1,5 @@
# Alembic Database Migration Profile - SQLite # Alembic Database Migration Profile - SQLite
# Database version management for the MuMuAINovel project # Database version management for the mumulingsi project
[alembic] [alembic]
# Migration Script storage directory (SQLite) # Migration Script storage directory (SQLite)
+2 -2
View File
@@ -59,7 +59,7 @@ alembic -c alembic-postgres.ini current
#### 配置环境变量 #### 配置环境变量
```bash ```bash
# .env 文件 # .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 ```bash
# 切换到 SQLite # 切换到 SQLite
DATABASE_URL=sqlite+aiosqlite:///./data/mumuai.db DATABASE_URL=sqlite+aiosqlite:///./data/mumulingsi.db
alembic -c alembic-sqlite.ini upgrade head alembic -c alembic-sqlite.ini upgrade head
# 切换到 PostgreSQL # 切换到 PostgreSQL
+2 -1
View File
@@ -25,7 +25,8 @@ from app.models import (
Settings, WritingStyle, ProjectDefaultStyle, Settings, WritingStyle, ProjectDefaultStyle,
RelationshipType, CharacterRelationship, Organization, OrganizationMember, RelationshipType, CharacterRelationship, Organization, OrganizationMember,
StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask, StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask,
RegenerationTask, Career, CharacterCareer, User, MCPPlugin, PromptTemplate RegenerationTask, Career, CharacterCareer, User, MCPPlugin, PromptTemplate,
BackgroundTask
) )
# Alembic Config 对象 # 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_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_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_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_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('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='验证码有效期(分钟)')) 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, Settings, WritingStyle, ProjectDefaultStyle,
RelationshipType, CharacterRelationship, Organization, OrganizationMember, RelationshipType, CharacterRelationship, Organization, OrganizationMember,
StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask, StoryMemory, PlotAnalysis, AnalysisTask, BatchGenerationTask,
RegenerationTask, Career, CharacterCareer, User, MCPPlugin, PromptTemplate RegenerationTask, Career, CharacterCareer, User, MCPPlugin, PromptTemplate,
BackgroundTask
) )
# Alembic Config 对象 # 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_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_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_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_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('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='验证码有效期(分钟)')) 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('status_message', sa.String(), nullable=True),
sa.Column('progress_details', sa.Text(), nullable=True), sa.Column('progress_details', sa.Text(), nullable=True),
sa.Column('error_message', 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('task_result', sa.Text(), nullable=True),
sa.Column('cancel_requested', sa.Boolean(), default=False), sa.Column('cancel_requested', sa.Boolean(), default=False),
sa.Column('retry_count', sa.Integer(), default=0), 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": "重置密码验证码", "reset_password": "重置密码验证码",
} }
scene_desc_map = { scene_desc_map = {
"register": "欢迎注册 MuMuAINovel", "register": "欢迎注册 墨木灵思",
"login": "你正在使用邮箱验证码登录 MuMuAINovel", "login": "你正在使用邮箱验证码登录 墨木灵思",
"reset_password": "你正在重置 MuMuAINovel 账号密码。", "reset_password": "你正在重置 墨木灵思 账号密码。",
} }
scene_title = scene_title_map.get(scene, "邮箱验证码") scene_title = scene_title_map.get(scene, "邮箱验证码")
scene_desc = scene_desc_map.get(scene, "你正在进行邮箱身份验证。") scene_desc = scene_desc_map.get(scene, "你正在进行邮箱身份验证。")
subject = f"MuMuAINovel {scene_title}" subject = f"墨木灵思 {scene_title}"
text_body = ( text_body = (
f"{scene_desc}\n\n" f"{scene_desc}\n\n"
f"你的验证码是:{code}\n" f"你的验证码是:{code}\n"
@@ -307,7 +307,7 @@ def _build_verification_mail_content(scene: str, code: str, ttl_minutes: int) ->
) )
html_body = f""" html_body = f"""
<div style="font-family: Arial, PingFang SC, Microsoft YaHei, sans-serif; line-height: 1.8; color: #1f2937;"> <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>{scene_desc}</p>
<p>你的验证码为:</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;"> <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配置
GITHUB_API_BASE = "https://api.github.com" GITHUB_API_BASE = "https://api.github.com"
REPO_OWNER = "xiamuceer-j" REPO_OWNER = "mumulingsi-project"
REPO_NAME = "MuMuAINovel" REPO_NAME = "mumulingsi"
# 缓存配置 # 缓存配置
_cache = { _cache = {
@@ -88,7 +88,7 @@ async def fetch_github_commits(page: int = 1, per_page: int = 30) -> List[dict]:
headers = { headers = {
"Accept": "application/vnd.github.v3+json", "Accept": "application/vnd.github.v3+json",
"User-Agent": "MuMuAINovel-App" "User-Agent": "mumulingsi-App"
} }
try: 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.foreshadow_service import foreshadow_service
from app.services.chapter_regenerator import ChapterRegenerator from app.services.chapter_regenerator import ChapterRegenerator
from app.logger import get_logger 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 from app.utils.sse_response import SSEResponse, create_sse_response
router = APIRouter(prefix="/chapters", tags=["章节管理"]) router = APIRouter(prefix="/chapters", tags=["章节管理"])
@@ -797,7 +797,7 @@ async def analyze_chapter_background(
user_id: str, user_id: str,
project_id: str, project_id: str,
task_id: str, task_id: str,
ai_service: AIService ai_service: Optional[AIService] = None
) -> bool: ) -> bool:
""" """
后台异步分析章节(支持并发,使用锁保护数据库写入) 后台异步分析章节(支持并发,使用锁保护数据库写入)
@@ -865,6 +865,13 @@ async def analyze_chapter_background(
task.progress = 20 task.progress = 20
await db_session.commit() 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( existing_foreshadows = await foreshadow_service.get_planted_foreshadows_for_analysis(
db=db_session, db=db_session,
@@ -2229,8 +2236,7 @@ async def _run_chapter_generation_bg(
chapter_id=chapter_id, chapter_id=chapter_id,
user_id=user_id, user_id=user_id,
project_id=current_chapter.project_id, project_id=current_chapter.project_id,
task_id=analysis_task.id, task_id=analysis_task.id
ai_service=ai_service
) )
) )
@@ -2441,7 +2447,7 @@ async def _run_batch_analysis_in_sequence(
tasks_queue: list[dict[str, int | str]], tasks_queue: list[dict[str, int | str]],
user_id: str, user_id: str,
project_id: str, project_id: str,
ai_service: AIService ai_service: Optional[AIService] = None
) -> None: ) -> None:
"""按章节顺序逐个执行分析任务。""" """按章节顺序逐个执行分析任务。"""
for index, task_item in enumerate(tasks_queue, start=1): for index, task_item in enumerate(tasks_queue, start=1):
@@ -2477,8 +2483,7 @@ async def batch_analyze_unanalyzed_chapters(
project_id: str, project_id: str,
payload: BatchAnalyzeUnanalyzedRequest, payload: BatchAnalyzeUnanalyzedRequest,
request: Request, request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db)
user_ai_service: AIService = Depends(get_user_ai_service)
): ):
"""自动识别项目中未完成分析的章节,并按章节顺序逐个启动分析。""" """自动识别项目中未完成分析的章节,并按章节顺序逐个启动分析。"""
user_id = getattr(request.state, "user_id", None) user_id = getattr(request.state, "user_id", None)
@@ -2585,8 +2590,7 @@ async def batch_analyze_unanalyzed_chapters(
_run_batch_analysis_in_sequence( _run_batch_analysis_in_sequence(
tasks_queue=tasks_queue, tasks_queue=tasks_queue,
user_id=user_id, user_id=user_id,
project_id=project_id, project_id=project_id
ai_service=user_ai_service
) )
) )
@@ -2818,8 +2822,7 @@ async def trigger_chapter_analysis(
chapter_id: str, chapter_id: str,
request: Request, request: Request,
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db)
user_ai_service: AIService = Depends(get_user_ai_service)
): ):
""" """
手动触发章节分析(用于重新分析或分析旧章节) 手动触发章节分析(用于重新分析或分析旧章节)
@@ -2876,8 +2879,7 @@ async def trigger_chapter_analysis(
chapter_id=chapter_id, chapter_id=chapter_id,
user_id=user_id, user_id=user_id,
project_id=project.id, project_id=project.id,
task_id=task_id, task_id=task_id
ai_service=user_ai_service
) )
return { return {
@@ -3274,8 +3276,7 @@ async def execute_batch_generation_in_order(
chapter_id=chapter_id, chapter_id=chapter_id,
user_id=user_id, user_id=user_id,
project_id=task.project_id, project_id=task.project_id,
task_id=analysis_task.id, task_id=analysis_task.id
ai_service=ai_service
) )
# 直接根据返回值判断 # 直接根据返回值判断
+355
View File
@@ -2451,6 +2451,284 @@ async def expand_outline_generator(
yield await tracker.error(f"展开失败: {str(e)}") 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="一对一创建章节(传统模式)") @router.post("/{outline_id}/create-single-chapter", summary="一对一创建章节(传统模式)")
async def create_single_chapter_from_outline( async def create_single_chapter_from_outline(
outline_id: str, outline_id: str,
@@ -2549,6 +2827,48 @@ async def create_single_chapter_from_outline(
raise HTTPException(status_code=500, detail=f"创建章节失败: {str(e)}") 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流式)") @router.post("/{outline_id}/expand-stream", summary="展开单个大纲为多章(SSE流式)")
async def expand_outline_to_chapters_stream( async def expand_outline_to_chapters_stream(
outline_id: str, outline_id: str,
@@ -2901,6 +3221,41 @@ async def batch_expand_outlines_generator(
yield await SSEResponse.send_error(f"批量展开失败: {str(e)}") 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流式)") @router.post("/batch-expand-stream", summary="批量展开大纲为多章(SSE流式)")
async def batch_expand_outlines_stream( async def batch_expand_outlines_stream(
data: Dict[str, Any], data: Dict[str, Any],
+121 -6
View File
@@ -19,6 +19,7 @@ from app.schemas.settings import (
SettingsCreate, SettingsUpdate, SettingsResponse, SettingsCreate, SettingsUpdate, SettingsResponse,
APIKeyPreset, APIKeyPresetConfig, PresetCreateRequest, APIKeyPreset, APIKeyPresetConfig, PresetCreateRequest,
PresetUpdateRequest, PresetResponse, PresetListResponse, PresetUpdateRequest, PresetResponse, PresetListResponse,
ChapterAnalysisPresetSelectionRequest,
SystemSMTPSettingsResponse, SystemSMTPSettingsUpdate, SMTPTestRequest SystemSMTPSettingsResponse, SystemSMTPSettingsUpdate, SMTPTestRequest
) )
from app.user_manager import User 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): def require_login(request: Request):
"""依赖:要求用户已登录""" """依赖:要求用户已登录"""
if not hasattr(request.state, "user") or not request.state.user: 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 从数据库直接创建用户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 from app.models.mcp_plugin import MCPPlugin
result = await db.execute( 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() mcp_plugins = mcp_result.scalars().all()
enable_mcp = any(plugin.enabled for plugin in mcp_plugins) if mcp_plugins else False 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( return create_user_ai_service_with_mcp(
api_provider=settings.api_provider, api_provider=settings.api_provider,
api_key=settings.api_key, api_key=settings.api_key,
@@ -310,9 +384,9 @@ async def test_system_smtp_settings(
if not from_email: if not from_email:
raise HTTPException(status_code=400, detail="请先配置发件人邮箱或 SMTP 用户名") raise HTTPException(status_code=400, detail="请先配置发件人邮箱或 SMTP 用户名")
subject = "MuMuAINovel SMTP 测试邮件" subject = "墨木灵思 SMTP 测试邮件"
text_body = ( text_body = (
"这是一封来自 MuMuAINovel 系统设置页面的 SMTP 测试邮件。\n\n" "这是一封来自 墨木灵思 系统设置页面的 SMTP 测试邮件。\n\n"
f"发送时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n" f"发送时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
f"SMTP 服务商:{settings.smtp_provider}\n" f"SMTP 服务商:{settings.smtp_provider}\n"
f"SMTP 主机:{settings.smtp_host}:{settings.smtp_port}\n" f"SMTP 主机:{settings.smtp_host}:{settings.smtp_port}\n"
@@ -320,7 +394,7 @@ async def test_system_smtp_settings(
) )
html_body = f""" html_body = f"""
<div style=\"font-family: Arial, sans-serif; line-height: 1.7; color: #1f1f1f;\"> <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> <p>这是一封来自系统设置页面的 SMTP 测试邮件</p>
<ul> <ul>
<li><strong>发送时间</strong>{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</li> <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格式错误,重置为空") logger.warning(f"用户 {user.user_id} 的preferences字段JSON格式错误,重置为空")
prefs = {} prefs = {}
api_presets = prefs.get('api_presets', {'presets': [], 'version': '1.0'}) api_presets = _get_api_presets_payload(prefs)
presets = api_presets.get('presets', []) 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( active_preset_id = next(
@@ -1057,7 +1134,8 @@ async def get_presets(
return { return {
"presets": presets, "presets": presets,
"total": len(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: except json.JSONDecodeError:
raise HTTPException(status_code=500, detail="配置数据格式错误") 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', []) presets = api_presets.get('presets', [])
# 找到预设 # 找到预设
@@ -1191,6 +1269,8 @@ async def delete_preset(
# 删除预设 # 删除预设
presets = [p for p in presets if p['id'] != preset_id] 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 # 保存回preferences
api_presets['presets'] = presets 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") @router.post("/presets/{preset_id}/test")
async def test_preset( async def test_preset(
preset_id: str, preset_id: str,
+109 -11
View File
@@ -1,10 +1,12 @@
"""后台任务API - 查询状态、取消任务""" """后台任务API - 查询状态、取消任务"""
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional from typing import Optional
from app.database import get_db from app.database import get_db
from app.models.background_task import BackgroundTask 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.services.background_task_service import background_task_service
from app.logger import get_logger from app.logger import get_logger
@@ -54,17 +56,17 @@ async def get_tasks(
limit: int = 20, limit: int = 20,
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""获取项目的后台任务列表""" """获取项目的后台任务列表(合并 BackgroundTask 和 BatchGenerationTask"""
user_id = getattr(request.state, 'user_id', None) user_id = getattr(request.state, 'user_id', None)
if not user_id: if not user_id:
raise HTTPException(status_code=401, detail="未登录") 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 project_id, user_id, db, task_type=task_type, limit=limit
) )
return { items = [
"items": [
{ {
"id": t.id, "id": t.id,
"task_type": t.task_type, "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, "created_at": t.created_at.isoformat() if t.created_at else None,
"completed_at": t.completed_at.isoformat() if t.completed_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="取消任务") @router.post("/{task_id}/cancel", summary="取消任务")
@@ -105,18 +151,70 @@ async def delete_task(
request: Request, request: Request,
db: AsyncSession = Depends(get_db) db: AsyncSession = Depends(get_db)
): ):
"""删除已完成/失败的任务记录""" """删除已完成/失败的任务记录(支持 BackgroundTask 和 BatchGenerationTask"""
user_id = getattr(request.state, 'user_id', None) user_id = getattr(request.state, 'user_id', None)
if not user_id: if not user_id:
raise HTTPException(status_code=401, detail="未登录") raise HTTPException(status_code=401, detail="未登录")
# 先尝试从 BackgroundTask 查找
task = await background_task_service.get_task(task_id, user_id, db) task = await background_task_service.get_task(task_id, user_id, db)
if not task: if task:
raise HTTPException(status_code=404, detail="任务不存在")
if task.status in ("pending", "running"): if task.status in ("pending", "running"):
raise HTTPException(status_code=400, detail="无法删除进行中的任务,请先取消") raise HTTPException(status_code=400, detail="无法删除进行中的任务,请先取消")
await db.delete(task) await db.delete(task)
await db.commit() await db.commit()
return {"message": "任务记录已删除"} 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 # 数据库配置:PostgreSQL
# 从环境变量获取数据库URL # 从环境变量获取数据库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"数据库类型: PostgreSQL")
config_logger.debug(f"数据库URL: {DATABASE_URL}") 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_version: str = "1.0.0"
app_host: str = "0.0.0.0" app_host: str = "0.0.0.0"
app_port: int = 8000 app_port: int = 8000
@@ -39,7 +39,7 @@ class Settings(BaseSettings):
log_backup_count: int = 30 # 保留30个备份文件 log_backup_count: int = 30 # 保留30个备份文件
# CORS配置 # 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 # 数据库配置 - PostgreSQL
database_url: str = DATABASE_URL database_url: str = DATABASE_URL
@@ -118,7 +118,7 @@ class Settings(BaseSettings):
SMTP_USE_TLS: bool = False SMTP_USE_TLS: bool = False
SMTP_USE_SSL: bool = True SMTP_USE_SSL: bool = True
SMTP_FROM_EMAIL: Optional[str] = None SMTP_FROM_EMAIL: Optional[str] = None
SMTP_FROM_NAME: str = "MuMuAINovel" SMTP_FROM_NAME: str = "墨木灵思"
EMAIL_AUTH_ENABLED: bool = True EMAIL_AUTH_ENABLED: bool = True
EMAIL_REGISTER_ENABLED: bool = True EMAIL_REGISTER_ENABLED: bool = True
EMAIL_VERIFICATION_CODE_TTL_MINUTES: int = 10 EMAIL_VERIFICATION_CODE_TTL_MINUTES: int = 10
@@ -126,7 +126,7 @@ class Settings(BaseSettings):
# 提示词工坊配置 # 提示词工坊配置
WORKSHOP_MODE: str = "client" # client: 本地部署实例, server: 云端中央服务器 WORKSHOP_MODE: str = "client" # client: 本地部署实例, server: 云端中央服务器
WORKSHOP_CLOUD_URL: str = "https://mumuverse.space:1566" # 云端服务地址 WORKSHOP_CLOUD_URL: str = "" # 云端服务地址
WORKSHOP_API_TIMEOUT: int = 30 # 云端API请求超时时间(秒) WORKSHOP_API_TIMEOUT: int = 30 # 云端API请求超时时间(秒)
class Config: class Config:
+5 -2
View File
@@ -44,9 +44,12 @@ class UvicornFormatter(logging.Formatter):
request_id = getattr(record, 'request_id', None) request_id = getattr(record, 'request_id', None)
request_id_str = f" [{request_id}]" if request_id else "" 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个空格,保持对齐 # 注意: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 ( from app.api import (
projects, outlines, characters, chapters, projects, outlines, characters, chapters,
wizard_stream, relationships, organizations, wizard_stream, relationships, organizations,
@@ -220,7 +226,7 @@ else:
@app.get("/") @app.get("/")
async def root(): async def root():
return { return {
"message": "欢迎使用MuMuAINovel", "message": "欢迎使用墨木灵思",
"version": config_settings.app_version, "version": config_settings.app_version,
"docs": "/docs", "docs": "/docs",
"notice": "请先构建前端: cd frontend && npm run build" "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_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_use_ssl = Column(Boolean, default=True, server_default="1", nullable=False, comment="是否启用 SSL")
smtp_from_email = Column(String(255), comment="发件人邮箱") 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_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="是否启用邮箱注册") 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="验证码有效期(分钟)") 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_tls: bool = Field(default=False, description="是否启用 TLS")
smtp_use_ssl: bool = Field(default=True, description="是否启用 SSL") smtp_use_ssl: bool = Field(default=True, description="是否启用 SSL")
smtp_from_email: Optional[str] = Field(default=None, description="发件人邮箱") 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_auth_enabled: bool = Field(default=True, description="是否启用邮箱认证")
email_register_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="验证码有效期(分钟)") 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="预设列表") presets: List[PresetResponse] = Field(..., description="预设列表")
total: int = Field(..., description="总数") total: int = Field(..., description="总数")
active_preset_id: Optional[str] = Field(None, description="当前激活的预设ID") 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 or settings.openai_api_key
) )
if not secret: if not secret:
secret = "mumuainovel-development-session-secret" secret = "mumulingsi-development-session-secret"
return str(secret).encode("utf-8") 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]: def normalize_provider(provider: Optional[str]) -> Optional[str]:
"""标准化 provider 名称,兼容渠道别名。""" """标准化 provider 名称,兼容渠道别名。"""
if provider == "mumu": if provider == "xinmi":
return "openai" return "openai"
return provider return provider
@@ -225,11 +225,11 @@ class CoverGenerationService:
return GeminiCoverProvider(api_key=api_key, base_url=normalized_base_url) return GeminiCoverProvider(api_key=api_key, base_url=normalized_base_url)
if provider_value == "grok": if provider_value == "grok":
return GrokCoverProvider(api_key=api_key, base_url=normalized_base_url) 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"): if normalized_base_url.endswith("/v1beta"):
return GeminiCoverProvider(api_key=api_key, base_url=normalized_base_url) 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") return GrokCoverProvider(api_key=api_key, base_url=normalized_base_url or "v1")
raise HTTPException(status_code=400, detail="当前版本仅支持 Gemini、Grok 或 MuMuのAPI 作为封面图片 Provider") raise HTTPException(status_code=400, detail="当前版本仅支持 Gemini、Grok 或 墨木灵思 API 作为封面图片 Provider")
def _save_cover_file( def _save_cover_file(
self, self,
+7 -4
View File
@@ -11,15 +11,18 @@ class WritingStyleManager:
""" """
将写作风格应用到基础提示词中 将写作风格应用到基础提示词中
注意写作风格已通过 system_prompt 注入system_prompt_with_style
此方法仅追加输出指令不再重复注入 style_content避免风格信息被注入两次
Args: Args:
base_prompt: 基础提示词 base_prompt: 基础提示词
style_content: 风格要求内容 style_content: 风格要求内容已通过 system_prompt 注入此处不使用
Returns: Returns:
组合后的提示词 追加输出指令后的提示词
""" """
# 在基础提示词末尾添加风格要求 # 写作风格已在 system_prompt 中注入,此处只追加输出格式指令
return f"{base_prompt}\n\n{style_content}\n\n请直接输出章节正文内容,不要包含章节标题和其他说明文字。" return f"{base_prompt}\n\n请直接输出章节正文内容,不要包含章节标题和其他说明文字。"
class PromptService: class PromptService:
+4 -4
View File
@@ -1,4 +1,4 @@
#!/bin/bash #!/bin/sh
# Docker 容器启动入口脚本 # Docker 容器启动入口脚本
# 功能:等待数据库就绪,执行迁移,启动应用 # 功能:等待数据库就绪,执行迁移,启动应用
@@ -17,7 +17,7 @@ if [ -z "$APP_NAME" ]; then
if [ -f "/app/.env.example" ]; then if [ -f "/app/.env.example" ]; then
APP_NAME=$(grep "^APP_NAME=" /app/.env.example | cut -d '=' -f2) APP_NAME=$(grep "^APP_NAME=" /app/.env.example | cut -d '=' -f2)
fi fi
APP_NAME="${APP_NAME:-MuMuAINovel}" APP_NAME="${APP_NAME:-墨木灵思}"
fi fi
BUILD_TIME=$(date '+%Y-%m-%d %H:%M:%S') BUILD_TIME=$(date '+%Y-%m-%d %H:%M:%S')
@@ -31,8 +31,8 @@ echo "================================================"
# 数据库配置(从环境变量读取) # 数据库配置(从环境变量读取)
DB_HOST="${DB_HOST:-postgres}" DB_HOST="${DB_HOST:-postgres}"
DB_PORT="${DB_PORT:-5432}" DB_PORT="${DB_PORT:-5432}"
DB_USER="${POSTGRES_USER:-mumuai}" DB_USER="${POSTGRES_USER:-mumulingsi}"
DB_NAME="${POSTGRES_DB:-mumuai_novel}" DB_NAME="${POSTGRES_DB:-mumulingsi_novel}"
# 等待数据库就绪 # 等待数据库就绪
echo "⏳ 等待数据库启动..." echo "⏳ 等待数据库启动..."
+1 -1
View File
@@ -8,7 +8,7 @@ CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- 模糊搜索和全文检索支
DO $$ DO $$
BEGIN BEGIN
RAISE NOTICE '=================================================='; RAISE NOTICE '==================================================';
RAISE NOTICE 'MuMuAINovel PostgreSQL 扩展安装完成'; RAISE NOTICE 'mumulingsi PostgreSQL 扩展安装完成';
RAISE NOTICE '已安装扩展:'; RAISE NOTICE '已安装扩展:';
RAISE NOTICE ' - uuid-ossp: UUID生成支持'; RAISE NOTICE ' - uuid-ossp: UUID生成支持';
RAISE NOTICE ' - pg_trgm: 模糊搜索和全文检索支持'; RAISE NOTICE ' - pg_trgm: 模糊搜索和全文检索支持';
+5 -5
View File
@@ -56,8 +56,8 @@ class PostgreSQLSetup:
port: int = 5432, port: int = 5432,
admin_user: str = "postgres", admin_user: str = "postgres",
admin_password: str = None, admin_password: str = None,
db_name: str = "mumuai_novel", db_name: str = "xinmi_novel",
db_user: str = "mumuai", db_user: str = "mumulingsi",
db_password: str = "123456" db_password: str = "123456"
): ):
""" """
@@ -374,9 +374,9 @@ async def main():
admin_password = getpass(f"管理员密码: ") admin_password = getpass(f"管理员密码: ")
print("\n请输入要创建的数据库信息:\n") print("\n请输入要创建的数据库信息:\n")
db_name = input("数据库名 [mumuai_novel]: ").strip() or "mumuai_novel" db_name = input("数据库名 [xinmi_novel]: ").strip() or "xinmi_novel"
db_user = input("数据库用户名 [mumuai]: ").strip() or "mumuai" db_user = input("数据库用户名 [mumulingsi]: ").strip() or "mumulingsi"
db_password = getpass("数据库用户密码 [mumuai123]: ") or "mumuai123" db_password = getpass("数据库用户密码 [xinmi123]: ") or "xinmi123"
print(f"\n{'='*60}") print(f"\n{'='*60}")
print(f"配置摘要:") print(f"配置摘要:")
+17 -10
View File
@@ -1,10 +1,10 @@
services: services:
postgres: postgres:
image: postgres:18-alpine image: postgres:18-alpine
container_name: mumuainovel-postgres container_name: mumulingsi-postgres
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-mumuai_novel} POSTGRES_DB: ${POSTGRES_DB:-mumulingsi_novel}
POSTGRES_USER: ${POSTGRES_USER:-mumuai} POSTGRES_USER: ${POSTGRES_USER:-mumulingsi}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-123456} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-123456}
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C" POSTGRES_INITDB_ARGS: "--encoding=UTF8 --locale=C"
TZ: ${TZ:-Asia/Shanghai} TZ: ${TZ:-Asia/Shanghai}
@@ -15,7 +15,7 @@ services:
- "${POSTGRES_PORT:-5432}:5432" - "${POSTGRES_PORT:-5432}:5432"
restart: unless-stopped restart: unless-stopped
healthcheck: 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 interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -49,12 +49,14 @@ services:
- -c - -c
- max_wal_size=${POSTGRES_MAX_WAL_SIZE:-4GB} - max_wal_size=${POSTGRES_MAX_WAL_SIZE:-4GB}
mumuainovel: mumulingsi:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: mumujie/mumuainovel:latest args:
container_name: mumuainovel - USE_CN_MIRROR=${USE_CN_MIRROR:-false}
image: mumulingsi-project/mumulingsi:latest
container_name: mumulingsi
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -65,19 +67,24 @@ services:
- ./storage/generated_covers:/app/storage/generated_covers - ./storage/generated_covers:/app/storage/generated_covers
- ./.env:/app/.env:ro - ./.env:/app/.env:ro
environment: environment:
# 时区配置
- TZ=${TZ:-Asia/Shanghai}
# 应用配置 # 应用配置
- APP_NAME=${APP_NAME:-MuMuAINovel} - APP_NAME=${APP_NAME:-墨木灵思}
- APP_VERSION=${APP_VERSION:-1.0.0} - APP_VERSION=${APP_VERSION:-1.0.0}
- APP_HOST=${APP_HOST:-0.0.0.0} - APP_HOST=${APP_HOST:-0.0.0.0}
- APP_PORT=8000 - APP_PORT=8000
- DEBUG=${DEBUG:-false} - 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_HOST=postgres
- DB_PORT=5432 - DB_PORT=5432
- POSTGRES_USER=${POSTGRES_USER:-mumulingsi}
- POSTGRES_DB=${POSTGRES_DB:-mumulingsi_novel}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-123456} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-123456}
# PostgreSQL 连接池配置 # 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" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/logo.svg" /> <link rel="icon" type="image/svg+xml" href="/logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+37 -34
View File
@@ -1,12 +1,12 @@
{ {
"name": "frontend", "name": "frontend",
"version": "1.4.4", "version": "1.4.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "frontend",
"version": "1.4.4", "version": "1.4.8",
"dependencies": { "dependencies": {
"@ant-design/icons": "^5.6.1", "@ant-design/icons": "^5.6.1",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@@ -2165,9 +2165,9 @@
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.0.2", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2461,14 +2461,14 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.13.6", "version": "1.15.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.15.11", "follow-redirects": "^1.15.11",
"form-data": "^4.0.5", "form-data": "^4.0.5",
"proxy-from-env": "^1.1.0" "proxy-from-env": "^2.1.0"
} }
}, },
"node_modules/babel-plugin-macros": { "node_modules/babel-plugin-macros": {
@@ -2504,9 +2504,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "1.1.12", "version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2708,9 +2708,9 @@
} }
}, },
"node_modules/cosmiconfig/node_modules/yaml": { "node_modules/cosmiconfig/node_modules/yaml": {
"version": "1.10.2", "version": "1.10.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz",
"integrity": "sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==", "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==",
"license": "ISC", "license": "ISC",
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@@ -3313,9 +3313,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/follow-redirects": { "node_modules/follow-redirects": {
"version": "1.15.11", "version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [ "funding": [
{ {
"type": "individual", "type": "individual",
@@ -3729,9 +3729,9 @@
} }
}, },
"node_modules/lodash": { "node_modules/lodash": {
"version": "4.17.23", "version": "4.18.1",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.23.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash.merge": { "node_modules/lodash.merge": {
@@ -3976,9 +3976,9 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.3", "version": "4.0.4",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3989,9 +3989,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.12",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -4045,10 +4045,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/proxy-from-env": { "node_modules/proxy-from-env": {
"version": "1.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT" "license": "MIT",
"engines": {
"node": ">=10"
}
}, },
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
@@ -5125,9 +5128,9 @@
} }
}, },
"node_modules/vite": { "node_modules/vite": {
"version": "7.3.1", "version": "7.3.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.4.7", "version": "1.4.8",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+2 -3
View File
@@ -21,19 +21,17 @@ import MCPPlugins from './pages/MCPPlugins';
import UserManagement from './pages/UserManagement'; import UserManagement from './pages/UserManagement';
import PromptTemplates from './pages/PromptTemplates'; import PromptTemplates from './pages/PromptTemplates';
import Sponsor from './pages/Sponsor'; import Sponsor from './pages/Sponsor';
import About from './pages/About';
// import Polish from './pages/Polish'; // import Polish from './pages/Polish';
import Login from './pages/Login'; import Login from './pages/Login';
import AuthCallback from './pages/AuthCallback'; import AuthCallback from './pages/AuthCallback';
import ProtectedRoute from './components/ProtectedRoute'; import ProtectedRoute from './components/ProtectedRoute';
import AppFooter from './components/AppFooter'; import AppFooter from './components/AppFooter';
import SpringFestival from './components/SpringFestival';
import './App.css'; import './App.css';
function App() { function App() {
return ( return (
<> <>
{/* 🧧 春节喜庆装饰 */}
<SpringFestival />
<BrowserRouter <BrowserRouter
future={{ future={{
v7_startTransition: true, v7_startTransition: true,
@@ -68,6 +66,7 @@ function App() {
<Route path="writing-styles" element={<WritingStyles />} /> <Route path="writing-styles" element={<WritingStyles />} />
<Route path="prompt-workshop" element={<PromptWorkshop />} /> <Route path="prompt-workshop" element={<PromptWorkshop />} />
<Route path="sponsor" element={<Sponsor />} /> <Route path="sponsor" element={<Sponsor />} />
<Route path="about" element={<About />} />
{/* <Route path="polish" element={<Polish />} /> */} {/* <Route path="polish" element={<Polish />} /> */}
</Route> </Route>
</Routes> </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 { useState, useEffect } from 'react';
import { Typography, Space, Divider, Badge, Button, Grid, theme } from 'antd'; import { Typography, Divider, Badge, Grid, theme } from 'antd';
import { GithubOutlined, CopyrightOutlined, HeartFilled, ClockCircleOutlined, GiftOutlined } from '@ant-design/icons'; import { ClockCircleOutlined, CopyrightOutlined } from '@ant-design/icons';
import { VERSION_INFO, getVersionString } from '../config/version'; import { VERSION_INFO, getVersionString } from '../config/version';
import { checkLatestVersion } from '../services/versionService'; 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)`; const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`;
useEffect(() => { useEffect(() => {
// 检查版本更新(每次都重新检查)
const checkVersion = async () => { const checkVersion = async () => {
try { try {
const result = await checkLatestVersion(); const result = await checkLatestVersion();
@@ -33,19 +32,16 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
} }
}; };
// 延迟3秒后检查,避免影响首次加载
const timer = setTimeout(checkVersion, 3000); const timer = setTimeout(checkVersion, 3000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, []); }, []);
// 点击版本号查看更新
const handleVersionClick = () => { const handleVersionClick = () => {
if (hasUpdate && releaseUrl) { if (hasUpdate && releaseUrl) {
window.open(releaseUrl, '_blank'); window.open(releaseUrl, '_blank');
} }
}; };
// 计算左边距:桌面端有侧边栏时需要偏移
const leftOffset = isMobile ? 0 : sidebarWidth; const leftOffset = isMobile ? 0 : sidebarWidth;
return ( return (
@@ -61,8 +57,8 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
padding: isMobile ? '8px 12px' : '10px 16px', padding: isMobile ? '8px 12px' : '10px 16px',
zIndex: 100, zIndex: 100,
boxShadow: `0 -2px 16px ${alphaColor(token.colorText, 0.08)}`, boxShadow: `0 -2px 16px ${alphaColor(token.colorText, 0.08)}`,
backgroundColor: alphaColor(token.colorBgContainer, 0.82), // 半透明背景以支持 backdrop-filter backgroundColor: alphaColor(token.colorBgContainer, 0.82),
transition: 'left 0.3s ease', // 平滑过渡 transition: 'left 0.3s ease',
}} }}
> >
<div <div
@@ -70,87 +66,11 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
maxWidth: 1400, maxWidth: 1400,
margin: '0 auto', margin: '0 auto',
textAlign: 'center', textAlign: 'center',
}}
>
{isMobile ? (
// 移动端:紧凑单行布局
<div style={{
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
gap: 8, gap: isMobile ? 8 : 16,
flexWrap: 'wrap' 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 <Text
onClick={handleVersionClick} onClick={handleVersionClick}
style={{ style={{
fontSize: 12, fontSize: isMobile ? 11 : 12,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 6, gap: 6,
color: token.colorTextSecondary, color: token.colorTextSecondary,
textShadow: 'none',
cursor: hasUpdate ? 'pointer' : 'default', 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},点击查看` : '当前版本'} title={hasUpdate ? `发现新版本 v${latestVersion},点击查看` : '当前版本'}
> >
@@ -184,65 +92,7 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
</Text> </Text>
</Badge> </Badge>
{/* GitHub 链接 */} <Divider type="vertical" style={{ borderColor: token.colorBorder }} />
<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>
{/* 许可证 */} {/* 许可证 */}
<Link <Link
@@ -250,7 +100,7 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
style={{ style={{
fontSize: 12, fontSize: isMobile ? 11 : 12,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 6, gap: 6,
@@ -261,39 +111,22 @@ export default function AppFooter({ sidebarWidth = 0 }: AppFooterProps) {
<span>{VERSION_INFO.license}</span> <span>{VERSION_INFO.license}</span>
</Link> </Link>
<Divider type="vertical" style={{ borderColor: token.colorBorder }} />
{/* 更新时间 */} {/* 更新时间 */}
<Text <Text
style={{ style={{
fontSize: 12, fontSize: isMobile ? 10 : 12,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 4, gap: 4,
color: token.colorTextTertiary, color: token.colorTextTertiary,
}} }}
> >
<ClockCircleOutlined style={{ fontSize: 12 }} /> <ClockCircleOutlined style={{ fontSize: isMobile ? 10 : 12 }} />
<span>{VERSION_INFO.buildTime}</span> <span>{VERSION_INFO.buildTime}</span>
</Text> </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>
</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) inset 0 1px 0 color-mix(in srgb, var(--ant-color-bg-container) 90%, transparent)
`; `;
const promptTemplateBaseShadow = ` const promptTemplateBaseShadow = '4px 4px 0 color-mix(in srgb, var(--ant-color-text) 10%, transparent)';
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
`;
// BookshelfPage 样式(书架/书本卡片) // BookshelfPage 样式(书架/书本卡片)
export const bookshelfCardStyles = { export const bookshelfCardStyles = {
@@ -107,28 +104,25 @@ export const bookshelfCardHoverHandlers = {
export const promptTemplateCardStyles = { export const promptTemplateCardStyles = {
templateCard: { templateCard: {
height: '100%', height: '100%',
borderRadius: 14, borderRadius: 2,
overflow: 'hidden', overflow: 'hidden',
border: '1px solid color-mix(in srgb, var(--ant-color-text) 8%, transparent)', border: '1px solid color-mix(in srgb, var(--ant-color-text) 14%, 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%)', background: 'var(--ant-color-bg-container)',
boxShadow: promptTemplateBaseShadow, 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, } as CSSProperties,
}; };
export const promptTemplateCardHoverHandlers = { export const promptTemplateCardHoverHandlers = {
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => { onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.currentTarget; const target = e.currentTarget;
target.style.transform = 'translateY(-6px)'; target.style.transform = 'translate(-2px, -2px)';
target.style.boxShadow = ` target.style.boxShadow = '6px 6px 0 color-mix(in srgb, var(--ant-color-primary) 25%, transparent)';
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.borderColor = 'color-mix(in srgb, var(--ant-color-primary) 24%, transparent)'; target.style.borderColor = 'color-mix(in srgb, var(--ant-color-primary) 24%, transparent)';
}, },
onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => { onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => {
const target = e.currentTarget; const target = e.currentTarget;
target.style.transform = 'translateY(0)'; target.style.transform = 'translate(0, 0)';
target.style.boxShadow = promptTemplateBaseShadow; target.style.boxShadow = promptTemplateBaseShadow;
target.style.borderColor = 'color-mix(in srgb, var(--ant-color-text) 8%, transparent)'; 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 { TextArea } = Input;
const { Text, Paragraph } = Typography; 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 { interface CareerDetail {
id: string; 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], buildTime: import.meta.env.VITE_BUILD_TIME || new Date().toISOString().split('T')[0],
// 项目信息 // 项目信息
projectName: 'MuMuAINovel', projectName: '墨木灵思',
projectFullName: 'MuMu AI 小说创作助手', projectFullName: '墨木灵思 - AI 智能小说创作助手',
// 链接信息 // 链接信息(不在代码里写死 GitHub;需要时在 .env 里配置 Vite 变量)
githubUrl: 'https://github.com/xiamuceer-j/MuMuAINovel', linuxDoUrl: '',
linuxDoUrl: 'https://linux.do/t/topic/1106333', /** 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', license: 'GPL v3.0',
licenseUrl: 'https://www.gnu.org/licenses/gpl-3.0.html', licenseUrl: 'https://www.gnu.org/licenses/gpl-3.0.html',
// 作者信息 // 作者信息
author: 'xiamuceer-j', author: '墨木灵思团队',
}; };
/** /**
+45 -3
View File
@@ -5,7 +5,14 @@ body,
} }
:root { :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; line-height: 1.5715;
font-weight: 400; font-weight: 400;
font-synthesis: none; font-synthesis: none;
@@ -16,6 +23,41 @@ body,
-ms-text-size-adjust: 100%; -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 { body {
margin: 0; margin: 0;
min-height: 100vh; min-height: 100vh;
@@ -219,8 +261,8 @@ body {
} }
.ant-tooltip .ant-tooltip-inner { .ant-tooltip .ant-tooltip-inner {
background: var(--app-tooltip-bg, #884d5c); background: var(--app-tooltip-bg, #292524);
border-radius: 8px; border-radius: 2px;
padding: 8px 16px; padding: 8px 16px;
font-weight: 500; font-weight: 500;
box-shadow: 0 4px 12px var(--app-tooltip-shadow, rgba(136, 77, 92, 0.3)); 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 { useNavigate } from 'react-router-dom';
import { Spin, Result, Button, Modal, Input, message, theme } from 'antd'; import { Spin, Result, Button, Modal, Input, message, theme } from 'antd';
import { authApi } from '../services/api'; import { authApi } from '../services/api';
import AnnouncementModal from '../components/AnnouncementModal';
export default function AuthCallback() { export default function AuthCallback() {
const navigate = useNavigate(); const navigate = useNavigate();
const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading'); const [status, setStatus] = useState<'loading' | 'success' | 'error'>('loading');
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const [showAnnouncement, setShowAnnouncement] = useState(false);
const [showPasswordModal, setShowPasswordModal] = useState(false); const [showPasswordModal, setShowPasswordModal] = useState(false);
const { token } = theme.useToken(); const { token } = theme.useToken();
const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`; const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`;
interface PasswordStatus { interface PasswordStatus {
has_password: boolean; has_password: boolean;
has_custom_password: boolean; has_custom_password: boolean;
@@ -26,64 +25,33 @@ export default function AuthCallback() {
useEffect(() => { useEffect(() => {
const handleCallback = async () => { const handleCallback = async () => {
try { try {
// 后端会通过 Cookie 自动设置认证信息
// 这里只需要验证登录状态
const currentUser = await authApi.getCurrentUser(); const currentUser = await authApi.getCurrentUser();
// 检查是否是首次登录(通过 Cookie 标记)
const isFirstLogin = document.cookie.includes('first_login=true'); const isFirstLogin = document.cookie.includes('first_login=true');
setStatus('success'); setStatus('success');
if (isFirstLogin) { if (isFirstLogin) {
// 首次登录:生成默认密码并显示提示
const defaultPassword = `${currentUser.username}@666`; const defaultPassword = `${currentUser.username}@666`;
const pwdStatus = { setPasswordStatus({
has_password: false, has_password: false,
has_custom_password: false, has_custom_password: false,
username: currentUser.username, username: currentUser.username,
default_password: defaultPassword default_password: defaultPassword
}; });
setPasswordStatus(pwdStatus);
// 清除首次登录标记 Cookie
document.cookie = 'first_login=; path=/; max-age=0'; document.cookie = 'first_login=; path=/; max-age=0';
setTimeout(() => setShowPasswordModal(true), 1000);
// 显示密码初始化弹窗
setTimeout(() => {
setShowPasswordModal(true);
}, 1000);
return; return;
} }
// 非首次登录:正常流程
// 从 sessionStorage 获取重定向地址
const redirect = sessionStorage.getItem('login_redirect') || '/'; const redirect = sessionStorage.getItem('login_redirect') || '/';
sessionStorage.removeItem('login_redirect'); sessionStorage.removeItem('login_redirect');
setTimeout(() => navigate(redirect), 1000);
// 检查是否永久隐藏公告或今日已隐藏
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);
}
} catch (error) { } catch (error) {
console.error('登录失败:', error); console.error('登录失败:', error);
setStatus('error'); setStatus('error');
setErrorMessage('登录失败,请重试'); setErrorMessage('登录失败,请重试');
} }
}; };
handleCallback(); handleCallback();
}, [navigate]); }, [navigate]);
@@ -98,9 +66,7 @@ export default function AuthCallback() {
}}> }}>
<div style={{ textAlign: 'center' }}> <div style={{ textAlign: 'center' }}>
<Spin size="large" /> <Spin size="large" />
<div style={{ marginTop: 20, color: token.colorWhite, fontSize: 16 }}> <div style={{ marginTop: 20, color: token.colorWhite, fontSize: 16 }}>...</div>
...
</div>
</div> </div>
</div> </div>
); );
@@ -119,55 +85,21 @@ export default function AuthCallback() {
status="error" status="error"
title="登录失败" title="登录失败"
subTitle={errorMessage} subTitle={errorMessage}
extra={ extra={<Button type="primary" onClick={() => navigate('/login')}></Button>}
<Button type="primary" onClick={() => navigate('/login')}>
</Button>
}
style={{ background: token.colorBgContainer, padding: 40, borderRadius: 8 }} style={{ background: token.colorBgContainer, padding: 40, borderRadius: 8 }}
/> />
</div> </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 handleSetPassword = async () => {
// 如果没有输入新密码,使用默认密码
const passwordToSet = newPassword || passwordStatus?.default_password; const passwordToSet = newPassword || passwordStatus?.default_password;
if (!passwordToSet) { message.error('请输入新密码'); return; }
if (!passwordToSet) { if (passwordToSet.length < 6) { message.error('密码长度至少为6个字符'); return; }
message.error('输入密码'); if (newPassword && newPassword !== confirmPassword) { message.error('两次输入密码不一致'); return; }
return;
}
if (passwordToSet.length < 6) {
message.error('密码长度至少为6个字符');
return;
}
if (newPassword && newPassword !== confirmPassword) {
message.error('两次输入的密码不一致');
return;
}
setSettingPassword(true); setSettingPassword(true);
try { try {
// 首次登录使用初始化接口,后续使用修改接口
const isFirstLogin = !passwordStatus?.has_password; const isFirstLogin = !passwordStatus?.has_password;
if (isFirstLogin) { if (isFirstLogin) {
await authApi.initializePassword(passwordToSet); await authApi.initializePassword(passwordToSet);
@@ -177,24 +109,9 @@ export default function AuthCallback() {
message.success('密码设置成功'); message.success('密码设置成功');
} }
setShowPasswordModal(false); setShowPasswordModal(false);
// 继续后续流程
const redirect = sessionStorage.getItem('login_redirect') || '/'; const redirect = sessionStorage.getItem('login_redirect') || '/';
sessionStorage.removeItem('login_redirect'); sessionStorage.removeItem('login_redirect');
setTimeout(() => navigate(redirect), 500);
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);
}
} catch { } catch {
message.error('密码设置失败,请重试'); message.error('密码设置失败,请重试');
} finally { } finally {
@@ -203,46 +120,19 @@ export default function AuthCallback() {
}; };
const handleSkipPasswordSetting = async () => { const handleSkipPasswordSetting = async () => {
// 首次登录时,如果跳过设置,使用默认密码初始化
const isFirstLogin = !passwordStatus?.has_password; const isFirstLogin = !passwordStatus?.has_password;
if (isFirstLogin && passwordStatus?.default_password) { if (isFirstLogin && passwordStatus?.default_password) {
try { try { await authApi.initializePassword(passwordStatus.default_password); }
await authApi.initializePassword(passwordStatus.default_password); catch (error) { console.error('初始化默认密码失败:', error); }
} catch (error) {
console.error('初始化默认密码失败:', error);
} }
}
setShowPasswordModal(false); setShowPasswordModal(false);
// 继续后续流程
const redirect = sessionStorage.getItem('login_redirect') || '/'; const redirect = sessionStorage.getItem('login_redirect') || '/';
sessionStorage.removeItem('login_redirect'); sessionStorage.removeItem('login_redirect');
setTimeout(() => navigate(redirect), 500);
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);
}
}; };
return ( return (
<> <>
<AnnouncementModal
visible={showAnnouncement}
onClose={handleAnnouncementClose}
onDoNotShowToday={handleDoNotShowToday}
onNeverShow={handleNeverShow}
/>
<Modal <Modal
title="设置账号密码" title="设置账号密码"
open={showPasswordModal} open={showPasswordModal}
@@ -255,45 +145,23 @@ export default function AuthCallback() {
width={500} width={500}
> >
<div style={{ marginBottom: 20 }}> <div style={{ marginBottom: 20 }}>
<p> Linux DO </p> <p></p>
<p>使</p> <p>使</p>
{passwordStatus?.default_password && ( {passwordStatus?.default_password && (
<div style={{ <div style={{ background: token.colorFillTertiary, padding: 12, borderRadius: 4, marginTop: 12 }}>
background: token.colorFillTertiary,
padding: 12,
borderRadius: 4,
marginTop: 12
}}>
<strong></strong>{passwordStatus.username}<br /> <strong></strong>{passwordStatus.username}<br />
<strong></strong><code style={{ <strong></strong><code style={{ background: token.colorBgContainer, padding: '2px 8px', borderRadius: 3, color: token.colorPrimary, fontSize: 14 }}>{passwordStatus.default_password}</code>
background: token.colorBgContainer,
padding: '2px 8px',
borderRadius: 3,
color: token.colorPrimary,
fontSize: 14
}}>{passwordStatus.default_password}</code>
</div> </div>
)} )}
</div> </div>
<div style={{ marginTop: 20 }}> <div style={{ marginTop: 20 }}>
<div style={{ marginBottom: 12 }}> <div style={{ marginBottom: 12 }}>
<label>6</label> <label>6</label>
<Input.Password <Input.Password value={newPassword} onChange={(e) => setNewPassword(e.target.value)} placeholder="请输入新密码" style={{ marginTop: 4 }} />
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="请输入新密码"
style={{ marginTop: 4 }}
/>
</div> </div>
<div> <div>
<label></label> <label></label>
<Input.Password <Input.Password value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} placeholder="请再次输入密码" style={{ marginTop: 4 }} />
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="请再次输入密码"
style={{ marginTop: 4 }}
/>
</div> </div>
</div> </div>
</Modal> </Modal>
@@ -308,7 +176,7 @@ export default function AuthCallback() {
<Result <Result
status="success" status="success"
title="登录成功" title="登录成功"
subTitle={showPasswordModal ? "请设置账号密码..." : (showAnnouncement ? "欢迎使用..." : "正在跳转...")} subTitle={showPasswordModal ? "请设置账号密码..." : "正在跳转..."}
style={{ background: alphaColor(token.colorBgContainer, 0.96), padding: 40, borderRadius: 8 }} style={{ background: alphaColor(token.colorBgContainer, 0.96), padding: 40, borderRadius: 8 }}
/> />
</div> </div>
+78 -28
View File
@@ -1,10 +1,11 @@
import { Card, Button, Spin, Space, Tag, Typography, Alert, theme } from 'antd'; 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 } from '@ant-design/icons'; 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 { useState } from 'react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { Project } from '../types'; import type { Project } from '../types';
import { bookshelfCardStyles, bookshelfCardHoverHandlers } from '../components/CardStyles'; import { bookshelfCardStyles, bookshelfCardHoverHandlers } from '../components/CardStyles';
import { useThemeMode } from '../theme/useThemeMode'; import { useThemeMode } from '../theme/useThemeMode';
import { VERSION_INFO } from '../config/version';
const { Paragraph } = Typography; const { Paragraph } = Typography;
@@ -215,42 +216,91 @@ export default function BookshelfPage({
</Button> </Button>
</Space> </Space>
</div> </div>
</Card>
{showApiTip && projects.length === 0 && ( {showApiTip && projects.length === 0 && (
<Alert <div
message="欢迎使用 MuMuAINovel" style={{
description={ marginTop: isMobile ? 12 : 14,
<div style={{ paddingTop: isMobile ? 12 : 14,
borderTop: `1px solid ${alphaColor(token.colorWhite, 0.28)}`,
position: 'relative',
zIndex: 1,
}}
>
<div
style={{
display: 'flex', display: 'flex',
flexDirection: isMobile ? 'column' : 'row', flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'flex-start' : 'center', alignItems: isMobile ? 'stretch' : 'center',
gap: isMobile ? 12 : 16, gap: isMobile ? 12 : 16,
justifyContent: 'space-between' justifyContent: 'space-between',
}}> }}
<span style={{ fontSize: isMobile ? 12 : 14 }}>
AI接口 OpenAI / Anthropic
</span>
<Button
size="small"
type="primary"
onClick={onGoSettings}
style={{ flexShrink: 0 }}
> >
<div
</Button>
</div>
}
type="info"
showIcon
closable
onClose={() => setShowApiTip(false)}
style={{ style={{
marginBottom: isMobile ? 16 : 24, display: 'flex',
borderRadius: 12 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}> <Spin spinning={loading}>
<div style={{ <div style={{
+22 -346
View File
@@ -1,9 +1,10 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'; 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 { 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, ClockCircleOutlined, LoadingOutlined } from '@ant-design/icons'; 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 { useStore } from '../store';
import { eventBus } from '../store/eventBus';
import { useChapterSync } from '../store/hooks'; 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 { projectApi, writingStyleApi, chapterApi } from '../services/api';
import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask, ExpansionPlanData } from '../types'; import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask, ExpansionPlanData } from '../types';
import type { TextAreaRef } from 'antd/es/input/TextArea'; import type { TextAreaRef } from 'antd/es/input/TextArea';
@@ -97,111 +98,6 @@ export default function Chapters() {
const [singleChapterProgress, setSingleChapterProgress] = useState(0); const [singleChapterProgress, setSingleChapterProgress] = useState(0);
const [singleChapterProgressMessage, setSingleChapterProgressMessage] = useState(''); 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); const [batchGenerateVisible, setBatchGenerateVisible] = useState(false);
@@ -643,7 +539,7 @@ export default function Chapters() {
// 启动轮询 // 启动轮询
startBatchPolling(task.batch_id); startBatchPolling(task.batch_id);
message.info('检测到未完成的批量生成任务,已在顶部显示进度'); message.info('检测到未完成的批量生成任务,请查看任务列表');
} }
} catch (error) { } catch (error) {
console.error('检查批量生成任务失败:', error); console.error('检查批量生成任务失败:', error);
@@ -1079,6 +975,7 @@ export default function Chapters() {
// 后台生成章节(关闭浏览器也不影响) // 后台生成章节(关闭浏览器也不影响)
// 不再强制显示进度弹窗,任务进度在右下角悬浮任务框中显示
const handleBackgroundGenerate = async () => { const handleBackgroundGenerate = async () => {
if (!editingId) return; if (!editingId) return;
if (!selectedStyleId) { if (!selectedStyleId) {
@@ -1087,12 +984,7 @@ export default function Chapters() {
} }
try { try {
setBgTaskVisible(true); await generateChapterBackground(
setBgTaskRunning(true);
setBgTaskProgress(0);
setBgTaskMessage("正在创建后台任务...");
const cancelFn = await generateChapterBackground(
editingId, editingId,
{ {
style_id: selectedStyleId, style_id: selectedStyleId,
@@ -1100,14 +992,10 @@ export default function Chapters() {
model: selectedModel, model: selectedModel,
narrative_perspective: temporaryNarrativePerspective, narrative_perspective: temporaryNarrativePerspective,
}, },
(status) => { () => {
setBgTaskProgress(status.progress || 0); // 进度更新由悬浮任务框处理,无需额外操作
setBgTaskMessage(status.status_message || "处理中...");
}, },
(_) => { (_) => {
setBgTaskProgress(100);
setBgTaskMessage("生成完成!");
setBgTaskRunning(false);
message.success("后台章节生成完成!"); message.success("后台章节生成完成!");
refreshChapters(); refreshChapters();
if (currentProject) { if (currentProject) {
@@ -1116,17 +1004,15 @@ export default function Chapters() {
loadAnalysisTasks(); loadAnalysisTasks();
}, },
(error) => { (error) => {
setBgTaskRunning(false);
setBgTaskMessage("失败: " + error);
message.error("后台生成失败: " + error); message.error("后台生成失败: " + error);
} }
); );
bgTaskCancelRef.current = cancelFn; message.info("章节生成任务已提交,可在右下角任务面板查看进度");
message.info("已提交后台生成任务,可以关闭此页面"); // 通知悬浮任务框刷新
eventBus.emit('background-task-created');
} catch (error) { } catch (error) {
message.error("创建后台任务失败"); message.error("创建后台任务失败");
setBgTaskRunning(false);
} }
}; };
const getStatusColor = (status: string) => { const getStatusColor = (status: string) => {
@@ -1243,7 +1129,7 @@ export default function Chapters() {
try { try {
setBatchGenerating(true); setBatchGenerating(true);
setBatchGenerateVisible(false); // 关闭配置对话框,避免遮挡进度弹窗 setBatchGenerateVisible(false); // 关闭配置对话框,任务进度在悬浮任务框中显示
const requestBody: { const requestBody: {
start_chapter_number: number; start_chapter_number: number;
@@ -1293,7 +1179,9 @@ export default function Chapters() {
estimated_time_minutes: result.estimated_time_minutes, 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( showBrowserNotification(
@@ -2092,23 +1980,17 @@ export default function Chapters() {
> >
{batchAnalyzableChapterCount > 0 ? ` (${batchAnalyzableChapterCount})` : ''} {batchAnalyzableChapterCount > 0 ? ` (${batchAnalyzableChapterCount})` : ''}
</Button> </Button>
<Button
icon={<ClockCircleOutlined />}
onClick={showTaskListModal}
>
{projectBgTasks.length > 0 && <Badge count={projectBgTasks.length} size="small" style={{ marginLeft: 4 }} />}
</Button>
<Button <Button
type="primary" type="primary"
icon={<RocketOutlined />} icon={<RocketOutlined />}
onClick={handleOpenBatchGenerate} onClick={handleOpenBatchGenerate}
disabled={chapters.length === 0} disabled={chapters.length === 0 || batchGenerating}
loading={batchGenerating}
block={isMobile} block={isMobile}
size={isMobile ? 'middle' : 'middle'} size={isMobile ? 'middle' : 'middle'}
style={{ background: token.colorInfo, borderColor: token.colorInfo }} style={batchGenerating ? {} : { background: token.colorInfo, borderColor: token.colorInfo }}
> >
{batchGenerating ? '生成中...' : '批量生成'}
</Button> </Button>
<Button <Button
type="default" type="default"
@@ -2123,102 +2005,6 @@ export default function Chapters() {
</Space> </Space>
</div> </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 }}> <div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{chapters.length === 0 ? ( {chapters.length === 0 ? (
@@ -2769,7 +2555,7 @@ export default function Chapters() {
icon={canGenerate ? <ThunderboltOutlined /> : <LockOutlined />} icon={canGenerate ? <ThunderboltOutlined /> : <LockOutlined />}
onClick={() => currentChapter && showGenerateModal(currentChapter)} onClick={() => currentChapter && showGenerateModal(currentChapter)}
loading={isContinuing} loading={isContinuing}
disabled={!canGenerate || bgTaskRunning} disabled={!canGenerate}
danger={!canGenerate} danger={!canGenerate}
style={{ fontWeight: 'bold' }} style={{ fontWeight: 'bold' }}
title={!canGenerate ? disabledReason : '根据大纲和前置章节内容创作(流式)'} title={!canGenerate ? disabledReason : '根据大纲和前置章节内容创作(流式)'}
@@ -2779,8 +2565,7 @@ export default function Chapters() {
<Button <Button
icon={<RocketOutlined />} icon={<RocketOutlined />}
onClick={handleBackgroundGenerate} onClick={handleBackgroundGenerate}
disabled={!canGenerate || bgTaskRunning || isContinuing} disabled={!canGenerate || isContinuing}
loading={bgTaskRunning}
style={{ fontWeight: 'bold' }} style={{ fontWeight: 'bold' }}
title={!canGenerate ? disabledReason : '后台生成:关闭浏览器也不影响,完成后自动保存'} title={!canGenerate ? disabledReason : '后台生成:关闭浏览器也不影响,完成后自动保存'}
> >
@@ -2792,26 +2577,6 @@ export default function Chapters() {
</Space.Compact> </Space.Compact>
</Form.Item> </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={{ <div style={{
@@ -3233,95 +2998,6 @@ export default function Chapters() {
message={singleChapterProgressMessage} 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 && ( {readingChapter && (
<ChapterReader <ChapterReader
+93 -478
View File
@@ -29,7 +29,6 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { authApi } from '../services/api'; import { authApi } from '../services/api';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import AnnouncementModal from '../components/AnnouncementModal';
import ThemeSwitch from '../components/ThemeSwitch'; import ThemeSwitch from '../components/ThemeSwitch';
const { Title, Paragraph, Text } = Typography; const { Title, Paragraph, Text } = Typography;
@@ -83,19 +82,26 @@ export default function Login() {
const [resetPasswordForm] = Form.useForm<ResetPasswordValues>(); const [resetPasswordForm] = Form.useForm<ResetPasswordValues>();
const { token } = theme.useToken(); const { token } = theme.useToken();
const alphaColor = (color: string, alpha: number) => `color-mix(in srgb, ${color} ${(alpha * 100).toFixed(0)}%, transparent)`; 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 primaryButtonStyle = {
const hoverButtonShadow = `0 12px 28px ${alphaColor(token.colorPrimary, 0.36)}`; height: 46,
const [showAnnouncement, setShowAnnouncement] = useState(false); fontSize: 15,
const [loginCodeSending, setLoginCodeSending] = useState(false); fontWeight: 700,
const [registerCodeSending, setRegisterCodeSending] = useState(false); borderRadius: 2,
const [resetCodeSending, setResetCodeSending] = useState(false); letterSpacing: '0.06em',
textTransform: 'uppercase' as const,
};
const [loginCountdown, setLoginCountdown] = useState(0); const [loginCountdown, setLoginCountdown] = useState(0);
const [registerCountdown, setRegisterCountdown] = useState(0); const [registerCountdown, setRegisterCountdown] = useState(0);
const [resetCountdown, setResetCountdown] = 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 [showResetPassword, setShowResetPassword] = useState(false);
const localAuthEnabled = authConfig.local_auth_enabled; const localAuthEnabled = authConfig.local_auth_enabled;
const linuxdoEnabled = authConfig.linuxdo_enabled;
const emailAuthEnabled = authConfig.email_auth_enabled; const emailAuthEnabled = authConfig.email_auth_enabled;
const emailRegisterEnabled = authConfig.email_register_enabled; const emailRegisterEnabled = authConfig.email_register_enabled;
@@ -105,27 +111,13 @@ export default function Login() {
{ value: registerCountdown, setter: setRegisterCountdown }, { value: registerCountdown, setter: setRegisterCountdown },
{ value: resetCountdown, setter: setResetCountdown }, { value: resetCountdown, setter: setResetCountdown },
].map(({ value, setter }) => { ].map(({ value, setter }) => {
if (value <= 0) { if (value <= 0) return null;
return null;
}
return window.setInterval(() => { return window.setInterval(() => {
setter((prev) => { setter((prev) => (prev <= 1 ? 0 : prev - 1));
if (prev <= 1) {
return 0;
}
return prev - 1;
});
}, 1000); }, 1000);
}); });
return () => { return () => timers.forEach((timer) => timer && window.clearInterval(timer));
timers.forEach((timer) => {
if (timer) {
window.clearInterval(timer);
}
});
};
}, [loginCountdown, registerCountdown, resetCountdown]); }, [loginCountdown, registerCountdown, resetCountdown]);
useEffect(() => { useEffect(() => {
@@ -141,8 +133,8 @@ export default function Login() {
} catch (error) { } catch (error) {
console.error('获取认证配置失败:', error); console.error('获取认证配置失败:', error);
setAuthConfig({ setAuthConfig({
local_auth_enabled: false, local_auth_enabled: true, // 默认开启本地,防止全关
linuxdo_enabled: true, linuxdo_enabled: false,
email_auth_enabled: false, email_auth_enabled: false,
email_register_enabled: false, email_register_enabled: false,
}); });
@@ -155,17 +147,8 @@ export default function Login() {
const handleLoginSuccess = () => { const handleLoginSuccess = () => {
message.success('登录成功!'); 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') || '/'; const redirect = searchParams.get('redirect') || '/';
navigate(redirect); navigate(redirect);
} else {
setShowAnnouncement(true);
}
}; };
const handleLocalLogin = async (values: LocalLoginValues) => { 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 loginTips = useMemo(() => {
const tips = [ const tips = [];
'首次 LinuxDO 登录会自动创建账号。',
];
if (localAuthEnabled) { if (localAuthEnabled) {
tips.unshift('本地登录默认账号:admin / admin123'); tips.push('本地登录默认账号:admin / admin123');
} }
if (emailAuthEnabled) { if (emailAuthEnabled) {
tips.push('邮箱注册用户支持通过邮箱验证码重置密码。'); tips.push('邮箱注册用户支持通过邮箱验证码重置密码。');
} }
return tips; return tips;
}, [emailAuthEnabled, localAuthEnabled]); }, [emailAuthEnabled, localAuthEnabled]);
@@ -355,7 +300,6 @@ export default function Login() {
]; ];
const renderLocalLogin = () => ( const renderLocalLogin = () => (
<>
<Form <Form
form={localForm} form={localForm}
layout="vertical" layout="vertical"
@@ -372,7 +316,7 @@ export default function Login() {
prefix={<UserOutlined style={{ color: token.colorTextTertiary }} />} prefix={<UserOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入管理账号/邮箱" placeholder="请输入管理账号/邮箱"
autoComplete="username" autoComplete="username"
style={{ height: 46, borderRadius: 12 }} style={{ height: 46, borderRadius: 2 }}
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
@@ -384,7 +328,7 @@ export default function Login() {
prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />} prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入访问密钥" placeholder="请输入访问密钥"
autoComplete="current-password" autoComplete="current-password"
style={{ height: 46, borderRadius: 12 }} style={{ height: 46, borderRadius: 2 }}
/> />
</Form.Item> </Form.Item>
<Form.Item style={{ marginBottom: 0, marginTop: 8 }}> <Form.Item style={{ marginBottom: 0, marginTop: 8 }}>
@@ -393,28 +337,12 @@ export default function Login() {
htmlType="submit" htmlType="submit"
loading={loading} loading={loading}
block block
style={{ style={primaryButtonStyle}
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,
}}
> >
</Button> </Button>
</Form.Item> </Form.Item>
</Form> </Form>
{linuxdoEnabled ? (
<>
<Divider style={{ margin: '18px 0 16px' }}></Divider>
{renderLinuxDOLogin()}
</>
) : null}
</>
); );
const renderEmailLogin = () => { const renderEmailLogin = () => {
@@ -429,13 +357,8 @@ export default function Login() {
</Button> </Button>
</Space> </Space>
<Card size="small" bordered={false} style={{ borderRadius: 12, background: token.colorFillAlter }}> <Card size="small" bordered={false} style={{ borderRadius: 2, background: token.colorFillAlter, border: `1px solid ${token.colorBorder}` }}>
<Form <Form form={resetPasswordForm} layout="vertical" onFinish={handleResetPassword} size="middle">
form={resetPasswordForm}
layout="vertical"
onFinish={handleResetPassword}
size="middle"
>
<Form.Item <Form.Item
name="email" name="email"
label="注册邮箱" label="注册邮箱"
@@ -458,11 +381,7 @@ export default function Login() {
> >
<Input placeholder="请输入重置验证码" maxLength={6} /> <Input placeholder="请输入重置验证码" maxLength={6} />
</Form.Item> </Form.Item>
<Button <Button onClick={sendResetCode} loading={resetCodeSending} disabled={resetCountdown > 0}>
onClick={sendResetCode}
loading={resetCodeSending}
disabled={resetCountdown > 0}
>
{resetCountdown > 0 ? `${resetCountdown}s 后重发` : '发送验证码'} {resetCountdown > 0 ? `${resetCountdown}s 后重发` : '发送验证码'}
</Button> </Button>
</Space.Compact> </Space.Compact>
@@ -470,10 +389,7 @@ export default function Login() {
<Form.Item <Form.Item
name="new_password" name="new_password"
label="新密码" label="新密码"
rules={[ rules={[{ required: true, message: '请输入新密码' }, { min: 6, message: '密码长度至少为 6 个字符' }]}
{ required: true, message: '请输入新密码' },
{ min: 6, message: '密码长度至少为 6 个字符' },
]}
> >
<Input.Password prefix={<LockOutlined />} placeholder="请输入新密码" /> <Input.Password prefix={<LockOutlined />} placeholder="请输入新密码" />
</Form.Item> </Form.Item>
@@ -485,9 +401,7 @@ export default function Login() {
{ required: true, message: '请再次输入新密码' }, { required: true, message: '请再次输入新密码' },
({ getFieldValue }) => ({ ({ getFieldValue }) => ({
validator(_, value) { validator(_, value) {
if (!value || getFieldValue('new_password') === value) { if (!value || getFieldValue('new_password') === value) return Promise.resolve();
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的新密码不一致')); return Promise.reject(new Error('两次输入的新密码不一致'));
}, },
}), }),
@@ -525,7 +439,7 @@ export default function Login() {
prefix={<MailOutlined style={{ color: token.colorTextTertiary }} />} prefix={<MailOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入已注册邮箱" placeholder="请输入已注册邮箱"
autoComplete="email" autoComplete="email"
style={{ height: 46, borderRadius: 12 }} style={{ height: 46, borderRadius: 2 }}
/> />
</Form.Item> </Form.Item>
@@ -543,15 +457,10 @@ export default function Login() {
prefix={<SafetyCertificateOutlined style={{ color: token.colorTextTertiary }} />} prefix={<SafetyCertificateOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入 6 位登录验证码" placeholder="请输入 6 位登录验证码"
maxLength={6} maxLength={6}
style={{ height: 46, borderRadius: '12px 0 0 12px' }} style={{ height: 46, borderRadius: '2px 0 0 2px' }}
/> />
</Form.Item> </Form.Item>
<Button <Button style={{ height: 46 }} onClick={sendLoginCode} loading={loginCodeSending} disabled={loginCountdown > 0}>
style={{ height: 46 }}
onClick={sendLoginCode}
loading={loginCodeSending}
disabled={loginCountdown > 0}
>
{loginCountdown > 0 ? `${loginCountdown}s 后重发` : '发送验证码'} {loginCountdown > 0 ? `${loginCountdown}s 后重发` : '发送验证码'}
</Button> </Button>
</Space.Compact> </Space.Compact>
@@ -563,15 +472,7 @@ export default function Login() {
htmlType="submit" htmlType="submit"
loading={loading} loading={loading}
block block
style={{ style={primaryButtonStyle}
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,
}}
> >
</Button> </Button>
@@ -606,7 +507,7 @@ export default function Login() {
prefix={<MailOutlined style={{ color: token.colorTextTertiary }} />} prefix={<MailOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入注册邮箱" placeholder="请输入注册邮箱"
autoComplete="email" autoComplete="email"
style={{ height: 46, borderRadius: 12 }} style={{ height: 46, borderRadius: 2 }}
/> />
</Form.Item> </Form.Item>
@@ -624,46 +525,34 @@ export default function Login() {
prefix={<SafetyCertificateOutlined style={{ color: token.colorTextTertiary }} />} prefix={<SafetyCertificateOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入 6 位验证码" placeholder="请输入 6 位验证码"
maxLength={6} maxLength={6}
style={{ height: 46, borderRadius: '12px 0 0 12px' }} style={{ height: 46, borderRadius: '2px 0 0 2px' }}
/> />
</Form.Item> </Form.Item>
<Button <Button style={{ height: 46 }} onClick={sendRegisterCode} loading={registerCodeSending} disabled={registerCountdown > 0}>
style={{ height: 46 }}
onClick={sendRegisterCode}
loading={registerCodeSending}
disabled={registerCountdown > 0}
>
{registerCountdown > 0 ? `${registerCountdown}s 后重发` : '发送验证码'} {registerCountdown > 0 ? `${registerCountdown}s 后重发` : '发送验证码'}
</Button> </Button>
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
<Form.Item <Form.Item name="display_name" label="昵称" rules={[{ max: 50, message: '昵称长度不能超过 50 个字符' }]}>
name="display_name"
label="昵称"
rules={[{ max: 50, message: '昵称长度不能超过 50 个字符' }]}
>
<Input <Input
prefix={<UserOutlined style={{ color: token.colorTextTertiary }} />} prefix={<UserOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="选填,默认使用邮箱前缀" placeholder="选填,默认使用邮箱前缀"
autoComplete="nickname" autoComplete="nickname"
style={{ height: 46, borderRadius: 12 }} style={{ height: 46, borderRadius: 2 }}
/> />
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="password" name="password"
label="登录密码" label="登录密码"
rules={[ rules={[{ required: true, message: '请输入登录密码' }, { min: 6, message: '密码长度至少为 6 个字符' }]}
{ required: true, message: '请输入登录密码' },
{ min: 6, message: '密码长度至少为 6 个字符' },
]}
> >
<Input.Password <Input.Password
prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />} prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请输入登录密码" placeholder="请输入登录密码"
autoComplete="new-password" autoComplete="new-password"
style={{ height: 46, borderRadius: 12 }} style={{ height: 46, borderRadius: 2 }}
/> />
</Form.Item> </Form.Item>
@@ -675,9 +564,7 @@ export default function Login() {
{ required: true, message: '请再次输入登录密码' }, { required: true, message: '请再次输入登录密码' },
({ getFieldValue }) => ({ ({ getFieldValue }) => ({
validator(_, value) { validator(_, value) {
if (!value || getFieldValue('password') === value) { if (!value || getFieldValue('password') === value) return Promise.resolve();
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致')); return Promise.reject(new Error('两次输入的密码不一致'));
}, },
}), }),
@@ -687,7 +574,7 @@ export default function Login() {
prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />} prefix={<LockOutlined style={{ color: token.colorTextTertiary }} />}
placeholder="请再次输入登录密码" placeholder="请再次输入登录密码"
autoComplete="new-password" autoComplete="new-password"
style={{ height: 46, borderRadius: 12 }} style={{ height: 46, borderRadius: 2 }}
/> />
</Form.Item> </Form.Item>
@@ -697,361 +584,89 @@ export default function Login() {
htmlType="submit" htmlType="submit"
loading={loading} loading={loading}
block block
style={{ style={primaryButtonStyle}
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,
}}
> >
</Button> </Button>
</Form.Item> </Form.Item>
<Text type="secondary" style={{ marginTop: 12, display: 'block' }}> <Text type="secondary" style={{ marginTop: 12, display: 'block' }}>
</Text> </Text>
</Form> </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 = [ const authTabs = [
...(localAuthEnabled ...(localAuthEnabled ? [{ key: 'local-login', label: '本地登录', children: renderLocalLogin() }] : []),
? [ ...(emailAuthEnabled ? [{ key: 'email-login', label: '邮箱登录', children: renderEmailLogin() }] : []),
{ ...(emailAuthEnabled && emailRegisterEnabled ? [{ key: 'email-register', label: '邮箱注册', children: renderEmailRegister() }] : []),
key: 'local-login',
label: '本地登录',
children: renderLocalLogin(),
},
]
: []),
...(emailAuthEnabled
? [
{
key: 'email-login',
label: '邮箱登录',
children: renderEmailLogin(),
},
]
: []),
...(emailAuthEnabled && emailRegisterEnabled
? [
{
key: 'email-register',
label: '邮箱注册',
children: renderEmailRegister(),
},
]
: []),
]; ];
if (checking) { if (checking) {
return ( return (
<div <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh', background: token.colorBgLayout }}>
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
minHeight: '100vh',
background: token.colorBgLayout,
}}
>
<Spin size="large" style={{ color: token.colorPrimary }} /> <Spin size="large" style={{ color: token.colorPrimary }} />
</div> </div>
); );
} }
const pageTexture = `repeating-linear-gradient(-12deg, transparent, transparent 31px, ${alphaColor(token.colorText, 0.035)} 31px, ${alphaColor(token.colorText, 0.035)} 32px)`;
return ( return (
<> <Layout style={{ minHeight: '100vh', background: token.colorBgLayout, backgroundImage: pageTexture }}>
<AnnouncementModal <div style={{ position: 'fixed', top: 16, right: 16, zIndex: 10, padding: '6px 8px', borderRadius: 2, background: token.colorBgContainer, border: `1px solid ${token.colorBorder}` }}>
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)',
}}
>
<ThemeSwitch size="small" /> <ThemeSwitch size="small" />
</div> </div>
<Row style={{ minHeight: '100vh' }}> <div style={{ minHeight: '100vh', display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '40px 20px 64px', width: '100%' }}>
<Col xs={0} lg={11}> <header style={{ width: '100%', maxWidth: 1080, marginBottom: 32, textAlign: 'center' }}>
<section <Space align="center" size={12} style={{ justifyContent: 'center', marginBottom: 12 }}>
style={{ <div style={{ width: 44, height: 44, border: `2px solid ${token.colorPrimary}`, display: 'flex', alignItems: 'center', justifyContent: 'center', background: token.colorBgContainer }}>
height: '100%', <img src="/logo.svg" alt="墨木灵思" style={{ width: 24, height: 24 }} />
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> </div>
<Title level={3} style={{ margin: 0, color: token.colorText }}> <Title level={3} className="app-serif-title" style={{ margin: 0, fontWeight: 700 }}></Title>
MuMuAINovel
</Title>
</Space> </Space>
<Title level={1} className="app-serif-title" style={{ margin: '0 0 10px', fontSize: 'clamp(26px, 4vw, 40px)', fontWeight: 700, lineHeight: 1.25 }}> AI</Title>
<Space direction="vertical" size={32} style={{ width: '100%' }}> <Paragraph style={{ margin: 0, color: token.colorTextSecondary, fontSize: 15, maxWidth: 520, marginInline: 'auto' }}> · · · </Paragraph>
<div style={{ maxWidth: 'min(860px, 100%)' }}> </header>
<Title <Row style={{ width: '100%', maxWidth: 1080, flex: 1 }} gutter={[28, 28]} align="stretch">
level={1} <Col xs={24} lg={13}>
style={{ <div>
marginBottom: 22, {featureItems.map((item, index) => (
color: token.colorText, <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}` }}>
lineHeight: 1.12, <Space align="start" size={12}>
fontWeight: 800, <span style={{ color: token.colorPrimary, fontSize: 18, marginTop: 2 }}>{item.icon}</span>
fontSize: 'clamp(52px, 3vw, 78px)', <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>
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>
</div> </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> </Space>
<Paragraph style={{ marginBottom: 0, color: token.colorTextSecondary, fontSize: 14, lineHeight: 1.65 }}> </div>
{item.description} ))}
</Paragraph> <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> </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> </Card>
</Col> </Col>
))}
</Row> </Row>
</Space> <Paragraph style={{ marginTop: 32, marginBottom: 0, fontSize: 12, color: token.colorTextTertiary, letterSpacing: '0.08em' }}>© 2026 · GPLv3</Paragraph>
<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>
</div> </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> </Layout>
</>
); );
} }
+63 -724
View File
@@ -1,13 +1,13 @@
import { useState, useEffect, useMemo, useRef } from 'react'; import { useState, useEffect, useMemo } 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 { 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, ClockCircleOutlined, ReloadOutlined, CloseCircleOutlined, LoadingOutlined } from '@ant-design/icons'; import { EditOutlined, DeleteOutlined, ThunderboltOutlined, BranchesOutlined, AppstoreAddOutlined, CheckCircleOutlined, ExclamationCircleOutlined, PlusOutlined, FileTextOutlined } from '@ant-design/icons';
import { useStore } from '../store'; import { useStore } from '../store';
import { eventBus } from '../store/eventBus';
import { getProjectTasks, type TaskStatus } from '../services/backgroundTaskService';
import { useOutlineSync } from '../store/hooks'; import { useOutlineSync } from '../store/hooks';
import { SSEPostClient } from '../utils/sseClient'; import { generateOutlineBackground } from '../services/backgroundTaskService';
import { SSEProgressModal } from '../components/SSEProgressModal';
import { generateOutlineBackground, getProjectTasks, cancelTask, deleteTask, type TaskStatus } from '../services/backgroundTaskService';
import { outlineApi, chapterApi, projectApi, characterApi } from '../services/api'; 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 { interface OutlineGenerateRequestData {
@@ -25,20 +25,6 @@ interface OutlineGenerateRequestData {
provider?: string; provider?: string;
} }
// 跳过的大纲信息类型
interface SkippedOutlineInfo {
outline_id: string;
outline_title: string;
reason: string;
}
// 场景类型
interface SceneInfo {
location: string;
characters: string[];
purpose: string;
}
// 角色/组织条目类型(新格式) // 角色/组织条目类型(新格式)
interface CharacterEntry { interface CharacterEntry {
name: string; name: string;
@@ -141,28 +127,6 @@ export default function Outline() {
// ✅ 新增:记录场景区域的展开/折叠状态 // ✅ 新增:记录场景区域的展开/折叠状态
const [scenesExpandStatus, setScenesExpandStatus] = useState<Record<string, boolean>>({}); 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(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
setIsMobile(window.innerWidth <= 768); setIsMobile(window.innerWidth <= 768);
@@ -190,10 +154,26 @@ export default function Outline() {
refreshOutlines(); refreshOutlines();
// 加载项目角色列表 // 加载项目角色列表
loadProjectCharacters(); loadProjectCharacters();
// 检查是否有活跃的大纲生成任务,恢复按钮禁用状态
checkActiveOutlineTasks();
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentProject?.id]); // 只依赖 ID,不依赖函数 }, [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 () => { const loadProjectCharacters = async () => {
if (!currentProject?.id) return; if (!currentProject?.id) return;
@@ -546,11 +526,6 @@ export default function Outline() {
// 关闭生成表单Modal // 关闭生成表单Modal
Modal.destroyAll(); Modal.destroyAll();
// 显示进度Modal
setSSEProgress(0);
setSSEMessage('正在连接AI服务...');
setSSEModalVisible(true);
// 准备请求数据 // 准备请求数据
const requestData: OutlineGenerateRequestData = { const requestData: OutlineGenerateRequestData = {
project_id: currentProject.id, project_id: currentProject.id,
@@ -583,35 +558,30 @@ export default function Outline() {
console.log('========================='); console.log('=========================');
// 使用后台任务生成(不怕断连,关闭浏览器也继续运行) // 使用后台任务生成(不怕断连,关闭浏览器也继续运行)
setSSEMessage('正在创建后台任务...'); // 不再强制显示进度弹窗,任务进度在右下角悬浮任务框中显示
await generateOutlineBackground(
const cancelFn = await generateOutlineBackground(
requestData, requestData,
(status) => { () => {
setSSEProgress(status.progress); // 进度更新由悬浮任务框处理,无需额外操作
setSSEMessage(status.status_message || '处理中...');
}, },
(result) => { (result) => {
message.success(result.task_result?.message as string || '大纲生成完成!'); message.success(result.task_result?.message as string || '大纲生成完成!');
setSSEModalVisible(false);
setIsGenerating(false); setIsGenerating(false);
cancelGenerateRef.current = null;
refreshOutlines(); refreshOutlines();
}, },
(error) => { (error) => {
message.error(`生成失败: ${error}`); message.error(`生成失败: ${error}`);
setSSEModalVisible(false);
setIsGenerating(false); setIsGenerating(false);
cancelGenerateRef.current = null;
} }
); );
cancelGenerateRef.current = cancelFn; message.info('大纲生成任务已提交,可在右下角任务面板查看进度');
// 通知悬浮任务框刷新
eventBus.emit('background-task-created');
} catch (error) { } catch (error) {
console.error('AI生成失败:', error); console.error('AI生成失败:', error);
message.error('AI生成失败'); message.error('AI生成失败');
setSSEModalVisible(false);
setIsGenerating(false); setIsGenerating(false);
} }
}; };
@@ -921,7 +891,7 @@ export default function Outline() {
}); });
}; };
// 展开单个大纲为多章 - 使用SSE显示进度 // 展开单个大纲为多章 - 提交后台任务并在悬浮任务面板显示进度
const handleExpandOutline = async (outlineId: string, outlineTitle: string) => { const handleExpandOutline = async (outlineId: string, outlineTitle: string) => {
try { try {
setIsExpanding(true); setIsExpanding(true);
@@ -1042,60 +1012,39 @@ export default function Outline() {
</Form> </Form>
</div> </div>
), ),
okText: '生成规划预览', okText: '提交后台任务',
cancelText: '取消', cancelText: '取消',
onOk: async () => { onOk: async () => {
try { try {
const values = await expansionForm.validateFields(); const values = await expansionForm.validateFields();
// 关闭配置表单
Modal.destroyAll(); Modal.destroyAll();
// 显示SSE进度Modal
setSSEProgress(0);
setSSEMessage('正在准备展开大纲...');
setSSEModalVisible(true);
setIsExpanding(true); setIsExpanding(true);
// 准备请求数据
const requestData = { const requestData = {
...values, ...values,
auto_create_chapters: false, // 第一步:仅生成规划 auto_create_chapters: true,
enable_scene_analysis: true enable_scene_analysis: true
}; };
// 使用SSE客户端调用新的流式端点 const response = await fetch(`/api/outlines/${outlineId}/expand-background`, {
const apiUrl = `/api/outlines/${outlineId}/expand-stream`; method: 'POST',
const client = new SSEPostClient(apiUrl, requestData, { headers: { 'Content-Type': 'application/json' },
onProgress: (msg: string, progress: number) => { body: JSON.stringify(requestData),
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);
}
}); });
// 开始连接 if (!response.ok) {
client.connect(); 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) { } catch (error) {
console.error('展开失败:', error); console.error('展开失败:', error);
message.error('展开失败'); message.error(error instanceof Error ? error.message : '展开失败');
setSSEModalVisible(false);
setIsExpanding(false); 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 = () => { const handleBatchExpandOutlines = () => {
if (!currentProject?.id || outlines.length === 0) { if (!currentProject?.id || outlines.length === 0) {
message.warning('没有可展开的大纲'); message.warning('没有可展开的大纲');
@@ -1583,524 +1405,50 @@ export default function Outline() {
</Form> </Form>
</div> </div>
), ),
okText: '开始展开', okText: '提交后台任务',
cancelText: '取消', cancelText: '取消',
okButtonProps: { type: 'primary' }, okButtonProps: { type: 'primary' },
onOk: async () => { onOk: async () => {
try { try {
const values = await batchExpansionForm.validateFields(); const values = await batchExpansionForm.validateFields();
// 关闭配置表单
Modal.destroyAll(); Modal.destroyAll();
// 显示SSE进度Modal
setSSEProgress(0);
setSSEMessage('正在准备批量展开...');
setSSEModalVisible(true);
setIsExpanding(true); setIsExpanding(true);
// 准备请求数据
const requestData = { const requestData = {
project_id: currentProject.id, project_id: currentProject.id,
...values, ...values,
auto_create_chapters: false // 第一步:仅生成规划 auto_create_chapters: true,
enable_scene_analysis: true
}; };
// 使用SSE客户端 const response = await fetch('/api/outlines/batch-expand-background', {
const apiUrl = `/api/outlines/batch-expand-stream`; method: 'POST',
const client = new SSEPostClient(apiUrl, requestData, { headers: { 'Content-Type': 'application/json' },
onProgress: (msg: string, progress: number) => { body: JSON.stringify(requestData),
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);
}
}); });
// 开始连接 if (!response.ok) {
client.connect(); 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) { } catch (error) {
console.error('批量展开失败:', error); console.error('批量展开失败:', error);
message.error('批量展开失败'); message.error(error instanceof Error ? error.message : '批量展开失败');
setSSEModalVisible(false);
setIsExpanding(false); 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 ( 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} {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%' }}> <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* 固定头部 */} {/* 固定头部 */}
@@ -2153,15 +1501,6 @@ export default function Outline() {
> >
{isMobile ? 'AI生成/续写' : 'AI生成/续写大纲'} {isMobile ? 'AI生成/续写' : 'AI生成/续写大纲'}
</Button> </Button>
<Tooltip title="查看后台任务进度">
<Button
icon={<ClockCircleOutlined />}
onClick={showTaskListModal}
block={isMobile}
>
{isMobile ? '任务' : '后台任务'}
</Button>
</Tooltip>
{outlines.length > 0 && currentProject?.outline_mode === 'one-to-many' && ( {outlines.length > 0 && currentProject?.outline_mode === 'one-to-many' && (
<Button <Button
icon={<AppstoreAddOutlined />} icon={<AppstoreAddOutlined />}
+34 -2
View File
@@ -23,9 +23,11 @@ import {
import { useStore } from '../store'; import { useStore } from '../store';
import { useCharacterSync, useOutlineSync, useChapterSync } from '../store/hooks'; import { useCharacterSync, useOutlineSync, useChapterSync } from '../store/hooks';
import { projectApi } from '../services/api'; import { projectApi } from '../services/api';
import { VERSION_INFO } from '../config/version';
import ThemeSwitch from '../components/ThemeSwitch'; import ThemeSwitch from '../components/ThemeSwitch';
import { useThemeMode } from '../theme/useThemeMode'; import { useThemeMode } from '../theme/useThemeMode';
import { getStoredSidebarCollapsed, setStoredSidebarCollapsed } from '../utils/sidebarState'; import { getStoredSidebarCollapsed, setStoredSidebarCollapsed } from '../utils/sidebarState';
import FloatingTaskPanel from '../components/FloatingTaskPanel';
const { Header, Sider, Content } = Layout; const { Header, Sider, Content } = Layout;
@@ -118,6 +120,11 @@ export default function ProjectDetail() {
icon: <HeartOutlined />, icon: <HeartOutlined />,
label: <Link to={`/project/${projectId}/sponsor`}></Link>, label: <Link to={`/project/${projectId}/sponsor`}></Link>,
}, },
{
key: 'about',
icon: <BulbOutlined />,
label: <Link to={`/project/${projectId}/about`}></Link>,
},
{ {
type: 'group' as const, type: 'group' as const,
label: '创作管理', 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 = [ const menuItemsCollapsed = [
@@ -193,6 +217,11 @@ export default function ProjectDetail() {
icon: <HeartOutlined />, icon: <HeartOutlined />,
label: <Link to={`/project/${projectId}/sponsor`}></Link>, label: <Link to={`/project/${projectId}/sponsor`}></Link>,
}, },
{
key: 'about',
icon: <BulbOutlined />,
label: <Link to={`/project/${projectId}/about`}></Link>,
},
{ {
key: 'world-setting', key: 'world-setting',
icon: <GlobalOutlined />, icon: <GlobalOutlined />,
@@ -447,7 +476,7 @@ export default function ProjectDetail() {
}}> }}>
<BookOutlined /> <BookOutlined />
</div> </div>
<span style={{ fontWeight: 600, fontSize: 16 }}>MuMuAINovel</span> <span style={{ fontWeight: 600, fontSize: 16 }}></span>
</div> </div>
} }
placement="left" placement="left"
@@ -543,7 +572,7 @@ export default function ProjectDetail() {
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis' textOverflow: 'ellipsis'
}}> }}>
MuMuAINovel
</span> </span>
</div> </div>
<Button <Button
@@ -656,6 +685,9 @@ export default function ProjectDetail() {
</Content> </Content>
</Layout> </Layout>
</Layout> </Layout>
{/* 悬浮任务框 */}
{projectId && <FloatingTaskPanel projectId={projectId} />}
</Layout> </Layout>
); );
} }
+85 -63
View File
@@ -9,7 +9,6 @@ import { eventBus, EventNames } from '../store/eventBus';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import type { Project, User } from '../types'; import type { Project, User } from '../types';
import UserMenu from '../components/UserMenu'; import UserMenu from '../components/UserMenu';
import ChangelogFloatingButton from '../components/ChangelogFloatingButton';
import ThemeSwitch from '../components/ThemeSwitch'; import ThemeSwitch from '../components/ThemeSwitch';
import { useThemeMode } from '../theme/useThemeMode'; import { useThemeMode } from '../theme/useThemeMode';
import SettingsPage from './Settings'; import SettingsPage from './Settings';
@@ -19,6 +18,8 @@ import PromptTemplates from './PromptTemplates';
import BookImport from './BookImport'; import BookImport from './BookImport';
import BookshelfPage from './BookshelfPage'; import BookshelfPage from './BookshelfPage';
import { getStoredSidebarCollapsed, setStoredSidebarCollapsed } from '../utils/sidebarState'; import { getStoredSidebarCollapsed, setStoredSidebarCollapsed } from '../utils/sidebarState';
import { VERSION_INFO } from '../config/version';
import { shellColors } from '../theme/themeConfig';
const { Text } = Typography; const { Text } = Typography;
@@ -386,10 +387,11 @@ export default function ProjectList() {
}; };
const isMobile = window.innerWidth <= 768; const isMobile = window.innerWidth <= 768;
const headerHeight = isMobile ? 56 : 70; const headerHeight = isMobile ? 56 : 64;
const expandedSiderWidth = 220; const expandedSiderWidth = 232;
const collapsedSiderWidth = 60; const collapsedSiderWidth = 64;
const desktopSiderWidth = collapsed ? collapsedSiderWidth : expandedSiderWidth; const desktopSiderWidth = collapsed ? collapsedSiderWidth : expandedSiderWidth;
const shell = shellColors[resolvedMode];
const currentViewTitle = activeView === 'projects' const currentViewTitle = activeView === 'projects'
? '我的书架' ? '我的书架'
@@ -447,9 +449,9 @@ export default function ProjectList() {
label: '系统设置', label: '系统设置',
}] : []), }] : []),
{ {
key: 'mumu-api', key: 'xinmi-api',
icon: <ApiOutlined />, icon: <ApiOutlined />,
label: 'MuMuのAPI', label: 'XinMi API',
}, },
], ],
}, },
@@ -487,12 +489,16 @@ export default function ProjectList() {
label: '系统设置', label: '系统设置',
}] : []), }] : []),
{ {
key: 'mumu-api', key: 'xinmi-api',
icon: <ApiOutlined />, icon: <ApiOutlined />,
label: 'MuMuのAPI', label: 'XinMi API',
}, },
]; ];
const openXinmiApi = () => {
window.open(VERSION_INFO.officialApiDocUrl, '_blank', 'noopener,noreferrer');
};
return ( return (
<div style={{ <div style={{
height: '100vh', height: '100vh',
@@ -505,10 +511,11 @@ export default function ProjectList() {
{!isMobile && ( {!isMobile && (
<div <div
className="app-shell-sider"
style={{ style={{
width: desktopSiderWidth, width: desktopSiderWidth,
background: token.colorBgContainer, background: shell.siderBg,
borderRight: `1px solid ${token.colorBorderSecondary}`, borderRight: `1px solid ${shell.siderBorder}`,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
position: 'fixed', position: 'fixed',
@@ -518,15 +525,16 @@ export default function ProjectList() {
height: '100vh', height: '100vh',
overflow: 'hidden', overflow: 'hidden',
transition: 'width 0.3s cubic-bezier(0.4, 0, 0.2, 1)', 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 zIndex: 1000
}}> }}>
<div style={{ <div style={{
height: 70, height: 64,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
padding: collapsed ? 0 : '0 12px', padding: collapsed ? 0 : '0 14px',
background: token.colorPrimary, background: shell.siderBg,
borderBottom: `1px solid ${shell.siderBorder}`,
flexShrink: 0, flexShrink: 0,
justifyContent: collapsed ? 'center' : 'space-between', justifyContent: collapsed ? 'center' : 'space-between',
gap: 8 gap: 8
@@ -537,7 +545,7 @@ export default function ProjectList() {
icon={<MenuUnfoldOutlined />} icon={<MenuUnfoldOutlined />}
onClick={() => setCollapsed(false)} onClick={() => setCollapsed(false)}
style={{ style={{
color: token.colorWhite, color: shell.siderText,
width: '100%', width: '100%',
height: '100%', height: '100%',
padding: 0, 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={{ <div style={{
width: 30, width: 32,
height: 30, height: 32,
background: alphaColor(token.colorWhite, 0.2), border: `1px solid ${shell.siderAccent}`,
borderRadius: 8, borderRadius: 2,
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
color: token.colorWhite, color: shell.siderAccent,
fontSize: 16, fontSize: 16,
backdropFilter: 'blur(4px)' flexShrink: 0,
}}> }}>
<BookOutlined /> <BookOutlined />
</div> </div>
<span style={{ <div style={{ minWidth: 0, display: 'flex', flexDirection: 'column', gap: 2 }}>
color: token.colorWhite, <span className="app-serif-title" style={{
color: shell.siderText,
fontWeight: 600, fontWeight: 600,
fontSize: 15, fontSize: 15,
fontFamily: token.fontFamily, lineHeight: 1.2,
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis' textOverflow: 'ellipsis'
}}> }}>
MuMuAINovel {VERSION_INFO.projectName}
</span> </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> </div>
<Button <Button
type="text" type="text"
icon={<MenuFoldOutlined />} icon={<MenuFoldOutlined />}
onClick={() => setCollapsed(true)} onClick={() => setCollapsed(true)}
style={{ style={{
color: token.colorWhite, color: shell.siderMuted,
width: 32, width: 32,
height: 32, height: 32,
padding: 0, padding: 0,
@@ -594,13 +616,14 @@ export default function ProjectList() {
<div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}> <div style={{ flex: 1, overflowY: 'auto', overflowX: 'hidden' }}>
<Menu <Menu
theme="dark"
mode="inline" mode="inline"
inlineCollapsed={collapsed} inlineCollapsed={collapsed}
selectedKeys={[activeView]} selectedKeys={[activeView]}
style={{ borderRight: 0, paddingTop: 12, width: '100%' }} style={{ borderRight: 0, paddingTop: 12, width: '100%', background: 'transparent' }}
onClick={({ key }) => { onClick={({ key }) => {
if (key === 'mumu-api') { if (key === 'xinmi-api') {
window.open('https://api.mumuverse.space/register?aff=4NN8', '_blank', 'noopener,noreferrer'); openXinmiApi();
return; return;
} }
changeView(key as ProjectListView); changeView(key as ProjectListView);
@@ -611,7 +634,7 @@ export default function ProjectList() {
<div style={{ <div style={{
padding: collapsed ? '12px 8px' : 16, padding: collapsed ? '12px 8px' : 16,
borderTop: `1px solid ${token.colorBorderSecondary}`, borderTop: `1px solid ${shell.siderBorder}`,
flexShrink: 0 flexShrink: 0
}}> }}>
{collapsed ? ( {collapsed ? (
@@ -624,17 +647,17 @@ export default function ProjectList() {
style={{ style={{
width: 40, width: 40,
height: 40, height: 40,
borderRadius: 20, borderRadius: 2,
background: alphaColor(token.colorBgContainer, 0.65), background: alphaColor(shell.siderText, 0.08),
border: `1px solid ${token.colorBorder}`, border: `1px solid ${shell.siderBorder}`,
color: token.colorTextSecondary, color: shell.siderMuted,
}} }}
/> />
<UserMenu compact /> <UserMenu compact />
</Space> </Space>
) : ( ) : (
<Space direction="vertical" style={{ width: '100%' }} size={12}> <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></span>
<span>{resolvedMode === 'dark' ? '深色' : '浅色'}</span> <span>{resolvedMode === 'dark' ? '深色' : '浅色'}</span>
</div> </div>
@@ -647,7 +670,8 @@ export default function ProjectList() {
)} )}
<div style={{ <div style={{
background: token.colorPrimary, background: token.colorBgContainer,
borderBottom: `2px solid ${shell.headerBorder}`,
padding: isMobile ? '0 12px' : '0 24px', padding: isMobile ? '0 12px' : '0 24px',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -657,7 +681,7 @@ export default function ProjectList() {
left: isMobile ? 0 : desktopSiderWidth, left: isMobile ? 0 : desktopSiderWidth,
right: 0, right: 0,
zIndex: 1000, zIndex: 1000,
boxShadow: `0 2px 10px ${alphaColor(token.colorText, 0.16)}`, boxShadow: 'none',
height: headerHeight, height: headerHeight,
flexShrink: 0, flexShrink: 0,
transition: 'left 0.3s cubic-bezier(0.4, 0, 0.2, 1)', transition: 'left 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
@@ -672,19 +696,19 @@ export default function ProjectList() {
onClick={() => setDrawerVisible(true)} onClick={() => setDrawerVisible(true)}
style={{ style={{
fontSize: 18, fontSize: 18,
color: token.colorWhite, color: token.colorText,
width: 36, width: 36,
height: 36 height: 36
}} }}
/> />
</div> </div>
<h2 style={{ <h2 className="app-serif-title" style={{
margin: 0, margin: 0,
color: token.colorWhite, color: token.colorText,
fontSize: 16, fontSize: 16,
fontWeight: 600, fontWeight: 600,
textShadow: `0 2px 4px ${alphaColor(token.colorText, 0.2)}`, textShadow: 'none',
flex: 1, flex: 1,
textAlign: 'center', textAlign: 'center',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
@@ -701,12 +725,12 @@ export default function ProjectList() {
<> <>
<div style={{ width: 40, zIndex: 1 }} /> <div style={{ width: 40, zIndex: 1 }} />
<h2 style={{ <h2 className="app-serif-title" style={{
margin: 0, margin: 0,
color: token.colorWhite, color: token.colorText,
fontSize: '24px', fontSize: '22px',
fontWeight: 600, fontWeight: 600,
textShadow: `0 2px 4px ${alphaColor(token.colorText, 0.2)}`, textShadow: 'none',
position: 'absolute', position: 'absolute',
left: '50%', left: '50%',
transform: 'translateX(-50%)', transform: 'translateX(-50%)',
@@ -735,29 +759,29 @@ export default function ProjectList() {
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
backdropFilter: 'blur(4px)', borderRadius: 2,
borderRadius: '28px',
minWidth: '56px', minWidth: '56px',
height: '56px', height: '52px',
padding: '0 12px', 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', cursor: 'default',
transition: 'transform 0.3s ease, box-shadow 0.3s ease', transition: 'transform 0.2s ease, box-shadow 0.2s ease',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-3px) scale(1.02)'; e.currentTarget.style.transform = 'translate(-1px, -1px)';
e.currentTarget.style.boxShadow = `inset 0 0 20px ${alphaColor(token.colorWhite, 0.25)}, 0 8px 16px ${alphaColor(token.colorText, 0.15)}`; e.currentTarget.style.boxShadow = `4px 4px 0 ${alphaColor(token.colorPrimary, 0.2)}`;
e.currentTarget.style.border = `1px solid ${alphaColor(token.colorWhite, 0.1)}`;
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0) scale(1)'; e.currentTarget.style.transform = 'translate(0, 0)';
e.currentTarget.style.boxShadow = `inset 0 0 15px ${alphaColor(token.colorWhite, 0.15)}, 0 4px 10px ${alphaColor(token.colorText, 0.1)}`; 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} {item.label}
</span> </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.label === '总字数' ? formatWordCount(item.value) : item.value}
{item.unit && <span style={{ fontSize: '10px', marginLeft: '2px', opacity: 0.8 }}>{item.unit}</span>} {item.unit && <span style={{ fontSize: '10px', marginLeft: '2px', opacity: 0.8 }}>{item.unit}</span>}
</span> </span>
@@ -789,7 +813,7 @@ export default function ProjectList() {
}}> }}>
<BookOutlined /> <BookOutlined />
</div> </div>
<span style={{ fontWeight: 600, fontSize: 16, fontFamily: token.fontFamily }}>MuMuAINovel</span> <span style={{ fontWeight: 600, fontSize: 16, fontFamily: token.fontFamily }}></span>
</div> </div>
} }
placement="left" placement="left"
@@ -804,8 +828,8 @@ export default function ProjectList() {
selectedKeys={[activeView]} selectedKeys={[activeView]}
style={{ borderRight: 0, paddingTop: 8 }} style={{ borderRight: 0, paddingTop: 8 }}
onClick={({ key }) => { onClick={({ key }) => {
if (key === 'mumu-api') { if (key === 'xinmi-api') {
window.open('https://api.mumuverse.space/register?aff=4NN8', '_blank', 'noopener,noreferrer'); openXinmiApi();
setDrawerVisible(false); setDrawerVisible(false);
return; return;
} }
@@ -887,8 +911,6 @@ export default function ProjectList() {
formatDate={formatDate} formatDate={formatDate}
/> />
)} )}
<ChangelogFloatingButton />
</div> </div>
</div> </div>
+30 -66
View File
@@ -248,8 +248,7 @@ export default function PromptTemplates() {
}; };
const currentTemplates = getCurrentTemplates(); const currentTemplates = getCurrentTemplates();
const pageBackground = `linear-gradient(180deg, ${token.colorBgLayout} 0%, ${token.colorFillSecondary} 100%)`; const pageBackground = token.colorBgLayout;
const headerBackground = `linear-gradient(135deg, ${token.colorPrimary} 0%, ${token.colorPrimaryHover} 100%)`;
return ( return (
<> <>
@@ -269,73 +268,37 @@ export default function PromptTemplates() {
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
}}> }}>
{/* 顶部导航卡片 */} {/* 顶部标题区 */}
<Card <Card
variant="borderless" variant="borderless"
className="app-prompt-header"
style={{ style={{
background: headerBackground, background: token.colorBgContainer,
borderRadius: isMobile ? 16 : 24, borderRadius: 2,
boxShadow: token.boxShadowSecondary, border: `1px solid ${token.colorBorder}`,
marginBottom: isMobile ? 20 : 24, marginBottom: isMobile ? 20 : 24,
border: 'none', boxShadow: `6px 6px 0 ${token.colorFillSecondary}`,
position: 'relative',
overflow: 'hidden'
}} }}
> >
{/* 装饰性背景元素 */} <Row align="middle" justify="space-between" gutter={[16, 16]}>
<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 }}>
<Col xs={24} sm={12} md={14}> <Col xs={24} sm={12} md={14}>
<Space direction="vertical" size={4}> <Space direction="vertical" size={4}>
<Title level={isMobile ? 3 : 2} style={{ margin: 0, color: token.colorWhite, textShadow: `0 2px 4px ${token.colorBgMask}` }}> <Title level={isMobile ? 3 : 2} className="app-serif-title" style={{ margin: 0, color: token.colorText }}>
<FileSearchOutlined style={{ color: token.colorWhite, opacity: 0.9, marginRight: 8 }} /> <FileSearchOutlined style={{ color: token.colorPrimary, marginRight: 8 }} />
</Title> </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生成提示词 AI
</Text> </Text>
</Space> </Space>
</Col> </Col>
<Col xs={24} sm={12} md={10}> <Col xs={24} sm={12} md={10}>
<Space wrap style={{ justifyContent: isMobile ? 'flex-start' : 'flex-end', width: '100%' }}> <Space wrap style={{ justifyContent: isMobile ? 'flex-start' : 'flex-end', width: '100%' }}>
<Button <Button icon={<DownloadOutlined />} onClick={handleExport} size={isMobile ? 'small' : 'middle'}>
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> </Button>
<Upload <Upload accept=".json" showUploadList={false} beforeUpload={handleImport}>
accept=".json" <Button icon={<UploadOutlined />} size={isMobile ? 'small' : 'middle'}>
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)',
}}
>
</Button> </Button>
</Upload> </Upload>
@@ -343,7 +306,6 @@ export default function PromptTemplates() {
</Col> </Col>
</Row> </Row>
{/* 使用提示 */}
<Alert <Alert
message={ message={
<Space align="center"> <Space align="center">
@@ -354,20 +316,20 @@ export default function PromptTemplates() {
description={ description={
<div> <div>
<Text style={{ fontSize: isMobile ? 12 : 13, display: 'block', marginBottom: 8 }}> <Text style={{ fontSize: isMobile ? 12 : 13, display: 'block', marginBottom: 8 }}>
<strong></strong>"编辑" <strong></strong>
</Text> </Text>
<Text style={{ fontSize: isMobile ? 12 : 13, display: 'block' }}> <Text style={{ fontSize: isMobile ? 12 : 13, display: 'block' }}>
<strong></strong>/使 <Text code>{'{variable_name}'}</Text> "重置" <strong></strong>/使 <Text code>{'{variable_name}'}</Text>
</Text> </Text>
</div> </div>
} }
type="info" type="info"
showIcon={false} showIcon={false}
style={{ style={{
marginTop: isMobile ? 16 : 24, marginTop: isMobile ? 16 : 20,
borderRadius: 12, borderRadius: 2,
background: token.colorInfoBg, background: token.colorFillTertiary,
border: `1px solid ${token.colorInfoBorder}` border: `1px solid ${token.colorBorder}`,
}} }}
/> />
</Card> </Card>
@@ -381,8 +343,9 @@ export default function PromptTemplates() {
variant="borderless" variant="borderless"
style={{ style={{
background: token.colorBgContainer, background: token.colorBgContainer,
borderRadius: isMobile ? 12 : 16, borderRadius: 2,
boxShadow: token.boxShadowSecondary, border: `1px solid ${token.colorBorder}`,
boxShadow: 'none',
marginBottom: isMobile ? 16 : 24 marginBottom: isMobile ? 16 : 24
}} }}
styles={{ body: { padding: isMobile ? '12px' : '16px' } }} styles={{ body: { padding: isMobile ? '12px' : '16px' } }}
@@ -407,8 +370,9 @@ export default function PromptTemplates() {
variant="borderless" variant="borderless"
style={{ style={{
background: token.colorBgContainer, background: token.colorBgContainer,
borderRadius: isMobile ? 12 : 16, borderRadius: 2,
boxShadow: token.boxShadowSecondary, border: `1px solid ${token.colorBorder}`,
boxShadow: 'none',
}} }}
> >
<Empty <Empty
@@ -430,7 +394,7 @@ export default function PromptTemplates() {
{/* 头部 */} {/* 头部 */}
<div style={{ <div style={{
background: template.is_system_default background: template.is_system_default
? token.colorFillTertiary ? token.colorFillSecondary
: token.colorPrimary, : token.colorPrimary,
padding: isMobile ? '16px' : '20px', padding: isMobile ? '16px' : '20px',
position: 'relative' position: 'relative'
@@ -490,7 +454,7 @@ export default function PromptTemplates() {
icon={<EditOutlined />} icon={<EditOutlined />}
onClick={() => handleEdit(template)} onClick={() => handleEdit(template)}
size={isMobile ? 'small' : 'middle'} size={isMobile ? 'small' : 'middle'}
style={{ borderRadius: 6 }} style={{ borderRadius: 2 }}
> >
</Button> </Button>
@@ -498,7 +462,7 @@ export default function PromptTemplates() {
icon={<ReloadOutlined />} icon={<ReloadOutlined />}
onClick={() => handleReset(template.template_key)} onClick={() => handleReset(template.template_key)}
size={isMobile ? 'small' : 'middle'} size={isMobile ? 'small' : 'middle'}
style={{ borderRadius: 6 }} style={{ borderRadius: 2 }}
> >
</Button> </Button>
+100 -42
View File
@@ -4,6 +4,7 @@ import { SaveOutlined, DeleteOutlined, ReloadOutlined, InfoCircleOutlined, Check
import { settingsApi, mcpPluginApi } from '../services/api'; import { settingsApi, mcpPluginApi } from '../services/api';
import type { SettingsUpdate, APIKeyPreset, PresetCreateRequest, APIKeyPresetConfig } from '../types'; import type { SettingsUpdate, APIKeyPreset, PresetCreateRequest, APIKeyPresetConfig } from '../types';
import { eventBus, EventNames } from '../store/eventBus'; import { eventBus, EventNames } from '../store/eventBus';
import { VERSION_INFO } from '../config/version';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { Option } = Select; const { Option } = Select;
@@ -48,6 +49,8 @@ export default function SettingsPage() {
const [presets, setPresets] = useState<APIKeyPreset[]>([]); const [presets, setPresets] = useState<APIKeyPreset[]>([]);
const [presetsLoading, setPresetsLoading] = useState(false); const [presetsLoading, setPresetsLoading] = useState(false);
const [activePresetId, setActivePresetId] = useState<string | undefined>(); 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 [editingPreset, setEditingPreset] = useState<APIKeyPreset | null>(null);
const [isPresetModalVisible, setIsPresetModalVisible] = useState(false); const [isPresetModalVisible, setIsPresetModalVisible] = useState(false);
const [testingPresetId, setTestingPresetId] = useState<string | null>(null); const [testingPresetId, setTestingPresetId] = useState<string | null>(null);
@@ -282,25 +285,29 @@ export default function SettingsPage() {
}); });
}; };
const mumuTextDefaultUrl = 'https://api.mumuverse.space/v1'; const xinmiApiHost = VERSION_INFO.xinmiApiBaseUrl.replace(/\/$/, '');
const mumuRegisterUrl = 'https://api.mumuverse.space/register?aff=4NN8'; const xinmiTextDefaultUrl = `${xinmiApiHost}/v1`;
const mumuCoverBaseUrlOptions = [ const officialApiDocUrl = VERSION_INFO.officialApiDocUrl;
{ value: 'https://api.mumuverse.space/v1beta', label: 'https://api.mumuverse.space/v1beta', defaultModel: 'gemini-3.1-flash-image-preview' }, const openOfficialApiDoc = () => {
{ value: 'https://api.mumuverse.space/v1', label: 'https://api.mumuverse.space/v1', defaultModel: 'gpt-image-1.5' }, 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 = { const defaultCoverSettings = {
cover_enabled: false, cover_enabled: false,
cover_api_provider: 'mumu', cover_api_provider: 'xinmi',
cover_api_key: '', cover_api_key: '',
cover_api_base_url: mumuCoverBaseUrlOptions[0].value, cover_api_base_url: xinmiCoverBaseUrlOptions[0].value,
cover_image_model: mumuCoverBaseUrlOptions[0].defaultModel, cover_image_model: xinmiCoverBaseUrlOptions[0].defaultModel,
}; };
const apiProviders = [ const apiProviders = [
{ {
value: 'mumu', value: 'xinmi',
label: 'MuMuのAPI', label: 'XinMi API',
defaultUrl: mumuTextDefaultUrl, defaultUrl: xinmiTextDefaultUrl,
defaultModel: 'gemini-3-flash-preview' defaultModel: 'gemini-3-flash-preview'
}, },
{ value: 'openai', label: 'OpenAI Compatible', defaultUrl: 'https://api.openai.com/v1' }, { value: 'openai', label: 'OpenAI Compatible', defaultUrl: 'https://api.openai.com/v1' },
@@ -319,7 +326,7 @@ export default function SettingsPage() {
if (provider.defaultUrl) { if (provider.defaultUrl) {
nextValues.api_base_url = provider.defaultUrl; nextValues.api_base_url = provider.defaultUrl;
} }
if (provider.value === 'mumu') { if (provider.value === 'xinmi') {
nextValues.api_key = ''; nextValues.api_key = '';
nextValues.llm_model = provider.defaultModel || 'gemini-3-flash-preview'; nextValues.llm_model = provider.defaultModel || 'gemini-3-flash-preview';
} }
@@ -332,10 +339,10 @@ export default function SettingsPage() {
const coverApiProviders = [ const coverApiProviders = [
{ {
value: 'mumu', value: 'xinmi',
label: 'MuMuのAPI', label: 'XinMi API',
defaultUrl: mumuCoverBaseUrlOptions[0].value, defaultUrl: xinmiCoverBaseUrlOptions[0].value,
defaultModel: mumuCoverBaseUrlOptions[0].defaultModel, defaultModel: xinmiCoverBaseUrlOptions[0].defaultModel,
}, },
{ value: 'gemini', label: 'Google Gemini', defaultUrl: 'https://generativelanguage.googleapis.com/v1beta' }, { value: 'gemini', label: 'Google Gemini', defaultUrl: 'https://generativelanguage.googleapis.com/v1beta' },
{ value: 'grok', label: 'Grok', defaultUrl: 'https://api.x.ai/v1' }, { value: 'grok', label: 'Grok', defaultUrl: 'https://api.x.ai/v1' },
@@ -352,20 +359,20 @@ export default function SettingsPage() {
if (provider.defaultUrl) { if (provider.defaultUrl) {
nextValues.cover_api_base_url = provider.defaultUrl; nextValues.cover_api_base_url = provider.defaultUrl;
} }
if (provider.value === 'mumu') { if (provider.value === 'xinmi') {
nextValues.cover_api_key = ''; 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); form.setFieldsValue(nextValues);
setCoverTestResult(null); setCoverTestResult(null);
}; };
const handleMumuCoverBaseUrlChange = (value: string) => { const handleXinmiCoverBaseUrlChange = (value: string) => {
const option = mumuCoverBaseUrlOptions.find(item => item.value === value); const option = xinmiCoverBaseUrlOptions.find(item => item.value === value);
form.setFieldsValue({ form.setFieldsValue({
cover_api_base_url: value, cover_api_base_url: value,
cover_image_model: option?.defaultModel || mumuCoverBaseUrlOptions[0].defaultModel, cover_image_model: option?.defaultModel || xinmiCoverBaseUrlOptions[0].defaultModel,
}); });
setCoverTestResult(null); setCoverTestResult(null);
}; };
@@ -511,6 +518,7 @@ export default function SettingsPage() {
const response = await settingsApi.getPresets(); const response = await settingsApi.getPresets();
setPresets(response.presets); setPresets(response.presets);
setActivePresetId(response.active_preset_id); setActivePresetId(response.active_preset_id);
setChapterAnalysisPresetId(response.chapter_analysis_preset_id);
} catch (error) { } catch (error) {
message.error('加载预设失败'); message.error('加载预设失败');
console.error(error); console.error(error);
@@ -608,7 +616,7 @@ export default function SettingsPage() {
if (provider.defaultUrl) { if (provider.defaultUrl) {
nextValues.api_base_url = provider.defaultUrl; nextValues.api_base_url = provider.defaultUrl;
} }
if (provider.value === 'mumu') { if (provider.value === 'xinmi') {
nextValues.api_key = ''; nextValues.api_key = '';
nextValues.llm_model = provider.defaultModel || 'gemini-3-flash-preview'; 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) => { const handlePresetDelete = async (presetId: string) => {
try { try {
await settingsApi.deletePreset(presetId); await settingsApi.deletePreset(presetId);
@@ -903,7 +928,7 @@ export default function SettingsPage() {
// return 'purple'; // return 'purple';
case 'gemini': case 'gemini':
return 'green'; return 'green';
case 'mumu': case 'xinmi':
return 'magenta'; return 'magenta';
default: default:
return 'default'; return 'default';
@@ -927,6 +952,38 @@ export default function SettingsPage() {
</Space> </Space>
</div> </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 ? ( {presets.length === 0 ? (
<Empty <Empty
description="暂无预设配置" description="暂无预设配置"
@@ -1007,6 +1064,7 @@ export default function SettingsPage() {
<Space> <Space>
<span style={{ fontWeight: 'bold' }}>{preset.name}</span> <span style={{ fontWeight: 'bold' }}>{preset.name}</span>
{isActive && <Tag color="success"></Tag>} {isActive && <Tag color="success"></Tag>}
{preset.id === chapterAnalysisPresetId && <Tag color="processing"></Tag>}
</Space> </Space>
} }
description={ description={
@@ -1175,11 +1233,11 @@ export default function SettingsPage() {
</Select> </Select>
</Form.Item> </Form.Item>
{selectedProvider === 'mumu' && ( {selectedProvider === 'xinmi' && (
<Alert <Alert
type="info" type="info"
showIcon showIcon
message="MuMuのAPI 专属供应商" message="墨木灵思 API 专属供应商"
description={ description={
<Space direction="vertical" size={8} style={{ width: '100%' }}> <Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text> <Text>
@@ -1188,9 +1246,9 @@ export default function SettingsPage() {
<div> <div>
<Button <Button
type="primary" type="primary"
onClick={() => window.open(mumuRegisterUrl, '_blank', 'noopener,noreferrer')} onClick={openOfficialApiDoc}
> >
MuMuのAPI API
</Button> </Button>
</div> </div>
</Space> </Space>
@@ -1687,22 +1745,22 @@ export default function SettingsPage() {
</Select> </Select>
</Form.Item> </Form.Item>
{selectedCoverProvider === 'mumu' && ( {selectedCoverProvider === 'xinmi' && (
<Alert <Alert
type="info" type="info"
showIcon showIcon
message="MuMuのAPI 专属适配器" message="墨木灵思 API 专属适配器"
description={ description={
<Space direction="vertical" size={8} style={{ width: '100%' }}> <Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text> <Text>
MuMuのAPI API Key MuMuのAPI API API Key
</Text> </Text>
<div> <div>
<Button <Button
type="primary" type="primary"
onClick={() => window.open(mumuRegisterUrl, '_blank', 'noopener,noreferrer')} onClick={openOfficialApiDoc}
> >
MuMuのAPI API
</Button> </Button>
</div> </div>
</Space> </Space>
@@ -1712,15 +1770,15 @@ export default function SettingsPage() {
)} )}
<Form.Item label="封面图片 API Key" name="cover_api_key" rules={[{ required: true, message: '请输入封面图片 API Key' }]}> <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>
<Form.Item label="封面图片 API 地址" name="cover_api_base_url" rules={[{ type: 'url', message: '请输入有效的URL' }]}> <Form.Item label="封面图片 API 地址" name="cover_api_base_url" rules={[{ type: 'url', message: '请输入有效的URL' }]}>
{selectedCoverProvider === 'mumu' ? ( {selectedCoverProvider === 'xinmi' ? (
<Select <Select
size={isMobile ? 'middle' : 'large'} size={isMobile ? 'middle' : 'large'}
onChange={handleMumuCoverBaseUrlChange} onChange={handleXinmiCoverBaseUrlChange}
options={mumuCoverBaseUrlOptions.map(option => ({ options={xinmiCoverBaseUrlOptions.map(option => ({
value: option.value, value: option.value,
label: option.label, label: option.label,
}))} }))}
@@ -1733,7 +1791,7 @@ export default function SettingsPage() {
<Form.Item label="封面图片模型" name="cover_image_model" rules={[{ required: true, message: '请输入封面图片模型名称' }]}> <Form.Item label="封面图片模型" name="cover_image_model" rules={[{ required: true, message: '请输入封面图片模型名称' }]}>
<Input <Input
size={isMobile ? 'middle' : 'large'} size={isMobile ? 'middle' : 'large'}
placeholder={selectedCoverProvider === 'mumu' placeholder={selectedCoverProvider === 'xinmi'
? '选择地址后自动填入推荐模型' ? '选择地址后自动填入推荐模型'
: selectedCoverProvider === 'grok' : selectedCoverProvider === 'grok'
? 'grok-2-image' ? 'grok-2-image'
@@ -1825,17 +1883,17 @@ export default function SettingsPage() {
style={{ marginBottom: 16 }} style={{ marginBottom: 16 }}
> >
<Select placeholder="选择提供商" onChange={handlePresetProviderChange}> <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="openai">OpenAI</Select.Option>
<Select.Option value="gemini">Google Gemini</Select.Option> <Select.Option value="gemini">Google Gemini</Select.Option>
</Select> </Select>
</Form.Item> </Form.Item>
{selectedPresetProvider === 'mumu' && ( {selectedPresetProvider === 'xinmi' && (
<Alert <Alert
type="info" type="info"
showIcon showIcon
message="MuMuのAPI 专属供应商" message="墨木灵思 API 专属供应商"
description={ description={
<Space direction="vertical" size={8} style={{ width: '100%' }}> <Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text> <Text>
@@ -1844,9 +1902,9 @@ export default function SettingsPage() {
<div> <div>
<Button <Button
type="primary" type="primary"
onClick={() => window.open(mumuRegisterUrl, '_blank', 'noopener,noreferrer')} onClick={openOfficialApiDoc}
> >
MuMuのAPI API
</Button> </Button>
</div> </div>
</Space> </Space>
+4 -4
View File
@@ -104,13 +104,13 @@ export default function Sponsor() {
color: token.colorWhite color: token.colorWhite
}}> }}>
<Title level={1} style={{ color: token.colorWhite, marginBottom: '8px', fontSize: 'clamp(24px, 5vw, 32px)', fontWeight: 'bold' }}> <Title level={1} style={{ color: token.colorWhite, marginBottom: '8px', fontSize: 'clamp(24px, 5vw, 32px)', fontWeight: 'bold' }}>
MuMuAINovel
</Title> </Title>
<Text type="secondary" style={{ color: token.colorWhite, fontSize: 'clamp(11px, 2vw, 13px)', letterSpacing: '2px' }}> <Text type="secondary" style={{ color: token.colorWhite, fontSize: 'clamp(11px, 2vw, 13px)', letterSpacing: '2px' }}>
SUPPORT MuMuAINovel SUPPORT
</Text> </Text>
<Title level={4} style={{ color: token.colorWhite, marginTop: '8px', marginBottom: '8px' }}> <Title level={4} style={{ color: token.colorWhite, marginTop: '8px', marginBottom: '8px' }}>
📚 MuMuAINovel - AI 📚 - AI
</Title> </Title>
</div> </div>
</div> </div>
@@ -222,7 +222,7 @@ export default function Sponsor() {
marginTop: 'auto' marginTop: 'auto'
}}> }}>
<Title level={4} style={{ marginBottom: '12px', fontSize: 'clamp(16px, 3vw, 20px)' }}> <Title level={4} style={{ marginBottom: '12px', fontSize: 'clamp(16px, 3vw, 20px)' }}>
💖 MuMuAINovel 💖
</Title> </Title>
<Paragraph style={{ fontSize: 'clamp(12px, 2vw, 14px)', color: token.colorTextSecondary, marginBottom: '12px' }}> <Paragraph style={{ fontSize: 'clamp(12px, 2vw, 14px)', color: token.colorTextSecondary, marginBottom: '12px' }}>
AI小说创作体验! AI小说创作体验!
+1 -1
View File
@@ -216,7 +216,7 @@ export default function SystemSettingsPage() {
</Col> </Col>
<Col xs={24} md={12}> <Col xs={24} md={12}>
<Form.Item name="smtp_from_name" label="发件人名称" rules={[{ required: true, message: '请输入发件人名称' }]}> <Form.Item name="smtp_from_name" label="发件人名称" rules={[{ required: true, message: '请输入发件人名称' }]}>
<Input placeholder="MuMuAINovel" /> <Input placeholder="墨木灵思" />
</Form.Item> </Form.Item>
</Col> </Col>
</Row> </Row>
+5
View File
@@ -315,6 +315,11 @@ export const settingsApi = {
suggestions?: string[]; suggestions?: string[];
}>(`/settings/presets/${presetId}/test`), }>(`/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) => createPresetFromCurrent: (name: string, description?: string) =>
api.post<unknown, APIKeyPreset>('/settings/presets/from-current', null, { api.post<unknown, APIKeyPreset>('/settings/presets/from-current', null, {
params: { name, description } params: { name, description }
+52 -1
View File
@@ -13,9 +13,10 @@ export interface TaskStatus {
status_message: string | null; status_message: string | null;
progress_details: { progress_details: {
stage: string; stage: string;
message: string; message?: string;
current_chars?: number; current_chars?: number;
retry_count?: number; retry_count?: number;
queue_size?: number;
} | null; } | null;
error_message: string | null; error_message: string | null;
task_result: Record<string, unknown> | null; task_result: Record<string, unknown> | null;
@@ -31,6 +32,22 @@ export interface TaskListResponse {
items: TaskStatus[]; 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(); 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> { export async function checkLatestVersion(): Promise<VersionCheckResult> {
try { try {
// 使用 shields.io 的 GitHub release badge API // 使用 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, { const response = await fetch(badgeUrl, {
method: 'GET', method: 'GET',
@@ -63,7 +63,7 @@ export async function checkLatestVersion(): Promise<VersionCheckResult> {
return { return {
hasUpdate, hasUpdate,
latestVersion, latestVersion,
releaseUrl: `https://github.com/xiamuceer-j/MuMuAINovel/releases/tag/v${latestVersion}`, releaseUrl: '',
}; };
} }
} }
@@ -74,7 +74,7 @@ export async function checkLatestVersion(): Promise<VersionCheckResult> {
return { return {
hasUpdate: false, hasUpdate: false,
latestVersion: VERSION_INFO.version, 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'>; export type ResolvedThemeMode = Exclude<ThemeMode, 'system'>;
/** 铜墨编辑部 — 暖赭石 + 纸感底色,与原先蓝紫圆角风区分 */
const sharedToken: ThemeConfig['token'] = { const sharedToken: ThemeConfig['token'] = {
colorPrimary: '#4D8088', colorPrimary: '#B45309',
borderRadius: 8, colorInfo: '#0D9488',
colorSuccess: '#15803D',
colorWarning: '#CA8A04',
colorError: '#B91C1C',
borderRadius: 2,
wireframe: false, 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'] = { const sharedComponents: ThemeConfig['components'] = {
Button: { Button: {
borderRadius: 8, borderRadius: 2,
controlHeight: 36, controlHeight: 40,
fontWeight: 600,
primaryShadow: 'none',
}, },
Card: { 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: { Tooltip: {
colorBgSpotlight: sharedToken.colorPrimary, colorBgSpotlight: '#292524',
}, },
}; };
@@ -28,17 +53,34 @@ const lightThemeConfig: ThemeConfig = {
algorithm: theme.defaultAlgorithm, algorithm: theme.defaultAlgorithm,
token: { token: {
...sharedToken, ...sharedToken,
colorBgBase: '#F8F6F1', colorBgBase: '#F5F0E6',
colorTextBase: '#2B2B2B', colorTextBase: '#292524',
colorBgLayout: '#F8F6F1', colorBgLayout: '#EDE8DC',
colorBgContainer: '#FFFFFF', colorBgContainer: '#FFFCF7',
colorBorder: '#C9BFB0',
colorBorderSecondary: '#DDD5C8',
colorFillSecondary: '#E8E2D6',
colorFillTertiary: '#F0EBE1',
colorPrimaryBg: '#FEF3C7',
colorPrimaryBorder: '#D97706',
colorPrimaryHover: '#92400E',
colorLink: '#9A3412',
colorLinkHover: '#7C2D12',
}, },
components: { components: {
...sharedComponents, ...sharedComponents,
Layout: { Layout: {
bodyBg: '#F8F6F1', bodyBg: '#EDE8DC',
headerBg: '#FFFFFF', headerBg: '#FFFCF7',
siderBg: '#FFFFFF', 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, algorithm: theme.darkAlgorithm,
token: { token: {
...sharedToken, ...sharedToken,
colorBgBase: '#141414', colorPrimary: '#F59E0B',
colorTextBase: '#f5f5f5', 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: { components: {
...sharedComponents, ...sharedComponents,
Layout: { Layout: {
bodyBg: '#0f1115', bodyBg: '#0C0A09',
headerBg: '#141414', headerBg: '#1C1917',
siderBg: '#141414', 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 => { export const getThemeConfig = (mode: ResolvedThemeMode): ThemeConfig => {
return mode === 'dark' ? darkThemeConfig : lightThemeConfig; 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'; 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 => { const isThemeMode = (value: string | null): value is ThemeMode => {
return value === 'light' || value === 'dark' || value === 'system'; return value === 'light' || value === 'dark' || value === 'system';
@@ -8,7 +9,18 @@ const isThemeMode = (value: string | null): value is ThemeMode => {
export const getStoredThemeMode = (): ThemeMode => { export const getStoredThemeMode = (): ThemeMode => {
try { 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)) { if (isThemeMode(value)) {
return value; return value;
} }
+1
View File
@@ -143,6 +143,7 @@ export interface PresetListResponse {
presets: APIKeyPreset[]; presets: APIKeyPreset[];
total: number; total: number;
active_preset_id?: string; active_preset_id?: string;
chapter_analysis_preset_id?: string;
} }
// LinuxDO 授权 URL 响应 // 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 => { export const getStoredSidebarCollapsed = (): boolean => {
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
@@ -6,7 +7,21 @@ export const getStoredSidebarCollapsed = (): boolean => {
} }
try { 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) { } catch (error) {
console.warn('读取侧边栏状态失败:', error); console.warn('读取侧边栏状态失败:', error);
return false; 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 #!/bin/bash
# ============================================================================= # =============================================================================
# MuMuAINovel Termux 一键安装脚本 # 墨木灵思 Termux 一键安装脚本
# ============================================================================= # =============================================================================
# #
set -e set -e
# ── 路径配置 ────────────────────────────────────────────────────────────────── # ── 路径配置 ──────────────────────────────────────────────────────────────────
INSTALL_DIR="$HOME/MuMuAINovel" # 项目安装目录 INSTALL_DIR="$HOME/mumulingsi" # 项目安装目录
DATA_DIR="$HOME/mumuainovel/data" # 数据库目录 DATA_DIR="$HOME/mumulingsi/data" # 数据库目录
LOG_DIR="$HOME/mumuainovel/logs" # 日志目录 LOG_DIR="$HOME/mumulingsi/logs" # 日志目录
REPO="https://ghfast.top/https://github.com/xiamuceer-j/MuMuAINovel.git" # GitHub 镜像 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' 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 ""
echo -e "${CYAN}╔══════════════════════════════════════════╗${NC}" echo -e "${CYAN}╔══════════════════════════════════════════╗${NC}"
echo -e "${CYAN}║ 📚 MuMuAINovel Termux 一键安装 ║${NC}" echo -e "${CYAN}║ 📚 墨木灵思 Termux 一键安装 ║${NC}"
echo -e "${CYAN}╚══════════════════════════════════════════╝${NC}" echo -e "${CYAN}╚══════════════════════════════════════════╝${NC}"
echo "" echo ""
@@ -118,7 +118,7 @@ LOG="$TMPDIR/patch.log"
# ── 4a. 修补 memory_service.py ────────────────────────────────────────────── # ── 4a. 修补 memory_service.py ──────────────────────────────────────────────
python3 << 'PYEOF' python3 << 'PYEOF'
import os 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: with open(f) as fh:
c = fh.read() c = fh.read()
@@ -150,11 +150,11 @@ python3 << 'PYEOF'
import os import os
home = os.path.expanduser("~") home = os.path.expanduser("~")
files = [ files = [
f"{home}/MuMuAINovel/backend/app/api/chapters.py", f"{home}/mumulingsi/backend/app/api/chapters.py",
f"{home}/MuMuAINovel/backend/app/api/memories.py", f"{home}/mumulingsi/backend/app/api/memories.py",
f"{home}/MuMuAINovel/backend/app/api/outlines.py", f"{home}/mumulingsi/backend/app/api/outlines.py",
f"{home}/MuMuAINovel/backend/app/api/projects.py", f"{home}/mumulingsi/backend/app/api/projects.py",
f"{home}/MuMuAINovel/backend/app/services/foreshadow_service.py", f"{home}/mumulingsi/backend/app/services/foreshadow_service.py",
] ]
old = 'from app.services.memory_service import memory_service' 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' 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" mkdir -p "$DATA_DIR" "$LOG_DIR"
if [ ! -f "$BACKEND/.env" ]; then if [ ! -f "$BACKEND/.env" ]; then
cat > "$BACKEND/.env" << 'ENVEOF' cat > "$BACKEND/.env" << 'ENVEOF'
# MuMuAINovel Termux 配置 # 墨木灵思 Termux 配置
APP_NAME=MuMuAINovel APP_NAME=墨木灵思
APP_HOST=0.0.0.0 APP_HOST=0.0.0.0
APP_PORT=8000 APP_PORT=8000
DEBUG=false DEBUG=false
TZ=Asia/Shanghai TZ=Asia/Shanghai
# SQLite 数据库(替代 PostgreSQL # 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_LEVEL=INFO
LOG_TO_FILE=true 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_MAX_BYTES=10485760
LOG_BACKUP_COUNT=5 LOG_BACKUP_COUNT=5
# CORS # 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 # ⚠️ 请填入你的 API Key
OPENAI_API_KEY=*** OPENAI_API_KEY=***
@@ -295,12 +295,12 @@ grep -E "built in" "$LOG" | sed 's/^/ /'
# ============================================================================= # =============================================================================
# 步骤 9: 创建启动脚本 # 步骤 9: 创建启动脚本
# 说明: 生成 ~/mumuainovel-start.sh,支持前台/后台运行 # 说明: 生成 ~/mumulingsi-start.sh,支持前台/后台运行
# ============================================================================= # =============================================================================
step 9 $TOTAL "创建启动脚本" step 9 $TOTAL "创建启动脚本"
cat > "$HOME/mumuainovel-start.sh" << STARTEOF cat > "$HOME/mumulingsi-start.sh" << STARTEOF
#!/bin/bash #!/bin/bash
# MuMuAINovel Termux 启动脚本 # 墨木灵思 Termux 启动脚本
set -e set -e
BACKEND="$BACKEND" BACKEND="$BACKEND"
@@ -313,44 +313,44 @@ export DATABASE_URL="sqlite+aiosqlite:///\$DATA_DIR/ai_story.db"
cd "\$BACKEND" cd "\$BACKEND"
if [ "\$1" = "--bg" ]; then if [ "\$1" = "--bg" ]; then
echo "🚀 后台启动 MuMuAINovel (端口 8000)..." echo "🚀 后台启动 墨木灵思 (端口 8000)..."
nohup "\$PYTHON" -m uvicorn app.main:app --host 0.0.0.0 --port 8000 \\ nohup "\$PYTHON" -m uvicorn app.main:app --host 0.0.0.0 --port 8000 \\
> "\$LOG_DIR/app.log" 2>&1 & > "\$LOG_DIR/app.log" 2>&1 &
echo \$! > "$HOME/mumuainovel.pid" echo \$! > "$HOME/mumulingsi.pid"
sleep 2 sleep 2
if kill -0 \$(cat "$HOME/mumuainovel.pid") 2>/dev/null; then if kill -0 \$(cat "$HOME/mumulingsi.pid") 2>/dev/null; then
echo "✅ 已启动, PID: \$(cat $HOME/mumuainovel.pid)" echo "✅ 已启动, PID: \$(cat $HOME/mumulingsi.pid)"
else else
echo "❌ 启动失败,查看日志: \$LOG_DIR/app.log" echo "❌ 启动失败,查看日志: \$LOG_DIR/app.log"
exit 1 exit 1
fi fi
else else
echo "🚀 启动 MuMuAINovel (端口 8000, Ctrl+C 停止)..." echo "🚀 启动 墨木灵思 (端口 8000, Ctrl+C 停止)..."
exec "\$PYTHON" -m uvicorn app.main:app --host 0.0.0.0 --port 8000 exec "\$PYTHON" -m uvicorn app.main:app --host 0.0.0.0 --port 8000
fi fi
STARTEOF STARTEOF
chmod +x "$HOME/mumuainovel-start.sh" chmod +x "$HOME/mumulingsi-start.sh"
info "启动脚本已创建: ~/mumuainovel-start.sh" info "启动脚本已创建: ~/mumulingsi-start.sh"
# ============================================================================= # =============================================================================
# 安装完成 # 安装完成
# ============================================================================= # =============================================================================
echo "" echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════╗${NC}" echo -e "${GREEN}╔══════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ 🎉 MuMuAINovel 安装完成! ║${NC}" echo -e "${GREEN}║ 🎉 墨木灵思 安装完成! ║${NC}"
echo -e "${GREEN}╠══════════════════════════════════════════════╣${NC}" echo -e "${GREEN}╠══════════════════════════════════════════════╣${NC}"
echo -e "${GREEN}║ ║${NC}" echo -e "${GREEN}║ ║${NC}"
echo -e "${GREEN}║ 前台运行(Ctrl+C 停止): ║${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}║ 后台运行: ║${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}║ 停止后台: ║${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}║ 查看日志: ║${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}║ ║${NC}"
echo -e "${GREEN}║ 🌐 访问: http://127.0.0.1:8000 ║${NC}" echo -e "${GREEN}║ 🌐 访问: http://127.0.0.1:8000 ║${NC}"
echo -e "${GREEN}║ 🔑 账号: admin / admin123 ║${NC}" echo -e "${GREEN}║ 🔑 账号: admin / admin123 ║${NC}"