update:新增用户API配置提示 优化大纲全新/续写的分批生成
This commit is contained in:
+494
-27
@@ -2,7 +2,7 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy import select, func, delete
|
from sqlalchemy import select, func, delete
|
||||||
from typing import List
|
from typing import List, AsyncGenerator, Dict, Any
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
@@ -23,6 +23,7 @@ from app.services.ai_service import AIService
|
|||||||
from app.services.prompt_service import prompt_service
|
from app.services.prompt_service import prompt_service
|
||||||
from app.logger import get_logger
|
from app.logger import get_logger
|
||||||
from app.api.settings import get_user_ai_service
|
from app.api.settings import get_user_ai_service
|
||||||
|
from app.utils.sse_response import SSEResponse, create_sse_response
|
||||||
|
|
||||||
router = APIRouter(prefix="/outlines", tags=["大纲管理"])
|
router = APIRouter(prefix="/outlines", tags=["大纲管理"])
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -479,27 +480,21 @@ async def _continue_outline(
|
|||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
user_ai_service: AIService
|
user_ai_service: AIService
|
||||||
) -> OutlineListResponse:
|
) -> OutlineListResponse:
|
||||||
"""续写大纲"""
|
"""续写大纲 - 分批生成,每批5章"""
|
||||||
logger.info(f"续写大纲 - 项目: {project.id}, 已有: {len(existing_outlines)} 章")
|
logger.info(f"续写大纲 - 项目: {project.id}, 已有: {len(existing_outlines)} 章")
|
||||||
|
|
||||||
# 分析已有大纲
|
# 分析已有大纲
|
||||||
current_chapter_count = len(existing_outlines)
|
current_chapter_count = len(existing_outlines)
|
||||||
last_chapter_number = existing_outlines[-1].order_index
|
last_chapter_number = existing_outlines[-1].order_index
|
||||||
|
|
||||||
# 获取最近2章的剧情
|
# 计算需要生成的总章数和批次
|
||||||
recent_outlines = existing_outlines[-2:] if len(existing_outlines) >= 2 else existing_outlines
|
total_chapters_to_generate = request.chapter_count
|
||||||
recent_plot = "\n".join([
|
batch_size = 5 # 每批生成5章
|
||||||
f"第{o.order_index}章《{o.title}》: {o.content}"
|
total_batches = (total_chapters_to_generate + batch_size - 1) // batch_size
|
||||||
for o in recent_outlines
|
|
||||||
])
|
|
||||||
# logger.debug(f"最近三章内容:{recent_plot}")
|
|
||||||
# 全部章节概览
|
|
||||||
all_chapters_brief = "\n".join([
|
|
||||||
f"第{o.order_index}章: {o.title}"
|
|
||||||
for o in existing_outlines
|
|
||||||
])
|
|
||||||
|
|
||||||
# 获取角色信息
|
logger.info(f"分批生成计划: 总共{total_chapters_to_generate}章,分{total_batches}批,每批{batch_size}章")
|
||||||
|
|
||||||
|
# 获取角色信息(所有批次共用)
|
||||||
characters_result = await db.execute(
|
characters_result = await db.execute(
|
||||||
select(Character).where(Character.project_id == project.id)
|
select(Character).where(Character.project_id == project.id)
|
||||||
)
|
)
|
||||||
@@ -518,28 +513,61 @@ async def _continue_outline(
|
|||||||
}
|
}
|
||||||
stage_instruction = stage_instructions.get(request.plot_stage, "")
|
stage_instruction = stage_instructions.get(request.plot_stage, "")
|
||||||
|
|
||||||
|
# 批量生成
|
||||||
|
all_new_outlines = []
|
||||||
|
current_start_chapter = last_chapter_number + 1
|
||||||
|
|
||||||
|
for batch_num in range(total_batches):
|
||||||
|
# 计算当前批次的章节数
|
||||||
|
remaining_chapters = total_chapters_to_generate - len(all_new_outlines)
|
||||||
|
current_batch_size = min(batch_size, remaining_chapters)
|
||||||
|
|
||||||
|
logger.info(f"开始生成第{batch_num + 1}/{total_batches}批,章节范围: {current_start_chapter}-{current_start_chapter + current_batch_size - 1}")
|
||||||
|
|
||||||
|
# 获取最新的大纲列表(包括之前批次生成的)
|
||||||
|
latest_result = await db.execute(
|
||||||
|
select(Outline)
|
||||||
|
.where(Outline.project_id == project.id)
|
||||||
|
.order_by(Outline.order_index)
|
||||||
|
)
|
||||||
|
latest_outlines = latest_result.scalars().all()
|
||||||
|
|
||||||
|
# 获取最近2章的剧情
|
||||||
|
recent_outlines = latest_outlines[-2:] if len(latest_outlines) >= 2 else latest_outlines
|
||||||
|
recent_plot = "\n".join([
|
||||||
|
f"第{o.order_index}章《{o.title}》: {o.content}"
|
||||||
|
for o in recent_outlines
|
||||||
|
])
|
||||||
|
|
||||||
|
# 全部章节概览
|
||||||
|
all_chapters_brief = "\n".join([
|
||||||
|
f"第{o.order_index}章: {o.title}"
|
||||||
|
for o in latest_outlines
|
||||||
|
])
|
||||||
|
|
||||||
# 使用标准续写提示词模板
|
# 使用标准续写提示词模板
|
||||||
prompt = prompt_service.get_outline_continue_prompt(
|
prompt = prompt_service.get_outline_continue_prompt(
|
||||||
title=project.title,
|
title=project.title,
|
||||||
theme=request.theme or project.theme or "未设定",
|
theme=request.theme or project.theme or "未设定",
|
||||||
genre=request.genre or project.genre or "通用",
|
genre=request.genre or project.genre or "通用",
|
||||||
narrative_perspective=request.narrative_perspective,
|
narrative_perspective=request.narrative_perspective,
|
||||||
chapter_count=request.chapter_count,
|
chapter_count=current_batch_size, # 当前批次的章节数
|
||||||
time_period=project.world_time_period or "未设定",
|
time_period=project.world_time_period or "未设定",
|
||||||
location=project.world_location or "未设定",
|
location=project.world_location or "未设定",
|
||||||
atmosphere=project.world_atmosphere or "未设定",
|
atmosphere=project.world_atmosphere or "未设定",
|
||||||
rules=project.world_rules or "未设定",
|
rules=project.world_rules or "未设定",
|
||||||
characters_info=characters_info or "暂无角色信息",
|
characters_info=characters_info or "暂无角色信息",
|
||||||
current_chapter_count=current_chapter_count,
|
current_chapter_count=len(latest_outlines),
|
||||||
all_chapters_brief=all_chapters_brief,
|
all_chapters_brief=all_chapters_brief,
|
||||||
recent_plot=recent_plot,
|
recent_plot=recent_plot,
|
||||||
plot_stage_instruction=stage_instruction,
|
plot_stage_instruction=stage_instruction,
|
||||||
start_chapter=last_chapter_number + 1,
|
start_chapter=current_start_chapter,
|
||||||
story_direction=request.story_direction or "自然延续",
|
story_direction=request.story_direction or "自然延续",
|
||||||
requirements=request.requirements or ""
|
requirements=request.requirements or ""
|
||||||
)
|
)
|
||||||
|
|
||||||
# 调用AI
|
# 调用AI生成当前批次
|
||||||
|
logger.info(f"正在调用AI生成第{batch_num + 1}批...")
|
||||||
ai_response = await user_ai_service.generate_text(
|
ai_response = await user_ai_service.generate_text(
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
provider=request.provider,
|
provider=request.provider,
|
||||||
@@ -549,34 +577,40 @@ async def _continue_outline(
|
|||||||
# 解析响应
|
# 解析响应
|
||||||
outline_data = _parse_ai_response(ai_response)
|
outline_data = _parse_ai_response(ai_response)
|
||||||
|
|
||||||
# 保存续写的大纲
|
# 保存当前批次的大纲
|
||||||
new_outlines = await _save_outlines(
|
batch_outlines = await _save_outlines(
|
||||||
project.id, outline_data, db, start_index=last_chapter_number + 1
|
project.id, outline_data, db, start_index=current_start_chapter
|
||||||
)
|
)
|
||||||
|
|
||||||
# 记录历史
|
# 记录历史
|
||||||
history = GenerationHistory(
|
history = GenerationHistory(
|
||||||
project_id=project.id,
|
project_id=project.id,
|
||||||
prompt=prompt,
|
prompt=f"[批次{batch_num + 1}/{total_batches}] {str(prompt)[:500]}",
|
||||||
generated_content=ai_response,
|
generated_content=ai_response,
|
||||||
model=request.model or "default"
|
model=request.model or "default"
|
||||||
)
|
)
|
||||||
db.add(history)
|
db.add(history)
|
||||||
|
|
||||||
|
# 提交当前批次
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
for outline in new_outlines:
|
for outline in batch_outlines:
|
||||||
await db.refresh(outline)
|
await db.refresh(outline)
|
||||||
|
|
||||||
|
all_new_outlines.extend(batch_outlines)
|
||||||
|
current_start_chapter += current_batch_size
|
||||||
|
|
||||||
|
logger.info(f"第{batch_num + 1}批生成完成,本批生成{len(batch_outlines)}章")
|
||||||
|
|
||||||
# 返回所有大纲(包括旧的和新的)
|
# 返回所有大纲(包括旧的和新的)
|
||||||
all_result = await db.execute(
|
final_result = await db.execute(
|
||||||
select(Outline)
|
select(Outline)
|
||||||
.where(Outline.project_id == project.id)
|
.where(Outline.project_id == project.id)
|
||||||
.order_by(Outline.order_index)
|
.order_by(Outline.order_index)
|
||||||
)
|
)
|
||||||
all_outlines = all_result.scalars().all()
|
all_outlines = final_result.scalars().all()
|
||||||
|
|
||||||
logger.info(f"续写完成 - 新增 {len(new_outlines)} 章,总计 {len(all_outlines)} 章")
|
logger.info(f"续写完成 - 共{total_batches}批,新增 {len(all_new_outlines)} 章,总计 {len(all_outlines)} 章")
|
||||||
return OutlineListResponse(total=len(all_outlines), items=all_outlines)
|
return OutlineListResponse(total=len(all_outlines), items=all_outlines)
|
||||||
|
|
||||||
|
|
||||||
@@ -659,3 +693,436 @@ async def _save_outlines(
|
|||||||
db.add(chapter)
|
db.add(chapter)
|
||||||
|
|
||||||
return outlines
|
return outlines
|
||||||
|
|
||||||
|
|
||||||
|
async def new_outline_generator(
|
||||||
|
data: Dict[str, Any],
|
||||||
|
db: AsyncSession,
|
||||||
|
user_ai_service: AIService
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""全新生成大纲SSE生成器"""
|
||||||
|
db_committed = False
|
||||||
|
try:
|
||||||
|
yield await SSEResponse.send_progress("开始生成大纲...", 5)
|
||||||
|
|
||||||
|
project_id = data.get("project_id")
|
||||||
|
# 确保chapter_count是整数(前端可能传字符串)
|
||||||
|
chapter_count = int(data.get("chapter_count", 10))
|
||||||
|
|
||||||
|
# 验证项目
|
||||||
|
yield await SSEResponse.send_progress("加载项目信息...", 10)
|
||||||
|
result = await db.execute(
|
||||||
|
select(Project).where(Project.id == project_id)
|
||||||
|
)
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
if not project:
|
||||||
|
yield await SSEResponse.send_error("项目不存在", 404)
|
||||||
|
return
|
||||||
|
|
||||||
|
yield await SSEResponse.send_progress(f"准备生成{chapter_count}章大纲...", 15)
|
||||||
|
|
||||||
|
# 获取角色信息
|
||||||
|
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
|
||||||
|
])
|
||||||
|
|
||||||
|
# 使用完整提示词
|
||||||
|
yield await SSEResponse.send_progress("准备AI提示词...", 20)
|
||||||
|
prompt = prompt_service.get_complete_outline_prompt(
|
||||||
|
title=project.title,
|
||||||
|
theme=data.get("theme") or project.theme or "未设定",
|
||||||
|
genre=data.get("genre") or project.genre or "通用",
|
||||||
|
chapter_count=chapter_count,
|
||||||
|
narrative_perspective=data.get("narrative_perspective") or "第三人称",
|
||||||
|
target_words=data.get("target_words") or project.target_words or 100000,
|
||||||
|
time_period=project.world_time_period or "未设定",
|
||||||
|
location=project.world_location or "未设定",
|
||||||
|
atmosphere=project.world_atmosphere or "未设定",
|
||||||
|
rules=project.world_rules or "未设定",
|
||||||
|
characters_info=characters_info or "暂无角色信息",
|
||||||
|
requirements=data.get("requirements") or ""
|
||||||
|
)
|
||||||
|
|
||||||
|
# 调用AI
|
||||||
|
yield await SSEResponse.send_progress("🤖 正在调用AI生成...", 30)
|
||||||
|
ai_response = await user_ai_service.generate_text(
|
||||||
|
prompt=prompt,
|
||||||
|
provider=data.get("provider"),
|
||||||
|
model=data.get("model")
|
||||||
|
)
|
||||||
|
|
||||||
|
yield await SSEResponse.send_progress("✅ AI生成完成,正在解析...", 70)
|
||||||
|
|
||||||
|
# 解析响应
|
||||||
|
outline_data = _parse_ai_response(ai_response)
|
||||||
|
|
||||||
|
# 删除旧大纲和章节
|
||||||
|
yield await SSEResponse.send_progress("清理旧数据...", 75)
|
||||||
|
logger.info(f"删除项目 {project_id} 的旧大纲和章节")
|
||||||
|
await db.execute(
|
||||||
|
delete(Outline).where(Outline.project_id == project_id)
|
||||||
|
)
|
||||||
|
await db.execute(
|
||||||
|
delete(Chapter).where(Chapter.project_id == project_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
# 保存新大纲
|
||||||
|
yield await SSEResponse.send_progress("💾 保存大纲到数据库...", 80)
|
||||||
|
outlines = await _save_outlines(
|
||||||
|
project_id, outline_data, db, start_index=1
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录历史
|
||||||
|
history = GenerationHistory(
|
||||||
|
project_id=project_id,
|
||||||
|
prompt=prompt,
|
||||||
|
generated_content=ai_response,
|
||||||
|
model=data.get("model") or "default"
|
||||||
|
)
|
||||||
|
db.add(history)
|
||||||
|
|
||||||
|
await db.commit()
|
||||||
|
db_committed = True
|
||||||
|
|
||||||
|
for outline in outlines:
|
||||||
|
await db.refresh(outline)
|
||||||
|
|
||||||
|
yield await SSEResponse.send_progress("整理结果数据...", 95)
|
||||||
|
|
||||||
|
logger.info(f"全新生成完成 - {len(outlines)} 章")
|
||||||
|
|
||||||
|
# 发送最终结果
|
||||||
|
yield await SSEResponse.send_result({
|
||||||
|
"message": f"成功生成{len(outlines)}章大纲",
|
||||||
|
"total_chapters": len(outlines),
|
||||||
|
"outlines": [
|
||||||
|
{
|
||||||
|
"id": outline.id,
|
||||||
|
"project_id": outline.project_id,
|
||||||
|
"title": outline.title,
|
||||||
|
"content": outline.content,
|
||||||
|
"order_index": outline.order_index,
|
||||||
|
"structure": outline.structure,
|
||||||
|
"created_at": outline.created_at.isoformat() if outline.created_at else None,
|
||||||
|
"updated_at": outline.updated_at.isoformat() if outline.updated_at else None
|
||||||
|
} for outline in outlines
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
yield await SSEResponse.send_progress("🎉 生成完成!", 100, "success")
|
||||||
|
yield await SSEResponse.send_done()
|
||||||
|
|
||||||
|
except GeneratorExit:
|
||||||
|
logger.warning("大纲生成器被提前关闭")
|
||||||
|
if not db_committed and db.in_transaction():
|
||||||
|
await db.rollback()
|
||||||
|
logger.info("大纲生成事务已回滚(GeneratorExit)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"大纲生成失败: {str(e)}")
|
||||||
|
if not db_committed and db.in_transaction():
|
||||||
|
await db.rollback()
|
||||||
|
logger.info("大纲生成事务已回滚(异常)")
|
||||||
|
yield await SSEResponse.send_error(f"生成失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
async def continue_outline_generator(
|
||||||
|
data: Dict[str, Any],
|
||||||
|
db: AsyncSession,
|
||||||
|
user_ai_service: AIService
|
||||||
|
) -> AsyncGenerator[str, None]:
|
||||||
|
"""大纲续写SSE生成器 - 分批生成,推送进度"""
|
||||||
|
db_committed = False
|
||||||
|
try:
|
||||||
|
yield await SSEResponse.send_progress("开始续写大纲...", 5)
|
||||||
|
|
||||||
|
project_id = data.get("project_id")
|
||||||
|
# 确保chapter_count是整数(前端可能传字符串)
|
||||||
|
total_chapters_to_generate = int(data.get("chapter_count", 5))
|
||||||
|
|
||||||
|
# 验证项目
|
||||||
|
yield await SSEResponse.send_progress("加载项目信息...", 10)
|
||||||
|
result = await db.execute(
|
||||||
|
select(Project).where(Project.id == project_id)
|
||||||
|
)
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
if not project:
|
||||||
|
yield await SSEResponse.send_error("项目不存在", 404)
|
||||||
|
return
|
||||||
|
|
||||||
|
# 获取现有大纲
|
||||||
|
yield await SSEResponse.send_progress("分析已有大纲...", 15)
|
||||||
|
existing_result = await db.execute(
|
||||||
|
select(Outline)
|
||||||
|
.where(Outline.project_id == project_id)
|
||||||
|
.order_by(Outline.order_index)
|
||||||
|
)
|
||||||
|
existing_outlines = existing_result.scalars().all()
|
||||||
|
|
||||||
|
if not existing_outlines:
|
||||||
|
yield await SSEResponse.send_error("续写模式需要已有大纲,当前项目没有大纲", 400)
|
||||||
|
return
|
||||||
|
|
||||||
|
current_chapter_count = len(existing_outlines)
|
||||||
|
last_chapter_number = existing_outlines[-1].order_index
|
||||||
|
|
||||||
|
yield await SSEResponse.send_progress(
|
||||||
|
f"当前已有{str(current_chapter_count)}章,将续写{str(total_chapters_to_generate)}章",
|
||||||
|
20
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取角色信息
|
||||||
|
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
|
||||||
|
])
|
||||||
|
|
||||||
|
# 分批配置
|
||||||
|
batch_size = 5
|
||||||
|
total_batches = (total_chapters_to_generate + batch_size - 1) // batch_size
|
||||||
|
yield await SSEResponse.send_progress(
|
||||||
|
f"分批生成计划: 总共{str(total_chapters_to_generate)}章,分{str(total_batches)}批,每批{str(batch_size)}章",
|
||||||
|
25
|
||||||
|
)
|
||||||
|
|
||||||
|
# 情节阶段指导
|
||||||
|
stage_instructions = {
|
||||||
|
"development": "继续展开情节,深化角色关系,推进主线冲突",
|
||||||
|
"climax": "进入故事高潮,矛盾激化,关键冲突爆发",
|
||||||
|
"ending": "解决主要冲突,收束伏笔,给出结局"
|
||||||
|
}
|
||||||
|
stage_instruction = stage_instructions.get(data.get("plot_stage", "development"), "")
|
||||||
|
|
||||||
|
# 批量生成
|
||||||
|
all_new_outlines = []
|
||||||
|
current_start_chapter = last_chapter_number + 1
|
||||||
|
|
||||||
|
for batch_num in range(total_batches):
|
||||||
|
# 计算当前批次的章节数
|
||||||
|
remaining_chapters = int(total_chapters_to_generate) - len(all_new_outlines)
|
||||||
|
current_batch_size = min(batch_size, remaining_chapters)
|
||||||
|
|
||||||
|
batch_progress = 25 + (batch_num * 60 // total_batches)
|
||||||
|
|
||||||
|
yield await SSEResponse.send_progress(
|
||||||
|
f"📝 第{str(batch_num + 1)}/{str(total_batches)}批: 生成第{str(current_start_chapter)}-{str(current_start_chapter + current_batch_size - 1)}章",
|
||||||
|
batch_progress
|
||||||
|
)
|
||||||
|
|
||||||
|
# 获取最新的大纲列表(包括之前批次生成的)
|
||||||
|
latest_result = await db.execute(
|
||||||
|
select(Outline)
|
||||||
|
.where(Outline.project_id == project_id)
|
||||||
|
.order_by(Outline.order_index)
|
||||||
|
)
|
||||||
|
latest_outlines = latest_result.scalars().all()
|
||||||
|
|
||||||
|
# 获取最近2章的剧情
|
||||||
|
recent_outlines = latest_outlines[-2:] if len(latest_outlines) >= 2 else latest_outlines
|
||||||
|
recent_plot = "\n".join([
|
||||||
|
f"第{o.order_index}章《{o.title}》: {o.content}"
|
||||||
|
for o in recent_outlines
|
||||||
|
])
|
||||||
|
|
||||||
|
# 全部章节概览
|
||||||
|
all_chapters_brief = "\n".join([
|
||||||
|
f"第{o.order_index}章: {o.title}"
|
||||||
|
for o in latest_outlines
|
||||||
|
])
|
||||||
|
|
||||||
|
yield await SSEResponse.send_progress(
|
||||||
|
f"🤖 调用AI生成第{str(batch_num + 1)}批...",
|
||||||
|
batch_progress + 5
|
||||||
|
)
|
||||||
|
|
||||||
|
# 使用标准续写提示词模板
|
||||||
|
prompt = prompt_service.get_outline_continue_prompt(
|
||||||
|
title=project.title,
|
||||||
|
theme=data.get("theme") or project.theme or "未设定",
|
||||||
|
genre=data.get("genre") or project.genre or "通用",
|
||||||
|
narrative_perspective=data.get("narrative_perspective") or project.narrative_perspective or "第三人称",
|
||||||
|
chapter_count=current_batch_size,
|
||||||
|
time_period=project.world_time_period or "未设定",
|
||||||
|
location=project.world_location or "未设定",
|
||||||
|
atmosphere=project.world_atmosphere or "未设定",
|
||||||
|
rules=project.world_rules or "未设定",
|
||||||
|
characters_info=characters_info or "暂无角色信息",
|
||||||
|
current_chapter_count=len(latest_outlines),
|
||||||
|
all_chapters_brief=all_chapters_brief,
|
||||||
|
recent_plot=recent_plot,
|
||||||
|
plot_stage_instruction=stage_instruction,
|
||||||
|
start_chapter=current_start_chapter,
|
||||||
|
story_direction=data.get("story_direction", "自然延续"),
|
||||||
|
requirements=data.get("requirements", "")
|
||||||
|
)
|
||||||
|
|
||||||
|
# 调用AI生成当前批次
|
||||||
|
ai_response = await user_ai_service.generate_text(
|
||||||
|
prompt=prompt,
|
||||||
|
provider=data.get("provider"),
|
||||||
|
model=data.get("model")
|
||||||
|
)
|
||||||
|
|
||||||
|
yield await SSEResponse.send_progress(
|
||||||
|
f"✅ 第{str(batch_num + 1)}批AI生成完成,正在解析...",
|
||||||
|
batch_progress + 10
|
||||||
|
)
|
||||||
|
|
||||||
|
# 解析响应
|
||||||
|
outline_data = _parse_ai_response(ai_response)
|
||||||
|
|
||||||
|
# 保存当前批次的大纲
|
||||||
|
batch_outlines = await _save_outlines(
|
||||||
|
project_id, outline_data, db, start_index=current_start_chapter
|
||||||
|
)
|
||||||
|
|
||||||
|
# 记录历史
|
||||||
|
history = GenerationHistory(
|
||||||
|
project_id=project_id,
|
||||||
|
prompt=f"[续写批次{batch_num + 1}/{total_batches}] {str(prompt)[:500]}",
|
||||||
|
generated_content=ai_response,
|
||||||
|
model=data.get("model") or "default"
|
||||||
|
)
|
||||||
|
db.add(history)
|
||||||
|
|
||||||
|
# 提交当前批次
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
for outline in batch_outlines:
|
||||||
|
await db.refresh(outline)
|
||||||
|
|
||||||
|
all_new_outlines.extend(batch_outlines)
|
||||||
|
current_start_chapter += current_batch_size
|
||||||
|
|
||||||
|
yield await SSEResponse.send_progress(
|
||||||
|
f"💾 第{str(batch_num + 1)}批保存成功!本批生成{str(len(batch_outlines))}章,累计新增{str(len(all_new_outlines))}章",
|
||||||
|
batch_progress + 15
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"第{str(batch_num + 1)}批生成完成,本批生成{str(len(batch_outlines))}章")
|
||||||
|
|
||||||
|
db_committed = True
|
||||||
|
|
||||||
|
# 返回所有大纲(包括旧的和新的)
|
||||||
|
final_result = await db.execute(
|
||||||
|
select(Outline)
|
||||||
|
.where(Outline.project_id == project_id)
|
||||||
|
.order_by(Outline.order_index)
|
||||||
|
)
|
||||||
|
all_outlines = final_result.scalars().all()
|
||||||
|
|
||||||
|
yield await SSEResponse.send_progress("整理结果数据...", 95)
|
||||||
|
|
||||||
|
# 发送最终结果
|
||||||
|
yield await SSEResponse.send_result({
|
||||||
|
"message": f"续写完成!共{str(total_batches)}批,新增{str(len(all_new_outlines))}章,总计{str(len(all_outlines))}章",
|
||||||
|
"total_batches": total_batches,
|
||||||
|
"new_chapters": len(all_new_outlines),
|
||||||
|
"total_chapters": len(all_outlines),
|
||||||
|
"outlines": [
|
||||||
|
{
|
||||||
|
"id": outline.id,
|
||||||
|
"project_id": outline.project_id,
|
||||||
|
"title": outline.title,
|
||||||
|
"content": outline.content,
|
||||||
|
"order_index": outline.order_index,
|
||||||
|
"structure": outline.structure,
|
||||||
|
"created_at": outline.created_at.isoformat() if outline.created_at else None,
|
||||||
|
"updated_at": outline.updated_at.isoformat() if outline.updated_at else None
|
||||||
|
} for outline in all_outlines
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
yield await SSEResponse.send_progress("🎉 续写完成!", 100, "success")
|
||||||
|
yield await SSEResponse.send_done()
|
||||||
|
|
||||||
|
except GeneratorExit:
|
||||||
|
logger.warning("大纲续写生成器被提前关闭")
|
||||||
|
if not db_committed and db.in_transaction():
|
||||||
|
await db.rollback()
|
||||||
|
logger.info("大纲续写事务已回滚(GeneratorExit)")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"大纲续写失败: {str(e)}")
|
||||||
|
if not db_committed and db.in_transaction():
|
||||||
|
await db.rollback()
|
||||||
|
logger.info("大纲续写事务已回滚(异常)")
|
||||||
|
yield await SSEResponse.send_error(f"续写失败: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate-stream", summary="AI生成/续写大纲(SSE流式)")
|
||||||
|
async def generate_outline_stream(
|
||||||
|
data: Dict[str, Any],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user_ai_service: AIService = Depends(get_user_ai_service)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
使用SSE流式生成或续写小说大纲,实时推送批次进度
|
||||||
|
|
||||||
|
支持模式:
|
||||||
|
- auto: 自动判断(无大纲→新建,有大纲→续写)
|
||||||
|
- new: 全新生成
|
||||||
|
- continue: 续写模式
|
||||||
|
|
||||||
|
请求体示例:
|
||||||
|
{
|
||||||
|
"project_id": "项目ID",
|
||||||
|
"chapter_count": 5, // 章节数
|
||||||
|
"mode": "auto", // auto/new/continue
|
||||||
|
"theme": "故事主题", // new模式必需
|
||||||
|
"story_direction": "故事发展方向", // continue模式可选
|
||||||
|
"plot_stage": "development", // continue模式:development/climax/ending
|
||||||
|
"narrative_perspective": "第三人称",
|
||||||
|
"requirements": "其他要求",
|
||||||
|
"provider": "openai", // 可选
|
||||||
|
"model": "gpt-4" // 可选
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
# 验证项目是否存在
|
||||||
|
result = await db.execute(
|
||||||
|
select(Project).where(Project.id == data.get("project_id"))
|
||||||
|
)
|
||||||
|
project = result.scalar_one_or_none()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="项目不存在")
|
||||||
|
|
||||||
|
# 判断模式
|
||||||
|
mode = data.get("mode", "auto")
|
||||||
|
|
||||||
|
# 获取现有大纲
|
||||||
|
existing_result = await db.execute(
|
||||||
|
select(Outline)
|
||||||
|
.where(Outline.project_id == data.get("project_id"))
|
||||||
|
.order_by(Outline.order_index)
|
||||||
|
)
|
||||||
|
existing_outlines = existing_result.scalars().all()
|
||||||
|
|
||||||
|
# 自动判断模式
|
||||||
|
if mode == "auto":
|
||||||
|
mode = "continue" if existing_outlines else "new"
|
||||||
|
logger.info(f"自动判断模式:{'续写' if existing_outlines else '新建'}")
|
||||||
|
|
||||||
|
# 根据模式选择生成器
|
||||||
|
if mode == "new":
|
||||||
|
return create_sse_response(new_outline_generator(data, db, user_ai_service))
|
||||||
|
elif mode == "continue":
|
||||||
|
if not existing_outlines:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="续写模式需要已有大纲,当前项目没有大纲"
|
||||||
|
)
|
||||||
|
return create_sse_response(continue_outline_generator(data, db, user_ai_service))
|
||||||
|
else:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"不支持的模式: {mode}"
|
||||||
|
)
|
||||||
@@ -148,7 +148,7 @@ async def get_db(request: Request):
|
|||||||
|
|
||||||
logger.debug(f"📊 会话关闭 [User:{user_id}][ID:{session_id}] - 活跃:{_session_stats['active']}, 总创建:{_session_stats['created']}, 总关闭:{_session_stats['closed']}, 错误:{_session_stats['errors']}")
|
logger.debug(f"📊 会话关闭 [User:{user_id}][ID:{session_id}] - 活跃:{_session_stats['active']}, 总创建:{_session_stats['created']}, 总关闭:{_session_stats['closed']}, 错误:{_session_stats['errors']}")
|
||||||
|
|
||||||
if _session_stats["active"] > 10:
|
if _session_stats["active"] > 100:
|
||||||
logger.warning(f"🚨 活跃会话数过多: {_session_stats['active']},可能存在连接泄漏!")
|
logger.warning(f"🚨 活跃会话数过多: {_session_stats['active']},可能存在连接泄漏!")
|
||||||
elif _session_stats["active"] < 0:
|
elif _session_stats["active"] < 0:
|
||||||
logger.error(f"🚨 活跃会话数异常: {_session_stats['active']},统计可能不准确!")
|
logger.error(f"🚨 活跃会话数异常: {_session_stats['active']},统计可能不准确!")
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ class PromptService:
|
|||||||
3. 不要引用任何本批次中不存在的角色或组织名称
|
3. 不要引用任何本批次中不存在的角色或组织名称
|
||||||
4. 文本描述中不要使用中文引号(""),改用【】或《》"""
|
4. 文本描述中不要使用中文引号(""),改用【】或《》"""
|
||||||
|
|
||||||
# 完整大纲生成提示词
|
# 向导大纲生成提示词
|
||||||
COMPLETE_OUTLINE_GENERATION = """你是一位经验丰富的小说作家和编剧。请根据以下信息生成完整的{chapter_count}章小说大纲:
|
COMPLETE_OUTLINE_GENERATION = """你是一位经验丰富的小说作家和编剧。请根据以下信息生成完整的{chapter_count}章小说大纲:
|
||||||
|
|
||||||
基本信息:
|
基本信息:
|
||||||
@@ -639,7 +639,7 @@ class PromptService:
|
|||||||
target_words: int, time_period: str, location: str,
|
target_words: int, time_period: str, location: str,
|
||||||
atmosphere: str, rules: str, characters_info: str,
|
atmosphere: str, rules: str, characters_info: str,
|
||||||
requirements: str = "") -> str:
|
requirements: str = "") -> str:
|
||||||
"""获取完整大纲生成提示词"""
|
"""获取向导大纲生成提示词"""
|
||||||
return cls.format_prompt(
|
return cls.format_prompt(
|
||||||
cls.COMPLETE_OUTLINE_GENERATION,
|
cls.COMPLETE_OUTLINE_GENERATION,
|
||||||
title=title,
|
title=title,
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
# 大纲分批续写功能说明
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
优化后的大纲续写功能实现了**分批生成**机制,每批次生成5章大纲。这种方式相比一次性生成所有章节具有以下优势:
|
||||||
|
|
||||||
|
## 优势
|
||||||
|
|
||||||
|
1. **降低API压力**:分批次调用AI接口,避免单次请求过大导致超时
|
||||||
|
2. **提高成功率**:小批量生成更稳定,减少因token限制导致的失败
|
||||||
|
3. **更好的连贯性**:每批次基于最新生成的内容继续,确保剧情连贯
|
||||||
|
4. **渐进式反馈**:用户可以看到分批次的进度,体验更好
|
||||||
|
5. **容错性强**:单个批次失败不影响已生成的内容
|
||||||
|
|
||||||
|
## 技术实现
|
||||||
|
|
||||||
|
### 核心逻辑
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 分批配置
|
||||||
|
batch_size = 5 # 每批生成5章
|
||||||
|
total_batches = (total_chapters_to_generate + batch_size - 1) // batch_size
|
||||||
|
|
||||||
|
# 批次循环
|
||||||
|
for batch_num in range(total_batches):
|
||||||
|
# 计算当前批次章节数
|
||||||
|
current_batch_size = min(batch_size, remaining_chapters)
|
||||||
|
|
||||||
|
# 获取最新大纲列表(包括之前批次生成的)
|
||||||
|
latest_outlines = await db.execute(...)
|
||||||
|
|
||||||
|
# 基于最新上下文生成
|
||||||
|
prompt = prompt_service.get_outline_continue_prompt(
|
||||||
|
chapter_count=current_batch_size, # 当前批次数量
|
||||||
|
...
|
||||||
|
)
|
||||||
|
|
||||||
|
# 保存并提交当前批次
|
||||||
|
await db.commit()
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键特性
|
||||||
|
|
||||||
|
1. **动态上下文更新**:每个批次都会获取最新的大纲列表,包括之前批次生成的内容
|
||||||
|
2. **智能章节数计算**:最后一批会自动调整为剩余章节数(不一定是5章)
|
||||||
|
3. **历史记录**:每个批次都会记录到 `GenerationHistory` 表,便于追溯
|
||||||
|
4. **事务安全**:每批次独立提交,确保已生成内容不会丢失
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
### API 调用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/outlines/generate
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"project_id": "project-uuid",
|
||||||
|
"mode": "continue",
|
||||||
|
"chapter_count": 15, # 将分3批生成(5+5+5)
|
||||||
|
"theme": "科幻冒险",
|
||||||
|
"narrative_perspective": "第三人称",
|
||||||
|
"plot_stage": "development",
|
||||||
|
"story_direction": "主角开始探索新世界"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生成过程
|
||||||
|
|
||||||
|
```
|
||||||
|
续写15章的执行流程:
|
||||||
|
|
||||||
|
批次1: 生成第11-15章 (5章)
|
||||||
|
├─ 获取已有1-10章
|
||||||
|
├─ 基于最近2章剧情
|
||||||
|
└─ 提交并保存
|
||||||
|
|
||||||
|
批次2: 生成第16-20章 (5章)
|
||||||
|
├─ 获取已有1-15章(包括批次1生成的)
|
||||||
|
├─ 基于最近2章剧情(第14-15章)
|
||||||
|
└─ 提交并保存
|
||||||
|
|
||||||
|
批次3: 生成第21-25章 (5章)
|
||||||
|
├─ 获取已有1-20章(包括批次1-2生成的)
|
||||||
|
├─ 基于最近2章剧情(第19-20章)
|
||||||
|
└─ 提交并保存
|
||||||
|
|
||||||
|
结果: 总计新增15章,分3批完成
|
||||||
|
```
|
||||||
|
|
||||||
|
## 日志示例
|
||||||
|
|
||||||
|
```
|
||||||
|
[INFO] 续写大纲 - 项目: abc-123, 已有: 10 章
|
||||||
|
[INFO] 分批生成计划: 总共15章,分3批,每批5章
|
||||||
|
[INFO] 开始生成第1/3批,章节范围: 11-15
|
||||||
|
[INFO] 正在调用AI生成第1批...
|
||||||
|
[INFO] 第1批生成完成,本批生成5章
|
||||||
|
[INFO] 开始生成第2/3批,章节范围: 16-20
|
||||||
|
[INFO] 正在调用AI生成第2批...
|
||||||
|
[INFO] 第2批生成完成,本批生成5章
|
||||||
|
[INFO] 开始生成第3/3批,章节范围: 21-25
|
||||||
|
[INFO] 正在调用AI生成第3批...
|
||||||
|
[INFO] 第3批生成完成,本批生成5章
|
||||||
|
[INFO] 续写完成 - 共3批,新增 15 章,总计 25 章
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
### 批次大小
|
||||||
|
|
||||||
|
当前固定为 **5章/批次**,在 `_continue_outline` 函数中定义:
|
||||||
|
|
||||||
|
```python
|
||||||
|
batch_size = 5 # 每批生成5章
|
||||||
|
```
|
||||||
|
|
||||||
|
如需调整,修改此值即可。建议值:
|
||||||
|
- 3-5章:最佳平衡点,稳定性高
|
||||||
|
- 6-8章:适合长篇小说,需要更强的AI模型
|
||||||
|
- 1-2章:超稳定模式,但会增加API调用次数
|
||||||
|
|
||||||
|
### 提示词优化
|
||||||
|
|
||||||
|
提示词已自动适配分批生成:
|
||||||
|
- `chapter_count`: 动态调整为当前批次的章节数
|
||||||
|
- `start_chapter`: 当前批次的起始章节号
|
||||||
|
- `current_chapter_count`: 实时更新已有章节总数
|
||||||
|
- `recent_plot`: 基于最新的2章剧情
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **API费用**:分批生成会增加API调用次数,但单次token消耗更少
|
||||||
|
2. **生成时间**:总时间会略长于一次性生成(因为有多次网络请求)
|
||||||
|
3. **连贯性**:通过获取最新上下文确保连贯性,实际效果可能优于一次性生成
|
||||||
|
4. **中断恢复**:如果某批次失败,已生成的批次内容会保留
|
||||||
|
|
||||||
|
## 未来优化方向
|
||||||
|
|
||||||
|
1. **可配置批次大小**:允许用户通过API参数自定义批次大小
|
||||||
|
2. **并行生成**:对于独立的批次可以考虑并行生成(需要仔细设计)
|
||||||
|
3. **进度推送**:通过WebSocket或SSE实时推送生成进度
|
||||||
|
4. **智能批次调整**:根据已有章节数和剩余章节数智能调整批次大小
|
||||||
|
|
||||||
|
## 相关文件
|
||||||
|
|
||||||
|
- **实现文件**: `backend/app/api/outlines.py` - `_continue_outline()` 函数
|
||||||
|
- **提示词模板**: `backend/app/services/prompt_service.py` - `OUTLINE_CONTINUE_GENERATION`
|
||||||
|
- **Schema定义**: `backend/app/schemas/outline.py` - `OutlineGenerateRequest`
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag } from 'antd';
|
import { Button, List, Modal, Form, Input, message, Empty, Space, Popconfirm, Card, Select, Radio, Tag, Progress } from 'antd';
|
||||||
import { EditOutlined, DeleteOutlined, ThunderboltOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
import { EditOutlined, DeleteOutlined, ThunderboltOutlined, ArrowUpOutlined, ArrowDownOutlined } from '@ant-design/icons';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { useOutlineSync } from '../store/hooks';
|
import { useOutlineSync } from '../store/hooks';
|
||||||
import { cardStyles } from '../components/CardStyles';
|
import { cardStyles } from '../components/CardStyles';
|
||||||
|
import { SSEPostClient } from '../utils/sseClient';
|
||||||
|
|
||||||
const { TextArea } = Input;
|
const { TextArea } = Input;
|
||||||
|
|
||||||
@@ -14,6 +15,11 @@ export default function Outline() {
|
|||||||
const [generateForm] = Form.useForm();
|
const [generateForm] = Form.useForm();
|
||||||
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
|
const [isMobile, setIsMobile] = useState(window.innerWidth <= 768);
|
||||||
|
|
||||||
|
// SSE进度状态
|
||||||
|
const [sseProgress, setSSEProgress] = useState(0);
|
||||||
|
const [sseMessage, setSSEMessage] = useState('');
|
||||||
|
const [sseModalVisible, setSSEModalVisible] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
setIsMobile(window.innerWidth <= 768);
|
setIsMobile(window.innerWidth <= 768);
|
||||||
@@ -23,13 +29,12 @@ export default function Outline() {
|
|||||||
return () => window.removeEventListener('resize', handleResize);
|
return () => window.removeEventListener('resize', handleResize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 使用同步 hooks(移除createOutline)
|
// 使用同步 hooks
|
||||||
const {
|
const {
|
||||||
refreshOutlines,
|
refreshOutlines,
|
||||||
updateOutline,
|
updateOutline,
|
||||||
deleteOutline,
|
deleteOutline,
|
||||||
reorderOutlines,
|
reorderOutlines
|
||||||
generateOutlines
|
|
||||||
} = useOutlineSync();
|
} = useOutlineSync();
|
||||||
|
|
||||||
// 初始加载大纲列表
|
// 初始加载大纲列表
|
||||||
@@ -159,9 +164,17 @@ export default function Outline() {
|
|||||||
const handleGenerate = async (values: GenerateFormValues) => {
|
const handleGenerate = async (values: GenerateFormValues) => {
|
||||||
try {
|
try {
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
// 如果是全新生成模式,keep_existing应该为false
|
|
||||||
const isNewMode = values.mode === 'new';
|
// 关闭生成表单Modal
|
||||||
const result = await generateOutlines({
|
Modal.destroyAll();
|
||||||
|
|
||||||
|
// 显示进度Modal
|
||||||
|
setSSEProgress(0);
|
||||||
|
setSSEMessage('正在连接AI服务...');
|
||||||
|
setSSEModalVisible(true);
|
||||||
|
|
||||||
|
// 准备请求数据
|
||||||
|
const requestData = {
|
||||||
project_id: currentProject.id,
|
project_id: currentProject.id,
|
||||||
genre: currentProject.genre || '通用',
|
genre: currentProject.genre || '通用',
|
||||||
theme: values.theme || currentProject.theme || '',
|
theme: values.theme || currentProject.theme || '',
|
||||||
@@ -169,20 +182,44 @@ export default function Outline() {
|
|||||||
narrative_perspective: values.narrative_perspective || currentProject.narrative_perspective || '第三人称',
|
narrative_perspective: values.narrative_perspective || currentProject.narrative_perspective || '第三人称',
|
||||||
target_words: currentProject.target_words || 100000,
|
target_words: currentProject.target_words || 100000,
|
||||||
requirements: values.requirements,
|
requirements: values.requirements,
|
||||||
// 续写参数
|
|
||||||
mode: values.mode || 'auto',
|
mode: values.mode || 'auto',
|
||||||
story_direction: values.story_direction,
|
story_direction: values.story_direction,
|
||||||
plot_stage: values.plot_stage || 'development',
|
plot_stage: values.plot_stage || 'development',
|
||||||
keep_existing: !isNewMode, // 全新生成模式下不保留旧大纲
|
provider: values.provider,
|
||||||
|
model: values.model
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用SSE客户端
|
||||||
|
const apiUrl = `/api/outlines/generate-stream`;
|
||||||
|
const client = new SSEPostClient(apiUrl, requestData, {
|
||||||
|
onProgress: (msg: string, progress: number) => {
|
||||||
|
setSSEMessage(msg);
|
||||||
|
setSSEProgress(progress);
|
||||||
|
},
|
||||||
|
onResult: (data: any) => {
|
||||||
|
console.log('生成完成,结果:', data);
|
||||||
|
},
|
||||||
|
onError: (error: string) => {
|
||||||
|
message.error(`生成失败: ${error}`);
|
||||||
|
setSSEModalVisible(false);
|
||||||
|
setIsGenerating(false);
|
||||||
|
},
|
||||||
|
onComplete: () => {
|
||||||
|
message.success('大纲生成完成!');
|
||||||
|
setSSEModalVisible(false);
|
||||||
|
setIsGenerating(false);
|
||||||
|
// 刷新大纲列表
|
||||||
|
refreshOutlines();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
message.success(`成功生成 ${result.length} 条大纲`);
|
|
||||||
Modal.destroyAll();
|
// 开始连接
|
||||||
// 刷新大纲列表,确保显示最新数据
|
client.connect();
|
||||||
await refreshOutlines();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('AI生成失败:', error);
|
console.error('AI生成失败:', error);
|
||||||
message.error('AI生成失败');
|
message.error('AI生成失败');
|
||||||
} finally {
|
setSSEModalVisible(false);
|
||||||
setIsGenerating(false);
|
setIsGenerating(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -335,6 +372,37 @@ export default function Outline() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{/* SSE进度Modal */}
|
||||||
|
<Modal
|
||||||
|
title="生成大纲中"
|
||||||
|
open={sseModalVisible}
|
||||||
|
footer={null}
|
||||||
|
closable={false}
|
||||||
|
centered
|
||||||
|
width={500}
|
||||||
|
>
|
||||||
|
<div style={{ padding: '20px 0' }}>
|
||||||
|
<Progress
|
||||||
|
percent={sseProgress}
|
||||||
|
status={sseProgress === 100 ? 'success' : 'active'}
|
||||||
|
strokeColor={{
|
||||||
|
'0%': '#108ee9',
|
||||||
|
'100%': '#87d068',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{
|
||||||
|
marginTop: 16,
|
||||||
|
color: '#666',
|
||||||
|
fontSize: 14,
|
||||||
|
minHeight: 40,
|
||||||
|
lineHeight: '20px'
|
||||||
|
}}>
|
||||||
|
{sseMessage}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
{/* 固定头部 */}
|
{/* 固定头部 */}
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -476,5 +544,6 @@ export default function Outline() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Card, Button, Empty, Modal, message, Spin, Row, Col, Statistic, Space, Tag, Progress, Typography, Tooltip, Badge } from 'antd';
|
import { Card, Button, Empty, Modal, message, Spin, Row, Col, Statistic, Space, Tag, Progress, Typography, Tooltip, Badge, Alert } from 'antd';
|
||||||
import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined, SettingOutlined } from '@ant-design/icons';
|
import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined, SettingOutlined, InfoCircleOutlined, CloseOutlined } from '@ant-design/icons';
|
||||||
import { useStore } from '../store';
|
import { useStore } from '../store';
|
||||||
import { useProjectSync } from '../store/hooks';
|
import { useProjectSync } from '../store/hooks';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
@@ -13,6 +13,7 @@ const { Title, Text, Paragraph } = Typography;
|
|||||||
export default function ProjectList() {
|
export default function ProjectList() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { projects, loading } = useStore();
|
const { projects, loading } = useStore();
|
||||||
|
const [showApiTip, setShowApiTip] = useState(true);
|
||||||
|
|
||||||
const { refreshProjects, deleteProject } = useProjectSync();
|
const { refreshProjects, deleteProject } = useProjectSync();
|
||||||
|
|
||||||
@@ -195,6 +196,59 @@ export default function ProjectList() {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
{showApiTip && projects.length === 0 && (
|
||||||
|
<Alert
|
||||||
|
message={
|
||||||
|
<Space align="center" style={{ width: '100%' }}>
|
||||||
|
<InfoCircleOutlined style={{ fontSize: 16, color: '#1890ff' }} />
|
||||||
|
<Text strong style={{ fontSize: window.innerWidth <= 768 ? 13 : 14 }}>
|
||||||
|
首次使用提示
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||||
|
<Text style={{ fontSize: window.innerWidth <= 768 ? 12 : 13 }}>
|
||||||
|
在开始创作之前,请先配置您的AI接口。系统支持OpenAI和Anthropic两种接口。
|
||||||
|
</Text>
|
||||||
|
<Space size={8}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
icon={<SettingOutlined />}
|
||||||
|
onClick={() => navigate('/settings')}
|
||||||
|
style={{
|
||||||
|
borderRadius: 6,
|
||||||
|
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||||
|
border: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
立即配置
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
onClick={() => setShowApiTip(false)}
|
||||||
|
style={{ borderRadius: 6 }}
|
||||||
|
>
|
||||||
|
暂不提醒
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
type="info"
|
||||||
|
showIcon={false}
|
||||||
|
closable
|
||||||
|
closeIcon={<CloseOutlined style={{ fontSize: 12 }} />}
|
||||||
|
onClose={() => setShowApiTip(false)}
|
||||||
|
style={{
|
||||||
|
marginTop: window.innerWidth <= 768 ? 16 : 24,
|
||||||
|
borderRadius: 12,
|
||||||
|
background: 'linear-gradient(135deg, #e6f7ff 0%, #f0f5ff 100%)',
|
||||||
|
border: '1px solid #91d5ff'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{projects.length > 0 && (
|
{projects.length > 0 && (
|
||||||
<Row gutter={[16, 16]} style={{ marginTop: window.innerWidth <= 768 ? 16 : 24 }}>
|
<Row gutter={[16, 16]} style={{ marginTop: window.innerWidth <= 768 ? 16 : 24 }}>
|
||||||
<Col xs={24} sm={8}>
|
<Col xs={24} sm={8}>
|
||||||
|
|||||||
@@ -116,9 +116,9 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
const apiProviders = [
|
const apiProviders = [
|
||||||
{ value: 'openai', label: 'OpenAI', defaultUrl: 'https://api.openai.com/v1' },
|
{ value: 'openai', label: 'OpenAI', defaultUrl: 'https://api.openai.com/v1' },
|
||||||
{ value: 'azure', label: 'Azure OpenAI', defaultUrl: 'https://YOUR-RESOURCE.openai.azure.com' },
|
// { value: 'azure', label: 'Azure OpenAI', defaultUrl: 'https://YOUR-RESOURCE.openai.azure.com' },
|
||||||
{ value: 'anthropic', label: 'Anthropic', defaultUrl: 'https://api.anthropic.com' },
|
{ value: 'anthropic', label: 'Anthropic', defaultUrl: 'https://api.anthropic.com' },
|
||||||
{ value: 'custom', label: '自定义', defaultUrl: '' },
|
// { value: 'custom', label: '自定义', defaultUrl: '' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleProviderChange = (value: string) => {
|
const handleProviderChange = (value: string) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user