update:1.新增手动创建大纲和章节,编写章节规划内容

2.新增项目更新日志页面,同步GitHub更新日志
3.新增章节内容生成时,选择本次生成人称
4.修复1 - N模式下,章节标题无法修改的问题
5.修复章节管理界面,批量生成后没有更新页面内容和状态
This commit is contained in:
xiamuceer
2025-12-06 14:08:20 +08:00
parent 187feac671
commit f831d07864
15 changed files with 1398 additions and 131 deletions
+233
View File
@@ -0,0 +1,233 @@
"""
更新日志API
提供GitHub提交历史的缓存和代理服务
"""
from fastapi import APIRouter, HTTPException, Query
from typing import List, Optional
import httpx
from datetime import datetime, timedelta
from pydantic import BaseModel
import logging
logger = logging.getLogger(__name__)
router = APIRouter()
# GitHub API配置
GITHUB_API_BASE = "https://api.github.com"
REPO_OWNER = "xiamuceer-j"
REPO_NAME = "MuMuAINovel"
# 缓存配置
_cache = {
"data": None,
"timestamp": None,
"ttl": timedelta(hours=1) # 缓存1小时
}
class GitHubAuthor(BaseModel):
"""GitHub作者信息"""
name: str
email: str
date: str
class GitHubCommitInfo(BaseModel):
"""GitHub提交信息"""
author: GitHubAuthor
message: str
class GitHubUser(BaseModel):
"""GitHub用户信息"""
login: str
avatar_url: str
class GitHubCommit(BaseModel):
"""GitHub提交数据"""
sha: str
commit: GitHubCommitInfo
html_url: str
author: Optional[GitHubUser] = None
class ChangelogResponse(BaseModel):
"""更新日志响应"""
commits: List[GitHubCommit]
cached: bool
cache_time: Optional[str] = None
def is_cache_valid() -> bool:
"""检查缓存是否有效"""
if _cache["data"] is None or _cache["timestamp"] is None:
return False
now = datetime.now()
cache_age = now - _cache["timestamp"]
return cache_age < _cache["ttl"]
async def fetch_github_commits(page: int = 1, per_page: int = 30) -> List[dict]:
"""从GitHub API获取提交历史"""
url = f"{GITHUB_API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/commits"
params = {
"author": REPO_OWNER,
"page": page,
"per_page": per_page
}
headers = {
"Accept": "application/vnd.github.v3+json",
"User-Agent": "MuMuAINovel-App"
}
try:
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.get(url, params=params, headers=headers)
response.raise_for_status()
return response.json()
except httpx.HTTPError as e:
logger.error(f"GitHub API请求失败: {str(e)}")
raise HTTPException(
status_code=502,
detail=f"获取GitHub提交历史失败: {str(e)}"
)
@router.get("/changelog", response_model=ChangelogResponse)
async def get_changelog(
page: int = Query(1, ge=1, description="页码"),
per_page: int = Query(30, ge=1, le=100, description="每页数量")
):
"""
获取更新日志
从GitHub获取项目的提交历史,支持缓存以减少API调用
- **page**: 页码,从1开始
- **per_page**: 每页返回的提交数量,最大100
"""
try:
# 只缓存第一页
if page == 1 and is_cache_valid():
logger.info("使用缓存的更新日志")
return ChangelogResponse(
commits=_cache["data"],
cached=True,
cache_time=_cache["timestamp"].isoformat()
)
# 从GitHub获取数据
logger.info(f"从GitHub获取更新日志 (page={page}, per_page={per_page})")
commits_data = await fetch_github_commits(page, per_page)
# 解析数据
commits = []
for commit_data in commits_data:
try:
commit = GitHubCommit(
sha=commit_data["sha"],
commit=GitHubCommitInfo(
author=GitHubAuthor(
name=commit_data["commit"]["author"]["name"],
email=commit_data["commit"]["author"]["email"],
date=commit_data["commit"]["author"]["date"]
),
message=commit_data["commit"]["message"]
),
html_url=commit_data["html_url"],
author=GitHubUser(
login=commit_data["author"]["login"],
avatar_url=commit_data["author"]["avatar_url"]
) if commit_data.get("author") else None
)
commits.append(commit)
except (KeyError, TypeError) as e:
logger.warning(f"解析提交数据失败: {str(e)}")
continue
# 缓存第一页数据
if page == 1:
_cache["data"] = commits
_cache["timestamp"] = datetime.now()
logger.info("已缓存更新日志")
return ChangelogResponse(
commits=commits,
cached=False,
cache_time=None
)
except HTTPException:
raise
except Exception as e:
logger.error(f"获取更新日志时发生错误: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"获取更新日志失败: {str(e)}"
)
@router.post("/changelog/refresh")
async def refresh_changelog():
"""
刷新更新日志缓存
强制从GitHub重新获取最新的提交历史
"""
try:
logger.info("刷新更新日志缓存")
# 清除缓存
_cache["data"] = None
_cache["timestamp"] = None
# 重新获取
commits_data = await fetch_github_commits(1, 30)
# 解析数据
commits = []
for commit_data in commits_data:
try:
commit = GitHubCommit(
sha=commit_data["sha"],
commit=GitHubCommitInfo(
author=GitHubAuthor(
name=commit_data["commit"]["author"]["name"],
email=commit_data["commit"]["author"]["email"],
date=commit_data["commit"]["author"]["date"]
),
message=commit_data["commit"]["message"]
),
html_url=commit_data["html_url"],
author=GitHubUser(
login=commit_data["author"]["login"],
avatar_url=commit_data["author"]["avatar_url"]
) if commit_data.get("author") else None
)
commits.append(commit)
except (KeyError, TypeError) as e:
logger.warning(f"解析提交数据失败: {str(e)}")
continue
# 更新缓存
_cache["data"] = commits
_cache["timestamp"] = datetime.now()
return {
"success": True,
"message": "缓存已刷新",
"commit_count": len(commits),
"cache_time": _cache["timestamp"].isoformat()
}
except Exception as e:
logger.error(f"刷新缓存时发生错误: {str(e)}")
raise HTTPException(
status_code=500,
detail=f"刷新缓存失败: {str(e)}"
)
+57 -7
View File
@@ -104,8 +104,8 @@ async def create_chapter(
user_id = getattr(request.state, 'user_id', None)
project = await verify_project_access(chapter.project_id, user_id, db)
# 计算字数
word_count = len(chapter.content)
# 计算字数(处理content可能为None的情况)
word_count = len(chapter.content) if chapter.content else 0
db_chapter = Chapter(
**chapter.model_dump(),
@@ -300,9 +300,9 @@ async def update_chapter(
for field, value in update_data.items():
setattr(chapter, field, value)
# 如果内容更新了,重新计算字数
if "content" in update_data and chapter.content:
new_word_count = len(chapter.content)
# 如果内容更新了,重新计算字数(包括清空内容的情况)
if "content" in update_data:
new_word_count = len(chapter.content) if chapter.content else 0
chapter.word_count = new_word_count
# 更新项目字数
@@ -312,6 +312,47 @@ async def update_chapter(
project = result.scalar_one_or_none()
if project:
project.current_words = project.current_words - old_word_count + new_word_count
# 如果内容被清空,清理相关数据
if not chapter.content or chapter.content.strip() == "":
chapter.status = "draft"
# 清理分析任务
analysis_tasks_result = await db.execute(
select(AnalysisTask).where(AnalysisTask.chapter_id == chapter_id)
)
analysis_tasks = analysis_tasks_result.scalars().all()
for task in analysis_tasks:
await db.delete(task)
# 清理分析结果
plot_analysis_result = await db.execute(
select(PlotAnalysis).where(PlotAnalysis.chapter_id == chapter_id)
)
plot_analyses = plot_analysis_result.scalars().all()
for analysis in plot_analyses:
await db.delete(analysis)
# 清理故事记忆(关系数据库)
story_memories_result = await db.execute(
select(StoryMemory).where(StoryMemory.chapter_id == chapter_id)
)
story_memories = story_memories_result.scalars().all()
for memory in story_memories:
await db.delete(memory)
# 清理向量数据库中的记忆数据
try:
await memory_service.delete_chapter_memories(
user_id=user_id,
project_id=chapter.project_id,
chapter_id=chapter_id
)
logger.info(f"✅ 已清理章节 {chapter_id[:8]} 的向量记忆数据")
except Exception as e:
logger.warning(f"⚠️ 清理向量记忆数据失败: {str(e)}")
logger.info(f"🗑️ 章节 {chapter_id[:8]} 内容已清空,已清理分析和记忆数据")
await db.commit()
await db.refresh(chapter)
@@ -954,6 +995,7 @@ async def generate_chapter_content_stream(
target_word_count = generate_request.target_word_count or 3000
enable_mcp = generate_request.enable_mcp if hasattr(generate_request, 'enable_mcp') else True
custom_model = generate_request.model if hasattr(generate_request, 'model') else None
temp_narrative_perspective = generate_request.narrative_perspective if hasattr(generate_request, 'narrative_perspective') else None
# 预先验证章节存在性(使用临时会话)
async for temp_db in get_db(request):
try:
@@ -1195,6 +1237,14 @@ async def generate_chapter_content_stream(
logger.warning(f"⚠️ MCP工具调用失败,降级为基础模式: {str(e)}")
yield f"data: {json.dumps({'type': 'progress', 'message': '⚠️ MCP工具暂时不可用,使用基础模式', 'progress': 32}, ensure_ascii=False)}\n\n"
# 🎭 确定使用的叙事人称(临时指定 > 项目默认 > 系统默认)
chapter_perspective = (
temp_narrative_perspective or
project.narrative_perspective or
'第三人称'
)
logger.info(f"📝 使用叙事人称: {chapter_perspective}")
# 📋 根据大纲模式构建差异化的章节大纲上下文
chapter_outline_content = ""
if outline_mode == 'one-to-one':
@@ -1245,7 +1295,7 @@ async def generate_chapter_content_stream(
title=project.title,
theme=project.theme or '',
genre=project.genre or '',
narrative_perspective=project.narrative_perspective or '第三人称',
narrative_perspective=chapter_perspective,
time_period=project.world_time_period or '未设定',
location=project.world_location or '未设定',
atmosphere=project.world_atmosphere or '未设定',
@@ -1278,7 +1328,7 @@ async def generate_chapter_content_stream(
title=project.title,
theme=project.theme or '',
genre=project.genre or '',
narrative_perspective=project.narrative_perspective or '第三人称',
narrative_perspective=chapter_perspective,
time_period=project.world_time_period or '未设定',
location=project.world_location or '未设定',
atmosphere=project.world_atmosphere or '未设定',
+18 -2
View File
@@ -75,14 +75,30 @@ async def create_outline(
request: Request,
db: AsyncSession = Depends(get_db)
):
"""创建新的章节大纲(不自动创建章节,需通过展开功能生成章节)"""
"""创建新的章节大纲(one-to-one模式会自动创建对应章节)"""
# 验证用户权限
user_id = getattr(request.state, 'user_id', None)
await verify_project_access(outline.project_id, user_id, db)
project = await verify_project_access(outline.project_id, user_id, db)
# 创建大纲
db_outline = Outline(**outline.model_dump())
db.add(db_outline)
await db.flush() # 确保大纲有ID
# 如果是one-to-one模式,自动创建对应的章节
if project.outline_mode == 'one-to-one':
chapter = Chapter(
project_id=outline.project_id,
title=db_outline.title,
summary=db_outline.content,
chapter_number=db_outline.order_index,
sub_index=1,
outline_id=None, # one-to-one模式不关联outline_id
status='pending',
content=""
)
db.add(chapter)
logger.info(f"一对一模式:为手动创建的大纲 {db_outline.title} (序号{db_outline.order_index}) 自动创建了对应章节")
await db.commit()
await db.refresh(db_outline)
+3 -1
View File
@@ -142,7 +142,8 @@ from app.api import (
projects, outlines, characters, chapters,
wizard_stream, relationships, organizations,
auth, users, settings, writing_styles, memories,
mcp_plugins, admin, inspiration, prompt_templates
mcp_plugins, admin, inspiration, prompt_templates,
changelog
)
app.include_router(auth.router, prefix="/api")
@@ -162,6 +163,7 @@ app.include_router(writing_styles.router, prefix="/api")
app.include_router(memories.router) # 记忆管理API (已包含/api前缀)
app.include_router(mcp_plugins.router, prefix="/api") # MCP插件管理API
app.include_router(prompt_templates.router, prefix="/api") # 提示词模板管理API
app.include_router(changelog.router, prefix="/api") # 更新日志API
static_dir = Path(__file__).parent.parent / "static"
if static_dir.exists():
+1
View File
@@ -79,6 +79,7 @@ class ChapterGenerateRequest(BaseModel):
)
enable_mcp: bool = Field(True, description="是否启用MCP工具增强(搜索参考资料)")
model: Optional[str] = Field(None, description="指定使用的AI模型,不提供则使用用户默认模型")
narrative_perspective: Optional[str] = Field(None, description="临时人称视角:first_person/third_person/omniscient,不提供则使用项目默认")
class BatchGenerateRequest(BaseModel):