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 BUILD_GUIDE.md
launcher.py launcher.py
launcher.spec launcher.spec
mumuainovel.md
data/ data/
+1
View File
@@ -351,6 +351,7 @@ MuMuAINovel/
- 提交 [Issue](https://github.com/xiamuceer-j/MuMuAINovel/issues) - 提交 [Issue](https://github.com/xiamuceer-j/MuMuAINovel/issues)
- Linux DO [讨论](https://linux.do/t/topic/1106333) - Linux DO [讨论](https://linux.do/t/topic/1106333)
- 加入QQ群 [QQ群](frontend/public/qq.jpg) - 加入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, ChapterGenerateRequest,
BatchGenerateRequest, BatchGenerateRequest,
BatchGenerateResponse, BatchGenerateResponse,
BatchGenerateStatusResponse BatchGenerateStatusResponse,
ExpansionPlanUpdate
) )
from app.schemas.regeneration import ( from app.schemas.regeneration import (
ChapterRegenerateRequest, ChapterRegenerateRequest,
@@ -1008,6 +1009,10 @@ async def generate_chapter_content_stream(
yield f"data: {json.dumps({'type': 'error', 'error': '项目不存在'}, ensure_ascii=False)}\n\n" yield f"data: {json.dumps({'type': 'error', 'error': '项目不存在'}, ensure_ascii=False)}\n\n"
return return
# 获取项目的大纲模式
outline_mode = project.outline_mode if project else 'one-to-many'
logger.info(f"📋 项目大纲模式: {outline_mode}")
# 获取对应的大纲 # 获取对应的大纲
outline_result = await db_session.execute( outline_result = await db_session.execute(
select(Outline) select(Outline)
@@ -1188,6 +1193,48 @@ async def generate_chapter_content_stream(
logger.warning(f"⚠️ MCP工具调用失败,降级为基础模式: {str(e)}") logger.warning(f"⚠️ MCP工具调用失败,降级为基础模式: {str(e)}")
yield f"data: {json.dumps({'type': 'progress', 'message': '⚠️ MCP工具暂时不可用,使用基础模式', 'progress': 32}, ensure_ascii=False)}\n\n" 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参考资料 # 根据是否有前置内容选择不同的提示词,并应用写作风格、记忆增强和MCP参考资料
if previous_content: if previous_content:
prompt = prompt_service.get_chapter_generation_with_context_prompt( prompt = prompt_service.get_chapter_generation_with_context_prompt(
@@ -1204,11 +1251,12 @@ async def generate_chapter_content_stream(
previous_content=previous_content, previous_content=previous_content,
chapter_number=current_chapter.chapter_number, chapter_number=current_chapter.chapter_number,
chapter_title=current_chapter.title, 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, style_content=style_content,
target_word_count=target_word_count, target_word_count=target_word_count,
memory_context=memory_context, memory_context=memory_context,
mcp_references=mcp_reference_materials mcp_references=mcp_reference_materials,
outline_mode=outline_mode
) )
else: else:
prompt = prompt_service.get_chapter_generation_prompt( prompt = prompt_service.get_chapter_generation_prompt(
@@ -1224,11 +1272,12 @@ async def generate_chapter_content_stream(
outlines_context=outlines_context, outlines_context=outlines_context,
chapter_number=current_chapter.chapter_number, chapter_number=current_chapter.chapter_number,
chapter_title=current_chapter.title, 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, style_content=style_content,
target_word_count=target_word_count, target_word_count=target_word_count,
memory_context=memory_context, memory_context=memory_context,
mcp_references=mcp_reference_materials mcp_references=mcp_reference_materials,
outline_mode=outline_mode
) )
if mcp_reference_materials: if mcp_reference_materials:
@@ -1238,11 +1287,39 @@ async def generate_chapter_content_stream(
# 流式生成内容 # 流式生成内容
full_content = "" full_content = ""
chunk_count = 0
last_progress = 0
async for chunk in user_ai_service.generate_text_stream(prompt=prompt): async for chunk in user_ai_service.generate_text_stream(prompt=prompt):
full_content += chunk full_content += chunk
chunk_count += 1
# 发送内容块
yield f"data: {json.dumps({'type': 'content', 'content': chunk}, ensure_ascii=False)}\n\n" 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) # 让出控制权 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 old_word_count = current_chapter.word_count or 0
current_chapter.content = full_content current_chapter.content = full_content
@@ -1297,6 +1374,9 @@ async def generate_chapter_content_stream(
ai_service=user_ai_service 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 # 发送完成事件(包含分析任务ID
completion_data = { completion_data = {
'type': 'done', 'type': 'done',
@@ -2216,6 +2296,10 @@ async def generate_single_chapter_for_batch(
if not project: if not project:
raise Exception("项目不存在") raise Exception("项目不存在")
# 获取项目的大纲模式
outline_mode = project.outline_mode if project else 'one-to-many'
logger.info(f"📋 批量生成 - 项目大纲模式: {outline_mode}")
# 获取对应的大纲 # 获取对应的大纲
outline_result = await db_session.execute( outline_result = await db_session.execute(
select(Outline) 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 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: if previous_content:
prompt = prompt_service.get_chapter_generation_with_context_prompt( prompt = prompt_service.get_chapter_generation_with_context_prompt(
@@ -2301,10 +2427,11 @@ async def generate_single_chapter_for_batch(
previous_content=previous_content, previous_content=previous_content,
chapter_number=chapter.chapter_number, chapter_number=chapter.chapter_number,
chapter_title=chapter.title, chapter_title=chapter.title,
chapter_outline=outline.content if outline else chapter.summary or '暂无大纲', chapter_outline=chapter_outline_content,
style_content=style_content, style_content=style_content,
target_word_count=target_word_count, target_word_count=target_word_count,
memory_context=memory_context memory_context=memory_context,
outline_mode=outline_mode
) )
else: else:
prompt = prompt_service.get_chapter_generation_prompt( prompt = prompt_service.get_chapter_generation_prompt(
@@ -2320,10 +2447,11 @@ async def generate_single_chapter_for_batch(
outlines_context=outlines_context, outlines_context=outlines_context,
chapter_number=chapter.chapter_number, chapter_number=chapter.chapter_number,
chapter_title=chapter.title, chapter_title=chapter.title,
chapter_outline=outline.content if outline else chapter.summary or '暂无大纲', chapter_outline=chapter_outline_content,
style_content=style_content, style_content=style_content,
target_word_count=target_word_count, 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": "规划信息更新成功"
}
+170 -7
View File
@@ -236,18 +236,29 @@ async def delete_outline(
# 验证用户权限 # 验证用户权限
user_id = getattr(request.state, 'user_id', None) 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 project_id = outline.project_id
deleted_order = outline.order_index deleted_order = outline.order_index
# 删除该大纲对应的所有章节(通过outline_id关联) # 根据项目模式删除对应的章节
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_result = await db.execute(
delete(Chapter).where(Chapter.outline_id == outline_id) delete(Chapter).where(Chapter.outline_id == outline_id)
) )
deleted_chapters_count = delete_result.rowcount deleted_chapters_count = delete_result.rowcount
logger.info(f"一对多模式:删除大纲 {outline_id},同时删除了 {deleted_chapters_count} 个关联章节")
logger.info(f"删除大纲 {outline_id},同时删除了 {deleted_chapters_count} 个关联章节")
# 删除大纲 # 删除大纲
await db.delete(outline) await db.delete(outline)
@@ -264,6 +275,21 @@ async def delete_outline(
for o in subsequent_outlines: for o in subsequent_outlines:
o.order_index -= 1 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() await db.commit()
return { return {
@@ -852,7 +878,17 @@ async def _save_outlines(
db: AsyncSession, db: AsyncSession,
start_index: int = 1 start_index: int = 1
) -> List[Outline]: ) -> 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 = [] outlines = []
for idx, chapter_data in enumerate(outline_data): for idx, chapter_data in enumerate(outline_data):
@@ -879,6 +915,28 @@ async def _save_outlines(
db.add(outline) db.add(outline)
outlines.append(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 return outlines
@@ -1646,6 +1704,104 @@ async def expand_outline_generator(
yield await SSEResponse.send_error(f"展开失败: {str(e)}") 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="展开单个大纲为多章") @router.post("/{outline_id}/expand", response_model=OutlineExpansionResponse, summary="展开单个大纲为多章")
async def expand_outline_to_chapters( async def expand_outline_to_chapters(
outline_id: str, outline_id: str,
@@ -1681,8 +1837,15 @@ async def expand_outline_to_chapters(
if not outline: if not outline:
raise HTTPException(status_code=404, detail="大纲不存在") 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: try:
# 创建展开服务实例 # 创建展开服务实例
+2 -3
View File
@@ -354,9 +354,8 @@ async def export_project_chapters(
txt_content.append("\n" + "=" * 80 + "\n\n") txt_content.append("\n" + "=" * 80 + "\n\n")
for chapter in chapters: 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.chapter_number}{chapter.title}")
txt_content.append(f"{chapter_display}{chapter.title}")
txt_content.append("-" * 80) txt_content.append("-" * 80)
txt_content.append("") # 空行 txt_content.append("") # 空行
+54 -9
View File
@@ -47,6 +47,7 @@ async def world_building_generator(
target_words = data.get("target_words") target_words = data.get("target_words")
chapter_count = data.get("chapter_count") chapter_count = data.get("chapter_count")
character_count = data.get("character_count") character_count = data.get("character_count")
outline_mode = data.get("outline_mode", "one-to-many") # 大纲模式,默认一对多
provider = data.get("provider") provider = data.get("provider")
model = data.get("model") model = data.get("model")
enable_mcp = data.get("enable_mcp", True) # 默认启用MCP enable_mcp = data.get("enable_mcp", True) # 默认启用MCP
@@ -215,6 +216,7 @@ async def world_building_generator(
target_words=target_words, target_words=target_words,
chapter_count=chapter_count, chapter_count=chapter_count,
character_count=character_count, character_count=character_count,
outline_mode=outline_mode, # 设置大纲模式
wizard_status="incomplete", wizard_status="incomplete",
wizard_step=1, wizard_step=1,
status="planning" status="planning"
@@ -1017,12 +1019,37 @@ async def outline_generator(
logger.info(f"✅ 成功创建{len(created_outlines)}个大纲节点") logger.info(f"✅ 成功创建{len(created_outlines)}个大纲节点")
# 向导流程中不展开大纲,避免等待时间过长 # 根据项目的大纲模式决定是否自动创建章节
# 用户可以在大纲页面手动展开需要的大纲节点 created_chapters = []
yield await SSEResponse.send_progress("跳过大纲展开,加快创建速度...", 85) 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.narrative_perspective = narrative_perspective
project.target_words = target_words project.target_words = target_words
project.status = "writing" project.status = "writing"
@@ -1034,22 +1061,40 @@ async def outline_generator(
logger.info(f"📊 向导大纲生成完成:") logger.info(f"📊 向导大纲生成完成:")
logger.info(f" - 创建大纲节点:{len(created_outlines)}") 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({ yield await SSEResponse.send_result({
"message": f"成功生成{len(created_outlines)}个大纲节点(未展开章节,可在大纲页面手动展开)", "message": result_message,
"outline_count": len(created_outlines), "outline_count": len(created_outlines),
"chapter_count": 0, "chapter_count": len(created_chapters),
"outline_mode": project.outline_mode,
"outlines": [ "outlines": [
{ {
"id": outline.id, "id": outline.id,
"order_index": outline.order_index, "order_index": outline.order_index,
"title": outline.title, "title": outline.title,
"content": outline.content[:100] + "..." if len(outline.content) > 100 else outline.content, "content": outline.content[:100] + "..." if len(outline.content) > 100 else outline.content,
"note": "可在大纲页面展开为章节" "note": result_note
} for outline in created_outlines } 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") 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 sqlalchemy.sql import func
from app.database import Base from app.database import Base
import uuid import uuid
@@ -20,6 +20,7 @@ class Project(Base):
status = Column(String(20), default="planning", comment="创作状态") status = Column(String(20), default="planning", comment="创作状态")
wizard_status = Column(String(20), default="incomplete", comment="向导完成状态: incomplete/completed") wizard_status = Column(String(20), default="incomplete", comment="向导完成状态: incomplete/completed")
wizard_step = Column(Integer, default=0, comment="向导当前步骤: 0-4") 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="时间背景") world_time_period = Column(Text, comment="时间背景")
@@ -35,5 +36,12 @@ class Project(Base):
created_at = Column(DateTime, server_default=func.now(), comment="创建时间") created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=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): def __repr__(self):
return f"<Project(id={self.id}, title={self.title})>" return f"<Project(id={self.id}, title={self.title})>"
+45 -1
View File
@@ -1,6 +1,6 @@
"""章节相关的Pydantic模型""" """章节相关的Pydantic模型"""
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Optional from typing import Optional, List, Dict, Any
from datetime import datetime from datetime import datetime
@@ -119,3 +119,47 @@ class BatchGenerateStatusResponse(BaseModel):
started_at: Optional[str] = None started_at: Optional[str] = None
completed_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模型""" """项目相关的Pydantic模型"""
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import Optional from typing import Optional, Literal
from datetime import datetime from datetime import datetime
@@ -11,6 +11,10 @@ class ProjectBase(BaseModel):
theme: Optional[str] = Field(None, description="主题") theme: Optional[str] = Field(None, description="主题")
genre: Optional[str] = Field(None, description="小说类型") genre: Optional[str] = Field(None, description="小说类型")
target_words: Optional[int] = 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): class ProjectCreate(ProjectBase):
@@ -51,6 +55,7 @@ class ProjectResponse(ProjectBase):
chapter_count: Optional[int] = None chapter_count: Optional[int] = None
narrative_perspective: Optional[str] = None narrative_perspective: Optional[str] = None
character_count: Optional[int] = None character_count: Optional[int] = None
outline_mode: str # 显式声明以确保响应中包含
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
@@ -73,6 +78,10 @@ class ProjectWizardRequest(BaseModel):
narrative_perspective: str = Field(..., description="叙事视角") narrative_perspective: str = Field(..., description="叙事视角")
character_count: int = Field(5, ge=5, description="角色数量(至少5个)") character_count: int = Field(5, ge=5, description="角色数量(至少5个)")
target_words: Optional[int] = 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="大纲章节模式"
)
class WorldBuildingResponse(BaseModel): class WorldBuildingResponse(BaseModel):
+41 -9
View File
@@ -207,14 +207,16 @@ class PromptService:
4. **无特殊符号**:文本中不使用引号、方括号等特殊符号包裹内容 4. **无特殊符号**:文本中不使用引号、方括号等特殊符号包裹内容
5. **丰富细节**:每个字段提供充实的原创内容,避免模板化表达 5. **丰富细节**:每个字段提供充实的原创内容,避免模板化表达
# 反面示例(避免这样的设定 请根据输入的类型和主题,生成**规模适当、风格匹配**的世界观设定
❌ 不好的设定:故事设定在大崩解后的XX纪元、新世界秩序、文明重启...
✅ 好的设定:故事设定在2024年的深圳,互联网创业浪潮下的年轻人...
❌ 不好的设定:升华纪元、共鸣指数、灵光纯度...(现代都市题材不要用这些) # JSON格式示例
✅ 好的设定:通过高考分数、学历背景、家庭条件来衡量个人价值...(符合现实)
请根据输入的类型和主题,生成**规模适当、风格匹配**的世界观设定。""" {{
"time_period": "时间背景与社会状态的详细描述(300-500字)",
"location": "空间环境与地理特征的详细描述(300-500字)",
"atmosphere": "感官体验与情感基调的详细描述(300-500字)",
"rules": "世界规则与社会结构的详细描述(300-500字)"
}}"""
# 批量角色生成提示词 # 批量角色生成提示词
CHARACTERS_BATCH_GENERATION = """你是一位专业的角色设定师。请根据以下世界观和要求,生成{count}个立体丰满的角色和组织: CHARACTERS_BATCH_GENERATION = """你是一位专业的角色设定师。请根据以下世界观和要求,生成{count}个立体丰满的角色和组织:
@@ -1019,7 +1021,8 @@ class PromptService:
chapter_outline: str, style_content: str = "", chapter_outline: str, style_content: str = "",
target_word_count: int = 3000, target_word_count: int = 3000,
memory_context: dict = None, 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字 target_word_count: 目标字数,默认3000字
memory_context: 记忆上下文(可选) memory_context: 记忆上下文(可选)
mcp_references: MCP工具搜索的参考资料(可选) mcp_references: MCP工具搜索的参考资料(可选)
outline_mode: 大纲模式 (one-to-one/one-to-many)
""" """
# 计算最大字数(目标字数+1000 # 计算最大字数(目标字数+1000
max_word_count = target_word_count + 1000 max_word_count = target_word_count + 1000
@@ -1050,6 +1054,13 @@ class PromptService:
mcp_text += mcp_references mcp_text += mcp_references
mcp_text += "\n" 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( base_prompt = cls.format_prompt(
cls.CHAPTER_GENERATION, cls.CHAPTER_GENERATION,
title=title, title=title,
@@ -1079,7 +1090,13 @@ class PromptService:
if insert_text: if insert_text:
base_prompt = base_prompt.replace( 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 = "", style_content: str = "",
target_word_count: int = 3000, target_word_count: int = 3000,
memory_context: dict = None, 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字 target_word_count: 目标字数,默认3000字
memory_context: 记忆上下文(可选) memory_context: 记忆上下文(可选)
mcp_references: MCP工具搜索的参考资料(可选) mcp_references: MCP工具搜索的参考资料(可选)
outline_mode: 大纲模式 (one-to-one/one-to-many)
""" """
# 计算最大字数(目标字数+1000 # 计算最大字数(目标字数+1000
max_word_count = target_word_count + 1000 max_word_count = target_word_count + 1000
@@ -1128,6 +1147,13 @@ class PromptService:
memory_text += "以下是通过MCP工具搜索到的相关参考资料,可用于丰富情节和细节:\n\n" memory_text += "以下是通过MCP工具搜索到的相关参考资料,可用于丰富情节和细节:\n\n"
memory_text += mcp_references 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( base_prompt = cls.format_prompt(
cls.CHAPTER_GENERATION_WITH_CONTEXT, cls.CHAPTER_GENERATION_WITH_CONTEXT,
title=title, title=title,
@@ -1149,6 +1175,12 @@ class PromptService:
memory_context=memory_text memory_context=memory_text
) )
# 插入模式说明
base_prompt = base_prompt.replace(
"本章信息:",
mode_instruction + "\n本章信息:"
)
# 如果有风格要求,应用到提示词中 # 如果有风格要求,应用到提示词中
if style_content: if style_content:
return WritingStyleManager.apply_style_to_prompt(base_prompt, 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", "name": "frontend",
"private": true, "private": true,
"version": "1.0.5", "version": "1.0.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
Binary file not shown.

After

Width:  |  Height:  |  Size: 243 KiB

@@ -16,6 +16,7 @@ export interface GenerationConfig {
target_words: number; target_words: number;
chapter_count: number; chapter_count: number;
character_count: number; character_count: number;
outline_mode?: 'one-to-one' | 'one-to-many'; // 大纲章节模式
} }
interface AIProjectGeneratorProps { interface AIProjectGeneratorProps {
@@ -183,6 +184,7 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
target_words: data.target_words, target_words: data.target_words,
chapter_count: data.chapter_count, chapter_count: data.chapter_count,
character_count: data.character_count, character_count: data.character_count,
outline_mode: data.outline_mode || 'one-to-many', // 传递大纲模式
}, },
{ {
onProgress: (msg, prog) => { onProgress: (msg, prog) => {
@@ -328,6 +330,7 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
target_words: data.target_words, target_words: data.target_words,
chapter_count: data.chapter_count, chapter_count: data.chapter_count,
character_count: data.character_count, character_count: data.character_count,
outline_mode: data.outline_mode || 'one-to-many', // 传递大纲模式
}, },
{ {
onProgress: (msg, prog) => { onProgress: (msg, prog) => {
@@ -504,6 +507,7 @@ export const AIProjectGenerator: React.FC<AIProjectGeneratorProps> = ({
target_words: generationData.target_words, target_words: generationData.target_words,
chapter_count: generationData.chapter_count, chapter_count: generationData.chapter_count,
character_count: generationData.character_count, character_count: generationData.character_count,
outline_mode: generationData.outline_mode || 'one-to-many', // 传递大纲模式
}, },
{ {
onProgress: (msg, prog) => { onProgress: (msg, prog) => {
+107 -23
View File
@@ -5,14 +5,17 @@ interface AnnouncementModalProps {
visible: boolean; visible: boolean;
onClose: () => void; onClose: () => void;
onDoNotShowToday: () => void; onDoNotShowToday: () => void;
onNeverShow: () => void;
} }
export default function AnnouncementModal({ visible, onClose, onDoNotShowToday }: AnnouncementModalProps) { export default function AnnouncementModal({ visible, onClose, onDoNotShowToday, onNeverShow }: AnnouncementModalProps) {
const [imageError, setImageError] = useState(false); const [qqImageError, setQqImageError] = useState(false);
const [wxImageError, setWxImageError] = useState(false);
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
setImageError(false); setQqImageError(false);
setWxImageError(false);
} }
}, [visible]); }, [visible]);
@@ -21,6 +24,11 @@ export default function AnnouncementModal({ visible, onClose, onDoNotShowToday }
onClose(); onClose();
}; };
const handleNeverShow = () => {
onNeverShow();
onClose();
};
return ( return (
<Modal <Modal
title="🎉 欢迎使用 AI小说创作助手" title="🎉 欢迎使用 AI小说创作助手"
@@ -28,15 +36,15 @@ export default function AnnouncementModal({ visible, onClose, onDoNotShowToday }
onCancel={onClose} onCancel={onClose}
footer={ footer={
<Space style={{ width: '100%', justifyContent: 'center' }}> <Space style={{ width: '100%', justifyContent: 'center' }}>
<Button onClick={onClose} size="large"> <Button onClick={handleDoNotShowToday} size="large">
</Button> </Button>
<Button type="primary" onClick={handleDoNotShowToday} size="large"> <Button type="primary" onClick={handleNeverShow} size="large">
</Button> </Button>
</Space> </Space>
} }
width={600} width={800}
centered centered
styles={{ styles={{
body: { body: {
@@ -65,44 +73,120 @@ export default function AnnouncementModal({ visible, onClose, onDoNotShowToday }
<li>📚 </li> <li>📚 </li>
</ul> </ul>
<p style={{ fontWeight: 600, color: '#333', marginBottom: '16px' }}> <p style={{ fontWeight: 600, color: '#333', marginBottom: '16px' }}>
QQ交流群
</p> </p>
</div> </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',
flexDirection: 'column',
alignItems: 'center',
minWidth: '280px',
}}>
<p style={{ fontWeight: 600, color: '#333', marginBottom: '12px', fontSize: '15px' }}>
QQ交流群
</p>
{!qqImageError ? (
<div style={{ <div style={{
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
padding: '20px', background: '#fff',
background: '#f5f5f5',
borderRadius: '8px', borderRadius: '8px',
padding: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}}> }}>
<img <img
src="/qq.jpg" src="/qq.jpg"
alt="QQ交流群二维码" alt="QQ交流群二维码"
style={{ style={{
maxWidth: '100%', maxWidth: '280px',
maxHeight: '360px', maxHeight: '280px',
borderRadius: '8px', width: 'auto',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)', height: 'auto',
display: 'block',
objectFit: 'contain',
}} }}
onError={() => setImageError(true)} onError={() => setQqImageError(true)}
/> />
</div> </div>
) : ( ) : (
<div style={{ <div style={{
padding: '40px', width: '280px',
background: '#f5f5f5', height: '280px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: '#fff',
borderRadius: '8px', borderRadius: '8px',
color: '#999', color: '#999',
}}> }}>
<p></p> <p></p>
<p style={{ fontSize: '12px', marginTop: '8px' }}>
qq.jpg frontend/public/
</p>
</div> </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={{ <div style={{
marginTop: '20px', marginTop: '20px',
@@ -113,7 +197,7 @@ export default function AnnouncementModal({ visible, onClose, onDoNotShowToday }
fontSize: '14px', fontSize: '14px',
color: '#ad6800', color: '#ad6800',
}}> }}>
💡 "今内不再示" 💡 "今内不再示""永不再展示"
</div> </div>
</div> </div>
</Modal> </Modal>
+52 -2
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Typography, Space, Divider, Badge, Tooltip } from 'antd'; import { Typography, Space, Divider, Badge, Tooltip, Button } from 'antd';
import { GithubOutlined, CopyrightOutlined, HeartFilled, ClockCircleOutlined } from '@ant-design/icons'; import { GithubOutlined, CopyrightOutlined, HeartFilled, ClockCircleOutlined, GiftOutlined } from '@ant-design/icons';
import { VERSION_INFO, getVersionString } from '../config/version'; import { VERSION_INFO, getVersionString } from '../config/version';
import { checkLatestVersion } from '../services/versionService'; import { checkLatestVersion } from '../services/versionService';
@@ -88,6 +88,26 @@ export default function AppFooter() {
</Tooltip> </Tooltip>
</Badge> </Badge>
<Divider type="vertical" style={{ margin: '0 4px', borderColor: 'rgba(255, 255, 255, 0.3)' }} /> <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 <Link
href={VERSION_INFO.githubUrl} href={VERSION_INFO.githubUrl}
target="_blank" target="_blank"
@@ -190,6 +210,36 @@ export default function AppFooter() {
LinuxDO LinuxDO
</Link> </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 <Link
href={VERSION_INFO.licenseUrl} 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 React from 'react';
import { Modal, Spin } from 'antd'; import { Modal, Spin, Button } from 'antd';
import { LoadingOutlined } from '@ant-design/icons'; import { LoadingOutlined, StopOutlined } from '@ant-design/icons';
interface SSEProgressModalProps { interface SSEProgressModalProps {
visible: boolean; visible: boolean;
@@ -9,6 +9,8 @@ interface SSEProgressModalProps {
title?: string; title?: string;
showPercentage?: boolean; showPercentage?: boolean;
showIcon?: boolean; showIcon?: boolean;
onCancel?: () => void;
cancelButtonText?: string;
} }
/** /**
@@ -22,6 +24,8 @@ export const SSEProgressModal: React.FC<SSEProgressModalProps> = ({
title = 'AI生成中...', title = 'AI生成中...',
showPercentage = true, showPercentage = true,
showIcon = true, showIcon = true,
onCancel,
cancelButtonText = '取消任务',
}) => { }) => {
if (!visible) return null; if (!visible) return null;
@@ -115,10 +119,28 @@ export const SSEProgressModal: React.FC<SSEProgressModalProps> = ({
<div style={{ <div style={{
textAlign: 'center', textAlign: 'center',
fontSize: 13, fontSize: 13,
color: '#8c8c8c' color: '#8c8c8c',
marginBottom: onCancel ? 16 : 0
}}> }}>
</div> </div>
{/* 取消按钮 */}
{onCancel && (
<div style={{
textAlign: 'center',
marginTop: 16
}}>
<Button
danger
size="large"
icon={<StopOutlined />}
onClick={onCancel}
>
{cancelButtonText}
</Button>
</div>
)}
</div> </div>
</Modal> </Modal>
); );
+31 -23
View File
@@ -41,20 +41,21 @@ export default function AuthCallback() {
const redirect = sessionStorage.getItem('login_redirect') || '/'; const redirect = sessionStorage.getItem('login_redirect') || '/';
sessionStorage.removeItem('login_redirect'); sessionStorage.removeItem('login_redirect');
// 检查今天是否已经显示过公告 // 检查是否永久隐藏公告或今日已隐藏
const doNotShowUntil = localStorage.getItem('announcement_do_not_show_until'); const hideForever = localStorage.getItem('announcement_hide_forever');
const now = new Date().getTime(); 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);
}, 1000);
} else {
// 延迟一下再跳转,让用户看到成功提示 // 延迟一下再跳转,让用户看到成功提示
setTimeout(() => { setTimeout(() => {
navigate(redirect); navigate(redirect);
}, 1000); }, 1000);
} else {
// 延迟一下再显示公告,让用户看到成功提示
setTimeout(() => {
setShowAnnouncement(true);
}, 1000);
} }
} catch (error) { } catch (error) {
console.error('登录失败:', error); console.error('登录失败:', error);
@@ -117,10 +118,14 @@ export default function AuthCallback() {
}; };
const handleDoNotShowToday = () => { const handleDoNotShowToday = () => {
// 设置到今天23:59:59不再显示 // 设置今日不再显示
const tomorrow = new Date(); const today = new Date().toDateString();
tomorrow.setHours(23, 59, 59, 999); localStorage.setItem('announcement_hide_today', today);
localStorage.setItem('announcement_do_not_show_until', tomorrow.getTime().toString()); };
const handleNeverShow = () => {
// 设置永久不再显示
localStorage.setItem('announcement_hide_forever', 'true');
}; };
const handleSetPassword = async () => { const handleSetPassword = async () => {
@@ -147,16 +152,17 @@ export default function AuthCallback() {
const redirect = sessionStorage.getItem('login_redirect') || '/'; const redirect = sessionStorage.getItem('login_redirect') || '/';
sessionStorage.removeItem('login_redirect'); sessionStorage.removeItem('login_redirect');
const doNotShowUntil = localStorage.getItem('announcement_do_not_show_until'); const hideForever = localStorage.getItem('announcement_hide_forever');
const now = new Date().getTime(); const hideToday = localStorage.getItem('announcement_hide_today');
const today = new Date().toDateString();
if (!doNotShowUntil || now > parseInt(doNotShowUntil)) { if (hideForever === 'true' || hideToday === today) {
setTimeout(() => { setTimeout(() => {
setShowAnnouncement(true); navigate(redirect);
}, 500); }, 500);
} else { } else {
setTimeout(() => { setTimeout(() => {
navigate(redirect); setShowAnnouncement(true);
}, 500); }, 500);
} }
} catch (error) { } catch (error) {
@@ -173,16 +179,17 @@ export default function AuthCallback() {
const redirect = sessionStorage.getItem('login_redirect') || '/'; const redirect = sessionStorage.getItem('login_redirect') || '/';
sessionStorage.removeItem('login_redirect'); sessionStorage.removeItem('login_redirect');
const doNotShowUntil = localStorage.getItem('announcement_do_not_show_until'); const hideForever = localStorage.getItem('announcement_hide_forever');
const now = new Date().getTime(); const hideToday = localStorage.getItem('announcement_hide_today');
const today = new Date().toDateString();
if (!doNotShowUntil || now > parseInt(doNotShowUntil)) { if (hideForever === 'true' || hideToday === today) {
setTimeout(() => { setTimeout(() => {
setShowAnnouncement(true); navigate(redirect);
}, 500); }, 500);
} else { } else {
setTimeout(() => { setTimeout(() => {
navigate(redirect); setShowAnnouncement(true);
}, 500); }, 500);
} }
}; };
@@ -193,6 +200,7 @@ export default function AuthCallback() {
visible={showAnnouncement} visible={showAnnouncement}
onClose={handleAnnouncementClose} onClose={handleAnnouncementClose}
onDoNotShowToday={handleDoNotShowToday} onDoNotShowToday={handleDoNotShowToday}
onNeverShow={handleNeverShow}
/> />
<Modal <Modal
+331 -74
View File
@@ -1,11 +1,12 @@
import { useState, useEffect, useRef, useMemo } from 'react'; 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 { 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 { useStore } from '../store';
import { useChapterSync } from '../store/hooks'; import { useChapterSync } from '../store/hooks';
import { projectApi, writingStyleApi } from '../services/api'; import { projectApi, writingStyleApi } from '../services/api';
import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask, ExpansionPlanData } from '../types'; import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask, ExpansionPlanData } from '../types';
import ChapterAnalysis from '../components/ChapterAnalysis'; import ChapterAnalysis from '../components/ChapterAnalysis';
import ExpansionPlanEditor from '../components/ExpansionPlanEditor';
import { SSELoadingOverlay } from '../components/SSELoadingOverlay'; import { SSELoadingOverlay } from '../components/SSELoadingOverlay';
import { SSEProgressModal } from '../components/SSEProgressModal'; import { SSEProgressModal } from '../components/SSEProgressModal';
import FloatingIndexPanel from '../components/FloatingIndexPanel'; import FloatingIndexPanel from '../components/FloatingIndexPanel';
@@ -33,6 +34,10 @@ export default function Chapters() {
const pollingIntervalsRef = useRef<Record<string, number>>({}); const pollingIntervalsRef = useRef<Record<string, number>>({});
const [isIndexPanelVisible, setIsIndexPanelVisible] = useState(false); const [isIndexPanelVisible, setIsIndexPanelVisible] = useState(false);
// 规划编辑状态
const [planEditorVisible, setPlanEditorVisible] = useState(false);
const [editingPlanChapter, setEditingPlanChapter] = useState<Chapter | null>(null);
// 单章节生成进度状态 // 单章节生成进度状态
const [singleChapterProgress, setSingleChapterProgress] = useState(0); const [singleChapterProgress, setSingleChapterProgress] = useState(0);
const [singleChapterProgressMessage, setSingleChapterProgressMessage] = useState(''); const [singleChapterProgressMessage, setSingleChapterProgressMessage] = useState('');
@@ -559,6 +564,7 @@ export default function Chapters() {
try { try {
setBatchGenerating(true); setBatchGenerating(true);
setBatchGenerateVisible(false); // 关闭配置对话框,避免遮挡进度弹窗
const response = await fetch(`/api/chapters/project/${currentProject.id}/batch-generate`, { const response = await fetch(`/api/chapters/project/${currentProject.id}/batch-generate`, {
method: 'POST', method: 'POST',
@@ -985,6 +991,57 @@ export default function Chapters() {
} }
}; };
// 打开规划编辑器
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 handleChapterSelect = (chapterId: string) => {
const element = document.getElementById(`chapter-item-${chapterId}`); const element = document.getElementById(`chapter-item-${chapterId}`);
if (element) { if (element) {
@@ -1037,62 +1094,38 @@ export default function Chapters() {
> >
TXT TXT
</Button> </Button>
{!isMobile && <Tag color="blue">/</Tag>} {!isMobile && (
<Tag color="blue">
{currentProject.outline_mode === 'one-to-one'
? '传统模式:章节由大纲一对一管理,请在大纲页面操作'
: '细化模式:章节可在大纲页面展开'}
</Tag>
)}
</Space> </Space>
</div> </div>
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}> <div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
{chapters.length === 0 ? ( {chapters.length === 0 ? (
<Empty description="还没有章节,开始创作吧!" /> <Empty description="还没有章节,开始创作吧!" />
) : ( ) : currentProject.outline_mode === 'one-to-one' ? (
<Collapse // one-to-one 模式:直接显示扁平列表
bordered={false}
defaultActiveKey={groupedChapters.map((_, idx) => idx.toString())}
expandIcon={({ isActive }) => <CaretRightOutlined rotate={isActive ? 90 : 0} />}
style={{ background: 'transparent' }}
>
{groupedChapters.map((group, groupIndex) => (
<Collapse.Panel
key={groupIndex.toString()}
header={
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Tag color={group.outlineId ? 'blue' : 'default'} style={{ margin: 0 }}>
{group.outlineId ? `📖 大纲 ${group.outlineOrder}` : '📝 未分类'}
</Tag>
<span style={{ fontWeight: 600, fontSize: 16 }}>
{group.outlineTitle}
</span>
<Badge
count={`${group.chapters.length}`}
style={{ backgroundColor: '#52c41a' }}
/>
<Badge
count={`${group.chapters.reduce((sum, ch) => sum + (ch.word_count || 0), 0)}`}
style={{ backgroundColor: '#1890ff' }}
/>
</div>
}
style={{
marginBottom: 16,
background: '#fff',
borderRadius: 8,
border: '1px solid #f0f0f0',
}}
>
<List <List
dataSource={group.chapters} dataSource={sortedChapters}
renderItem={(item) => ( renderItem={(item) => (
<List.Item <List.Item
id={`chapter-item-${item.id}`} id={`chapter-item-${item.id}`}
style={{ style={{
padding: '16px 0', padding: '16px',
marginBottom: 16,
background: '#fff',
borderRadius: 8, borderRadius: 8,
transition: 'background 0.3s ease', border: '1px solid #f0f0f0',
flexDirection: isMobile ? 'column' : 'row', flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'flex-start' : 'center', alignItems: isMobile ? 'flex-start' : 'center',
}} }}
actions={isMobile ? undefined : [ actions={isMobile ? undefined : [
<Button <Button
type="text"
icon={<EditOutlined />} icon={<EditOutlined />}
onClick={() => handleOpenEditor(item.id)} onClick={() => handleOpenEditor(item.id)}
> >
@@ -1112,6 +1145,7 @@ export default function Chapters() {
} }
> >
<Button <Button
type="text"
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />} icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
onClick={() => handleShowAnalysis(item.id)} onClick={() => handleShowAnalysis(item.id)}
disabled={!hasContent || isAnalyzing} disabled={!hasContent || isAnalyzing}
@@ -1129,22 +1163,6 @@ export default function Chapters() {
> >
</Button>, </Button>,
<Popconfirm
title="确定删除这个章节吗?"
description="删除后将无法恢复,章节内容和分析结果都将被删除。"
onConfirm={() => handleDeleteChapter(item.id)}
okText="确定删除"
cancelText="取消"
okButtonProps={{ danger: true }}
>
<Button
type="text"
danger
icon={<DeleteOutlined />}
>
</Button>
</Popconfirm>,
]} ]}
> >
<div style={{ width: '100%' }}> <div style={{ width: '100%' }}>
@@ -1165,13 +1183,6 @@ export default function Chapters() {
<Tag color={getStatusColor(item.status)}>{getStatusText(item.status)}</Tag> <Tag color={getStatusColor(item.status)}>{getStatusText(item.status)}</Tag>
<Badge count={`${item.word_count || 0}`} style={{ backgroundColor: '#52c41a' }} /> <Badge count={`${item.word_count || 0}`} style={{ backgroundColor: '#52c41a' }} />
{renderAnalysisStatus(item.id)} {renderAnalysisStatus(item.id)}
{item.expansion_plan && (
<Tooltip title="已有展开规划,点击信息图标查看详情">
<Tag icon={<CheckCircleOutlined />} color="blue">
</Tag>
</Tooltip>
)}
{!canGenerateChapter(item) && ( {!canGenerateChapter(item) && (
<Tooltip title={getGenerateDisabledReason(item)}> <Tooltip title={getGenerateDisabledReason(item)}>
<Tag icon={<LockOutlined />} color="warning"> <Tag icon={<LockOutlined />} color="warning">
@@ -1179,17 +1190,6 @@ export default function Chapters() {
</Tag> </Tag>
</Tooltip> </Tooltip>
)} )}
{item.expansion_plan && (
<Tooltip title="查看展开规划详情">
<InfoCircleOutlined
style={{ color: '#1890ff', cursor: 'pointer', fontSize: 16 }}
onClick={(e) => {
e.stopPropagation();
showExpansionPlanModal(item);
}}
/>
</Tooltip>
)}
</Space> </Space>
</div> </div>
} }
@@ -1245,6 +1245,225 @@ export default function Chapters() {
size="small" size="small"
title="修改信息" title="修改信息"
/> />
</Space>
)}
</div>
</List.Item>
)}
/>
) : (
// one-to-many 模式:按大纲分组显示
<Collapse
bordered={false}
defaultActiveKey={groupedChapters.map((_, idx) => idx.toString())}
expandIcon={({ isActive }) => <CaretRightOutlined rotate={isActive ? 90 : 0} />}
style={{ background: 'transparent' }}
>
{groupedChapters.map((group, groupIndex) => (
<Collapse.Panel
key={groupIndex.toString()}
header={
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<Tag color={group.outlineId ? 'blue' : 'default'} style={{ margin: 0 }}>
{group.outlineId ? `📖 大纲 ${group.outlineOrder}` : '📝 未分类'}
</Tag>
<span style={{ fontWeight: 600, fontSize: 16 }}>
{group.outlineTitle}
</span>
<Badge
count={`${group.chapters.length}`}
style={{ backgroundColor: '#52c41a' }}
/>
<Badge
count={`${group.chapters.reduce((sum, ch) => sum + (ch.word_count || 0), 0)}`}
style={{ backgroundColor: '#1890ff' }}
/>
</div>
}
style={{
marginBottom: 16,
background: '#fff',
borderRadius: 8,
border: '1px solid #f0f0f0',
}}
>
<List
dataSource={group.chapters}
renderItem={(item) => (
<List.Item
id={`chapter-item-${item.id}`}
style={{
padding: '16px 0',
borderRadius: 8,
transition: 'background 0.3s ease',
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>,
// 只在 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 />}
>
</Button>
</Popconfirm>
] : []),
]}
>
<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>
)}
{item.expansion_plan && (
<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>
}
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="修改信息"
/>
{/* 只在 one-to-many 模式下显示删除按钮 */}
{currentProject.outline_mode === 'one-to-many' && (
<Popconfirm <Popconfirm
title="确定删除?" title="确定删除?"
description="删除后无法恢复" description="删除后无法恢复"
@@ -1261,6 +1480,7 @@ export default function Chapters() {
title="删除章节" title="删除章节"
/> />
</Popconfirm> </Popconfirm>
)}
</Space> </Space>
)} )}
</div> </div>
@@ -1781,6 +2001,18 @@ export default function Chapters() {
: `批量生成进行中... (${batchProgress?.completed || 0}/${batchProgress?.total || 0})` : `批量生成进行中... (${batchProgress?.completed || 0}/${batchProgress?.total || 0})`
} }
title="批量生成章节" title="批量生成章节"
onCancel={() => {
Modal.confirm({
title: '确认取消',
content: '确定要取消批量生成吗?已生成的章节将保留。',
okText: '确定取消',
cancelText: '继续生成',
okButtonProps: { danger: true },
centered: true,
onOk: handleCancelBatchGenerate,
});
}}
cancelButtonText="取消任务"
/> />
<FloatButton <FloatButton
@@ -1797,6 +2029,31 @@ export default function Chapters() {
groupedChapters={groupedChapters} groupedChapters={groupedChapters}
onChapterSelect={handleChapterSelect} 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> </div>
); );
} }
+219 -9
View File
@@ -8,13 +8,14 @@ import { AIProjectGenerator, type GenerationConfig } from '../components/AIProje
const { Title, Text, Paragraph } = Typography; const { Title, Text, Paragraph } = Typography;
const { TextArea } = Input; 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 { interface Message {
type: 'ai' | 'user'; type: 'ai' | 'user';
content: string; content: string;
options?: string[]; options?: string[];
isMultiSelect?: boolean; isMultiSelect?: boolean;
optionsDisabled?: boolean; // 标记选项是否已禁用
} }
interface WizardData { interface WizardData {
@@ -23,8 +24,24 @@ interface WizardData {
theme: string; theme: string;
genre: string[]; genre: string[];
narrative_perspective: 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 Inspiration: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const [currentStep, setCurrentStep] = useState<Step>('idea'); const [currentStep, setCurrentStep] = useState<Step>('idea');
@@ -56,6 +73,112 @@ const Inspiration: React.FC = () => {
context: Partial<WizardData>; context: Partial<WizardData>;
} | null>(null); } | 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 = () => { const scrollToBottom = () => {
setTimeout(() => { 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 () => { const handleSendMessage = async () => {
if (!inputValue.trim()) { if (!inputValue.trim()) {
@@ -191,6 +314,7 @@ const Inspiration: React.FC = () => {
return; return;
} }
// 对于多选类型,不立即禁用选项
if (currentStep === 'genre') { if (currentStep === 'genre') {
const newSelected = selectedOptions.includes(option) const newSelected = selectedOptions.includes(option)
? selectedOptions.filter(o => o !== option) ? selectedOptions.filter(o => o !== option)
@@ -199,6 +323,19 @@ const Inspiration: React.FC = () => {
return; 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') { if (currentStep === 'perspective') {
const userMessage: Message = { const userMessage: Message = {
type: 'user', type: 'user',
@@ -206,9 +343,46 @@ const Inspiration: React.FC = () => {
}; };
setMessages(prev => [...prev, userMessage]); setMessages(prev => [...prev, userMessage]);
const updatedData = { ...wizardData, narrative_perspective: option, genre: wizardData.genre || [] } as WizardData; const updatedData = { ...wizardData, narrative_perspective: option };
setWizardData(updatedData); 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 = ` const summary = `
太棒了!你的小说设定已完成,请确认: 太棒了!你的小说设定已完成,请确认:
@@ -217,6 +391,7 @@ const Inspiration: React.FC = () => {
🎯 主题:${updatedData.theme} 🎯 主题:${updatedData.theme}
🏷️ 类型:${updatedData.genre.join('、')} 🏷️ 类型:${updatedData.genre.join('、')}
👁️ 视角:${updatedData.narrative_perspective} 👁️ 视角:${updatedData.narrative_perspective}
📋 大纲模式:${modeText}
请选择下一步操作: 请选择下一步操作:
`.trim(); `.trim();
@@ -245,6 +420,9 @@ const Inspiration: React.FC = () => {
}; };
setMessages(prev => [...prev, aiMessage]); setMessages(prev => [...prev, aiMessage]);
// 清除缓存(对话完成,进入生成阶段)
clearCache();
// 开始生成项目 // 开始生成项目
const data = wizardData as WizardData; const data = wizardData as WizardData;
const config: GenerationConfig = { const config: GenerationConfig = {
@@ -256,6 +434,7 @@ const Inspiration: React.FC = () => {
target_words: 100000, target_words: 100000,
chapter_count: 3, chapter_count: 3,
character_count: 5, character_count: 5,
outline_mode: data.outline_mode,
}; };
setGenerationConfig(config); setGenerationConfig(config);
setCurrentStep('generating'); setCurrentStep('generating');
@@ -308,6 +487,11 @@ const Inspiration: React.FC = () => {
updatedData.genre = [input]; updatedData.genre = [input];
} else if (currentStep === 'perspective') { } else if (currentStep === 'perspective') {
updatedData.narrative_perspective = input; updatedData.narrative_perspective = input;
} else if (currentStep === 'outline_mode') {
// 大纲模式不支持自定义输入
message.warning('请从选项中选择一个大纲模式');
setLoading(false);
return;
} }
setWizardData(updatedData); setWizardData(updatedData);
@@ -326,6 +510,19 @@ const Inspiration: React.FC = () => {
return; 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 = { const userMessage: Message = {
type: 'user', type: 'user',
content: selectedOptions.join('、'), content: selectedOptions.join('、'),
@@ -340,7 +537,7 @@ const Inspiration: React.FC = () => {
try { try {
const aiMessage: Message = { const aiMessage: Message = {
type: 'ai', type: 'ai',
content: '很好!最后一步,请选择小说的叙事视角:', content: '很好!接下来,请选择小说的叙事视角:',
options: ['第一人称', '第三人称', '全知视角'] options: ['第一人称', '第三人称', '全知视角']
}; };
setMessages(prev => [...prev, aiMessage]); setMessages(prev => [...prev, aiMessage]);
@@ -458,6 +655,9 @@ const Inspiration: React.FC = () => {
}; };
const handleRestart = () => { const handleRestart = () => {
// 清除缓存
clearCache();
setCurrentStep('idea'); setCurrentStep('idea');
setMessages([ setMessages([
{ {
@@ -478,11 +678,14 @@ const Inspiration: React.FC = () => {
// 生成完成回调 // 生成完成回调
const handleComplete = (projectId: string) => { const handleComplete = (projectId: string) => {
console.log('灵感模式项目创建完成:', projectId); console.log('灵感模式项目创建完成:', projectId);
// 确保清除缓存
clearCache();
setCurrentStep('complete'); setCurrentStep('complete');
}; };
// 返回对话界面 // 返回对话界面
const handleBackToChat = () => { const handleBackToChat = () => {
clearCache();
setCurrentStep('idea'); setCurrentStep('idea');
setGenerationConfig(null); setGenerationConfig(null);
handleRestart(); handleRestart();
@@ -543,29 +746,36 @@ const Inspiration: React.FC = () => {
{msg.options.map((option, optIndex) => ( {msg.options.map((option, optIndex) => (
<Card <Card
key={optIndex} key={optIndex}
hoverable hoverable={!msg.optionsDisabled}
size="small" size="small"
onClick={() => handleSelectOption(option)} onClick={() => !msg.optionsDisabled && handleSelectOption(option)}
style={{ style={{
cursor: 'pointer', cursor: msg.optionsDisabled ? 'not-allowed' : 'pointer',
border: msg.isMultiSelect && selectedOptions.includes(option) border: msg.isMultiSelect && selectedOptions.includes(option)
? '2px solid #1890ff' ? '2px solid #1890ff'
: '1px solid #d9d9d9', : '1px solid #d9d9d9',
background: msg.isMultiSelect && selectedOptions.includes(option) background: msg.optionsDisabled
? '#f5f5f5'
: msg.isMultiSelect && selectedOptions.includes(option)
? '#e6f7ff' ? '#e6f7ff'
: '#fff', : '#fff',
opacity: msg.optionsDisabled ? 0.6 : 1,
animation: 'floatIn 0.6s ease-out', animation: 'floatIn 0.6s ease-out',
animationDelay: `${optIndex * 0.1}s`, animationDelay: `${optIndex * 0.1}s`,
animationFillMode: 'both', animationFillMode: 'both',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)', transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
}} }}
onMouseEnter={(e) => { onMouseEnter={(e) => {
if (!msg.optionsDisabled) {
e.currentTarget.style.transform = 'translateY(-2px) scale(1.02)'; e.currentTarget.style.transform = 'translateY(-2px) scale(1.02)';
e.currentTarget.style.boxShadow = '0 4px 12px rgba(24,144,255,0.2)'; e.currentTarget.style.boxShadow = '0 4px 12px rgba(24,144,255,0.2)';
}
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
if (!msg.optionsDisabled) {
e.currentTarget.style.transform = 'translateY(0) scale(1)'; e.currentTarget.style.transform = 'translateY(0) scale(1)';
e.currentTarget.style.boxShadow = 'none'; e.currentTarget.style.boxShadow = 'none';
}
}} }}
> >
{option} {option}
@@ -733,7 +943,7 @@ const Inspiration: React.FC = () => {
{(currentStep === 'idea' || currentStep === 'title' || currentStep === 'description' || {(currentStep === 'idea' || currentStep === 'title' || currentStep === 'description' ||
currentStep === 'theme' || currentStep === 'genre' || currentStep === 'perspective' || currentStep === 'theme' || currentStep === 'genre' || currentStep === 'perspective' ||
currentStep === 'confirm') && renderChat()} currentStep === 'outline_mode' || currentStep === 'confirm') && renderChat()}
{(currentStep === 'generating' || currentStep === 'complete') && generationConfig && ( {(currentStep === 'generating' || currentStep === 'complete') && generationConfig && (
<AIProjectGenerator <AIProjectGenerator
config={generationConfig} config={generationConfig}
+17 -10
View File
@@ -50,15 +50,17 @@ export default function Login() {
if (response.success) { if (response.success) {
message.success('登录成功!'); message.success('登录成功!');
// 检查今天是否已经显示过公告 // 检查是否永久隐藏公告
const doNotShowUntil = localStorage.getItem('announcement_do_not_show_until'); const hideForever = localStorage.getItem('announcement_hide_forever');
const now = new Date().getTime(); const hideToday = localStorage.getItem('announcement_hide_today');
const today = new Date().toDateString();
if (!doNotShowUntil || now > parseInt(doNotShowUntil)) { // 如果永久隐藏或今日已隐藏,则不显示公告
setShowAnnouncement(true); if (hideForever === 'true' || hideToday === today) {
} else {
const redirect = searchParams.get('redirect') || '/'; const redirect = searchParams.get('redirect') || '/';
navigate(redirect); navigate(redirect);
} else {
setShowAnnouncement(true);
} }
} }
} catch (error) { } catch (error) {
@@ -203,10 +205,14 @@ export default function Login() {
}; };
const handleDoNotShowToday = () => { const handleDoNotShowToday = () => {
// 设置到今天23:59:59不再显示 // 设置今日不再显示
const tomorrow = new Date(); const today = new Date().toDateString();
tomorrow.setHours(23, 59, 59, 999); localStorage.setItem('announcement_hide_today', today);
localStorage.setItem('announcement_do_not_show_until', tomorrow.getTime().toString()); };
const handleNeverShow = () => {
// 设置永久不再显示
localStorage.setItem('announcement_hide_forever', 'true');
}; };
return ( return (
@@ -215,6 +221,7 @@ export default function Login() {
visible={showAnnouncement} visible={showAnnouncement}
onClose={handleAnnouncementClose} onClose={handleAnnouncementClose}
onDoNotShowToday={handleDoNotShowToday} onDoNotShowToday={handleDoNotShowToday}
onNeverShow={handleNeverShow}
/> />
<div style={{ <div style={{
display: 'flex', display: 'flex',
+19 -4
View File
@@ -1377,7 +1377,14 @@ export default function Outline() {
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: isMobile ? 'stretch' : 'center' alignItems: isMobile ? 'stretch' : 'center'
}}> }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
<h2 style={{ margin: 0, fontSize: isMobile ? 18 : 24 }}></h2> <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}> <Space size="small" wrap={isMobile}>
<Button <Button
type="primary" type="primary"
@@ -1388,7 +1395,7 @@ export default function Outline() {
> >
{isMobile ? 'AI生成/续写' : 'AI生成/续写大纲'} {isMobile ? 'AI生成/续写' : 'AI生成/续写大纲'}
</Button> </Button>
{outlines.length > 0 && ( {outlines.length > 0 && currentProject?.outline_mode === 'one-to-many' && (
<Tooltip title="将所有大纲展开为多章,实现从大纲到章节的一对多关系"> <Tooltip title="将所有大纲展开为多章,实现从大纲到章节的一对多关系">
<Button <Button
icon={<AppstoreAddOutlined />} icon={<AppstoreAddOutlined />}
@@ -1421,6 +1428,7 @@ export default function Outline() {
alignItems: isMobile ? 'flex-start' : 'center' alignItems: isMobile ? 'flex-start' : 'center'
}} }}
actions={isMobile ? undefined : [ actions={isMobile ? undefined : [
...(currentProject?.outline_mode === 'one-to-many' ? [
<Tooltip title="展开为多章"> <Tooltip title="展开为多章">
<Button <Button
type="text" type="text"
@@ -1430,7 +1438,8 @@ export default function Outline() {
> >
</Button> </Button>
</Tooltip>, </Tooltip>
] : []), // 一对一模式:不显示任何展开/创建按钮
<Button <Button
type="text" type="text"
icon={<EditOutlined />} icon={<EditOutlined />}
@@ -1458,11 +1467,13 @@ export default function Outline() {
{item.order_index || '?'} {item.order_index || '?'}
</span> </span>
<span>{item.title}</span> <span>{item.title}</span>
{/* ✅ 新增:展开状态标识 */} {/* ✅ 新增:展开状态标识 - 仅在一对多模式显示 */}
{outlineExpandStatus[item.id] ? ( {currentProject?.outline_mode === 'one-to-many' && (
outlineExpandStatus[item.id] ? (
<Tag color="success" icon={<CheckCircleOutlined />}></Tag> <Tag color="success" icon={<CheckCircleOutlined />}></Tag>
) : ( ) : (
<Tag color="default"></Tag> <Tag color="default"></Tag>
)
)} )}
</Space> </Space>
} }
@@ -1482,6 +1493,8 @@ export default function Outline() {
onClick={() => handleOpenEditModal(item.id)} onClick={() => handleOpenEditModal(item.id)}
size="small" size="small"
/> />
{/* 一对多模式:显示展开按钮 */}
{currentProject?.outline_mode === 'one-to-many' && (
<Tooltip title="展开为多章"> <Tooltip title="展开为多章">
<Button <Button
type="text" type="text"
@@ -1491,6 +1504,8 @@ export default function Outline() {
size="small" size="small"
/> />
</Tooltip> </Tooltip>
)}
{/* 一对一模式:不显示任何展开/创建按钮 */}
<Popconfirm <Popconfirm
title="确定删除这条大纲吗?" title="确定删除这条大纲吗?"
onConfirm={() => handleDeleteOutline(item.id)} onConfirm={() => handleDeleteOutline(item.id)}
+14 -2
View File
@@ -801,7 +801,12 @@ export default function ProjectList() {
borderRadius: 8 borderRadius: 8
}}> }}>
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#1890ff' }}> <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> </div>
<Text type="secondary" style={{ fontSize: 12 }}></Text> <Text type="secondary" style={{ fontSize: 12 }}></Text>
</div> </div>
@@ -814,7 +819,14 @@ export default function ProjectList() {
borderRadius: 8 borderRadius: 8
}}> }}>
<div style={{ fontSize: 20, fontWeight: 'bold', color: '#52c41a' }}> <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> </div>
<Text type="secondary" style={{ fontSize: 12 }}></Text> <Text type="secondary" style={{ fontSize: 12 }}></Text>
</div> </div>
+69 -2
View File
@@ -2,10 +2,10 @@ import { useState, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { import {
Form, Input, InputNumber, Select, Button, Card, Form, Input, InputNumber, Select, Button, Card,
Row, Col, Typography, Space, message Row, Col, Typography, Space, message, Radio
} from 'antd'; } from 'antd';
import { import {
RocketOutlined, ArrowLeftOutlined RocketOutlined, ArrowLeftOutlined, CheckCircleOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import { AIProjectGenerator, type GenerationConfig } from '../components/AIProjectGenerator'; import { AIProjectGenerator, type GenerationConfig } from '../components/AIProjectGenerator';
import type { WizardBasicInfo } from '../types'; import type { WizardBasicInfo } from '../types';
@@ -83,6 +83,7 @@ export default function ProjectWizardNew() {
target_words: values.target_words || 100000, target_words: values.target_words || 100000,
chapter_count: 3, // 默认生成3章大纲 chapter_count: 3, // 默认生成3章大纲
character_count: values.character_count || 5, character_count: values.character_count || 5,
outline_mode: values.outline_mode || 'one-to-many', // 添加大纲模式
}; };
setGenerationConfig(config); setGenerationConfig(config);
@@ -120,6 +121,7 @@ export default function ProjectWizardNew() {
narrative_perspective: '第三人称', narrative_perspective: '第三人称',
character_count: 5, character_count: 5,
target_words: 100000, target_words: 100000,
outline_mode: 'one-to-many', // 默认为细化模式
}} }}
> >
<Form.Item <Form.Item
@@ -181,6 +183,71 @@ export default function ProjectWizardNew() {
</Select> </Select>
</Form.Item> </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}> <Row gutter={16}>
<Col xs={24} sm={12}> <Col xs={24} sm={12}>
<Form.Item <Form.Item
+1
View File
@@ -515,6 +515,7 @@ export const wizardStreamApi = {
target_words?: number; target_words?: number;
chapter_count?: number; chapter_count?: number;
character_count?: number; character_count?: number;
outline_mode?: 'one-to-one' | 'one-to-many'; // 添加大纲模式参数
provider?: string; provider?: string;
model?: string; model?: string;
}, },
+4
View File
@@ -54,6 +54,7 @@ export interface Project {
status: 'planning' | 'writing' | 'revising' | 'completed'; status: 'planning' | 'writing' | 'revising' | 'completed';
wizard_status?: 'incomplete' | 'completed'; wizard_status?: 'incomplete' | 'completed';
wizard_step?: number; wizard_step?: number;
outline_mode: 'one-to-one' | 'one-to-many'; // 大纲章节模式
world_time_period?: string; world_time_period?: string;
world_location?: string; world_location?: string;
world_atmosphere?: string; world_atmosphere?: string;
@@ -71,6 +72,7 @@ export interface ProjectCreate {
theme?: string; theme?: string;
genre?: string; genre?: string;
target_words?: number; target_words?: number;
outline_mode?: 'one-to-one' | 'one-to-many'; // 大纲章节模式,默认one-to-many
wizard_status?: 'incomplete' | 'completed'; wizard_status?: 'incomplete' | 'completed';
wizard_step?: number; wizard_step?: number;
world_time_period?: string; world_time_period?: string;
@@ -111,6 +113,7 @@ export interface ProjectWizardRequest {
narrative_perspective: string; narrative_perspective: string;
character_count?: number; character_count?: number;
target_words?: number; target_words?: number;
outline_mode?: 'one-to-one' | 'one-to-many'; // 大纲章节模式
world_building?: { world_building?: {
time_period: string; time_period: string;
location: string; location: string;
@@ -462,6 +465,7 @@ export interface WizardBasicInfo {
narrative_perspective: string; narrative_perspective: string;
character_count?: number; character_count?: number;
target_words?: number; target_words?: number;
outline_mode?: 'one-to-one' | 'one-to-many'; // 大纲章节模式
} }
// API 错误响应类型 // API 错误响应类型