update:1.新增手动创建大纲和章节,编写章节规划内容
2.新增项目更新日志页面,同步GitHub更新日志 3.新增章节内容生成时,选择本次生成人称 4.修复1 - N模式下,章节标题无法修改的问题 5.修复章节管理界面,批量生成后没有更新页面内容和状态
This commit is contained in:
@@ -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)}"
|
||||
)
|
||||
@@ -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 '未设定',
|
||||
|
||||
@@ -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
@@ -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():
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user