update:1.小说项目创建支持双模式生成,大纲-章节(一对一&一对多) 2.新增章节管理-编辑章节规划功能 3.修复灵感模式可重复点击选项问题,刷新对话内容丢失问题

This commit is contained in:
xiamuceer
2025-11-27 17:29:23 +08:00
parent 8121c04af9
commit deb6cc37a4
27 changed files with 1797 additions and 216 deletions
+1
View File
@@ -106,6 +106,7 @@ dmypy.json
BUILD_GUIDE.md
launcher.py
launcher.spec
mumuainovel.md
data/
+1
View File
@@ -351,6 +351,7 @@ MuMuAINovel/
- 提交 [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.jpg)
---
+198 -9
View File
@@ -28,7 +28,8 @@ from app.schemas.chapter import (
ChapterGenerateRequest,
BatchGenerateRequest,
BatchGenerateResponse,
BatchGenerateStatusResponse
BatchGenerateStatusResponse,
ExpansionPlanUpdate
)
from app.schemas.regeneration import (
ChapterRegenerateRequest,
@@ -1008,6 +1009,10 @@ async def generate_chapter_content_stream(
yield f"data: {json.dumps({'type': 'error', 'error': '项目不存在'}, ensure_ascii=False)}\n\n"
return
# 获取项目的大纲模式
outline_mode = project.outline_mode if project else 'one-to-many'
logger.info(f"📋 项目大纲模式: {outline_mode}")
# 获取对应的大纲
outline_result = await db_session.execute(
select(Outline)
@@ -1188,6 +1193,48 @@ 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_outline_content = ""
if outline_mode == 'one-to-one':
# 一对一模式:使用大纲的 content
chapter_outline_content = outline.content if outline else current_chapter.summary or '暂无大纲'
logger.info(f"✏️ 一对一模式:使用大纲内容作为章节指导")
else:
# 一对多模式:优先使用 expansion_plan 的详细规划
if current_chapter.expansion_plan:
try:
plan = json.loads(current_chapter.expansion_plan)
chapter_outline_content = f"""【本章详细规划】
剧情摘要:{plan.get('plot_summary', '')}
关键事件:
{chr(10).join(f'- {event}' for event in plan.get('key_events', []))}
角色焦点:{', '.join(plan.get('character_focus', []))}
情感基调:{plan.get('emotional_tone', '未设定')}
叙事目标:{plan.get('narrative_goal', '未设定')}
冲突类型:{plan.get('conflict_type', '未设定')}"""
# 可选:附加章节 summary
if current_chapter.summary and current_chapter.summary.strip():
chapter_outline_content += f"\n\n【章节补充说明】\n{current_chapter.summary}"
# 可选:附加大纲的背景信息
if outline:
chapter_outline_content += f"\n\n【大纲节点背景】\n{outline.content}"
logger.info(f"✏️ 一对多模式:使用expansion_plan详细规划({len(chapter_outline_content)}字符)")
except json.JSONDecodeError as e:
logger.warning(f"⚠️ expansion_plan解析失败: {e},回退到大纲内容")
chapter_outline_content = outline.content if outline else current_chapter.summary or '暂无大纲'
else:
# 没有expansion_plan,使用大纲内容
chapter_outline_content = outline.content if outline else current_chapter.summary or '暂无大纲'
logger.warning(f"⚠️ 一对多模式但无expansion_plan,使用大纲内容")
# 根据是否有前置内容选择不同的提示词,并应用写作风格、记忆增强和MCP参考资料
if previous_content:
prompt = prompt_service.get_chapter_generation_with_context_prompt(
@@ -1204,11 +1251,12 @@ async def generate_chapter_content_stream(
previous_content=previous_content,
chapter_number=current_chapter.chapter_number,
chapter_title=current_chapter.title,
chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲',
chapter_outline=chapter_outline_content,
style_content=style_content,
target_word_count=target_word_count,
memory_context=memory_context,
mcp_references=mcp_reference_materials
mcp_references=mcp_reference_materials,
outline_mode=outline_mode
)
else:
prompt = prompt_service.get_chapter_generation_prompt(
@@ -1224,11 +1272,12 @@ async def generate_chapter_content_stream(
outlines_context=outlines_context,
chapter_number=current_chapter.chapter_number,
chapter_title=current_chapter.title,
chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲',
chapter_outline=chapter_outline_content,
style_content=style_content,
target_word_count=target_word_count,
memory_context=memory_context,
mcp_references=mcp_reference_materials
mcp_references=mcp_reference_materials,
outline_mode=outline_mode
)
if mcp_reference_materials:
@@ -1238,11 +1287,39 @@ async def generate_chapter_content_stream(
# 流式生成内容
full_content = ""
chunk_count = 0
last_progress = 0
async for chunk in user_ai_service.generate_text_stream(prompt=prompt):
full_content += chunk
chunk_count += 1
# 发送内容块
yield f"data: {json.dumps({'type': 'content', 'content': chunk}, ensure_ascii=False)}\n\n"
# 每50个chunk发送一次进度更新(估算)
if chunk_count % 50 == 0:
current_word_count = len(full_content)
# 根据目标字数估算进度(35%起步,最高95%,为后续保存留5%)
estimated_progress = min(95, 35 + int((current_word_count / target_word_count) * 60))
# 只在进度变化时发送
if estimated_progress > last_progress:
progress_data = {
'type': 'progress',
'progress': estimated_progress,
'message': f'正在创作中... 已生成 {current_word_count}',
'word_count': current_word_count,
'status': 'processing'
}
yield f"data: {json.dumps(progress_data, ensure_ascii=False)}\n\n"
last_progress = estimated_progress
await asyncio.sleep(0) # 让出控制权
# 发送保存进度
yield f"data: {json.dumps({'type': 'progress', 'progress': 98, 'message': '正在保存章节...', 'status': 'processing'}, ensure_ascii=False)}\n\n"
# 更新章节内容到数据库
old_word_count = current_chapter.word_count or 0
current_chapter.content = full_content
@@ -1297,6 +1374,9 @@ async def generate_chapter_content_stream(
ai_service=user_ai_service
)
# 发送最终进度100%
yield f"data: {json.dumps({'type': 'progress', 'progress': 100, 'message': '创作完成!', 'word_count': new_word_count, 'status': 'success'}, ensure_ascii=False)}\n\n"
# 发送完成事件(包含分析任务ID
completion_data = {
'type': 'done',
@@ -2216,6 +2296,10 @@ async def generate_single_chapter_for_batch(
if not project:
raise Exception("项目不存在")
# 获取项目的大纲模式
outline_mode = project.outline_mode if project else 'one-to-many'
logger.info(f"📋 批量生成 - 项目大纲模式: {outline_mode}")
# 获取对应的大纲
outline_result = await db_session.execute(
select(Outline)
@@ -2285,6 +2369,48 @@ async def generate_single_chapter_for_batch(
character_names=[c.name for c in characters] if characters else None
)
# 📋 根据大纲模式构建差异化的章节大纲上下文
chapter_outline_content = ""
if outline_mode == 'one-to-one':
# 一对一模式:使用大纲的 content
chapter_outline_content = outline.content if outline else chapter.summary or '暂无大纲'
logger.info(f"✏️ 批量生成 - 一对一模式:使用大纲内容")
else:
# 一对多模式:优先使用 expansion_plan 的详细规划
if chapter.expansion_plan:
try:
plan = json.loads(chapter.expansion_plan)
chapter_outline_content = f"""【本章详细规划】
剧情摘要:{plan.get('plot_summary', '')}
关键事件:
{chr(10).join(f'- {event}' for event in plan.get('key_events', []))}
角色焦点:{', '.join(plan.get('character_focus', []))}
情感基调:{plan.get('emotional_tone', '未设定')}
叙事目标:{plan.get('narrative_goal', '未设定')}
冲突类型:{plan.get('conflict_type', '未设定')}"""
# 可选:附加章节 summary
if chapter.summary and chapter.summary.strip():
chapter_outline_content += f"\n\n【章节补充说明】\n{chapter.summary}"
# 可选:附加大纲的背景信息
if outline:
chapter_outline_content += f"\n\n【大纲节点背景】\n{outline.content}"
logger.info(f"✏️ 批量生成 - 一对多模式:使用expansion_plan详细规划")
except json.JSONDecodeError as e:
logger.warning(f"⚠️ expansion_plan解析失败: {e},回退到大纲内容")
chapter_outline_content = outline.content if outline else chapter.summary or '暂无大纲'
else:
# 没有expansion_plan,使用大纲内容
chapter_outline_content = outline.content if outline else chapter.summary or '暂无大纲'
logger.warning(f"⚠️ 批量生成 - 一对多模式但无expansion_plan,使用大纲内容")
# 生成提示词
if previous_content:
prompt = prompt_service.get_chapter_generation_with_context_prompt(
@@ -2301,10 +2427,11 @@ async def generate_single_chapter_for_batch(
previous_content=previous_content,
chapter_number=chapter.chapter_number,
chapter_title=chapter.title,
chapter_outline=outline.content if outline else chapter.summary or '暂无大纲',
chapter_outline=chapter_outline_content,
style_content=style_content,
target_word_count=target_word_count,
memory_context=memory_context
memory_context=memory_context,
outline_mode=outline_mode
)
else:
prompt = prompt_service.get_chapter_generation_prompt(
@@ -2320,10 +2447,11 @@ async def generate_single_chapter_for_batch(
outlines_context=outlines_context,
chapter_number=chapter.chapter_number,
chapter_title=chapter.title,
chapter_outline=outline.content if outline else chapter.summary or '暂无大纲',
chapter_outline=chapter_outline_content,
style_content=style_content,
target_word_count=target_word_count,
memory_context=memory_context
memory_context=memory_context,
outline_mode=outline_mode
)
# 非流式生成内容
@@ -2643,3 +2771,64 @@ async def get_regeneration_tasks(
]
}
@router.put("/{chapter_id}/expansion-plan", response_model=dict, summary="更新章节规划信息")
async def update_chapter_expansion_plan(
chapter_id: str,
expansion_plan: ExpansionPlanUpdate,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
更新章节的展开规划信息
Args:
chapter_id: 章节ID
expansion_plan: 规划信息更新数据
Returns:
更新后的章节规划信息
"""
# 获取章节
result = await db.execute(
select(Chapter).where(Chapter.id == chapter_id)
)
chapter = result.scalar_one_or_none()
if not chapter:
raise HTTPException(status_code=404, detail="章节不存在")
# 验证用户权限
user_id = getattr(request.state, 'user_id', None)
await verify_project_access(chapter.project_id, user_id, db)
# 准备更新数据(排除None值)
plan_data = expansion_plan.model_dump(exclude_unset=True, exclude_none=True)
# 如果已有规划,合并更新;否则创建新规划
if chapter.expansion_plan:
try:
existing_plan = json.loads(chapter.expansion_plan)
# 合并更新
existing_plan.update(plan_data)
chapter.expansion_plan = json.dumps(existing_plan, ensure_ascii=False)
except json.JSONDecodeError:
logger.warning(f"章节 {chapter_id} 的expansion_plan格式错误,将覆盖")
chapter.expansion_plan = json.dumps(plan_data, ensure_ascii=False)
else:
chapter.expansion_plan = json.dumps(plan_data, ensure_ascii=False)
await db.commit()
await db.refresh(chapter)
logger.info(f"章节规划更新成功: {chapter_id}")
# 返回更新后的规划数据
updated_plan = json.loads(chapter.expansion_plan) if chapter.expansion_plan else None
return {
"id": chapter.id,
"expansion_plan": updated_plan,
"message": "规划信息更新成功"
}
+174 -11
View File
@@ -236,18 +236,29 @@ async def delete_outline(
# 验证用户权限
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)
project_id = outline.project_id
deleted_order = outline.order_index
# 删除该大纲对应的所有章节(通过outline_id关联)
delete_result = await db.execute(
delete(Chapter).where(Chapter.outline_id == outline_id)
)
deleted_chapters_count = delete_result.rowcount
logger.info(f"删除大纲 {outline_id},同时删除了 {deleted_chapters_count} 个关联章节")
# 根据项目模式删除对应的章节
if project.outline_mode == 'one-to-one':
# one-to-one模式:通过chapter_number删除对应章节
delete_result = await db.execute(
delete(Chapter).where(
Chapter.project_id == project_id,
Chapter.chapter_number == outline.order_index
)
)
deleted_chapters_count = delete_result.rowcount
logger.info(f"一对一模式:删除大纲 {outline_id}(序号{outline.order_index}),同时删除了第{outline.order_index}章({deleted_chapters_count}个章节)")
else:
# one-to-many模式:通过outline_id删除关联章节
delete_result = await db.execute(
delete(Chapter).where(Chapter.outline_id == outline_id)
)
deleted_chapters_count = delete_result.rowcount
logger.info(f"一对多模式:删除大纲 {outline_id},同时删除了 {deleted_chapters_count} 个关联章节")
# 删除大纲
await db.delete(outline)
@@ -264,6 +275,21 @@ async def delete_outline(
for o in subsequent_outlines:
o.order_index -= 1
# 如果是one-to-one模式,还需要重新排序后续章节的chapter_number
if project.outline_mode == 'one-to-one':
chapters_result = await db.execute(
select(Chapter).where(
Chapter.project_id == project_id,
Chapter.chapter_number > deleted_order
).order_by(Chapter.chapter_number)
)
subsequent_chapters = chapters_result.scalars().all()
for ch in subsequent_chapters:
ch.chapter_number -= 1
logger.info(f"一对一模式:重新排序了 {len(subsequent_chapters)} 个后续章节")
await db.commit()
return {
@@ -852,7 +878,17 @@ async def _save_outlines(
db: AsyncSession,
start_index: int = 1
) -> List[Outline]:
"""保存大纲到数据库(不自动创建章节)"""
"""
保存大纲到数据库
如果项目为one-to-one模式,同时自动创建对应的章节
"""
# 获取项目信息以确定outline_mode
project_result = await db.execute(
select(Project).where(Project.id == project_id)
)
project = project_result.scalar_one_or_none()
outlines = []
for idx, chapter_data in enumerate(outline_data):
@@ -879,6 +915,28 @@ async def _save_outlines(
db.add(outline)
outlines.append(outline)
# 如果是one-to-one模式,自动创建章节
if project and project.outline_mode == 'one-to-one':
await db.flush() # 确保大纲有ID
for outline in outlines:
await db.refresh(outline)
# 为每个大纲创建对应的章节
chapter = Chapter(
project_id=project_id,
title=outline.title,
summary=outline.content,
chapter_number=outline.order_index,
sub_index=1,
outline_id=None, # one-to-one模式不关联outline_id
status='pending',
content=""
)
db.add(chapter)
logger.info(f"一对一模式:为{len(outlines)}个大纲自动创建了对应的章节")
return outlines
@@ -1646,6 +1704,104 @@ async def expand_outline_generator(
yield await SSEResponse.send_error(f"展开失败: {str(e)}")
@router.post("/{outline_id}/create-single-chapter", summary="一对一创建章节(传统模式)")
async def create_single_chapter_from_outline(
outline_id: str,
request: Request,
db: AsyncSession = Depends(get_db)
):
"""
传统模式:一个大纲对应创建一个章节
适用场景:
- 项目的outline_mode为'one-to-one'
- 直接将大纲内容作为章节摘要
- 不调用AI,不展开
流程:
1. 验证项目模式为one-to-one
2. 检查该大纲是否已创建章节
3. 创建章节记录(outline_id=NULLchapter_number=outline.order_index
返回:创建的章节信息
"""
# 验证用户权限
user_id = getattr(request.state, 'user_id', None)
# 获取大纲
result = await db.execute(
select(Outline).where(Outline.id == outline_id)
)
outline = result.scalar_one_or_none()
if not outline:
raise HTTPException(status_code=404, detail="大纲不存在")
# 验证项目权限并获取项目信息
project = await verify_project_access(outline.project_id, user_id, db)
# 验证项目模式
if project.outline_mode != 'one-to-one':
raise HTTPException(
status_code=400,
detail=f"当前项目为{project.outline_mode}模式,不支持一对一创建。请使用展开功能。"
)
# 检查该大纲对应的章节是否已存在
existing_chapter_result = await db.execute(
select(Chapter).where(
Chapter.project_id == outline.project_id,
Chapter.chapter_number == outline.order_index,
Chapter.sub_index == 1
)
)
existing_chapter = existing_chapter_result.scalar_one_or_none()
if existing_chapter:
raise HTTPException(
status_code=400,
detail=f"{outline.order_index}章已存在,不能重复创建"
)
try:
# 创建章节(outline_id=NULL表示一对一模式)
new_chapter = Chapter(
project_id=outline.project_id,
title=outline.title,
summary=outline.content, # 使用大纲内容作为摘要
chapter_number=outline.order_index,
sub_index=1, # 一对一模式固定为1
outline_id=None, # 传统模式不关联outline_id
status='pending'
)
db.add(new_chapter)
await db.commit()
await db.refresh(new_chapter)
logger.info(f"一对一模式:为大纲 {outline.title} 创建章节 {new_chapter.chapter_number}")
return {
"message": "章节创建成功",
"chapter": {
"id": new_chapter.id,
"project_id": new_chapter.project_id,
"title": new_chapter.title,
"summary": new_chapter.summary,
"chapter_number": new_chapter.chapter_number,
"sub_index": new_chapter.sub_index,
"outline_id": new_chapter.outline_id,
"status": new_chapter.status,
"created_at": new_chapter.created_at.isoformat() if new_chapter.created_at else None
}
}
except Exception as e:
logger.error(f"一对一创建章节失败: {str(e)}", exc_info=True)
await db.rollback()
raise HTTPException(status_code=500, detail=f"创建章节失败: {str(e)}")
@router.post("/{outline_id}/expand", response_model=OutlineExpansionResponse, summary="展开单个大纲为多章")
async def expand_outline_to_chapters(
outline_id: str,
@@ -1681,8 +1837,15 @@ async def expand_outline_to_chapters(
if not outline:
raise HTTPException(status_code=404, detail="大纲不存在")
# 验证项目权限
await verify_project_access(outline.project_id, user_id, db)
# 验证项目权限并获取项目信息
project = await verify_project_access(outline.project_id, user_id, db)
# 验证项目模式
if project.outline_mode != 'one-to-many':
raise HTTPException(
status_code=400,
detail=f"当前项目为{project.outline_mode}模式,不支持展开功能。请使用一对一创建。"
)
try:
# 创建展开服务实例
+2 -3
View File
@@ -354,9 +354,8 @@ async def export_project_chapters(
txt_content.append("\n" + "=" * 80 + "\n\n")
for chapter in chapters:
# 处理子章节序号显示
chapter_display = f"{chapter.chapter_number}-{chapter.sub_index}" if chapter.sub_index and chapter.sub_index > 1 else str(chapter.chapter_number)
txt_content.append(f"{chapter_display}{chapter.title}")
# 只显示主章节号,不显示子索引
txt_content.append(f"{chapter.chapter_number}{chapter.title}")
txt_content.append("-" * 80)
txt_content.append("") # 空行
+55 -10
View File
@@ -47,6 +47,7 @@ async def world_building_generator(
target_words = data.get("target_words")
chapter_count = data.get("chapter_count")
character_count = data.get("character_count")
outline_mode = data.get("outline_mode", "one-to-many") # 大纲模式,默认一对多
provider = data.get("provider")
model = data.get("model")
enable_mcp = data.get("enable_mcp", True) # 默认启用MCP
@@ -215,6 +216,7 @@ async def world_building_generator(
target_words=target_words,
chapter_count=chapter_count,
character_count=character_count,
outline_mode=outline_mode, # 设置大纲模式
wizard_status="incomplete",
wizard_step=1,
status="planning"
@@ -1017,39 +1019,82 @@ async def outline_generator(
logger.info(f"✅ 成功创建{len(created_outlines)}个大纲节点")
# 向导流程中不展开大纲,避免等待时间过长
# 用户可以在大纲页面手动展开需要的大纲节点
yield await SSEResponse.send_progress("跳过大纲展开,加快创建速度...", 85)
# 根据项目的大纲模式决定是否自动创建章节
created_chapters = []
if project.outline_mode == 'one-to-one':
# 一对一模式:自动为每个大纲创建对应的章节
yield await SSEResponse.send_progress("一对一模式:自动创建章节...", 50)
for outline in created_outlines:
chapter = Chapter(
project_id=project_id,
title=outline.title,
content="", # 空内容,等待用户生成
outline_id=None, # 一对一模式下不关联outline_id
chapter_number=outline.order_index, # 使用chapter_number而不是order_index
status="pending"
)
db.add(chapter)
created_chapters.append(chapter)
await db.flush()
for chapter in created_chapters:
await db.refresh(chapter)
logger.info(f"✅ 一对一模式:自动创建了{len(created_chapters)}个章节")
yield await SSEResponse.send_progress(f"已自动创建{len(created_chapters)}个章节", 85)
else:
# 一对多模式:跳过自动创建,用户可手动展开
yield await SSEResponse.send_progress("细化模式:跳过自动创建章节", 85)
logger.info(f"📝 细化模式:跳过章节创建,用户可在大纲页面手动展开")
# 更新项目信息
project.chapter_count = 0 # 向导阶段不创建章节
project.chapter_count = len(created_chapters) # 记录实际创建章节
project.narrative_perspective = narrative_perspective
project.target_words = target_words
project.status = "writing"
project.wizard_status = "completed"
project.wizard_step = 3
project.wizard_step = 3
await db.commit()
db_committed = True
logger.info(f"📊 向导大纲生成完成:")
logger.info(f" - 创建大纲节点:{len(created_outlines)}")
logger.info(f" - 提示:可在大纲页面手动展开为章节")
logger.info(f" - 创建章节:{len(created_chapters)}")
logger.info(f" - 大纲模式:{project.outline_mode}")
# 构建结果消息
if project.outline_mode == 'one-to-one':
result_message = f"成功生成{len(created_outlines)}个大纲节点并自动创建{len(created_chapters)}个章节(传统模式)"
result_note = "已自动创建章节,可直接生成内容"
else:
result_message = f"成功生成{len(created_outlines)}个大纲节点(细化模式,可在大纲页面手动展开)"
result_note = "可在大纲页面展开为多个章节"
# 发送结果
yield await SSEResponse.send_result({
"message": f"成功生成{len(created_outlines)}个大纲节点(未展开章节,可在大纲页面手动展开)",
"message": result_message,
"outline_count": len(created_outlines),
"chapter_count": 0,
"chapter_count": len(created_chapters),
"outline_mode": project.outline_mode,
"outlines": [
{
"id": outline.id,
"order_index": outline.order_index,
"title": outline.title,
"content": outline.content[:100] + "..." if len(outline.content) > 100 else outline.content,
"note": "可在大纲页面展开为章节"
"note": result_note
} for outline in created_outlines
]
],
"chapters": [
{
"id": chapter.id,
"chapter_number": chapter.chapter_number,
"title": chapter.title,
"status": chapter.status
} for chapter in created_chapters
] if created_chapters else []
})
yield await SSEResponse.send_progress("完成!", 100, "success")
+9 -1
View File
@@ -1,5 +1,5 @@
"""项目数据模型"""
from sqlalchemy import Column, String, Text, DateTime, Integer
from sqlalchemy import Column, String, Text, DateTime, Integer, CheckConstraint
from sqlalchemy.sql import func
from app.database import Base
import uuid
@@ -20,6 +20,7 @@ class Project(Base):
status = Column(String(20), default="planning", comment="创作状态")
wizard_status = Column(String(20), default="incomplete", comment="向导完成状态: incomplete/completed")
wizard_step = Column(Integer, default=0, comment="向导当前步骤: 0-4")
outline_mode = Column(String(20), nullable=False, default="one-to-many", comment="大纲章节模式: one-to-one(传统模式) 或 one-to-many(细化模式)")
# 世界构建字段
world_time_period = Column(Text, comment="时间背景")
@@ -35,5 +36,12 @@ class Project(Base):
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
__table_args__ = (
CheckConstraint(
"outline_mode IN ('one-to-one', 'one-to-many')",
name='check_outline_mode'
),
)
def __repr__(self):
return f"<Project(id={self.id}, title={self.title})>"
+46 -2
View File
@@ -1,6 +1,6 @@
"""章节相关的Pydantic模型"""
from pydantic import BaseModel, Field
from typing import Optional
from typing import Optional, List, Dict, Any
from datetime import datetime
@@ -118,4 +118,48 @@ class BatchGenerateStatusResponse(BaseModel):
created_at: Optional[str] = None
started_at: Optional[str] = None
completed_at: Optional[str] = None
error_message: Optional[str] = None
error_message: Optional[str] = None
class SceneData(BaseModel):
"""场景数据模型"""
location: str = Field(..., description="场景地点")
characters: List[str] = Field(..., description="参与角色列表")
purpose: str = Field(..., description="场景目的")
class ExpansionPlanUpdate(BaseModel):
"""章节规划更新模型"""
key_events: Optional[List[str]] = Field(None, description="关键事件列表")
character_focus: Optional[List[str]] = Field(None, description="涉及角色列表")
emotional_tone: Optional[str] = Field(None, description="情感基调")
narrative_goal: Optional[str] = Field(None, description="叙事目标")
conflict_type: Optional[str] = Field(None, description="冲突类型")
estimated_words: Optional[int] = Field(None, description="预估字数", ge=500, le=10000)
scenes: Optional[List[SceneData]] = Field(None, description="场景列表")
class Config:
json_schema_extra = {
"example": {
"key_events": ["主角遇到挑战", "关键决策时刻"],
"character_focus": ["张三", "李四"],
"emotional_tone": "紧张激烈",
"narrative_goal": "推进主线剧情",
"conflict_type": "内心冲突",
"estimated_words": 3000,
"scenes": [
{
"location": "城市广场",
"characters": ["张三", "李四"],
"purpose": "初次相遇"
}
]
}
}
class ExpansionPlanResponse(BaseModel):
"""章节规划响应模型"""
id: str = Field(..., description="章节ID")
expansion_plan: Optional[Dict[str, Any]] = Field(None, description="规划数据")
message: str = Field(..., description="响应消息")
+10 -1
View File
@@ -1,6 +1,6 @@
"""项目相关的Pydantic模型"""
from pydantic import BaseModel, Field
from typing import Optional
from typing import Optional, Literal
from datetime import datetime
@@ -11,6 +11,10 @@ class ProjectBase(BaseModel):
theme: Optional[str] = Field(None, description="主题")
genre: Optional[str] = Field(None, description="小说类型")
target_words: Optional[int] = Field(None, description="目标字数")
outline_mode: Literal["one-to-one", "one-to-many"] = Field(
default="one-to-many",
description="大纲章节模式: one-to-one(传统模式,1大纲→1章节) 或 one-to-many(细化模式,1大纲→N章节)"
)
class ProjectCreate(ProjectBase):
@@ -51,6 +55,7 @@ class ProjectResponse(ProjectBase):
chapter_count: Optional[int] = None
narrative_perspective: Optional[str] = None
character_count: Optional[int] = None
outline_mode: str # 显式声明以确保响应中包含
created_at: datetime
updated_at: datetime
@@ -73,6 +78,10 @@ class ProjectWizardRequest(BaseModel):
narrative_perspective: str = Field(..., description="叙事视角")
character_count: int = Field(5, ge=5, description="角色数量(至少5个)")
target_words: Optional[int] = Field(None, description="目标字数")
outline_mode: Literal["one-to-one", "one-to-many"] = Field(
default="one-to-many",
description="大纲章节模式"
)
class WorldBuildingResponse(BaseModel):
+41 -9
View File
@@ -207,14 +207,16 @@ class PromptService:
4. **无特殊符号**:文本中不使用引号、方括号等特殊符号包裹内容
5. **丰富细节**:每个字段提供充实的原创内容,避免模板化表达
# 反面示例(避免这样的设定
❌ 不好的设定:故事设定在大崩解后的XX纪元、新世界秩序、文明重启...
✅ 好的设定:故事设定在2024年的深圳,互联网创业浪潮下的年轻人...
请根据输入的类型和主题,生成**规模适当、风格匹配**的世界观设定
❌ 不好的设定:升华纪元、共鸣指数、灵光纯度...(现代都市题材不要用这些)
✅ 好的设定:通过高考分数、学历背景、家庭条件来衡量个人价值...(符合现实)
# JSON格式示例
请根据输入的类型和主题,生成**规模适当、风格匹配**的世界观设定。"""
{{
"time_period": "时间背景与社会状态的详细描述(300-500字)",
"location": "空间环境与地理特征的详细描述(300-500字)",
"atmosphere": "感官体验与情感基调的详细描述(300-500字)",
"rules": "世界规则与社会结构的详细描述(300-500字)"
}}"""
# 批量角色生成提示词
CHARACTERS_BATCH_GENERATION = """你是一位专业的角色设定师。请根据以下世界观和要求,生成{count}个立体丰满的角色和组织:
@@ -1019,7 +1021,8 @@ class PromptService:
chapter_outline: str, style_content: str = "",
target_word_count: int = 3000,
memory_context: dict = None,
mcp_references: str = "") -> str:
mcp_references: str = "",
outline_mode: str = "one-to-many") -> str:
"""
获取章节完整创作提示词
@@ -1028,6 +1031,7 @@ class PromptService:
target_word_count: 目标字数,默认3000字
memory_context: 记忆上下文(可选)
mcp_references: MCP工具搜索的参考资料(可选)
outline_mode: 大纲模式 (one-to-one/one-to-many)
"""
# 计算最大字数(目标字数+1000
max_word_count = target_word_count + 1000
@@ -1050,6 +1054,13 @@ class PromptService:
mcp_text += mcp_references
mcp_text += "\n"
# 根据大纲模式添加创作指导
mode_instruction = ""
if outline_mode == 'one-to-one':
mode_instruction = "\n\n【创作模式说明】\n本章采用一对一模式:一个大纲节点对应一个章节。请充分展开大纲中的情节,注重叙事的完整性和丰满度。\n"
else:
mode_instruction = "\n\n【创作模式说明】\n本章采用细纲模式:本章是大纲节点的细化展开之一。请严格遵循上述详细规划中的剧情点、角色焦点和情感基调,确保与整体规划保持一致。\n"
base_prompt = cls.format_prompt(
cls.CHAPTER_GENERATION,
title=title,
@@ -1079,7 +1090,13 @@ class PromptService:
if insert_text:
base_prompt = base_prompt.replace(
"本章信息:",
insert_text + "\n\n本章信息:"
insert_text + mode_instruction + "\n\n本章信息:"
)
else:
# 没有记忆和MCP时也要插入模式说明
base_prompt = base_prompt.replace(
"本章信息:",
mode_instruction + "\n\n本章信息:"
)
# 如果有风格要求,应用到提示词中
@@ -1098,7 +1115,8 @@ class PromptService:
style_content: str = "",
target_word_count: int = 3000,
memory_context: dict = None,
mcp_references: str = "") -> str:
mcp_references: str = "",
outline_mode: str = "one-to-many") -> str:
"""
获取章节完整创作提示词(带前置章节上下文和记忆增强)
@@ -1107,6 +1125,7 @@ class PromptService:
target_word_count: 目标字数,默认3000字
memory_context: 记忆上下文(可选)
mcp_references: MCP工具搜索的参考资料(可选)
outline_mode: 大纲模式 (one-to-one/one-to-many)
"""
# 计算最大字数(目标字数+1000
max_word_count = target_word_count + 1000
@@ -1128,6 +1147,13 @@ class PromptService:
memory_text += "以下是通过MCP工具搜索到的相关参考资料,可用于丰富情节和细节:\n\n"
memory_text += mcp_references
# 根据大纲模式添加创作指导
mode_instruction = ""
if outline_mode == 'one-to-one':
mode_instruction = "\n\n【创作模式说明】\n本章采用一对一模式:一个大纲节点对应一个章节。请在承接前文的基础上,充分展开大纲中的情节,保持叙事的完整性。\n"
else:
mode_instruction = "\n\n【创作模式说明】\n本章采用细纲模式:本章是大纲节点的细化展开之一。请严格遵循上述详细规划(expansion_plan)中的剧情点、角色焦点、情感基调和叙事目标,确保与整体规划保持一致,同时自然衔接前文内容。\n"
base_prompt = cls.format_prompt(
cls.CHAPTER_GENERATION_WITH_CONTEXT,
title=title,
@@ -1149,6 +1175,12 @@ class PromptService:
memory_context=memory_text
)
# 插入模式说明
base_prompt = base_prompt.replace(
"本章信息:",
mode_instruction + "\n本章信息:"
)
# 如果有风格要求,应用到提示词中
if style_content:
return WritingStyleManager.apply_style_to_prompt(base_prompt, style_content)
@@ -0,0 +1,25 @@
-- Migration: Add outline_mode to projects table
-- Description: 为项目表添加大纲模式字段,支持一对一和一对多两种模式
-- Date: 2025-11-27
-- 1. 添加 outline_mode 字段
ALTER TABLE projects
ADD COLUMN outline_mode VARCHAR(20) NOT NULL DEFAULT 'one-to-many';
-- 2. 添加检查约束,确保只能是两个有效值之一
ALTER TABLE projects
ADD CONSTRAINT check_outline_mode
CHECK (outline_mode IN ('one-to-one', 'one-to-many'));
-- 3. 创建索引以提高查询性能
CREATE INDEX idx_projects_outline_mode ON projects(outline_mode);
-- 4. 为现有项目设置默认模式为一对多(细化模式)
-- 这是因为现有项目大多使用展开功能
UPDATE projects SET outline_mode = 'one-to-many' WHERE outline_mode IS NULL;
-- 5. 添加注释
COMMENT ON COLUMN projects.outline_mode IS '大纲章节模式: one-to-one(传统模式,1大纲→1章节) 或 one-to-many(细化模式,1大纲→N章节)';
-- 验证迁移结果
-- SELECT id, title, outline_mode FROM projects LIMIT 10;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "1.0.5",
"version": "1.0.6",
"type": "module",
"scripts": {
"dev": "vite",
Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

@@ -16,6 +16,7 @@ export interface GenerationConfig {
target_words: number;
chapter_count: number;
character_count: number;
outline_mode?: 'one-to-one' | 'one-to-many'; // 大纲章节模式
}
interface AIProjectGeneratorProps {
@@ -183,6 +184,7 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
target_words: data.target_words,
chapter_count: data.chapter_count,
character_count: data.character_count,
outline_mode: data.outline_mode || 'one-to-many', // 传递大纲模式
},
{
onProgress: (msg, prog) => {
@@ -328,6 +330,7 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
target_words: data.target_words,
chapter_count: data.chapter_count,
character_count: data.character_count,
outline_mode: data.outline_mode || 'one-to-many', // 传递大纲模式
},
{
onProgress: (msg, prog) => {
@@ -504,6 +507,7 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
target_words: generationData.target_words,
chapter_count: generationData.chapter_count,
character_count: generationData.character_count,
outline_mode: generationData.outline_mode || 'one-to-many', // 传递大纲模式
},
{
onProgress: (msg, prog) => {
+122 -38
View File
@@ -5,14 +5,17 @@ interface AnnouncementModalProps {
visible: boolean;
onClose: () => void;
onDoNotShowToday: () => void;
onNeverShow: () => void;
}
export default function AnnouncementModal({ visible, onClose, onDoNotShowToday }: AnnouncementModalProps) {
const [imageError, setImageError] = useState(false);
export default function AnnouncementModal({ visible, onClose, onDoNotShowToday, onNeverShow }: AnnouncementModalProps) {
const [qqImageError, setQqImageError] = useState(false);
const [wxImageError, setWxImageError] = useState(false);
useEffect(() => {
if (visible) {
setImageError(false);
setQqImageError(false);
setWxImageError(false);
}
}, [visible]);
@@ -21,6 +24,11 @@ export default function AnnouncementModal({ visible, onClose, onDoNotShowToday }
onClose();
};
const handleNeverShow = () => {
onNeverShow();
onClose();
};
return (
<Modal
title="🎉 欢迎使用 AI小说创作助手"
@@ -28,15 +36,15 @@ export default function AnnouncementModal({ visible, onClose, onDoNotShowToday }
onCancel={onClose}
footer={
<Space style={{ width: '100%', justifyContent: 'center' }}>
<Button onClick={onClose} size="large">
<Button onClick={handleDoNotShowToday} size="large">
</Button>
<Button type="primary" onClick={handleDoNotShowToday} size="large">
<Button type="primary" onClick={handleNeverShow} size="large">
</Button>
</Space>
}
width={600}
width={800}
centered
styles={{
body: {
@@ -65,44 +73,120 @@ export default function AnnouncementModal({ visible, onClose, onDoNotShowToday }
<li>📚 </li>
</ul>
<p style={{ fontWeight: 600, color: '#333', marginBottom: '16px' }}>
QQ交流群
</p>
</div>
{!imageError ? (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
gap: '24px',
padding: '20px',
background: '#f5f5f5',
borderRadius: '8px',
flexWrap: 'wrap',
}}>
{/* QQ 二维码 */}
<div style={{
display: 'flex',
justifyContent: 'center',
flexDirection: 'column',
alignItems: 'center',
padding: '20px',
background: '#f5f5f5',
borderRadius: '8px',
minWidth: '280px',
}}>
<img
src="/qq.jpg"
alt="QQ交流群二维码"
style={{
maxWidth: '100%',
maxHeight: '360px',
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}}
onError={() => setImageError(true)}
/>
</div>
) : (
<div style={{
padding: '40px',
background: '#f5f5f5',
borderRadius: '8px',
color: '#999',
}}>
<p></p>
<p style={{ fontSize: '12px', marginTop: '8px' }}>
qq.jpg frontend/public/
<p style={{ fontWeight: 600, color: '#333', marginBottom: '12px', fontSize: '15px' }}>
QQ交流群
</p>
{!qqImageError ? (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: '#fff',
borderRadius: '8px',
padding: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}}>
<img
src="/qq.jpg"
alt="QQ交流群二维码"
style={{
maxWidth: '280px',
maxHeight: '280px',
width: 'auto',
height: 'auto',
display: 'block',
objectFit: 'contain',
}}
onError={() => setQqImageError(true)}
/>
</div>
) : (
<div style={{
width: '280px',
height: '280px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: '#fff',
borderRadius: '8px',
color: '#999',
}}>
<p></p>
</div>
)}
</div>
)}
{/* 微信二维码 */}
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
minWidth: '280px',
}}>
<p style={{ fontWeight: 600, color: '#333', marginBottom: '12px', fontSize: '15px' }}>
</p>
{!wxImageError ? (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: '#fff',
borderRadius: '8px',
padding: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}}>
<img
src="/WX.png"
alt="微信交流群二维码"
style={{
maxWidth: '280px',
maxHeight: '280px',
width: 'auto',
height: 'auto',
display: 'block',
objectFit: 'contain',
}}
onError={() => setWxImageError(true)}
/>
</div>
) : (
<div style={{
width: '280px',
height: '280px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: '#fff',
borderRadius: '8px',
color: '#999',
}}>
<p></p>
</div>
)}
</div>
</div>
<div style={{
marginTop: '20px',
@@ -113,7 +197,7 @@ export default function AnnouncementModal({ visible, onClose, onDoNotShowToday }
fontSize: '14px',
color: '#ad6800',
}}>
💡 "今内不再示"
💡 "今内不再示""永不再展示"
</div>
</div>
</Modal>
+52 -2
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Typography, Space, Divider, Badge, Tooltip } from 'antd';
import { GithubOutlined, CopyrightOutlined, HeartFilled, ClockCircleOutlined } from '@ant-design/icons';
import { Typography, Space, Divider, Badge, Tooltip, Button } from 'antd';
import { GithubOutlined, CopyrightOutlined, HeartFilled, ClockCircleOutlined, GiftOutlined } from '@ant-design/icons';
import { VERSION_INFO, getVersionString } from '../config/version';
import { checkLatestVersion } from '../services/versionService';
@@ -88,6 +88,26 @@ export default function AppFooter() {
</Tooltip>
</Badge>
<Divider type="vertical" style={{ margin: '0 4px', borderColor: 'rgba(255, 255, 255, 0.3)' }} />
<Button
type="primary"
size="small"
icon={<GiftOutlined />}
onClick={() => window.open('https://mumuverse.space:1588/', '_blank')}
style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
boxShadow: '0 2px 8px rgba(102, 126, 234, 0.4)',
fontSize: 11,
height: 24,
padding: '0 8px',
display: 'flex',
alignItems: 'center',
gap: 4,
}}
>
</Button>
<Divider type="vertical" style={{ margin: '0 4px', borderColor: 'rgba(255, 255, 255, 0.3)' }} />
<Link
href={VERSION_INFO.githubUrl}
target="_blank"
@@ -190,6 +210,36 @@ export default function AppFooter() {
LinuxDO
</Link>
{/* 赞助按钮 */}
<Button
type="primary"
icon={<GiftOutlined style={{ fontSize: 14 }} />}
onClick={() => window.open('https://mumuverse.space:1588/', '_blank')}
style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none',
boxShadow: '0 4px 12px rgba(102, 126, 234, 0.5)',
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 rgba(102, 126, 234, 0.6)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(102, 126, 234, 0.5)';
}}
>
</Button>
{/* 许可证 */}
<Link
href={VERSION_INFO.licenseUrl}
@@ -0,0 +1,324 @@
import { Modal, Form, Input, InputNumber, Select, Tag, Space, Button, message } from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { useState, useEffect } from 'react';
import type { ExpansionPlanData, Character } from '../types';
import { characterApi } from '../services/api';
const { TextArea } = Input;
interface ExpansionPlanEditorProps {
visible: boolean;
planData: ExpansionPlanData | null;
projectId: string;
onSave: (data: ExpansionPlanData) => Promise<void>;
onCancel: () => void;
}
export default function ExpansionPlanEditor({
visible,
planData,
projectId,
onSave,
onCancel
}: ExpansionPlanEditorProps) {
const [form] = Form.useForm();
const [loading, setLoading] = useState(false);
// 关键事件标签输入
const [keyEventInput, setKeyEventInput] = useState('');
const [keyEvents, setKeyEvents] = useState<string[]>([]);
// 角色列表和选择
const [availableCharacters, setAvailableCharacters] = useState<Character[]>([]);
const [characters, setCharacters] = useState<string[]>([]);
const [loadingCharacters, setLoadingCharacters] = useState(false);
// 加载项目角色列表
useEffect(() => {
if (visible && projectId) {
loadCharacters();
}
}, [visible, projectId]);
const loadCharacters = async () => {
try {
setLoadingCharacters(true);
setAvailableCharacters([]); // 重置为空数组
const response = await characterApi.getCharacters(projectId);
console.log('加载到的角色数据:', response);
// API返回的是 {total, items} 格式,需要提取items
let chars: Character[] = [];
if (Array.isArray(response)) {
chars = response;
} else if (response && typeof response === 'object' && 'items' in response && Array.isArray((response as any).items)) {
chars = (response as any).items;
} else {
console.error('角色API返回格式异常:', response);
message.warning('角色数据格式异常');
}
setAvailableCharacters(chars);
console.log('设置的角色列表:', chars);
} catch (error: any) {
console.error('加载角色列表失败:', error);
setAvailableCharacters([]);
message.error('加载角色列表失败: ' + (error?.message || '未知错误'));
} finally {
setLoadingCharacters(false);
}
};
// 当planData变化时更新状态
useEffect(() => {
if (planData) {
setKeyEvents(planData.key_events || []);
setCharacters(planData.character_focus || []);
form.setFieldsValue({
emotional_tone: planData.emotional_tone,
narrative_goal: planData.narrative_goal,
conflict_type: planData.conflict_type,
estimated_words: planData.estimated_words
});
} else {
// 重置状态
setKeyEvents([]);
setCharacters([]);
form.resetFields();
}
}, [planData, form, visible]);
const handleAddKeyEvent = () => {
if (keyEventInput.trim()) {
setKeyEvents([...keyEvents, keyEventInput.trim()]);
setKeyEventInput('');
}
};
const handleAddCharacter = (characterName: string) => {
if (characterName && !characters.includes(characterName)) {
setCharacters([...characters, characterName]);
}
};
const handleSubmit = async () => {
try {
setLoading(true);
const values = await form.validateFields();
// 验证至少有一个关键事件
if (keyEvents.length === 0) {
message.warning('请至少添加一个关键事件');
setLoading(false);
return;
}
// 验证至少有一个角色
if (characters.length === 0) {
message.warning('请至少添加一个涉及角色');
setLoading(false);
return;
}
const updatedPlan: ExpansionPlanData = {
key_events: keyEvents,
character_focus: characters,
emotional_tone: values.emotional_tone,
narrative_goal: values.narrative_goal,
conflict_type: values.conflict_type,
estimated_words: values.estimated_words,
scenes: planData?.scenes || null
};
await onSave(updatedPlan);
// message.success('规划信息保存成功');
} catch (error) {
console.error('保存失败:', error);
message.error('保存失败,请重试');
} finally {
setLoading(false);
}
};
const handleCancel = () => {
form.resetFields();
setKeyEvents([]);
setCharacters([]);
setKeyEventInput('');
onCancel();
};
return (
<Modal
title="编辑章节规划"
open={visible}
onCancel={handleCancel}
width={700}
centered
footer={[
<Button key="cancel" onClick={handleCancel} disabled={loading}>
</Button>,
<Button key="submit" type="primary" loading={loading} onClick={handleSubmit}>
</Button>
]}
>
<Form
form={form}
layout="vertical"
initialValues={{
emotional_tone: '紧张激烈',
conflict_type: '人物冲突',
estimated_words: 3000
}}
>
{/* 关键事件 */}
<Form.Item
label="关键事件"
tooltip="至少添加一个关键事件"
required
>
<Space direction="vertical" style={{ width: '100%' }}>
<Space.Compact style={{ width: '100%' }}>
<Input
placeholder="输入关键事件后按回车或点击添加"
value={keyEventInput}
onChange={(e) => setKeyEventInput(e.target.value)}
onPressEnter={handleAddKeyEvent}
/>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddKeyEvent}
>
</Button>
</Space.Compact>
<Space wrap>
{keyEvents.map((event, idx) => (
<Tag
key={idx}
closable
onClose={(e) => {
e.preventDefault();
setKeyEvents(keyEvents.filter((_, i) => i !== idx));
}}
color="purple"
style={{ marginBottom: 8 }}
>
<span style={{ fontWeight: 'bold', marginRight: 4 }}>#{idx + 1}</span>
{event}
</Tag>
))}
</Space>
</Space>
</Form.Item>
{/* 涉及角色 */}
<Form.Item
label="涉及角色"
tooltip="从项目现有角色中选择"
required
>
<Space direction="vertical" style={{ width: '100%' }}>
<Select
placeholder="选择角色"
style={{ width: '100%' }}
loading={loadingCharacters}
onChange={handleAddCharacter}
value={undefined}
showSearch
optionFilterProp="children"
filterOption={(input, option) =>
(option?.label ?? '').toLowerCase().includes(input.toLowerCase())
}
options={Array.isArray(availableCharacters)
? availableCharacters
.filter(char => !characters.includes(char.name))
.map(char => ({
label: char.name,
value: char.name,
}))
: []}
notFoundContent={
loadingCharacters ? '加载中...' :
!Array.isArray(availableCharacters) ? '加载角色失败' :
availableCharacters.length === 0 ? '暂无角色,请先在角色管理中创建' :
'所有角色已添加'
}
/>
<Space wrap>
{characters.map((char, idx) => (
<Tag
key={idx}
closable
onClose={() => setCharacters(characters.filter((_, i) => i !== idx))}
color="cyan"
>
{char}
</Tag>
))}
</Space>
</Space>
</Form.Item>
{/* 情感基调 */}
<Form.Item
label="情感基调"
name="emotional_tone"
rules={[{ required: true, message: '请输入情感基调' }]}
tooltip="例如:紧张激烈、温馨感人、悬疑惊悚等"
>
<Input
placeholder="输入情感基调,例如:紧张激烈、温馨感人等"
maxLength={20}
/>
</Form.Item>
{/* 冲突类型 */}
<Form.Item
label="冲突类型"
name="conflict_type"
rules={[{ required: true, message: '请输入冲突类型' }]}
tooltip="例如:人物冲突、内心冲突、环境冲突等"
>
<Input
placeholder="输入冲突类型,例如:人物冲突、内心冲突等"
maxLength={20}
/>
</Form.Item>
{/* 预估字数 */}
<Form.Item
label="预估字数"
name="estimated_words"
rules={[{ required: true, message: '请输入预估字数' }]}
>
<InputNumber
min={500}
max={10000}
step={100}
style={{ width: '100%' }}
formatter={(value) => `${value}`}
parser={(value) => value?.replace(' 字', '') as any}
/>
</Form.Item>
{/* 叙事目标 */}
<Form.Item
label="叙事目标"
name="narrative_goal"
rules={[{ required: true, message: '请输入叙事目标' }]}
>
<TextArea
rows={3}
placeholder="描述本章要达成的叙事目标,例如:推进主线剧情、深化角色关系、揭示重要信息等..."
maxLength={500}
showCount
/>
</Form.Item>
</Form>
</Modal>
);
}
+25 -3
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { Modal, Spin } from 'antd';
import { LoadingOutlined } from '@ant-design/icons';
import { Modal, Spin, Button } from 'antd';
import { LoadingOutlined, StopOutlined } from '@ant-design/icons';
interface SSEProgressModalProps {
visible: boolean;
@@ -9,6 +9,8 @@ interface SSEProgressModalProps {
title?: string;
showPercentage?: boolean;
showIcon?: boolean;
onCancel?: () => void;
cancelButtonText?: string;
}
/**
@@ -22,6 +24,8 @@ export const SSEProgressModal: React.FC<SSEProgressModalProps> = ({
title = 'AI生成中...',
showPercentage = true,
showIcon = true,
onCancel,
cancelButtonText = '取消任务',
}) => {
if (!visible) return null;
@@ -115,10 +119,28 @@ export const SSEProgressModal: React.FC<SSEProgressModalProps> = ({
<div style={{
textAlign: 'center',
fontSize: 13,
color: '#8c8c8c'
color: '#8c8c8c',
marginBottom: onCancel ? 16 : 0
}}>
</div>
{/* 取消按钮 */}
{onCancel && (
<div style={{
textAlign: 'center',
marginTop: 16
}}>
<Button
danger
size="large"
icon={<StopOutlined />}
onClick={onCancel}
>
{cancelButtonText}
</Button>
</div>
)}
</div>
</Modal>
);
+31 -23
View File
@@ -41,20 +41,21 @@ export default function AuthCallback() {
const redirect = sessionStorage.getItem('login_redirect') || '/';
sessionStorage.removeItem('login_redirect');
// 检查今天是否已经显示过公告
const doNotShowUntil = localStorage.getItem('announcement_do_not_show_until');
const now = new Date().getTime();
// 检查是否永久隐藏公告或今日已隐藏
const hideForever = localStorage.getItem('announcement_hide_forever');
const hideToday = localStorage.getItem('announcement_hide_today');
const today = new Date().toDateString();
if (!doNotShowUntil || now > parseInt(doNotShowUntil)) {
// 延迟一下再显示公告,让用户看到成功提示
setTimeout(() => {
setShowAnnouncement(true);
}, 1000);
} else {
if (hideForever === 'true' || hideToday === today) {
// 延迟一下再跳转,让用户看到成功提示
setTimeout(() => {
navigate(redirect);
}, 1000);
} else {
// 延迟一下再显示公告,让用户看到成功提示
setTimeout(() => {
setShowAnnouncement(true);
}, 1000);
}
} catch (error) {
console.error('登录失败:', error);
@@ -117,10 +118,14 @@ export default function AuthCallback() {
};
const handleDoNotShowToday = () => {
// 设置到今天23:59:59不再显示
const tomorrow = new Date();
tomorrow.setHours(23, 59, 59, 999);
localStorage.setItem('announcement_do_not_show_until', tomorrow.getTime().toString());
// 设置今日不再显示
const today = new Date().toDateString();
localStorage.setItem('announcement_hide_today', today);
};
const handleNeverShow = () => {
// 设置永久不再显示
localStorage.setItem('announcement_hide_forever', 'true');
};
const handleSetPassword = async () => {
@@ -147,16 +152,17 @@ export default function AuthCallback() {
const redirect = sessionStorage.getItem('login_redirect') || '/';
sessionStorage.removeItem('login_redirect');
const doNotShowUntil = localStorage.getItem('announcement_do_not_show_until');
const now = new Date().getTime();
const hideForever = localStorage.getItem('announcement_hide_forever');
const hideToday = localStorage.getItem('announcement_hide_today');
const today = new Date().toDateString();
if (!doNotShowUntil || now > parseInt(doNotShowUntil)) {
if (hideForever === 'true' || hideToday === today) {
setTimeout(() => {
setShowAnnouncement(true);
navigate(redirect);
}, 500);
} else {
setTimeout(() => {
navigate(redirect);
setShowAnnouncement(true);
}, 500);
}
} catch (error) {
@@ -173,16 +179,17 @@ export default function AuthCallback() {
const redirect = sessionStorage.getItem('login_redirect') || '/';
sessionStorage.removeItem('login_redirect');
const doNotShowUntil = localStorage.getItem('announcement_do_not_show_until');
const now = new Date().getTime();
const hideForever = localStorage.getItem('announcement_hide_forever');
const hideToday = localStorage.getItem('announcement_hide_today');
const today = new Date().toDateString();
if (!doNotShowUntil || now > parseInt(doNotShowUntil)) {
if (hideForever === 'true' || hideToday === today) {
setTimeout(() => {
setShowAnnouncement(true);
navigate(redirect);
}, 500);
} else {
setTimeout(() => {
navigate(redirect);
setShowAnnouncement(true);
}, 500);
}
};
@@ -193,6 +200,7 @@ export default function AuthCallback() {
visible={showAnnouncement}
onClose={handleAnnouncementClose}
onDoNotShowToday={handleDoNotShowToday}
onNeverShow={handleNeverShow}
/>
<Modal
+307 -50
View File
@@ -1,11 +1,12 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber, Alert, Radio, Descriptions, Collapse, Popconfirm, FloatButton } from 'antd';
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined, DeleteOutlined, BookOutlined } from '@ant-design/icons';
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined, DeleteOutlined, BookOutlined, FormOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { useChapterSync } from '../store/hooks';
import { projectApi, writingStyleApi } from '../services/api';
import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask, ExpansionPlanData } from '../types';
import ChapterAnalysis from '../components/ChapterAnalysis';
import ExpansionPlanEditor from '../components/ExpansionPlanEditor';
import { SSELoadingOverlay } from '../components/SSELoadingOverlay';
import { SSEProgressModal } from '../components/SSEProgressModal';
import FloatingIndexPanel from '../components/FloatingIndexPanel';
@@ -33,6 +34,10 @@ export default function Chapters() {
const pollingIntervalsRef = useRef<Record<string, number>>({});
const [isIndexPanelVisible, setIsIndexPanelVisible] = useState(false);
// 规划编辑状态
const [planEditorVisible, setPlanEditorVisible] = useState(false);
const [editingPlanChapter, setEditingPlanChapter] = useState<Chapter | null>(null);
// 单章节生成进度状态
const [singleChapterProgress, setSingleChapterProgress] = useState(0);
const [singleChapterProgressMessage, setSingleChapterProgressMessage] = useState('');
@@ -559,6 +564,7 @@ export default function Chapters() {
try {
setBatchGenerating(true);
setBatchGenerateVisible(false); // 关闭配置对话框,避免遮挡进度弹窗
const response = await fetch(`/api/chapters/project/${currentProject.id}/batch-generate`, {
method: 'POST',
@@ -978,12 +984,63 @@ export default function Chapters() {
const updatedProject = await projectApi.getProject(currentProject.id);
setCurrentProject(updatedProject);
}
message.success('章节删除成功');
} catch (error: any) {
message.error('删除章节失败:' + (error.message || '未知错误'));
}
};
// 打开规划编辑器
const handleOpenPlanEditor = (chapter: Chapter) => {
// 检查是否有规划数据
if (!chapter.expansion_plan) {
message.warning('该章节暂无规划信息');
return;
}
try {
// 尝试解析JSON,验证数据有效性
JSON.parse(chapter.expansion_plan);
setEditingPlanChapter(chapter);
setPlanEditorVisible(true);
} catch (error) {
console.error('规划数据格式错误:', error);
message.error('规划数据格式错误,无法编辑');
}
};
// 保存规划信息
const handleSavePlan = async (planData: ExpansionPlanData) => {
if (!editingPlanChapter) return;
try {
const response = await fetch(`/api/chapters/${editingPlanChapter.id}/expansion-plan`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(planData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || '更新失败');
}
// 刷新章节列表
await refreshChapters();
message.success('规划信息更新成功');
// 关闭编辑器
setPlanEditorVisible(false);
setEditingPlanChapter(null);
} catch (error: any) {
message.error('保存规划失败:' + (error.message || '未知错误'));
throw error;
}
};
const handleChapterSelect = (chapterId: string) => {
const element = document.getElementById(`chapter-item-${chapterId}`);
@@ -1037,14 +1094,165 @@ export default function Chapters() {
>
TXT
</Button>
{!isMobile && <Tag color="blue">/</Tag>}
{!isMobile && (
<Tag color="blue">
{currentProject.outline_mode === 'one-to-one'
? '传统模式:章节由大纲一对一管理,请在大纲页面操作'
: '细化模式:章节可在大纲页面展开'}
</Tag>
)}
</Space>
</div>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{chapters.length === 0 ? (
<Empty description="还没有章节,开始创作吧!" />
) : currentProject.outline_mode === 'one-to-one' ? (
// one-to-one 模式:直接显示扁平列表
<List
dataSource={sortedChapters}
renderItem={(item) => (
<List.Item
id={`chapter-item-${item.id}`}
style={{
padding: '16px',
marginBottom: 16,
background: '#fff',
borderRadius: 8,
border: '1px solid #f0f0f0',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'flex-start' : 'center',
}}
actions={isMobile ? undefined : [
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleOpenEditor(item.id)}
>
</Button>,
(() => {
const task = analysisTasksMap[item.id];
const isAnalyzing = task && (task.status === 'pending' || task.status === 'running');
const hasContent = item.content && item.content.trim() !== '';
return (
<Tooltip
title={
!hasContent ? '请先生成章节内容' :
isAnalyzing ? '分析进行中,请稍候...' :
''
}
>
<Button
type="text"
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
disabled={!hasContent || isAnalyzing}
loading={isAnalyzing}
>
{isAnalyzing ? '分析中' : '查看分析'}
</Button>
</Tooltip>
);
})(),
<Button
type="text"
icon={<SettingOutlined />}
onClick={() => handleOpenModal(item.id)}
>
</Button>,
]}
>
<div style={{ width: '100%' }}>
<List.Item.Meta
avatar={!isMobile && <FileTextOutlined style={{ fontSize: 32, color: '#1890ff' }} />}
title={
<div style={{
display: 'flex',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'flex-start' : 'center',
gap: isMobile ? 6 : 12,
width: '100%'
}}>
<span style={{ fontSize: isMobile ? 14 : 16, fontWeight: 500, flexShrink: 0 }}>
{item.chapter_number}{item.title}
</span>
<Space wrap size={isMobile ? 4 : 8}>
<Tag color={getStatusColor(item.status)}>{getStatusText(item.status)}</Tag>
<Badge count={`${item.word_count || 0}`} style={{ backgroundColor: '#52c41a' }} />
{renderAnalysisStatus(item.id)}
{!canGenerateChapter(item) && (
<Tooltip title={getGenerateDisabledReason(item)}>
<Tag icon={<LockOutlined />} color="warning">
</Tag>
</Tooltip>
)}
</Space>
</div>
}
description={
item.content ? (
<div style={{ marginTop: 8, color: 'rgba(0,0,0,0.65)', lineHeight: 1.6, fontSize: isMobile ? 12 : 14 }}>
{item.content.substring(0, isMobile ? 80 : 150)}
{item.content.length > (isMobile ? 80 : 150) && '...'}
</div>
) : (
<span style={{ color: 'rgba(0,0,0,0.45)', fontSize: isMobile ? 12 : 14 }}></span>
)
}
/>
{isMobile && (
<Space style={{ marginTop: 12, width: '100%', justifyContent: 'flex-end' }} wrap>
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleOpenEditor(item.id)}
size="small"
title="编辑内容"
/>
{(() => {
const task = analysisTasksMap[item.id];
const isAnalyzing = task && (task.status === 'pending' || task.status === 'running');
const hasContent = item.content && item.content.trim() !== '';
return (
<Tooltip
title={
!hasContent ? '请先生成章节内容' :
isAnalyzing ? '分析中' :
'查看分析'
}
>
<Button
type="text"
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
size="small"
disabled={!hasContent || isAnalyzing}
loading={isAnalyzing}
/>
</Tooltip>
);
})()}
<Button
type="text"
icon={<SettingOutlined />}
onClick={() => handleOpenModal(item.id)}
size="small"
title="修改信息"
/>
</Space>
)}
</div>
</List.Item>
)}
/>
) : (
// one-to-many 模式:按大纲分组显示
<Collapse
bordered={false}
defaultActiveKey={groupedChapters.map((_, idx) => idx.toString())}
@@ -1093,6 +1301,7 @@ export default function Chapters() {
}}
actions={isMobile ? undefined : [
<Button
type="text"
icon={<EditOutlined />}
onClick={() => handleOpenEditor(item.id)}
>
@@ -1112,6 +1321,7 @@ export default function Chapters() {
}
>
<Button
type="text"
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
disabled={!hasContent || isAnalyzing}
@@ -1129,22 +1339,25 @@ export default function Chapters() {
>
</Button>,
<Popconfirm
title="确定删除这个章节吗?"
description="删除后将无法恢复,章节内容和分析结果都将被删除。"
onConfirm={() => handleDeleteChapter(item.id)}
okText="确定删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
// 只在 one-to-many 模式下显示删除按钮
...(currentProject.outline_mode === 'one-to-many' ? [
<Popconfirm
title="确定删除这个章节吗?"
description="删除后将无法恢复,章节内容和分析结果都将被删除。"
onConfirm={() => handleDeleteChapter(item.id)}
okText="确定删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
</Button>
</Popconfirm>,
<Button
type="text"
danger
icon={<DeleteOutlined />}
>
</Button>
</Popconfirm>
] : []),
]}
>
<div style={{ width: '100%' }}>
@@ -1165,13 +1378,6 @@ export default function Chapters() {
<Tag color={getStatusColor(item.status)}>{getStatusText(item.status)}</Tag>
<Badge count={`${item.word_count || 0}`} style={{ backgroundColor: '#52c41a' }} />
{renderAnalysisStatus(item.id)}
{item.expansion_plan && (
<Tooltip title="已有展开规划,点击信息图标查看详情">
<Tag icon={<CheckCircleOutlined />} color="blue">
</Tag>
</Tooltip>
)}
{!canGenerateChapter(item) && (
<Tooltip title={getGenerateDisabledReason(item)}>
<Tag icon={<LockOutlined />} color="warning">
@@ -1180,15 +1386,26 @@ export default function Chapters() {
</Tooltip>
)}
{item.expansion_plan && (
<Tooltip title="查看展开规划详情">
<InfoCircleOutlined
style={{ color: '#1890ff', cursor: 'pointer', fontSize: 16 }}
onClick={(e) => {
e.stopPropagation();
showExpansionPlanModal(item);
}}
/>
</Tooltip>
<Space size={4}>
<Tooltip title="查看展开详情">
<InfoCircleOutlined
style={{ color: '#1890ff', cursor: 'pointer', fontSize: 16 }}
onClick={(e) => {
e.stopPropagation();
showExpansionPlanModal(item);
}}
/>
</Tooltip>
<Tooltip title="编辑规划信息">
<FormOutlined
style={{ color: '#52c41a', cursor: 'pointer', fontSize: 16 }}
onClick={(e) => {
e.stopPropagation();
handleOpenPlanEditor(item);
}}
/>
</Tooltip>
</Space>
)}
</Space>
</div>
@@ -1245,22 +1462,25 @@ export default function Chapters() {
size="small"
title="修改信息"
/>
<Popconfirm
title="确定删除?"
description="删除后无法恢复"
onConfirm={() => handleDeleteChapter(item.id)}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
size="small"
title="删除章节"
/>
</Popconfirm>
{/* 只在 one-to-many 模式下显示删除按钮 */}
{currentProject.outline_mode === 'one-to-many' && (
<Popconfirm
title="确定删除?"
description="删除后无法恢复"
onConfirm={() => handleDeleteChapter(item.id)}
okText="删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
size="small"
title="删除章节"
/>
</Popconfirm>
)}
</Space>
)}
</div>
@@ -1781,6 +2001,18 @@ export default function Chapters() {
: `批量生成进行中... (${batchProgress?.completed || 0}/${batchProgress?.total || 0})`
}
title="批量生成章节"
onCancel={() => {
Modal.confirm({
title: '确认取消',
content: '确定要取消批量生成吗?已生成的章节将保留。',
okText: '确定取消',
cancelText: '继续生成',
okButtonProps: { danger: true },
centered: true,
onOk: handleCancelBatchGenerate,
});
}}
cancelButtonText="取消任务"
/>
<FloatButton
@@ -1797,6 +2029,31 @@ export default function Chapters() {
groupedChapters={groupedChapters}
onChapterSelect={handleChapterSelect}
/>
{/* 规划编辑器 */}
{editingPlanChapter && currentProject && (() => {
let parsedPlanData = null;
try {
if (editingPlanChapter.expansion_plan) {
parsedPlanData = JSON.parse(editingPlanChapter.expansion_plan);
}
} catch (error) {
console.error('解析规划数据失败:', error);
}
return (
<ExpansionPlanEditor
visible={planEditorVisible}
planData={parsedPlanData}
projectId={currentProject.id}
onSave={handleSavePlan}
onCancel={() => {
setPlanEditorVisible(false);
setEditingPlanChapter(null);
}}
/>
);
})()}
</div>
);
}
+223 -13
View File
@@ -8,13 +8,14 @@ import { AIProjectGenerator, type GenerationConfig } from '../components/AIProje
const { Title, Text, Paragraph } = Typography;
const { TextArea } = Input;
type Step = 'idea' | 'title' | 'description' | 'theme' | 'genre' | 'perspective' | 'confirm' | 'generating' | 'complete';
type Step = 'idea' | 'title' | 'description' | 'theme' | 'genre' | 'perspective' | 'outline_mode' | 'confirm' | 'generating' | 'complete';
interface Message {
type: 'ai' | 'user';
content: string;
options?: string[];
isMultiSelect?: boolean;
optionsDisabled?: boolean; // 标记选项是否已禁用
}
interface WizardData {
@@ -23,8 +24,24 @@ interface WizardData {
theme: string;
genre: string[];
narrative_perspective: string;
outline_mode: 'one-to-one' | 'one-to-many';
}
// 缓存数据接口
interface CacheData {
messages: Message[];
currentStep: Step;
wizardData: Partial<WizardData>;
initialIdea: string;
selectedOptions: string[];
timestamp: number;
}
// 缓存键
const CACHE_KEY = 'inspiration_conversation_cache';
// 缓存有效期:24小时
const CACHE_EXPIRY = 24 * 60 * 60 * 1000;
const Inspiration: React.FC = () => {
const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState<Step>('idea');
@@ -56,6 +73,112 @@ const Inspiration: React.FC = () => {
context: Partial<WizardData>;
} | null>(null);
// 标记是否已经加载缓存
const [cacheLoaded, setCacheLoaded] = useState(false);
// ==================== 缓存管理函数 ====================
// 保存到缓存
const saveToCache = () => {
try {
// 只在对话阶段保存,生成阶段不保存
if (currentStep === 'generating' || currentStep === 'complete') {
return;
}
// 只有用户有输入时才保存(至少两条消息:AI问候+用户回复)
if (messages.length <= 1) {
return;
}
const cacheData: CacheData = {
messages,
currentStep,
wizardData,
initialIdea,
selectedOptions,
timestamp: Date.now()
};
localStorage.setItem(CACHE_KEY, JSON.stringify(cacheData));
console.log('💾 对话已自动保存');
} catch (error) {
console.error('保存缓存失败:', error);
}
};
// 从缓存恢复
const restoreFromCache = (): boolean => {
try {
const cached = localStorage.getItem(CACHE_KEY);
if (!cached) {
return false;
}
const cacheData: CacheData = JSON.parse(cached);
const age = Date.now() - cacheData.timestamp;
// 检查缓存是否过期
if (age > CACHE_EXPIRY) {
console.log('⏰ 缓存已过期,清除');
clearCache();
return false;
}
// 必须有有效的对话数据
if (!cacheData.messages || cacheData.messages.length <= 1) {
return false;
}
// 恢复所有状态
setMessages(cacheData.messages);
setCurrentStep(cacheData.currentStep);
setWizardData(cacheData.wizardData);
setInitialIdea(cacheData.initialIdea);
setSelectedOptions(cacheData.selectedOptions);
console.log('✅ 已恢复上次的对话进度');
message.success('已恢复上次的对话进度', 2);
return true;
} catch (error) {
console.error('恢复缓存失败:', error);
clearCache();
return false;
}
};
// 清除缓存
const clearCache = () => {
try {
localStorage.removeItem(CACHE_KEY);
console.log('🗑️ 缓存已清除');
} catch (error) {
console.error('清除缓存失败:', error);
}
};
// ==================== 组件挂载时恢复缓存 ====================
useEffect(() => {
if (!cacheLoaded) {
restoreFromCache();
setCacheLoaded(true);
}
}, []);
// ==================== 自动保存:状态变化时保存 ====================
useEffect(() => {
// 防抖保存
const timer = setTimeout(() => {
if (cacheLoaded) {
saveToCache();
}
}, 500);
return () => clearTimeout(timer);
}, [messages, currentStep, wizardData, initialIdea, selectedOptions, cacheLoaded]);
// 自动滚动到底部
const scrollToBottom = () => {
setTimeout(() => {
@@ -116,7 +239,7 @@ const Inspiration: React.FC = () => {
};
// 步骤顺序
const stepOrder: Step[] = ['idea', 'title', 'description', 'theme', 'genre', 'perspective', 'confirm'];
const stepOrder: Step[] = ['idea', 'title', 'description', 'theme', 'genre', 'perspective', 'outline_mode', 'confirm'];
const handleSendMessage = async () => {
if (!inputValue.trim()) {
@@ -191,6 +314,7 @@ const Inspiration: React.FC = () => {
return;
}
// 对于多选类型,不立即禁用选项
if (currentStep === 'genre') {
const newSelected = selectedOptions.includes(option)
? selectedOptions.filter(o => o !== option)
@@ -199,6 +323,19 @@ const Inspiration: React.FC = () => {
return;
}
// 立即禁用当前消息的选项(单选场景)
setMessages(prev => {
const newMessages = [...prev];
const lastAiMessageIndex = newMessages.map((m, i) => m.type === 'ai' && m.options ? i : -1).filter(i => i >= 0).pop();
if (lastAiMessageIndex !== undefined && lastAiMessageIndex >= 0) {
newMessages[lastAiMessageIndex] = {
...newMessages[lastAiMessageIndex],
optionsDisabled: true
};
}
return newMessages;
});
if (currentStep === 'perspective') {
const userMessage: Message = {
type: 'user',
@@ -206,9 +343,46 @@ const Inspiration: React.FC = () => {
};
setMessages(prev => [...prev, userMessage]);
const updatedData = { ...wizardData, narrative_perspective: option, genre: wizardData.genre || [] } as WizardData;
const updatedData = { ...wizardData, narrative_perspective: option };
setWizardData(updatedData);
// 询问大纲模式
const aiMessage: Message = {
type: 'ai',
content: `很好!现在请选择你想要的大纲模式:
📋 **一对一模式**:传统模式,一个大纲对应一个章节,适合结构清晰、章节独立的小说。
📚 **一对多模式**:细化模式,一个大纲可以展开成多个章节,适合需要详细展开情节的小说。
请选择:`,
options: ['📋 一对一模式', '📚 一对多模式']
};
setMessages(prev => [...prev, aiMessage]);
setCurrentStep('outline_mode');
return;
}
if (currentStep === 'outline_mode') {
const userMessage: Message = {
type: 'user',
content: option,
};
setMessages(prev => [...prev, userMessage]);
// 将选项转换为实际的模式值
const modeValue: 'one-to-one' | 'one-to-many' =
option === '📋 一对一模式' ? 'one-to-one' : 'one-to-many';
const updatedData = {
...wizardData,
outline_mode: modeValue,
genre: wizardData.genre || []
} as WizardData;
setWizardData(updatedData);
// 显示摘要
const modeText = modeValue === 'one-to-one' ? '一对一模式' : '一对多模式';
const summary = `
太棒了!你的小说设定已完成,请确认:
@@ -217,6 +391,7 @@ const Inspiration: React.FC = () => {
🎯 主题:${updatedData.theme}
🏷️ 类型:${updatedData.genre.join('、')}
👁️ 视角:${updatedData.narrative_perspective}
📋 大纲模式:${modeText}
请选择下一步操作:
`.trim();
@@ -245,6 +420,9 @@ const Inspiration: React.FC = () => {
};
setMessages(prev => [...prev, aiMessage]);
// 清除缓存(对话完成,进入生成阶段)
clearCache();
// 开始生成项目
const data = wizardData as WizardData;
const config: GenerationConfig = {
@@ -256,6 +434,7 @@ const Inspiration: React.FC = () => {
target_words: 100000,
chapter_count: 3,
character_count: 5,
outline_mode: data.outline_mode,
};
setGenerationConfig(config);
setCurrentStep('generating');
@@ -308,6 +487,11 @@ const Inspiration: React.FC = () => {
updatedData.genre = [input];
} else if (currentStep === 'perspective') {
updatedData.narrative_perspective = input;
} else if (currentStep === 'outline_mode') {
// 大纲模式不支持自定义输入
message.warning('请从选项中选择一个大纲模式');
setLoading(false);
return;
}
setWizardData(updatedData);
@@ -326,6 +510,19 @@ const Inspiration: React.FC = () => {
return;
}
// 禁用类型选择的选项
setMessages(prev => {
const newMessages = [...prev];
const lastAiMessageIndex = newMessages.map((m, i) => m.type === 'ai' && m.options ? i : -1).filter(i => i >= 0).pop();
if (lastAiMessageIndex !== undefined && lastAiMessageIndex >= 0) {
newMessages[lastAiMessageIndex] = {
...newMessages[lastAiMessageIndex],
optionsDisabled: true
};
}
return newMessages;
});
const userMessage: Message = {
type: 'user',
content: selectedOptions.join('、'),
@@ -340,7 +537,7 @@ const Inspiration: React.FC = () => {
try {
const aiMessage: Message = {
type: 'ai',
content: '很好!最后一步,请选择小说的叙事视角:',
content: '很好!接下来,请选择小说的叙事视角:',
options: ['第一人称', '第三人称', '全知视角']
};
setMessages(prev => [...prev, aiMessage]);
@@ -458,6 +655,9 @@ const Inspiration: React.FC = () => {
};
const handleRestart = () => {
// 清除缓存
clearCache();
setCurrentStep('idea');
setMessages([
{
@@ -478,11 +678,14 @@ const Inspiration: React.FC = () => {
// 生成完成回调
const handleComplete = (projectId: string) => {
console.log('灵感模式项目创建完成:', projectId);
// 确保清除缓存
clearCache();
setCurrentStep('complete');
};
// 返回对话界面
const handleBackToChat = () => {
clearCache();
setCurrentStep('idea');
setGenerationConfig(null);
handleRestart();
@@ -543,29 +746,36 @@ const Inspiration: React.FC = () => {
{msg.options.map((option, optIndex) => (
<Card
key={optIndex}
hoverable
hoverable={!msg.optionsDisabled}
size="small"
onClick={() => handleSelectOption(option)}
onClick={() => !msg.optionsDisabled && handleSelectOption(option)}
style={{
cursor: 'pointer',
cursor: msg.optionsDisabled ? 'not-allowed' : 'pointer',
border: msg.isMultiSelect && selectedOptions.includes(option)
? '2px solid #1890ff'
: '1px solid #d9d9d9',
background: msg.isMultiSelect && selectedOptions.includes(option)
background: msg.optionsDisabled
? '#f5f5f5'
: msg.isMultiSelect && selectedOptions.includes(option)
? '#e6f7ff'
: '#fff',
opacity: msg.optionsDisabled ? 0.6 : 1,
animation: 'floatIn 0.6s ease-out',
animationDelay: `${optIndex * 0.1}s`,
animationFillMode: 'both',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'translateY(-2px) scale(1.02)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(24,144,255,0.2)';
if (!msg.optionsDisabled) {
e.currentTarget.style.transform = 'translateY(-2px) scale(1.02)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(24,144,255,0.2)';
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'translateY(0) scale(1)';
e.currentTarget.style.boxShadow = 'none';
if (!msg.optionsDisabled) {
e.currentTarget.style.transform = 'translateY(0) scale(1)';
e.currentTarget.style.boxShadow = 'none';
}
}}
>
{option}
@@ -733,7 +943,7 @@ const Inspiration: React.FC = () => {
{(currentStep === 'idea' || currentStep === 'title' || currentStep === 'description' ||
currentStep === 'theme' || currentStep === 'genre' || currentStep === 'perspective' ||
currentStep === 'confirm') && renderChat()}
currentStep === 'outline_mode' || currentStep === 'confirm') && renderChat()}
{(currentStep === 'generating' || currentStep === 'complete') && generationConfig && (
<AIProjectGenerator
config={generationConfig}
+17 -10
View File
@@ -50,15 +50,17 @@ export default function Login() {
if (response.success) {
message.success('登录成功!');
// 检查今天是否已经显示过公告
const doNotShowUntil = localStorage.getItem('announcement_do_not_show_until');
const now = new Date().getTime();
// 检查是否永久隐藏公告
const hideForever = localStorage.getItem('announcement_hide_forever');
const hideToday = localStorage.getItem('announcement_hide_today');
const today = new Date().toDateString();
if (!doNotShowUntil || now > parseInt(doNotShowUntil)) {
setShowAnnouncement(true);
} else {
// 如果永久隐藏或今日已隐藏,则不显示公告
if (hideForever === 'true' || hideToday === today) {
const redirect = searchParams.get('redirect') || '/';
navigate(redirect);
} else {
setShowAnnouncement(true);
}
}
} catch (error) {
@@ -203,10 +205,14 @@ export default function Login() {
};
const handleDoNotShowToday = () => {
// 设置到今天23:59:59不再显示
const tomorrow = new Date();
tomorrow.setHours(23, 59, 59, 999);
localStorage.setItem('announcement_do_not_show_until', tomorrow.getTime().toString());
// 设置今日不再显示
const today = new Date().toDateString();
localStorage.setItem('announcement_hide_today', today);
};
const handleNeverShow = () => {
// 设置永久不再显示
localStorage.setItem('announcement_hide_forever', 'true');
};
return (
@@ -215,6 +221,7 @@ export default function Login() {
visible={showAnnouncement}
onClose={handleAnnouncementClose}
onDoNotShowToday={handleDoNotShowToday}
onNeverShow={handleNeverShow}
/>
<div style={{
display: 'flex',
+41 -26
View File
@@ -1377,7 +1377,14 @@ export default function Outline() {
justifyContent: 'space-between',
alignItems: isMobile ? 'stretch' : 'center'
}}>
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}></h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}></h2>
{currentProject?.outline_mode && (
<Tag color={currentProject.outline_mode === 'one-to-one' ? 'blue' : 'green'} style={{ width: 'fit-content' }}>
{currentProject.outline_mode === 'one-to-one' ? '传统模式 (1→1)' : '细化模式 (1→N)'}
</Tag>
)}
</div>
<Space size="small" wrap={isMobile}>
<Button
type="primary"
@@ -1388,7 +1395,7 @@ export default function Outline() {
>
{isMobile ? 'AI生成/续写' : 'AI生成/续写大纲'}
</Button>
{outlines.length > 0 && (
{outlines.length > 0 && currentProject?.outline_mode === 'one-to-many' && (
<Tooltip title="将所有大纲展开为多章,实现从大纲到章节的一对多关系">
<Button
icon={<AppstoreAddOutlined />}
@@ -1421,16 +1428,18 @@ export default function Outline() {
alignItems: isMobile ? 'flex-start' : 'center'
}}
actions={isMobile ? undefined : [
<Tooltip title="展开为多章">
<Button
type="text"
icon={<BranchesOutlined />}
onClick={() => handleExpandOutline(item.id, item.title)}
loading={isExpanding}
>
</Button>
</Tooltip>,
...(currentProject?.outline_mode === 'one-to-many' ? [
<Tooltip title="展开为多章">
<Button
type="text"
icon={<BranchesOutlined />}
onClick={() => handleExpandOutline(item.id, item.title)}
loading={isExpanding}
>
</Button>
</Tooltip>
] : []), // 一对一模式:不显示任何展开/创建按钮
<Button
type="text"
icon={<EditOutlined />}
@@ -1458,11 +1467,13 @@ export default function Outline() {
{item.order_index || '?'}
</span>
<span>{item.title}</span>
{/* ✅ 新增:展开状态标识 */}
{outlineExpandStatus[item.id] ? (
<Tag color="success" icon={<CheckCircleOutlined />}></Tag>
) : (
<Tag color="default"></Tag>
{/* ✅ 新增:展开状态标识 - 仅在一对多模式显示 */}
{currentProject?.outline_mode === 'one-to-many' && (
outlineExpandStatus[item.id] ? (
<Tag color="success" icon={<CheckCircleOutlined />}></Tag>
) : (
<Tag color="default"></Tag>
)
)}
</Space>
}
@@ -1482,15 +1493,19 @@ export default function Outline() {
onClick={() => handleOpenEditModal(item.id)}
size="small"
/>
<Tooltip title="展开为多章">
<Button
type="text"
icon={<BranchesOutlined />}
onClick={() => handleExpandOutline(item.id, item.title)}
loading={isExpanding}
size="small"
/>
</Tooltip>
{/* 一对多模式:显示展开按钮 */}
{currentProject?.outline_mode === 'one-to-many' && (
<Tooltip title="展开为多章">
<Button
type="text"
icon={<BranchesOutlined />}
onClick={() => handleExpandOutline(item.id, item.title)}
loading={isExpanding}
size="small"
/>
</Tooltip>
)}
{/* 一对一模式:不显示任何展开/创建按钮 */}
<Popconfirm
title="确定删除这条大纲吗?"
onConfirm={() => handleDeleteOutline(item.id)}
+14 -2
View File
@@ -801,7 +801,12 @@ export default function ProjectList() {
borderRadius: 8
}}>
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#1890ff' }}>
{(project.current_words / 1000).toFixed(1)}K
{project.current_words >= 1000000
? (project.current_words / 1000000).toFixed(1) + 'M'
: project.current_words >= 1000
? (project.current_words / 1000).toFixed(1) + 'K'
: project.current_words
}
</div>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
</div>
@@ -814,7 +819,14 @@ export default function ProjectList() {
borderRadius: 8
}}>
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#52c41a' }}>
{project.target_words ? (project.target_words / 1000).toFixed(0) + 'K' : '--'}
{project.target_words
? (project.target_words >= 1000000
? (project.target_words / 1000000).toFixed(1) + 'M'
: project.target_words >= 1000
? (project.target_words / 1000).toFixed(1) + 'K'
: project.target_words)
: '--'
}
</div>
<Text type="secondary" style={{ fontSize: 12 }}></Text>
</div>
+69 -2
View File
@@ -2,10 +2,10 @@ import { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Form, Input, InputNumber, Select, Button, Card,
Row, Col, Typography, Space, message
Row, Col, Typography, Space, message, Radio
} from 'antd';
import {
RocketOutlined, ArrowLeftOutlined
RocketOutlined, ArrowLeftOutlined, CheckCircleOutlined
} from '@ant-design/icons';
import { AIProjectGenerator, type GenerationConfig } from '../components/AIProjectGenerator';
import type { WizardBasicInfo } from '../types';
@@ -83,6 +83,7 @@ export default function ProjectWizardNew() {
target_words: values.target_words || 100000,
chapter_count: 3, // 默认生成3章大纲
character_count: values.character_count || 5,
outline_mode: values.outline_mode || 'one-to-many', // 添加大纲模式
};
setGenerationConfig(config);
@@ -120,6 +121,7 @@ export default function ProjectWizardNew() {
narrative_perspective: '第三人称',
character_count: 5,
target_words: 100000,
outline_mode: 'one-to-many', // 默认为细化模式
}}
>
<Form.Item
@@ -181,6 +183,71 @@ export default function ProjectWizardNew() {
</Select>
</Form.Item>
<Form.Item
label="大纲章节模式"
name="outline_mode"
rules={[{ required: true, message: '请选择大纲章节模式' }]}
tooltip="创建后不可更改,请根据创作习惯选择"
>
<Radio.Group size="large">
<Row gutter={16}>
<Col xs={24} sm={12}>
<Card
hoverable
style={{
borderColor: form.getFieldValue('outline_mode') === 'one-to-one' ? '#1890ff' : '#d9d9d9',
borderWidth: 2,
height: '100%',
}}
onClick={() => form.setFieldValue('outline_mode', 'one-to-one')}
>
<Radio value="one-to-one" style={{ width: '100%' }}>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<div style={{ fontSize: 16, fontWeight: 'bold' }}>
<CheckCircleOutlined style={{ marginRight: 8, color: '#52c41a' }} />
(11)
</div>
<div style={{ fontSize: 12, color: '#666' }}>
</div>
<div style={{ fontSize: 11, color: '#999' }}>
💡
</div>
</Space>
</Radio>
</Card>
</Col>
<Col xs={24} sm={12}>
<Card
hoverable
style={{
borderColor: form.getFieldValue('outline_mode') === 'one-to-many' ? '#1890ff' : '#d9d9d9',
borderWidth: 2,
height: '100%',
}}
onClick={() => form.setFieldValue('outline_mode', 'one-to-many')}
>
<Radio value="one-to-many" style={{ width: '100%' }}>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<div style={{ fontSize: 16, fontWeight: 'bold' }}>
<CheckCircleOutlined style={{ marginRight: 8, color: '#52c41a' }} />
(1N)
</div>
<div style={{ fontSize: 12, color: '#666' }}>
</div>
<div style={{ fontSize: 11, color: '#999' }}>
💡
</div>
</Space>
</Radio>
</Card>
</Col>
</Row>
</Radio.Group>
</Form.Item>
<Row gutter={16}>
<Col xs={24} sm={12}>
<Form.Item
+1
View File
@@ -515,6 +515,7 @@ export const wizardStreamApi = {
target_words?: number;
chapter_count?: number;
character_count?: number;
outline_mode?: 'one-to-one' | 'one-to-many'; // 添加大纲模式参数
provider?: string;
model?: string;
},
+4
View File
@@ -54,6 +54,7 @@ export interface Project {
status: 'planning' | 'writing' | 'revising' | 'completed';
wizard_status?: 'incomplete' | 'completed';
wizard_step?: number;
outline_mode: 'one-to-one' | 'one-to-many'; // 大纲章节模式
world_time_period?: string;
world_location?: string;
world_atmosphere?: string;
@@ -71,6 +72,7 @@ export interface ProjectCreate {
theme?: string;
genre?: string;
target_words?: number;
outline_mode?: 'one-to-one' | 'one-to-many'; // 大纲章节模式,默认one-to-many
wizard_status?: 'incomplete' | 'completed';
wizard_step?: number;
world_time_period?: string;
@@ -111,6 +113,7 @@ export interface ProjectWizardRequest {
narrative_perspective: string;
character_count?: number;
target_words?: number;
outline_mode?: 'one-to-one' | 'one-to-many'; // 大纲章节模式
world_building?: {
time_period: string;
location: string;
@@ -462,6 +465,7 @@ export interface WizardBasicInfo {
narrative_perspective: string;
character_count?: number;
target_words?: number;
outline_mode?: 'one-to-one' | 'one-to-many'; // 大纲章节模式
}
// API 错误响应类型