update:1.更新大纲细化功能

This commit is contained in:
xiamuceer
2025-11-18 22:14:55 +08:00
parent a2229f7780
commit 9b17774e13
14 changed files with 3285 additions and 370 deletions
+43 -3
View File
@@ -2,6 +2,7 @@
from fastapi import APIRouter, Depends, HTTPException, Request, Query, BackgroundTasks
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
from sqlalchemy.orm import selectinload
import json
import asyncio
from typing import Optional
@@ -125,7 +126,7 @@ async def get_project_chapters(
request: Request,
db: AsyncSession = Depends(get_db)
):
"""获取指定项目的所有章节(路径参数版本"""
"""获取指定项目的所有章节(带大纲信息"""
# 验证用户权限
user_id = getattr(request.state, 'user_id', None)
await verify_project_access(project_id, user_id, db)
@@ -136,7 +137,7 @@ async def get_project_chapters(
)
total = count_result.scalar_one()
# 获取章节列表
# 获取章节列表,同时加载关联的大纲信息
result = await db.execute(
select(Chapter)
.where(Chapter.project_id == project_id)
@@ -144,7 +145,46 @@ async def get_project_chapters(
)
chapters = result.scalars().all()
return ChapterListResponse(total=total, items=chapters)
# 获取所有大纲信息(用于填充outline_title
outline_ids = [ch.outline_id for ch in chapters if ch.outline_id]
outlines_map = {}
if outline_ids:
outlines_result = await db.execute(
select(Outline).where(Outline.id.in_(outline_ids))
)
outlines_map = {o.id: o for o in outlines_result.scalars().all()}
# 为所有章节添加大纲信息(统一处理)
chapters_with_outline = []
for chapter in chapters:
chapter_dict = {
"id": chapter.id,
"project_id": chapter.project_id,
"chapter_number": chapter.chapter_number,
"title": chapter.title,
"content": chapter.content,
"summary": chapter.summary,
"word_count": chapter.word_count,
"status": chapter.status,
"outline_id": chapter.outline_id,
"sub_index": chapter.sub_index,
"expansion_plan": chapter.expansion_plan,
"created_at": chapter.created_at,
"updated_at": chapter.updated_at,
}
# 添加大纲信息
if chapter.outline_id and chapter.outline_id in outlines_map:
outline = outlines_map[chapter.outline_id]
chapter_dict["outline_title"] = outline.title
chapter_dict["outline_order"] = outline.order_index
else:
chapter_dict["outline_title"] = None
chapter_dict["outline_order"] = None
chapters_with_outline.append(chapter_dict)
return ChapterListResponse(total=total, items=chapters_with_outline)
@router.get("/{chapter_id}", response_model=ChapterResponse, summary="获取章节详情")
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -354,7 +354,9 @@ async def export_project_chapters(
txt_content.append("\n" + "=" * 80 + "\n\n")
for chapter in chapters:
txt_content.append(f"{chapter.chapter_number}{chapter.title}")
# 处理子章节序号显示
chapter_display = f"{chapter.chapter_number}-{chapter.sub_index}" if chapter.sub_index and chapter.sub_index > 1 else str(chapter.chapter_number)
txt_content.append(f"{chapter_display}{chapter.title}")
txt_content.append("-" * 80)
txt_content.append("") # 空行
+9 -1
View File
@@ -17,8 +17,16 @@ class Chapter(Base):
summary = Column(Text, comment="章节摘要")
word_count = Column(Integer, default=0, comment="字数统计")
status = Column(String(20), default="draft", comment="章节状态")
# 大纲关联字段(实现一对多关系)
outline_id = Column(String(36), ForeignKey("outlines.id", ondelete="SET NULL"), nullable=True, comment="关联的大纲ID")
sub_index = Column(Integer, default=1, comment="大纲下的子章节序号")
# 大纲展开规划数据(JSON格式)
expansion_plan = Column(Text, comment="展开规划详情(JSON): 包含key_events, character_focus, emotional_tone等")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
def __repr__(self):
return f"<Chapter(id={self.id}, chapter_number={self.chapter_number}, title={self.title})>"
return f"<Chapter(id={self.id}, chapter_number={self.chapter_number}, title={self.title}, outline_id={self.outline_id})>"
+11
View File
@@ -12,6 +12,9 @@ class ChapterBase(BaseModel):
summary: Optional[str] = Field(None, description="章节摘要")
word_count: Optional[int] = Field(0, description="字数")
status: Optional[str] = Field("draft", description="章节状态")
outline_id: Optional[str] = Field(None, description="关联的大纲ID")
sub_index: Optional[int] = Field(1, description="大纲下的子章节序号")
expansion_plan: Optional[str] = Field(None, description="展开规划详情(JSON)")
class ChapterCreate(BaseModel):
@@ -22,6 +25,9 @@ class ChapterCreate(BaseModel):
content: Optional[str] = Field(None, description="章节内容")
summary: Optional[str] = Field(None, description="章节摘要")
status: Optional[str] = Field("draft", description="章节状态")
outline_id: Optional[str] = Field(None, description="关联的大纲ID")
sub_index: Optional[int] = Field(1, description="大纲下的子章节序号")
expansion_plan: Optional[str] = Field(None, description="展开规划详情(JSON)")
class ChapterUpdate(BaseModel):
@@ -44,6 +50,11 @@ class ChapterResponse(BaseModel):
summary: Optional[str] = None
word_count: int = 0
status: str
outline_id: Optional[str] = None
sub_index: Optional[int] = 1
expansion_plan: Optional[str] = None
outline_title: Optional[str] = None # 大纲标题(从Outline表联查)
outline_order: Optional[int] = None # 大纲排序序号(从Outline表联查)
created_at: datetime
updated_at: datetime
+65 -7
View File
@@ -78,12 +78,70 @@ class OutlineListResponse(BaseModel):
items: list[OutlineResponse]
class OutlineReorderItem(BaseModel):
"""单个大纲重排序"""
id: str = Field(..., description="大纲ID")
order_index: int = Field(..., description="新的序号", ge=1)
class ChapterPlanItem(BaseModel):
"""单个章节规划"""
sub_index: int = Field(..., description="子章节序号", ge=1)
title: str = Field(..., description="章节标题")
plot_summary: str = Field(..., description="剧情摘要(200-300字)")
key_events: list[str] = Field(..., description="关键事件列表")
character_focus: list[str] = Field(..., description="主要涉及的角色")
emotional_tone: str = Field(..., description="情感基调")
narrative_goal: str = Field(..., description="叙事目标")
conflict_type: str = Field(..., description="冲突类型")
estimated_words: int = Field(3000, description="预计字数", ge=1000)
scenes: Optional[list[str]] = Field(None, description="场景列表(可选)")
class OutlineReorderRequest(BaseModel):
"""大纲批量重排序请求"""
orders: list[OutlineReorderItem] = Field(..., description="排序列表")
class OutlineExpansionRequest(BaseModel):
"""大纲展开为多章节的请求模型(outline_id从路径参数获取)"""
target_chapter_count: int = Field(3, description="目标章节数", ge=1, le=10)
expansion_strategy: str = Field("balanced", description="展开策略: balanced(均衡), climax(高潮重点), detail(细节丰富)")
enable_scene_analysis: bool = Field(False, description="是否包含场景规划")
auto_create_chapters: bool = Field(True, description="是否自动创建章节记录")
provider: Optional[str] = Field(None, description="AI提供商")
model: Optional[str] = Field(None, description="AI模型")
class OutlineExpansionResponse(BaseModel):
"""大纲展开响应模型"""
outline_id: str = Field(..., description="大纲ID")
outline_title: str = Field(..., description="大纲标题")
target_chapter_count: int = Field(..., description="目标章节数")
actual_chapter_count: int = Field(..., description="实际生成的章节数")
expansion_strategy: str = Field(..., description="使用的展开策略")
chapter_plans: list[ChapterPlanItem] = Field(..., description="章节规划列表")
created_chapters: Optional[list] = Field(None, description="已创建的章节列表")
class BatchOutlineExpansionRequest(BaseModel):
"""批量大纲展开请求模型"""
project_id: str = Field(..., description="项目ID")
outline_ids: Optional[list[str]] = Field(None, description="要展开的大纲ID列表(为空则展开所有)")
chapters_per_outline: int = Field(3, description="每个大纲的目标章节数", ge=1, le=10)
expansion_strategy: str = Field("balanced", description="展开策略")
enable_scene_analysis: bool = Field(False, description="是否包含场景规划")
auto_create_chapters: bool = Field(True, description="是否自动创建章节记录")
provider: Optional[str] = Field(None, description="AI提供商")
model: Optional[str] = Field(None, description="AI模型")
class BatchOutlineExpansionResponse(BaseModel):
"""批量大纲展开响应模型"""
project_id: str = Field(..., description="项目ID")
total_outlines_expanded: int = Field(..., description="总共展开的大纲数")
total_chapters_created: int = Field(..., description="总共创建的章节数")
expansion_results: list[OutlineExpansionResponse] = Field(..., description="展开结果列表")
skipped_outlines: Optional[list[dict]] = Field(None, description="跳过的大纲列表(已展开)")
class CreateChaptersFromPlansRequest(BaseModel):
"""根据已有规划创建章节的请求模型"""
chapter_plans: list[ChapterPlanItem] = Field(..., description="章节规划列表(来自之前的AI生成结果)")
class CreateChaptersFromPlansResponse(BaseModel):
"""根据已有规划创建章节的响应模型"""
outline_id: str = Field(..., description="大纲ID")
outline_title: str = Field(..., description="大纲标题")
chapters_created: int = Field(..., description="创建的章节数")
created_chapters: list = Field(..., description="创建的章节列表")
@@ -0,0 +1,723 @@
"""大纲剧情展开服务 - 将大纲节点展开为多个章节"""
from typing import List, Dict, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func
import json
from app.models.outline import Outline
from app.models.project import Project
from app.models.character import Character
from app.models.chapter import Chapter
from app.services.ai_service import AIService
from app.logger import get_logger
logger = get_logger(__name__)
class PlotExpansionService:
"""大纲剧情展开服务"""
def __init__(self, ai_service: AIService):
self.ai_service = ai_service
async def analyze_outline_for_chapters(
self,
outline: Outline,
project: Project,
db: AsyncSession,
target_chapter_count: int = 3,
expansion_strategy: str = "balanced",
enable_scene_analysis: bool = True,
provider: Optional[str] = None,
model: Optional[str] = None,
batch_size: int = 5,
progress_callback: Optional[callable] = None
) -> List[Dict[str, Any]]:
"""
分析单个大纲,生成多章节规划支持分批生成
Args:
outline: 大纲对象
project: 项目对象
db: 数据库会话
target_chapter_count: 目标生成章节数
expansion_strategy: 展开策略(balanced/climax/detail)
enable_scene_analysis: 是否启用场景级分析
provider: AI提供商
model: AI模型
batch_size: 每批生成的章节数默认5章
progress_callback: 进度回调函数(可选)
Returns:
章节规划列表
"""
logger.info(f"开始分析大纲 {outline.id},目标生成 {target_chapter_count}")
# 如果章节数较少,直接生成
if target_chapter_count <= batch_size:
return await self._generate_chapters_single_batch(
outline=outline,
project=project,
db=db,
target_chapter_count=target_chapter_count,
expansion_strategy=expansion_strategy,
enable_scene_analysis=enable_scene_analysis,
provider=provider,
model=model
)
# 章节数较多,分批生成
logger.info(f"章节数({target_chapter_count})超过批次大小({batch_size}),启用分批生成")
return await self._generate_chapters_in_batches(
outline=outline,
project=project,
db=db,
target_chapter_count=target_chapter_count,
expansion_strategy=expansion_strategy,
enable_scene_analysis=enable_scene_analysis,
provider=provider,
model=model,
batch_size=batch_size,
progress_callback=progress_callback
)
async def _generate_chapters_single_batch(
self,
outline: Outline,
project: Project,
db: AsyncSession,
target_chapter_count: int,
expansion_strategy: str,
enable_scene_analysis: bool,
provider: Optional[str],
model: Optional[str]
) -> List[Dict[str, Any]]:
"""单批次生成章节规划"""
# 获取角色信息
characters_result = await db.execute(
select(Character).where(Character.project_id == project.id)
)
characters = characters_result.scalars().all()
characters_info = "\n".join([
f"- {char.name} ({'组织' if char.is_organization else '角色'}, {char.role_type}): "
f"{char.personality[:100] if char.personality else '暂无描述'}"
for char in characters
])
# 获取大纲上下文(前后大纲)
context_info = await self._get_outline_context(outline, project.id, db)
# 构建分析提示词
prompt = self._build_expansion_prompt(
outline=outline,
project=project,
characters_info=characters_info,
context_info=context_info,
target_chapter_count=target_chapter_count,
expansion_strategy=expansion_strategy,
enable_scene_analysis=enable_scene_analysis
)
# 调用AI生成章节规划
logger.info(f"调用AI生成章节规划...")
ai_response = await self.ai_service.generate_text(
prompt=prompt,
provider=provider,
model=model
)
# 提取内容
ai_content = ai_response.get("content", "") if isinstance(ai_response, dict) else ai_response
# 解析AI响应
chapter_plans = self._parse_expansion_response(ai_content, outline.id)
logger.info(f"成功生成 {len(chapter_plans)} 个章节规划")
return chapter_plans
async def _generate_chapters_in_batches(
self,
outline: Outline,
project: Project,
db: AsyncSession,
target_chapter_count: int,
expansion_strategy: str,
enable_scene_analysis: bool,
provider: Optional[str],
model: Optional[str],
batch_size: int,
progress_callback: Optional[callable]
) -> List[Dict[str, Any]]:
"""分批生成章节规划"""
# 计算批次数
total_batches = (target_chapter_count + batch_size - 1) // batch_size
logger.info(f"分批生成计划: 总共{target_chapter_count}章,分{total_batches}批,每批{batch_size}")
# 获取角色信息(所有批次共用)
characters_result = await db.execute(
select(Character).where(Character.project_id == project.id)
)
characters = characters_result.scalars().all()
characters_info = "\n".join([
f"- {char.name} ({'组织' if char.is_organization else '角色'}, {char.role_type}): "
f"{char.personality[:100] if char.personality else '暂无描述'}"
for char in characters
])
# 获取大纲上下文
context_info = await self._get_outline_context(outline, project.id, db)
all_chapter_plans = []
for batch_num in range(total_batches):
# 计算当前批次的章节数
remaining_chapters = target_chapter_count - len(all_chapter_plans)
current_batch_size = min(batch_size, remaining_chapters)
current_start_index = len(all_chapter_plans) + 1
logger.info(f"开始生成第{batch_num + 1}/{total_batches}批,章节范围: {current_start_index}-{current_start_index + current_batch_size - 1}")
# 回调通知进度
if progress_callback:
await progress_callback(batch_num + 1, total_batches, current_start_index, current_batch_size)
# 构建当前批次的提示词(包含已生成章节的上下文)
prompt = self._build_batch_expansion_prompt(
outline=outline,
project=project,
characters_info=characters_info,
context_info=context_info,
target_chapter_count=current_batch_size,
expansion_strategy=expansion_strategy,
enable_scene_analysis=enable_scene_analysis,
start_index=current_start_index,
previous_chapters=all_chapter_plans,
total_chapters=target_chapter_count
)
# 调用AI生成当前批次
logger.info(f"调用AI生成第{batch_num + 1}批...")
ai_response = await self.ai_service.generate_text(
prompt=prompt,
provider=provider,
model=model
)
# 提取内容
ai_content = ai_response.get("content", "") if isinstance(ai_response, dict) else ai_response
# 解析AI响应
batch_plans = self._parse_expansion_response(ai_content, outline.id)
# 调整sub_index以保持连续性
for i, plan in enumerate(batch_plans):
plan["sub_index"] = current_start_index + i
all_chapter_plans.extend(batch_plans)
logger.info(f"{batch_num + 1}批生成完成,本批生成{len(batch_plans)}章,累计{len(all_chapter_plans)}")
logger.info(f"分批生成完成,共生成 {len(all_chapter_plans)} 个章节规划")
return all_chapter_plans
async def batch_expand_outlines(
self,
project_id: str,
db: AsyncSession,
ai_service: AIService,
target_chapters_per_outline: int = 3,
expansion_strategy: str = "balanced",
provider: Optional[str] = None,
model: Optional[str] = None
) -> Dict[str, Any]:
"""
批量展开所有大纲为章节
Returns:
{
"total_outlines": 总大纲数,
"total_chapters_planned": 规划的总章节数,
"expansions": [每个大纲的展开结果]
}
"""
logger.info(f"开始批量展开项目 {project_id} 的所有大纲")
# 获取项目
project_result = await db.execute(
select(Project).where(Project.id == project_id)
)
project = project_result.scalar_one_or_none()
if not project:
raise ValueError(f"项目 {project_id} 不存在")
# 获取所有大纲
outlines_result = await db.execute(
select(Outline)
.where(Outline.project_id == project_id)
.order_by(Outline.order_index)
)
outlines = outlines_result.scalars().all()
if not outlines:
logger.warning(f"项目 {project_id} 没有大纲")
return {
"total_outlines": 0,
"total_chapters_planned": 0,
"expansions": []
}
# 逐个展开大纲
expansions = []
total_chapters = 0
for outline in outlines:
try:
chapter_plans = await self.analyze_outline_for_chapters(
outline=outline,
project=project,
db=db,
target_chapter_count=target_chapters_per_outline,
expansion_strategy=expansion_strategy,
provider=provider,
model=model
)
expansions.append({
"outline_id": outline.id,
"outline_title": outline.title,
"chapter_plans": chapter_plans,
"chapter_count": len(chapter_plans)
})
total_chapters += len(chapter_plans)
logger.info(f"大纲 {outline.title} 展开为 {len(chapter_plans)}")
except Exception as e:
logger.error(f"展开大纲 {outline.id} 失败: {str(e)}")
expansions.append({
"outline_id": outline.id,
"outline_title": outline.title,
"error": str(e),
"chapter_count": 0
})
result = {
"total_outlines": len(outlines),
"total_chapters_planned": total_chapters,
"expansions": expansions
}
logger.info(f"批量展开完成: {len(outlines)} 个大纲 → {total_chapters} 个章节规划")
return result
async def create_chapters_from_plans(
self,
outline_id: str,
chapter_plans: List[Dict[str, Any]],
project_id: str,
db: AsyncSession,
start_chapter_number: int = None
) -> List[Chapter]:
"""
根据章节规划创建实际的章节记录
Args:
outline_id: 大纲ID
chapter_plans: 章节规划列表
project_id: 项目ID
db: 数据库会话
start_chapter_number: 起始章节号如果为None则自动计算
Returns:
创建的章节列表
"""
logger.info(f"根据规划创建 {len(chapter_plans)} 个章节记录")
# 如果没有指定起始章节号,自动计算
if start_chapter_number is None:
# 查询项目中已有章节的最大序号
max_number_result = await db.execute(
select(func.max(Chapter.chapter_number))
.where(Chapter.project_id == project_id)
)
max_number = max_number_result.scalar()
start_chapter_number = (max_number or 0) + 1
logger.info(f"自动计算起始章节号: {start_chapter_number} (当前最大序号: {max_number})")
chapters = []
for idx, plan in enumerate(chapter_plans):
# 保存完整的展开规划数据(JSON格式)
expansion_plan_json = json.dumps({
"key_events": plan.get("key_events", []),
"character_focus": plan.get("character_focus", []),
"emotional_tone": plan.get("emotional_tone", ""),
"narrative_goal": plan.get("narrative_goal", ""),
"conflict_type": plan.get("conflict_type", ""),
"estimated_words": plan.get("estimated_words", 3000),
"scenes": plan.get("scenes", []) if plan.get("scenes") else None
}, ensure_ascii=False)
chapter = Chapter(
project_id=project_id,
outline_id=outline_id,
chapter_number=start_chapter_number + idx,
sub_index=plan.get("sub_index", idx + 1),
title=plan.get("title", f"{start_chapter_number + idx}"),
summary=plan.get("plot_summary", ""),
expansion_plan=expansion_plan_json,
status="draft"
)
db.add(chapter)
chapters.append(chapter)
await db.commit()
for chapter in chapters:
await db.refresh(chapter)
logger.info(f"成功创建 {len(chapters)} 个章节记录(已保存展开规划数据)")
return chapters
async def _get_outline_context(
self,
outline: Outline,
project_id: str,
db: AsyncSession
) -> str:
"""获取大纲的上下文(前后大纲)"""
# 获取前一个大纲
prev_result = await db.execute(
select(Outline)
.where(
Outline.project_id == project_id,
Outline.order_index < outline.order_index
)
.order_by(Outline.order_index.desc())
.limit(1)
)
prev_outline = prev_result.scalar_one_or_none()
# 获取后一个大纲
next_result = await db.execute(
select(Outline)
.where(
Outline.project_id == project_id,
Outline.order_index > outline.order_index
)
.order_by(Outline.order_index)
.limit(1)
)
next_outline = next_result.scalar_one_or_none()
context = ""
if prev_outline:
context += f"【前一节】{prev_outline.title}: {prev_outline.content[:200]}...\n\n"
if next_outline:
context += f"【后一节】{next_outline.title}: {next_outline.content[:200]}...\n"
return context if context else "(无前后文)"
def _build_expansion_prompt(
self,
outline: Outline,
project: Project,
characters_info: str,
context_info: str,
target_chapter_count: int,
expansion_strategy: str,
enable_scene_analysis: bool
) -> str:
"""构建大纲展开提示词"""
strategy_desc = {
"balanced": "均衡展开:每章剧情量相当,节奏平稳",
"climax": "高潮重点:重点章节剧情丰富,其他章节简洁过渡",
"detail": "细节丰富:每章都深入描写,场景和情感细腻"
}
strategy_instruction = strategy_desc.get(expansion_strategy, strategy_desc["balanced"])
# 场景字段(避免f-string中的反斜杠)
scene_field = ',\n "main_scenes": ["场景1", "场景2"]' if enable_scene_analysis else ''
scene_instruction = ""
if enable_scene_analysis:
scene_instruction = """
5. 场景分析每章需包含
- 主要场景地点
- 场景氛围
- 关键道具/环境元素
"""
prompt = f"""你是专业的小说情节架构师。请分析以下大纲节点,将其展开为 {target_chapter_count} 个章节的详细规划。
项目信息
小说名称{project.title}
类型{project.genre or '通用'}
主题{project.theme or '未设定'}
叙事视角{project.narrative_perspective or '第三人称'}
世界观背景
时间背景{project.world_time_period or '未设定'}
地理位置{project.world_location or '未设定'}
氛围基调{project.world_atmosphere or '未设定'}
角色信息
{characters_info or '暂无角色'}
当前大纲节点 - 展开对象
序号 {outline.order_index}
标题{outline.title}
内容{outline.content}
上下文参考
{context_info}
展开策略
{strategy_instruction}
重要约束 - 必须严格遵守
1. **内容边界约束**
- 只能展开当前大纲节点中明确描述的内容
- 绝对不能推进到后续大纲的内容如果有后一节信息
- 不要让剧情快速推进要深化而非跨越
2. **展开原则**
- 将当前大纲的单一事件拆解为多个细节丰富的章节
- 深入挖掘情感心理环境对话等细节
- 放慢叙事节奏让读者充分体验当前阶段的剧情
- 每个章节都应该是当前大纲内容的不同侧面或阶段
3. **如何避免剧情越界**
- 如果当前大纲描述"主角遇到困境"展开时应详写困境的发现分析情感冲击等
- 不要直接写到"解决困境"除非原大纲明确包含解决过程
- 如果看到后一节的内容那些是禁区绝不提前展开
任务要求
1. 深度分析该大纲的剧情容量和叙事节奏
2. 识别关键剧情点冲突点和情感转折点仅限当前大纲范围内
3. 将大纲拆解为 {target_chapter_count} 个章节每章需包含
- sub_index: 子章节序号1, 2, 3...
- title: 章节标题体现该章核心冲突或情感
- plot_summary: 剧情摘要200-300详细描述该章发生的事件仅限当前大纲内容
- key_events: 关键事件列表3-5个关键剧情点必须在当前大纲范围内
- character_focus: 角色焦点主要涉及的角色名称
- emotional_tone: 情感基调紧张温馨悲伤激动等
- narrative_goal: 叙事目标该章要达成的叙事效果
- conflict_type: 冲突类型内心挣扎人际冲突环境挑战等
- estimated_words: 预计字数建议2000-5000
{scene_instruction}
4. 确保章节间
- 衔接自然流畅
- 剧情递进合理但不超出当前大纲边界
- 节奏张弛有度
- 每章都有明确的叙事价值
- 最后一章结束时剧情发展程度应恰好完成当前大纲描述的内容不多不少
输出格式
请严格按照以下JSON数组格式输出不要添加任何其他文字
[
{{
"sub_index": 1,
"title": "章节标题",
"plot_summary": "该章详细剧情摘要...",
"key_events": ["关键事件1", "关键事件2", "关键事件3"],
"character_focus": ["角色A", "角色B"],
"emotional_tone": "情感基调",
"narrative_goal": "叙事目标",
"conflict_type": "冲突类型",
"estimated_words": 3000{scene_field}
}}
]
请开始分析并生成章节规划
"""
return prompt
def _build_batch_expansion_prompt(
self,
outline: Outline,
project: Project,
characters_info: str,
context_info: str,
target_chapter_count: int,
expansion_strategy: str,
enable_scene_analysis: bool,
start_index: int,
previous_chapters: List[Dict[str, Any]],
total_chapters: int
) -> str:
"""构建分批展开提示词"""
strategy_desc = {
"balanced": "均衡展开:每章剧情量相当,节奏平稳",
"climax": "高潮重点:重点章节剧情丰富,其他章节简洁过渡",
"detail": "细节丰富:每章都深入描写,场景和情感细腻"
}
strategy_instruction = strategy_desc.get(expansion_strategy, strategy_desc["balanced"])
# 场景字段
scene_field = ',\n "main_scenes": ["场景1", "场景2"]' if enable_scene_analysis else ''
scene_instruction = ""
if enable_scene_analysis:
scene_instruction = """
5. 场景分析每章需包含
- 主要场景地点
- 场景氛围
- 关键道具/环境元素
"""
# 构建已生成章节的摘要
previous_context = ""
if previous_chapters:
previous_summaries = []
for ch in previous_chapters[-3:]: # 只显示最近3章
previous_summaries.append(
f"{ch['sub_index']}节《{ch['title']}》: {ch['plot_summary'][:100]}..."
)
previous_context = f"""
已生成章节概要接续生成注意衔接
{chr(10).join(previous_summaries)}
当前是第{start_index}-{start_index + target_chapter_count - 1}{total_chapters}节中的一部分
"""
prompt = f"""你是专业的小说情节架构师。请继续分析以下大纲节点,将其展开为第{start_index}-{start_index + target_chapter_count - 1}节(共{target_chapter_count}个章节)的详细规划。
项目信息
小说名称{project.title}
类型{project.genre or '通用'}
主题{project.theme or '未设定'}
叙事视角{project.narrative_perspective or '第三人称'}
世界观背景
时间背景{project.world_time_period or '未设定'}
地理位置{project.world_location or '未设定'}
氛围基调{project.world_atmosphere or '未设定'}
角色信息
{characters_info or '暂无角色'}
当前大纲节点 - 展开对象
序号 {outline.order_index}
标题{outline.title}
内容{outline.content}
上下文参考
{context_info}
{previous_context}
展开策略
{strategy_instruction}
重要约束 - 必须严格遵守
1. **内容边界约束**
- 只能展开当前大纲节点中明确描述的内容
- 绝对不能推进到后续大纲的内容如果有后一节信息
- 不要让剧情快速推进要深化而非跨越
2. **分批连续性约束**
- 这是第{start_index}-{start_index + target_chapter_count - 1}是整个展开的一部分
- 必须与前面已生成的章节自然衔接
- 从第{start_index}节开始编号sub_index从{start_index}开始
- 继续深化当前大纲的内容保持叙事连贯性
3. **展开原则**
- 将当前大纲的单一事件拆解为多个细节丰富的章节
- 深入挖掘情感心理环境对话等细节
- 放慢叙事节奏让读者充分体验当前阶段的剧情
- 每个章节都应该是当前大纲内容的不同侧面或阶段
任务要求
1. 深度分析该大纲的剧情容量和叙事节奏
2. 识别关键剧情点冲突点和情感转折点仅限当前大纲范围内
3. 生成第{start_index}-{start_index + target_chapter_count - 1}节的章节规划每章需包含
- sub_index: 子章节序号{start_index}开始
- title: 章节标题体现该章核心冲突或情感
- plot_summary: 剧情摘要200-300详细描述该章发生的事件
- key_events: 关键事件列表3-5个关键剧情点
- character_focus: 角色焦点主要涉及的角色名称
- emotional_tone: 情感基调紧张温馨悲伤激动等
- narrative_goal: 叙事目标该章要达成的叙事效果
- conflict_type: 冲突类型内心挣扎人际冲突环境挑战等
- estimated_words: 预计字数建议2000-5000
{scene_instruction}
4. 确保章节间
- 与前面章节衔接自然流畅
- 剧情递进合理但不超出当前大纲边界
- 节奏张弛有度
- 每章都有明确的叙事价值
输出格式
请严格按照以下JSON数组格式输出不要添加任何其他文字
[
{{
"sub_index": {start_index},
"title": "章节标题",
"plot_summary": "该章详细剧情摘要...",
"key_events": ["关键事件1", "关键事件2", "关键事件3"],
"character_focus": ["角色A", "角色B"],
"emotional_tone": "情感基调",
"narrative_goal": "叙事目标",
"conflict_type": "冲突类型",
"estimated_words": 3000{scene_field}
}}
]
请开始分析并生成第{start_index}-{start_index + target_chapter_count - 1}节的章节规划
"""
return prompt
def _parse_expansion_response(
self,
ai_response: str,
outline_id: str
) -> List[Dict[str, Any]]:
"""解析AI的展开响应"""
try:
# 清理响应文本
cleaned_text = ai_response.strip()
if cleaned_text.startswith('```json'):
cleaned_text = cleaned_text[7:]
if cleaned_text.startswith('```'):
cleaned_text = cleaned_text[3:]
if cleaned_text.endswith('```'):
cleaned_text = cleaned_text[:-3]
cleaned_text = cleaned_text.strip()
# 解析JSON
chapter_plans = json.loads(cleaned_text)
# 确保是列表
if not isinstance(chapter_plans, list):
chapter_plans = [chapter_plans]
# 为每个章节规划添加outline_id
for plan in chapter_plans:
plan["outline_id"] = outline_id
return chapter_plans
except json.JSONDecodeError as e:
logger.error(f"解析AI响应失败: {e}, 响应内容: {ai_response[:500]}")
# 返回一个基础规划
return [{
"outline_id": outline_id,
"sub_index": 1,
"title": "AI解析失败的默认章节",
"plot_summary": ai_response[:500],
"key_events": ["解析失败"],
"character_focus": [],
"emotional_tone": "未知",
"narrative_goal": "需要重新生成",
"conflict_type": "未知",
"estimated_words": 3000
}]
# 工厂函数
def create_plot_expansion_service(ai_service: AIService) -> PlotExpansionService:
"""创建剧情展开服务实例"""
return PlotExpansionService(ai_service)
+141
View File
@@ -789,6 +789,81 @@ class PromptService:
1. 只返回纯JSON对象不要有```json```这样的标记
2. 文本中不要使用中文引号""改用
3. 不要有任何额外的文字说明"""
# 大纲展开为多章节的提示词
OUTLINE_EXPANSION = """你是专业的小说情节架构师。请分析以下大纲节点,将其展开为 {target_chapters} 个章节的详细规划。
项目信息
小说名称{title}
类型{genre}
主题{theme}
叙事视角{narrative_perspective}
世界观背景
时间背景{time_period}
地理位置{location}
氛围基调{atmosphere}
世界规则{rules}
角色信息
{characters_info}
大纲节点
序号 {outline_order}
标题{outline_title}
内容{outline_content}
上下文
{context_info}
展开策略
{strategy_instruction}
任务要求
1. 深度分析该大纲的剧情容量和叙事节奏
2. 识别关键剧情点冲突点和情感转折点
3. 将大纲拆解为 {target_chapters} 个章节每章需包含
- sub_index: 子章节序号1, 2, 3...
- title: 章节标题体现该章核心冲突或情感
- plot_summary: 剧情摘要200-300详细描述该章发生的事件
- key_events: 关键事件列表3-5个关键剧情点
- character_focus: 角色焦点主要涉及的角色名称
- emotional_tone: 情感基调紧张温馨悲伤激动等
- narrative_goal: 叙事目标该章要达成的叙事效果
- conflict_type: 冲突类型内心挣扎人际冲突环境挑战等
- estimated_words: 预计字数建议2000-5000
{scene_instruction}
4. 确保章节间
- 衔接自然流畅
- 剧情递进合理
- 节奏张弛有度
- 每章都有明确的叙事价值
**重要格式要求**
1. 只返回纯JSON数组格式不要包含任何markdown标记代码块标记或其他说明文字
2. 不要在JSON字符串值中使用中文引号""''请使用
3. 文本描述中的专有名词使用标记
请严格按照以下JSON数组格式输出
[
{{
"sub_index": 1,
"title": "章节标题",
"plot_summary": "该章详细剧情摘要(200-300字)...",
"key_events": ["关键事件1", "关键事件2", "关键事件3"],
"character_focus": ["角色A", "角色B"],
"emotional_tone": "情感基调",
"narrative_goal": "叙事目标",
"conflict_type": "冲突类型",
"estimated_words": 3000{scene_field}
}}
]
再次强调
1. 只返回纯JSON数组不要有```json```这样的标记
2. 数组中要包含{target_chapters}个章节对象
3. 每个plot_summary必须是200-300字的详细描述
4. 文本中不要使用中文引号""改用"""
@staticmethod
def format_prompt(template: str, **kwargs) -> str:
@@ -1106,6 +1181,72 @@ class PromptService:
project_context=project_context,
user_input=user_input
)
@classmethod
def get_outline_expansion_prompt(cls, title: str, genre: str, theme: str,
narrative_perspective: str, time_period: str,
location: str, atmosphere: str, rules: str,
characters_info: str, outline_order: int,
outline_title: str, outline_content: str,
context_info: str, strategy: str = "balanced",
target_chapters: int = 3,
include_scenes: bool = False) -> str:
"""
获取大纲展开为多章节的提示词
Args:
title: 小说名称
genre: 类型
theme: 主题
narrative_perspective: 叙事视角
time_period: 时间背景
location: 地理位置
atmosphere: 氛围基调
rules: 世界规则
characters_info: 角色信息
outline_order: 大纲序号
outline_title: 大纲标题
outline_content: 大纲内容
context_info: 上下文信息
strategy: 展开策略 (balanced/climax/detail)
target_chapters: 目标章节数
include_scenes: 是否包含场景字段
"""
# 根据策略生成指导说明
strategy_instructions = {
"balanced": "采用均衡策略:将大纲内容平均分配到各章节,保持节奏均匀,每章剧情密度相当。",
"climax": "采用高潮重点策略:识别大纲中的高潮部分,为其分配更多章节进行细致展开,其他部分适当精简。",
"detail": "采用细节丰富策略:深挖大纲中的每个细节,为每个关键事件、情感转折都安排足够的叙事空间。"
}
strategy_instruction = strategy_instructions.get(strategy, strategy_instructions["balanced"])
# 场景相关的指令和字段
scene_instruction = ""
scene_field = ""
if include_scenes:
scene_instruction = "\n - scenes: 场景列表(2-4个具体场景描述)"
scene_field = ',\n "scenes": ["场景1", "场景2"]'
return cls.format_prompt(
cls.OUTLINE_EXPANSION,
title=title,
genre=genre,
theme=theme,
narrative_perspective=narrative_perspective,
time_period=time_period,
location=location,
atmosphere=atmosphere,
rules=rules,
characters_info=characters_info,
outline_order=outline_order,
outline_title=outline_title,
outline_content=outline_content,
context_info=context_info,
strategy_instruction=strategy_instruction,
target_chapters=target_chapters,
scene_instruction=scene_instruction,
scene_field=scene_field
)
# 创建全局提示词服务实例
@@ -0,0 +1,45 @@
-- 为Chapter表添加与Outline的关联关系
-- 实现大纲到章节的一对多关系
-- 添加outline_id外键字段
ALTER TABLE chapters
ADD COLUMN outline_id VARCHAR(36) NULL;
-- 添加sub_index字段,表示在该大纲下的子章节序号
ALTER TABLE chapters
ADD COLUMN sub_index INTEGER DEFAULT 1;
-- 添加字段注释(PostgreSQL语法)
COMMENT ON COLUMN chapters.outline_id IS '关联的大纲ID';
COMMENT ON COLUMN chapters.sub_index IS '大纲下的子章节序号';
-- 添加外键约束
ALTER TABLE chapters
ADD CONSTRAINT fk_chapter_outline
FOREIGN KEY (outline_id)
REFERENCES outlines(id)
ON DELETE SET NULL;
-- 创建索引优化查询性能
CREATE INDEX idx_chapters_outline_id ON chapters(outline_id);
CREATE INDEX idx_chapters_outline_sub ON chapters(outline_id, sub_index);
-- 说明:
-- outline_id为NULL表示旧数据或独立章节
-- outline_id有值表示该章节由某个大纲展开生成
-- sub_index表示在该大纲下的第几个子章节(从1开始)
-- 为 chapters 表添加 expansion_plan 字段
-- 用于存储大纲展开规划的详细数据(JSON格式)
-- 添加字段
ALTER TABLE chapters ADD COLUMN IF NOT EXISTS expansion_plan TEXT;
-- 添加注释
COMMENT ON COLUMN chapters.expansion_plan IS '展开规划详情(JSON): 包含key_events, character_focus, emotional_tone等';
-- 查看修改结果
SELECT column_name, data_type, is_nullable, column_default
FROM information_schema.columns
WHERE table_name = 'chapters'
ORDER BY ordinal_position;
+263 -90
View File
@@ -1,11 +1,10 @@
import { useState, useEffect, useRef } from 'react';
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber, Progress, Alert, Radio } from 'antd';
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined } from '@ant-design/icons';
import { useState, useEffect, useRef, useMemo } from 'react';
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber, Progress, Alert, Radio, Descriptions, Collapse } from 'antd';
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined, RocketOutlined, StopOutlined, InfoCircleOutlined, CaretRightOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import { useChapterSync } from '../store/hooks';
import { projectApi, writingStyleApi } from '../services/api';
import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask } from '../types';
import { cardStyles } from '../components/CardStyles';
import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask, ExpansionPlanData } from '../types';
import ChapterAnalysis from '../components/ChapterAnalysis';
import { SSELoadingOverlay } from '../components/SSELoadingOverlay';
@@ -479,6 +478,34 @@ export default function Chapters() {
const sortedChapters = [...chapters].sort((a, b) => a.chapter_number - b.chapter_number);
// 按大纲分组章节
const groupedChapters = useMemo(() => {
const groups: Record<string, {
outlineId: string | null;
outlineTitle: string;
outlineOrder: number;
chapters: Chapter[];
}> = {};
sortedChapters.forEach(chapter => {
const key = chapter.outline_id || 'uncategorized';
if (!groups[key]) {
groups[key] = {
outlineId: chapter.outline_id || null,
outlineTitle: chapter.outline_title || '未分类章节',
outlineOrder: chapter.outline_order ?? 999,
chapters: []
};
}
groups[key].chapters.push(chapter);
});
// 转换为数组并按大纲顺序排序
return Object.values(groups).sort((a, b) => a.outlineOrder - b.outlineOrder);
}, [sortedChapters]);
const handleExport = () => {
if (chapters.length === 0) {
message.warning('当前项目没有章节,无法导出');
@@ -709,6 +736,97 @@ export default function Chapters() {
}
};
// 显示展开规划详情
const showExpansionPlanModal = (chapter: Chapter) => {
if (!chapter.expansion_plan) return;
try {
const planData: ExpansionPlanData = JSON.parse(chapter.expansion_plan);
Modal.info({
title: (
<Space>
<InfoCircleOutlined style={{ color: '#1890ff' }} />
<span>{chapter.chapter_number}</span>
</Space>
),
width: 800,
content: (
<div style={{ marginTop: 16 }}>
<Descriptions column={1} size="small" bordered>
<Descriptions.Item label="章节标题">
<strong>{chapter.title}</strong>
</Descriptions.Item>
<Descriptions.Item label="情感基调">
<Tag color="blue">{planData.emotional_tone}</Tag>
</Descriptions.Item>
<Descriptions.Item label="冲突类型">
<Tag color="orange">{planData.conflict_type}</Tag>
</Descriptions.Item>
<Descriptions.Item label="预估字数">
<Tag color="green">{planData.estimated_words}</Tag>
</Descriptions.Item>
<Descriptions.Item label="叙事目标">
{planData.narrative_goal}
</Descriptions.Item>
<Descriptions.Item label="关键事件">
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{planData.key_events.map((event, idx) => (
<div key={idx} style={{ padding: '4px 0' }}>
<Tag color="purple">{idx + 1}</Tag> {event}
</div>
))}
</Space>
</Descriptions.Item>
<Descriptions.Item label="涉及角色">
<Space wrap>
{planData.character_focus.map((char, idx) => (
<Tag key={idx} color="cyan">{char}</Tag>
))}
</Space>
</Descriptions.Item>
{planData.scenes && planData.scenes.length > 0 && (
<Descriptions.Item label="场景规划">
<Space direction="vertical" size="small" style={{ width: '100%' }}>
{planData.scenes.map((scene, idx) => (
<Card key={idx} size="small" style={{ backgroundColor: '#fafafa' }}>
<div style={{ marginBottom: 4 }}>
<strong>📍 </strong>{scene.location}
</div>
<div style={{ marginBottom: 4 }}>
<strong>👥 </strong>
<Space size="small" wrap style={{ marginLeft: 8 }}>
{scene.characters.map((char, charIdx) => (
<Tag key={charIdx}>{char}</Tag>
))}
</Space>
</div>
<div>
<strong>🎯 </strong>{scene.purpose}
</div>
</Card>
))}
</Space>
</Descriptions.Item>
)}
</Descriptions>
<Alert
message="提示"
description="这些是AI在大纲展开时生成的规划信息,可以作为创作章节内容时的参考。"
type="info"
showIcon
style={{ marginTop: 16 }}
/>
</div>
),
okText: '关闭',
});
} catch (error) {
console.error('解析展开规划失败:', error);
message.error('展开规划数据格式错误');
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{
@@ -754,21 +872,54 @@ export default function Chapters() {
<div style={{ flex: 1, overflowY: 'auto' }}>
{chapters.length === 0 ? (
<Empty description="还没有章节,开始创作吧!" />
) : (
<Card style={cardStyles.base}>
<List
dataSource={sortedChapters}
renderItem={(item) => (
<List.Item
<Empty description="还没有章节,开始创作吧!" />
) : (
<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={{
padding: '16px 0',
marginBottom: 16,
background: '#fff',
borderRadius: 8,
transition: 'background 0.3s ease',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'flex-start' : 'center'
border: '1px solid #f0f0f0',
}}
actions={isMobile ? undefined : [
>
<List
dataSource={group.chapters}
renderItem={(item) => (
<List.Item
style={{
padding: '16px 0',
borderRadius: 8,
transition: 'background 0.3s ease',
flexDirection: isMobile ? 'column' : 'row',
alignItems: isMobile ? 'flex-start' : 'center',
}}
actions={isMobile ? undefined : [
<Button
icon={<EditOutlined />}
onClick={() => handleOpenEditor(item.id)}
@@ -806,85 +957,107 @@ export default function Chapters() {
>
</Button>,
]}
>
<div style={{ width: '100%' }}>
<List.Item.Meta
avatar={!isMobile && <FileTextOutlined style={{ fontSize: 32, color: '#1890ff' }} />}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? 4 : 8, flexWrap: 'wrap', fontSize: isMobile ? 14 : 16 }}>
<span>{item.chapter_number}{item.title}</span>
<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>
)}
</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() !== '';
]}
>
<div style={{ width: '100%' }}>
<List.Item.Meta
avatar={!isMobile && <FileTextOutlined style={{ fontSize: 32, color: '#1890ff' }} />}
title={
<div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? 4 : 8, flexWrap: 'wrap', fontSize: isMobile ? 14 : 16 }}>
<span>
{item.chapter_number}{item.title}
</span>
<Tag color={getStatusColor(item.status)}>{getStatusText(item.status)}</Tag>
<Badge count={`${item.word_count || 0}`} style={{ backgroundColor: '#52c41a' }} />
{renderAnalysisStatus(item.id)}
{item.expansion_plan && (
<Tooltip title="已有展开规划,点击信息图标查看详情">
<Tag icon={<CheckCircleOutlined />} color="blue">
</Tag>
</Tooltip>
)}
{!canGenerateChapter(item) && (
<Tooltip title={getGenerateDisabledReason(item)}>
<Tag icon={<LockOutlined />} color="warning">
</Tag>
</Tooltip>
)}
{item.expansion_plan && (
<Tooltip title="查看展开规划详情">
<InfoCircleOutlined
style={{ color: '#1890ff', cursor: 'pointer', fontSize: 16 }}
onClick={(e) => {
e.stopPropagation();
showExpansionPlanModal(item);
}}
/>
</Tooltip>
)}
</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>
)
}
/>
return (
<Tooltip
title={
!hasContent ? '请先生成章节内容' :
isAnalyzing ? '分析中' :
'查看分析'
}
>
{isMobile && (
<Space style={{ marginTop: 12, width: '100%', justifyContent: 'flex-end' }} wrap>
<Button
type="text"
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
icon={<EditOutlined />}
onClick={() => handleOpenEditor(item.id)}
size="small"
disabled={!hasContent || isAnalyzing}
loading={isAnalyzing}
title="编辑内容"
/>
</Tooltip>
);
})()}
<Button
type="text"
icon={<SettingOutlined />}
onClick={() => handleOpenModal(item.id)}
size="small"
title="修改信息"
/>
</Space>
{(() => {
const task = analysisTasksMap[item.id];
const isAnalyzing = task && (task.status === 'pending' || task.status === 'running');
const hasContent = item.content && item.content.trim() !== '';
return (
<Tooltip
title={
!hasContent ? '请先生成章节内容' :
isAnalyzing ? '分析中' :
'查看分析'
}
>
<Button
type="text"
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
onClick={() => handleShowAnalysis(item.id)}
size="small"
disabled={!hasContent || isAnalyzing}
loading={isAnalyzing}
/>
</Tooltip>
);
})()}
<Button
type="text"
icon={<SettingOutlined />}
onClick={() => handleOpenModal(item.id)}
size="small"
title="修改信息"
/>
</Space>
)}
</div>
</List.Item>
)}
</div>
</List.Item>
)}
/>
</Card>
/>
</Collapse.Panel>
))}
</Collapse>
)}
</div>
File diff suppressed because it is too large Load Diff
+63
View File
@@ -18,6 +18,10 @@ import type {
OutlineCreate,
OutlineUpdate,
OutlineReorderRequest,
OutlineExpansionRequest,
OutlineExpansionResponse,
BatchOutlineExpansionRequest,
BatchOutlineExpansionResponse,
Character,
CharacterUpdate,
Chapter,
@@ -295,6 +299,65 @@ export const outlineApi = {
generateOutline: (data: GenerateOutlineRequest) =>
api.post<unknown, { total: number; items: Outline[] }>('/outlines/generate', data).then(res => res.items),
// 获取大纲关联的章节
getOutlineChapters: (outlineId: string) =>
api.get<unknown, {
has_chapters: boolean;
outline_id: string;
outline_title: string;
chapter_count: number;
chapters: Array<{
id: string;
chapter_number: number;
title: string;
summary: string;
sub_index: number;
status: string;
word_count: number;
}>;
expansion_plans: Array<{
sub_index: number;
title: string;
plot_summary: string;
key_events: string[];
character_focus: string[];
emotional_tone: string;
narrative_goal: string;
conflict_type: string;
estimated_words: number;
scenes?: Array<{
location: string;
characters: string[];
purpose: string;
}> | null;
}> | null;
}>(`/outlines/${outlineId}/chapters`),
// 单个大纲展开为多章
expandOutline: (outlineId: string, data: OutlineExpansionRequest) =>
api.post<unknown, OutlineExpansionResponse>(`/outlines/${outlineId}/expand`, data),
// 根据已有规划创建章节(避免重复AI调用)
createChaptersFromPlans: (outlineId: string, chapterPlans: any[]) =>
api.post<unknown, {
outline_id: string;
outline_title: string;
chapters_created: number;
created_chapters: Array<{
id: string;
chapter_number: number;
title: string;
summary: string;
outline_id: string;
sub_index: number;
status: string;
}>;
}>(`/outlines/${outlineId}/create-chapters-from-plans`, { chapter_plans: chapterPlans }),
// 批量展开大纲
batchExpandOutlines: (data: BatchOutlineExpansionRequest) =>
api.post<unknown, BatchOutlineExpansionResponse>('/outlines/batch-expand', data),
};
export const characterApi = {
-18
View File
@@ -200,23 +200,6 @@ export function useOutlineSync() {
}
}, [removeOutline]);
// 重排序大纲(带同步)
const reorderOutlines = useCallback(async (orders: Array<{ id: string; order_index: number }>, projectId?: string) => {
try {
await outlineApi.reorderOutlines({ orders });
// 重新获取完整列表以确保顺序正确
const id = projectId || currentProject?.id;
if (id) {
const data = await outlineApi.getOutlines(id);
const outlines = Array.isArray(data) ? data : (data as PaginationResponse<Outline>).items || [];
setOutlines(outlines);
}
} catch (error) {
console.error('重排序大纲失败:', error);
throw error;
}
}, [currentProject?.id, setOutlines]); // 添加 currentProject?.id 到依赖数组
// AI生成大纲(带同步)
const generateOutlines = useCallback(async (data: GenerateOutlineRequest) => {
try {
@@ -235,7 +218,6 @@ export function useOutlineSync() {
createOutline,
updateOutline: updateOutlineSync,
deleteOutline,
reorderOutlines,
generateOutlines,
};
}
+86
View File
@@ -202,6 +202,21 @@ export interface CharacterUpdate {
color?: string;
}
// 展开规划数据结构
export interface ExpansionPlanData {
key_events: string[];
character_focus: string[];
emotional_tone: string;
narrative_goal: string;
conflict_type: string;
estimated_words: number;
scenes?: Array<{
location: string;
characters: string[];
purpose: string;
}> | null;
}
// 章节类型定义
export interface Chapter {
id: string;
@@ -212,6 +227,11 @@ export interface Chapter {
chapter_number: number;
word_count: number;
status: 'draft' | 'writing' | 'completed';
expansion_plan?: string; // JSON字符串,解析后为ExpansionPlanData
outline_id?: string; // 关联的大纲ID
sub_index?: number; // 大纲下的子章节序号
outline_title?: string; // 大纲标题(从后端联表查询获得)
outline_order?: number; // 大纲排序序号(从后端联表查询获得)
created_at: string;
updated_at: string;
}
@@ -284,6 +304,72 @@ export interface OutlineReorderRequest {
orders: OutlineReorderItem[];
}
// 大纲展开相关类型定义
export interface ChapterPlanItem {
sub_index: number;
title: string;
plot_summary: string;
key_events: string[];
character_focus: string[];
emotional_tone: string;
narrative_goal: string;
conflict_type: string;
estimated_words: number;
scenes?: Array<{
location: string;
characters: string[];
purpose: string;
}>;
}
export interface OutlineExpansionRequest {
target_chapter_count: number;
expansion_strategy?: 'balanced' | 'climax' | 'detail';
auto_create_chapters?: boolean;
provider?: string;
model?: string;
}
export interface OutlineExpansionResponse {
outline_id: string;
outline_title: string;
target_chapter_count: number;
actual_chapter_count: number;
expansion_strategy: string;
chapter_plans: ChapterPlanItem[];
created_chapters?: Array<{
id: string;
chapter_number: number;
title: string;
summary: string;
outline_id: string;
sub_index: number;
status: string;
}> | null;
}
export interface BatchOutlineExpansionRequest {
project_id: string;
outline_ids?: string[];
chapters_per_outline: number;
expansion_strategy?: 'balanced' | 'climax' | 'detail';
auto_create_chapters?: boolean;
provider?: string;
model?: string;
}
export interface BatchOutlineExpansionResponse {
project_id: string;
total_outlines_expanded: number;
total_chapters_created: number;
expansion_results: OutlineExpansionResponse[];
skipped_outlines?: Array<{
outline_id: string;
outline_title: string;
reason: string;
}>;
}
export interface GenerateCharacterRequest {
project_id: string;
name?: string;