fix:优化章节分析并发问题
This commit is contained in:
+63
-41
@@ -7,6 +7,7 @@ import json
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from asyncio import Queue, Lock
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models.chapter import Chapter
|
from app.models.chapter import Chapter
|
||||||
@@ -34,6 +35,17 @@ from app.api.settings import get_user_ai_service
|
|||||||
router = APIRouter(prefix="/chapters", tags=["章节管理"])
|
router = APIRouter(prefix="/chapters", tags=["章节管理"])
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
# 全局数据库写入锁(每个用户一个锁,用于保护SQLite写入操作)
|
||||||
|
db_write_locks: dict[str, Lock] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db_write_lock(user_id: str) -> Lock:
|
||||||
|
"""获取或创建用户的数据库写入锁"""
|
||||||
|
if user_id not in db_write_locks:
|
||||||
|
db_write_locks[user_id] = Lock()
|
||||||
|
logger.debug(f"🔒 为用户 {user_id} 创建数据库写入锁")
|
||||||
|
return db_write_locks[user_id]
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ChapterResponse, summary="创建章节")
|
@router.post("", response_model=ChapterResponse, summary="创建章节")
|
||||||
async def create_chapter(
|
async def create_chapter(
|
||||||
@@ -318,7 +330,7 @@ async def analyze_chapter_background(
|
|||||||
ai_service: AIService
|
ai_service: AIService
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
后台异步分析章节
|
后台异步分析章节(支持并发,使用锁保护数据库写入)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
chapter_id: 章节ID
|
chapter_id: 章节ID
|
||||||
@@ -328,11 +340,10 @@ async def analyze_chapter_background(
|
|||||||
ai_service: AI服务实例
|
ai_service: AI服务实例
|
||||||
"""
|
"""
|
||||||
db_session = None
|
db_session = None
|
||||||
try:
|
write_lock = await get_db_write_lock(user_id)
|
||||||
logger.info(f"🔍 开始后台分析章节: {chapter_id}")
|
|
||||||
|
|
||||||
# 等待一小段时间,确保主会话的commit已经持久化到磁盘
|
try:
|
||||||
await asyncio.sleep(0.1)
|
logger.info(f"🔍 开始分析章节: {chapter_id}, 任务ID: {task_id}")
|
||||||
|
|
||||||
# 创建独立数据库会话
|
# 创建独立数据库会话
|
||||||
from app.database import get_engine
|
from app.database import get_engine
|
||||||
@@ -346,34 +357,30 @@ async def analyze_chapter_background(
|
|||||||
)
|
)
|
||||||
db_session = AsyncSessionLocal()
|
db_session = AsyncSessionLocal()
|
||||||
|
|
||||||
# 1. 获取任务(添加重试逻辑)
|
# 1. 获取任务(读操作)
|
||||||
task = None
|
|
||||||
for retry in range(3):
|
|
||||||
task_result = await db_session.execute(
|
task_result = await db_session.execute(
|
||||||
select(AnalysisTask).where(AnalysisTask.id == task_id)
|
select(AnalysisTask).where(AnalysisTask.id == task_id)
|
||||||
)
|
)
|
||||||
task = task_result.scalar_one_or_none()
|
task = task_result.scalar_one_or_none()
|
||||||
if task:
|
|
||||||
break
|
|
||||||
if retry < 2:
|
|
||||||
logger.warning(f"⚠️ 第{retry+1}次未找到任务 {task_id},等待后重试...")
|
|
||||||
await asyncio.sleep(0.2)
|
|
||||||
|
|
||||||
if not task:
|
if not task:
|
||||||
logger.error(f"❌ 任务不存在: {task_id}")
|
logger.error(f"❌ 任务不存在: {task_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# 更新任务状态(写操作,需要锁)
|
||||||
|
async with write_lock:
|
||||||
task.status = 'running'
|
task.status = 'running'
|
||||||
task.started_at = datetime.now()
|
task.started_at = datetime.now()
|
||||||
task.progress = 10
|
task.progress = 10
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
# 2. 获取章节信息
|
# 2. 获取章节信息(读操作)
|
||||||
chapter_result = await db_session.execute(
|
chapter_result = await db_session.execute(
|
||||||
select(Chapter).where(Chapter.id == chapter_id)
|
select(Chapter).where(Chapter.id == chapter_id)
|
||||||
)
|
)
|
||||||
chapter = chapter_result.scalar_one_or_none()
|
chapter = chapter_result.scalar_one_or_none()
|
||||||
if not chapter or not chapter.content:
|
if not chapter or not chapter.content:
|
||||||
|
async with write_lock:
|
||||||
task.status = 'failed'
|
task.status = 'failed'
|
||||||
task.error_message = '章节不存在或内容为空'
|
task.error_message = '章节不存在或内容为空'
|
||||||
task.completed_at = datetime.now()
|
task.completed_at = datetime.now()
|
||||||
@@ -381,6 +388,7 @@ async def analyze_chapter_background(
|
|||||||
logger.error(f"❌ 章节不存在或内容为空: {chapter_id}")
|
logger.error(f"❌ 章节不存在或内容为空: {chapter_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
async with write_lock:
|
||||||
task.progress = 20
|
task.progress = 20
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
@@ -394,6 +402,7 @@ async def analyze_chapter_background(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not analysis_result:
|
if not analysis_result:
|
||||||
|
async with write_lock:
|
||||||
task.status = 'failed'
|
task.status = 'failed'
|
||||||
task.error_message = 'AI分析失败,请检查日志'
|
task.error_message = 'AI分析失败,请检查日志'
|
||||||
task.completed_at = datetime.now()
|
task.completed_at = datetime.now()
|
||||||
@@ -401,10 +410,12 @@ async def analyze_chapter_background(
|
|||||||
logger.error(f"❌ AI分析失败: {chapter_id}")
|
logger.error(f"❌ AI分析失败: {chapter_id}")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
async with write_lock:
|
||||||
task.progress = 60
|
task.progress = 60
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
|
|
||||||
# 4. 保存分析结果到数据库(先检查是否已存在)
|
# 4. 保存分析结果到数据库(写操作,需要锁)
|
||||||
|
async with write_lock:
|
||||||
existing_analysis_result = await db_session.execute(
|
existing_analysis_result = await db_session.execute(
|
||||||
select(PlotAnalysis).where(PlotAnalysis.chapter_id == chapter_id)
|
select(PlotAnalysis).where(PlotAnalysis.chapter_id == chapter_id)
|
||||||
)
|
)
|
||||||
@@ -481,16 +492,18 @@ async def analyze_chapter_background(
|
|||||||
chapter_content=chapter.content or ""
|
chapter_content=chapter.content or ""
|
||||||
)
|
)
|
||||||
|
|
||||||
# 先删除该章节的旧记忆(支持重新分析)
|
# 先删除该章节的旧记忆(写操作,需要锁)
|
||||||
|
async with write_lock:
|
||||||
old_memories_result = await db_session.execute(
|
old_memories_result = await db_session.execute(
|
||||||
select(StoryMemory).where(StoryMemory.chapter_id == chapter_id)
|
select(StoryMemory).where(StoryMemory.chapter_id == chapter_id)
|
||||||
)
|
)
|
||||||
old_memories = old_memories_result.scalars().all()
|
old_memories = old_memories_result.scalars().all()
|
||||||
for old_mem in old_memories:
|
for old_mem in old_memories:
|
||||||
await db_session.delete(old_mem)
|
await db_session.delete(old_mem)
|
||||||
|
await db_session.commit()
|
||||||
logger.info(f" 删除旧记忆: {len(old_memories)}条")
|
logger.info(f" 删除旧记忆: {len(old_memories)}条")
|
||||||
|
|
||||||
# 准备批量添加的记忆数据
|
# 准备批量添加的记忆数据(不需要锁)
|
||||||
memory_records = []
|
memory_records = []
|
||||||
for mem in memories:
|
for mem in memories:
|
||||||
memory_id = f"{chapter_id}_{mem['type']}_{len(memory_records)}"
|
memory_id = f"{chapter_id}_{mem['type']}_{len(memory_records)}"
|
||||||
@@ -501,11 +514,13 @@ async def analyze_chapter_background(
|
|||||||
'metadata': mem['metadata']
|
'metadata': mem['metadata']
|
||||||
})
|
})
|
||||||
|
|
||||||
# 从metadata中提取位置信息
|
# 保存到关系数据库(写操作,需要锁)
|
||||||
|
async with write_lock:
|
||||||
|
for mem in memories:
|
||||||
|
memory_id = memory_records[memories.index(mem)]['id']
|
||||||
text_position = mem['metadata'].get('text_position', -1)
|
text_position = mem['metadata'].get('text_position', -1)
|
||||||
text_length = mem['metadata'].get('text_length', 0)
|
text_length = mem['metadata'].get('text_length', 0)
|
||||||
|
|
||||||
# 同时保存到关系数据库
|
|
||||||
story_memory = StoryMemory(
|
story_memory = StoryMemory(
|
||||||
id=memory_id,
|
id=memory_id,
|
||||||
project_id=project_id,
|
project_id=project_id,
|
||||||
@@ -516,15 +531,14 @@ async def analyze_chapter_background(
|
|||||||
importance_score=mem['metadata'].get('importance_score', 0.5),
|
importance_score=mem['metadata'].get('importance_score', 0.5),
|
||||||
tags=mem['metadata'].get('tags', []),
|
tags=mem['metadata'].get('tags', []),
|
||||||
is_foreshadow=mem['metadata'].get('is_foreshadow', 0),
|
is_foreshadow=mem['metadata'].get('is_foreshadow', 0),
|
||||||
story_timeline=chapter.chapter_number, # 使用章节序号作为时间线
|
story_timeline=chapter.chapter_number,
|
||||||
chapter_position=text_position, # 保存文本位置
|
chapter_position=text_position,
|
||||||
text_length=text_length, # 保存文本长度
|
text_length=text_length,
|
||||||
related_characters=mem['metadata'].get('related_characters', []),
|
related_characters=mem['metadata'].get('related_characters', []),
|
||||||
related_locations=mem['metadata'].get('related_locations', [])
|
related_locations=mem['metadata'].get('related_locations', [])
|
||||||
)
|
)
|
||||||
db_session.add(story_memory)
|
db_session.add(story_memory)
|
||||||
|
|
||||||
# 记录日志便于调试
|
|
||||||
if text_position >= 0:
|
if text_position >= 0:
|
||||||
logger.debug(f" 保存记忆 {memory_id}: position={text_position}, length={text_length}")
|
logger.debug(f" 保存记忆 {memory_id}: position={text_position}, length={text_length}")
|
||||||
|
|
||||||
@@ -539,6 +553,8 @@ async def analyze_chapter_background(
|
|||||||
)
|
)
|
||||||
logger.info(f"✅ 添加{added_count}条记忆到向量库")
|
logger.info(f"✅ 添加{added_count}条记忆到向量库")
|
||||||
|
|
||||||
|
# 最终更新任务状态(写操作,需要锁)
|
||||||
|
async with write_lock:
|
||||||
task.progress = 100
|
task.progress = 100
|
||||||
task.status = 'completed'
|
task.status = 'completed'
|
||||||
task.completed_at = datetime.now()
|
task.completed_at = datetime.now()
|
||||||
@@ -548,10 +564,10 @@ async def analyze_chapter_background(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"❌ 后台分析异常: {str(e)}", exc_info=True)
|
logger.error(f"❌ 后台分析异常: {str(e)}", exc_info=True)
|
||||||
# 确保任务状态被更新为failed,避免前端一直轮询
|
# 确保任务状态被更新为failed(写操作,需要锁)
|
||||||
if db_session:
|
if db_session:
|
||||||
try:
|
try:
|
||||||
# 重新获取任务以确保有最新状态
|
async with write_lock:
|
||||||
task_result = await db_session.execute(
|
task_result = await db_session.execute(
|
||||||
select(AnalysisTask).where(AnalysisTask.id == task_id)
|
select(AnalysisTask).where(AnalysisTask.id == task_id)
|
||||||
)
|
)
|
||||||
@@ -560,7 +576,7 @@ async def analyze_chapter_background(
|
|||||||
task.status = 'failed'
|
task.status = 'failed'
|
||||||
task.error_message = str(e)[:500]
|
task.error_message = str(e)[:500]
|
||||||
task.completed_at = datetime.now()
|
task.completed_at = datetime.now()
|
||||||
task.progress = 0 # 重置进度
|
task.progress = 0
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
logger.info(f"✅ 任务状态已更新为failed: {task_id}")
|
logger.info(f"✅ 任务状态已更新为failed: {task_id}")
|
||||||
else:
|
else:
|
||||||
@@ -835,7 +851,7 @@ async def generate_chapter_content_stream(
|
|||||||
|
|
||||||
logger.info(f"成功创作章节 {chapter_id},共 {new_word_count} 字")
|
logger.info(f"成功创作章节 {chapter_id},共 {new_word_count} 字")
|
||||||
|
|
||||||
# 创建分析任务并启动后台分析
|
# 创建分析任务
|
||||||
analysis_task = AnalysisTask(
|
analysis_task = AnalysisTask(
|
||||||
chapter_id=chapter_id,
|
chapter_id=chapter_id,
|
||||||
user_id=current_user_id,
|
user_id=current_user_id,
|
||||||
@@ -845,11 +861,15 @@ async def generate_chapter_content_stream(
|
|||||||
)
|
)
|
||||||
db_session.add(analysis_task)
|
db_session.add(analysis_task)
|
||||||
await db_session.commit()
|
await db_session.commit()
|
||||||
# 不需要refresh,只需要获取ID
|
await db_session.refresh(analysis_task)
|
||||||
|
|
||||||
task_id = analysis_task.id
|
task_id = analysis_task.id
|
||||||
|
logger.info(f"📋 已创建分析任务: {task_id}")
|
||||||
|
|
||||||
# 启动后台分析任务
|
# 短暂延迟确保SQLite WAL完成写入
|
||||||
|
await asyncio.sleep(0.05)
|
||||||
|
|
||||||
|
# 直接启动后台分析(并发执行)
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
analyze_chapter_background,
|
analyze_chapter_background,
|
||||||
chapter_id=chapter_id,
|
chapter_id=chapter_id,
|
||||||
@@ -859,8 +879,6 @@ async def generate_chapter_content_stream(
|
|||||||
ai_service=user_ai_service
|
ai_service=user_ai_service
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"📋 已创建分析任务: {task_id}")
|
|
||||||
|
|
||||||
# 发送完成事件(包含分析任务ID)
|
# 发送完成事件(包含分析任务ID)
|
||||||
completion_data = {
|
completion_data = {
|
||||||
'type': 'done',
|
'type': 'done',
|
||||||
@@ -870,13 +888,13 @@ async def generate_chapter_content_stream(
|
|||||||
}
|
}
|
||||||
yield f"data: {json.dumps(completion_data, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps(completion_data, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
# 发送分析排队事件
|
# 发送分析开始事件
|
||||||
analysis_queued_data = {
|
analysis_started_data = {
|
||||||
'type': 'analysis_queued',
|
'type': 'analysis_started',
|
||||||
'task_id': task_id,
|
'task_id': task_id,
|
||||||
'message': '章节分析已加入队列'
|
'message': '章节分析已开始'
|
||||||
}
|
}
|
||||||
yield f"data: {json.dumps(analysis_queued_data, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps(analysis_started_data, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
break # 退出async for db_session循环
|
break # 退出async for db_session循环
|
||||||
|
|
||||||
@@ -1211,11 +1229,17 @@ async def trigger_chapter_analysis(
|
|||||||
)
|
)
|
||||||
db.add(analysis_task)
|
db.add(analysis_task)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
# 注意:不需要refresh,因为我们只需要id,而id在commit后已经生成
|
|
||||||
|
|
||||||
task_id = analysis_task.id
|
task_id = analysis_task.id
|
||||||
|
logger.info(f"📋 创建分析任务: {task_id}, 章节: {chapter_id}")
|
||||||
|
|
||||||
# 启动后台分析任务
|
# 刷新数据库会话,确保其他会话可以看到新任务
|
||||||
|
await db.refresh(analysis_task)
|
||||||
|
|
||||||
|
# 短暂延迟确保SQLite WAL完成写入(让其他会话可见)
|
||||||
|
await asyncio.sleep(3)
|
||||||
|
|
||||||
|
# 直接启动后台分析(并发执行)
|
||||||
background_tasks.add_task(
|
background_tasks.add_task(
|
||||||
analyze_chapter_background,
|
analyze_chapter_background,
|
||||||
chapter_id=chapter_id,
|
chapter_id=chapter_id,
|
||||||
@@ -1225,11 +1249,9 @@ async def trigger_chapter_analysis(
|
|||||||
ai_service=user_ai_service
|
ai_service=user_ai_service
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"📋 手动触发分析任务: {task_id}")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"chapter_id": chapter_id,
|
"chapter_id": chapter_id,
|
||||||
"status": "pending",
|
"status": "pending",
|
||||||
"message": "分析任务已创建并加入队列"
|
"message": "分析任务已创建并开始执行"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
|||||||
if (visible && chapterId) {
|
if (visible && chapterId) {
|
||||||
fetchAnalysisStatus();
|
fetchAnalysisStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 清理函数:组件卸载或关闭时清除轮询
|
||||||
|
return () => {
|
||||||
|
// 清除可能存在的轮询
|
||||||
|
};
|
||||||
}, [visible, chapterId]);
|
}, [visible, chapterId]);
|
||||||
|
|
||||||
const fetchAnalysisStatus = async () => {
|
const fetchAnalysisStatus = async () => {
|
||||||
@@ -117,16 +122,8 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
|||||||
throw new Error(errorData.detail || '触发分析失败');
|
throw new Error(errorData.detail || '触发分析失败');
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
// 触发成功后立即关闭Modal,让父组件的状态管理接管
|
||||||
setTask({
|
onClose();
|
||||||
task_id: result.task_id,
|
|
||||||
chapter_id: chapterId,
|
|
||||||
status: 'pending',
|
|
||||||
progress: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
// 开始轮询
|
|
||||||
startPolling();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -134,6 +131,7 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const renderStatusIcon = () => {
|
const renderStatusIcon = () => {
|
||||||
if (!task) return null;
|
if (!task) return null;
|
||||||
|
|
||||||
@@ -480,7 +478,7 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
|||||||
<Button key="close" onClick={onClose}>
|
<Button key="close" onClick={onClose}>
|
||||||
关闭
|
关闭
|
||||||
</Button>,
|
</Button>,
|
||||||
!task && (
|
!task && !loading && (
|
||||||
<Button
|
<Button
|
||||||
key="analyze"
|
key="analyze"
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -491,19 +489,30 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
|||||||
开始分析
|
开始分析
|
||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
task && (task.status === 'failed' || task.status === 'completed') && (
|
task && (task.status === 'failed') && (
|
||||||
<Button
|
<Button
|
||||||
key="reanalyze"
|
key="reanalyze"
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<ReloadOutlined />}
|
icon={<ReloadOutlined />}
|
||||||
onClick={triggerAnalysis}
|
onClick={triggerAnalysis}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
danger={task.status === 'failed'}
|
danger
|
||||||
|
>
|
||||||
|
重新分析
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
task && task.status === 'completed' && (
|
||||||
|
<Button
|
||||||
|
key="reanalyze"
|
||||||
|
type="default"
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={triggerAnalysis}
|
||||||
|
loading={loading}
|
||||||
>
|
>
|
||||||
重新分析
|
重新分析
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
]}
|
].filter(Boolean)}
|
||||||
>
|
>
|
||||||
{loading && !task && (
|
{loading && !task && (
|
||||||
<div style={{ textAlign: 'center', padding: '48px' }}>
|
<div style={{ textAlign: 'center', padding: '48px' }}>
|
||||||
@@ -518,11 +527,6 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter
|
|||||||
description={error}
|
description={error}
|
||||||
type="error"
|
type="error"
|
||||||
showIcon
|
showIcon
|
||||||
action={
|
|
||||||
<Button size="small" danger onClick={triggerAnalysis}>
|
|
||||||
开始分析
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Card, List, Button, Space, Empty, Tag, Spin, Alert, Switch, Drawer, message, Progress } from 'antd';
|
import { Card, List, Button, Space, Empty, Tag, Spin, Alert, Switch, Drawer, message } from 'antd';
|
||||||
import {
|
import {
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
EyeInvisibleOutlined,
|
EyeInvisibleOutlined,
|
||||||
MenuOutlined,
|
MenuOutlined,
|
||||||
ReloadOutlined,
|
|
||||||
LeftOutlined,
|
LeftOutlined,
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
@@ -72,8 +71,6 @@ const ChapterAnalysis: React.FC = () => {
|
|||||||
const [showAnnotations, setShowAnnotations] = useState(true);
|
const [showAnnotations, setShowAnnotations] = useState(true);
|
||||||
const [activeAnnotationId, setActiveAnnotationId] = useState<string | undefined>();
|
const [activeAnnotationId, setActiveAnnotationId] = useState<string | undefined>();
|
||||||
const [sidebarVisible, setSidebarVisible] = useState(false);
|
const [sidebarVisible, setSidebarVisible] = useState(false);
|
||||||
const [analyzing, setAnalyzing] = useState(false);
|
|
||||||
const [analysisProgress, setAnalysisProgress] = useState(0);
|
|
||||||
const [scrollToContentAnnotation, setScrollToContentAnnotation] = useState<string | undefined>();
|
const [scrollToContentAnnotation, setScrollToContentAnnotation] = useState<string | undefined>();
|
||||||
const [scrollToSidebarAnnotation, setScrollToSidebarAnnotation] = useState<string | undefined>();
|
const [scrollToSidebarAnnotation, setScrollToSidebarAnnotation] = useState<string | undefined>();
|
||||||
|
|
||||||
@@ -165,105 +162,6 @@ const ChapterAnalysis: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReanalyze = async () => {
|
|
||||||
if (!selectedChapter) return;
|
|
||||||
|
|
||||||
let pollInterval: number | null = null;
|
|
||||||
let timeoutId: number | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
setAnalyzing(true);
|
|
||||||
setAnalysisProgress(0);
|
|
||||||
message.loading({ content: '开始分析章节...', key: 'analyze', duration: 0 });
|
|
||||||
|
|
||||||
// 触发分析任务
|
|
||||||
const triggerRes = await api.post(`/chapters/${selectedChapter.id}/analyze`);
|
|
||||||
const triggerData = triggerRes.data || triggerRes;
|
|
||||||
const taskId = triggerData.task_id;
|
|
||||||
|
|
||||||
console.log('分析任务已创建:', taskId);
|
|
||||||
|
|
||||||
// 开始轮询状态
|
|
||||||
let pollCount = 0;
|
|
||||||
const maxPolls = 60; // 最多轮询60次(2分钟)
|
|
||||||
|
|
||||||
pollInterval = setInterval(async () => {
|
|
||||||
pollCount++;
|
|
||||||
|
|
||||||
if (pollCount > maxPolls) {
|
|
||||||
if (pollInterval) clearInterval(pollInterval);
|
|
||||||
if (timeoutId) clearTimeout(timeoutId);
|
|
||||||
setAnalyzing(false);
|
|
||||||
message.warning({ content: '分析超时,请稍后刷新页面查看结果', key: 'analyze' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const statusRes = await api.get(`/chapters/${selectedChapter.id}/analysis/status`);
|
|
||||||
const responseData = statusRes.data || statusRes;
|
|
||||||
|
|
||||||
if (!responseData) {
|
|
||||||
console.warn(`第${pollCount}次轮询:响应数据为空`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { status, progress, error_message } = responseData;
|
|
||||||
console.log(`第${pollCount}次轮询:status=${status}, progress=${progress}`);
|
|
||||||
|
|
||||||
setAnalysisProgress(progress || 0);
|
|
||||||
|
|
||||||
if (status === 'completed') {
|
|
||||||
if (pollInterval) clearInterval(pollInterval);
|
|
||||||
if (timeoutId) clearTimeout(timeoutId);
|
|
||||||
setAnalyzing(false);
|
|
||||||
message.success({ content: '分析完成!', key: 'analyze' });
|
|
||||||
|
|
||||||
// 重新加载标注数据
|
|
||||||
try {
|
|
||||||
const annotationsRes = await api.get(`/chapters/${selectedChapter.id}/annotations`);
|
|
||||||
setAnnotationsData(annotationsRes.data || annotationsRes);
|
|
||||||
} catch (annotErr) {
|
|
||||||
console.error('加载标注数据失败:', annotErr);
|
|
||||||
message.warning('分析完成,但加载标注数据失败,请刷新页面');
|
|
||||||
}
|
|
||||||
} else if (status === 'failed') {
|
|
||||||
if (pollInterval) clearInterval(pollInterval);
|
|
||||||
if (timeoutId) clearTimeout(timeoutId);
|
|
||||||
setAnalyzing(false);
|
|
||||||
message.error({
|
|
||||||
content: `分析失败:${error_message || '未知错误'}`,
|
|
||||||
key: 'analyze'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// pending 或 running 状态继续轮询
|
|
||||||
} catch (pollErr) {
|
|
||||||
console.error(`第${pollCount}次轮询失败:`, pollErr);
|
|
||||||
// 轮询错误不中断,继续下一次轮询
|
|
||||||
}
|
|
||||||
}, 2000);
|
|
||||||
|
|
||||||
// 设置总超时(2分钟)
|
|
||||||
timeoutId = setTimeout(() => {
|
|
||||||
if (pollInterval) clearInterval(pollInterval);
|
|
||||||
setAnalyzing(false);
|
|
||||||
message.warning({ content: '分析超时,请稍后刷新页面查看结果', key: 'analyze' });
|
|
||||||
}, 120000);
|
|
||||||
|
|
||||||
} catch (err: any) {
|
|
||||||
// 清理定时器
|
|
||||||
if (pollInterval) clearInterval(pollInterval);
|
|
||||||
if (timeoutId) clearTimeout(timeoutId);
|
|
||||||
|
|
||||||
setAnalyzing(false);
|
|
||||||
const errorMsg = err.response?.data?.detail || err.message || '触发分析失败';
|
|
||||||
console.error('触发分析失败:', errorMsg, err);
|
|
||||||
message.error({
|
|
||||||
content: errorMsg,
|
|
||||||
key: 'analyze'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const hasAnnotations = annotationsData && annotationsData.annotations.length > 0;
|
const hasAnnotations = annotationsData && annotationsData.annotations.length > 0;
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
@@ -352,15 +250,6 @@ const ChapterAnalysis: React.FC = () => {
|
|||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
<Space>
|
<Space>
|
||||||
<Button
|
|
||||||
icon={<ReloadOutlined />}
|
|
||||||
onClick={handleReanalyze}
|
|
||||||
loading={analyzing}
|
|
||||||
disabled={analyzing || !selectedChapter?.content || selectedChapter.content.trim() === ''}
|
|
||||||
title={!selectedChapter?.content || selectedChapter.content.trim() === '' ? '章节内容为空,无法分析' : ''}
|
|
||||||
>
|
|
||||||
{analyzing ? '分析中...' : '重新分析'}
|
|
||||||
</Button>
|
|
||||||
{hasAnnotations && (
|
{hasAnnotations && (
|
||||||
<>
|
<>
|
||||||
<Switch
|
<Switch
|
||||||
@@ -382,16 +271,7 @@ const ChapterAnalysis: React.FC = () => {
|
|||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{analyzing && (
|
{hasAnnotations && annotationsData && (
|
||||||
<div style={{ marginTop: 12 }}>
|
|
||||||
<Progress percent={analysisProgress} size="small" status="active" />
|
|
||||||
<span style={{ fontSize: 12, color: '#666', marginLeft: 8 }}>
|
|
||||||
正在分析章节...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!analyzing && hasAnnotations && annotationsData && (
|
|
||||||
<div style={{ marginTop: 12, fontSize: 12, color: '#999' }}>
|
<div style={{ marginTop: 12, fontSize: 12, color: '#999' }}>
|
||||||
共有 {annotationsData.summary.total_annotations} 个标注:
|
共有 {annotationsData.summary.total_annotations} 个标注:
|
||||||
{annotationsData.summary.hooks > 0 && ` 🎣${annotationsData.summary.hooks}个钩子`}
|
{annotationsData.summary.hooks > 0 && ` 🎣${annotationsData.summary.hooks}个钩子`}
|
||||||
|
|||||||
+238
-14
@@ -1,10 +1,10 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber } from 'antd';
|
import { List, Button, Modal, Form, Input, Select, message, Empty, Space, Badge, Tag, Card, Tooltip, InputNumber } from 'antd';
|
||||||
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined } from '@ant-design/icons';
|
import { EditOutlined, FileTextOutlined, ThunderboltOutlined, LockOutlined, DownloadOutlined, SettingOutlined, FundOutlined, SyncOutlined, CheckCircleOutlined, CloseCircleOutlined } 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 } from '../types';
|
import type { Chapter, ChapterUpdate, ApiError, WritingStyle, AnalysisTask } from '../types';
|
||||||
import { cardStyles } from '../components/CardStyles';
|
import { cardStyles } from '../components/CardStyles';
|
||||||
import ChapterAnalysis from '../components/ChapterAnalysis';
|
import ChapterAnalysis from '../components/ChapterAnalysis';
|
||||||
|
|
||||||
@@ -26,6 +26,9 @@ export default function Chapters() {
|
|||||||
const [targetWordCount, setTargetWordCount] = useState<number>(3000);
|
const [targetWordCount, setTargetWordCount] = useState<number>(3000);
|
||||||
const [analysisVisible, setAnalysisVisible] = useState(false);
|
const [analysisVisible, setAnalysisVisible] = useState(false);
|
||||||
const [analysisChapterId, setAnalysisChapterId] = useState<string | null>(null);
|
const [analysisChapterId, setAnalysisChapterId] = useState<string | null>(null);
|
||||||
|
// 分析任务状态管理
|
||||||
|
const [analysisTasksMap, setAnalysisTasksMap] = useState<Record<string, AnalysisTask>>({});
|
||||||
|
const pollingIntervalsRef = useRef<Record<string, number>>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
@@ -46,10 +49,96 @@ export default function Chapters() {
|
|||||||
if (currentProject?.id) {
|
if (currentProject?.id) {
|
||||||
refreshChapters();
|
refreshChapters();
|
||||||
loadWritingStyles();
|
loadWritingStyles();
|
||||||
|
loadAnalysisTasks();
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [currentProject?.id]);
|
}, [currentProject?.id]);
|
||||||
|
|
||||||
|
// 清理轮询定时器
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
Object.values(pollingIntervalsRef.current).forEach(interval => {
|
||||||
|
clearInterval(interval);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 加载所有章节的分析任务状态
|
||||||
|
const loadAnalysisTasks = async () => {
|
||||||
|
if (!chapters || chapters.length === 0) return;
|
||||||
|
|
||||||
|
const tasksMap: Record<string, AnalysisTask> = {};
|
||||||
|
|
||||||
|
for (const chapter of chapters) {
|
||||||
|
// 只查询有内容的章节
|
||||||
|
if (chapter.content && chapter.content.trim() !== '') {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/chapters/${chapter.id}/analysis/status`);
|
||||||
|
if (response.ok) {
|
||||||
|
const task: AnalysisTask = await response.json();
|
||||||
|
tasksMap[chapter.id] = task;
|
||||||
|
|
||||||
|
// 如果任务正在运行,启动轮询
|
||||||
|
if (task.status === 'pending' || task.status === 'running') {
|
||||||
|
startPollingTask(chapter.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// 404或其他错误表示没有分析任务,忽略
|
||||||
|
console.debug(`章节 ${chapter.id} 暂无分析任务`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAnalysisTasksMap(tasksMap);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 启动单个章节的任务轮询
|
||||||
|
const startPollingTask = (chapterId: string) => {
|
||||||
|
// 如果已经在轮询,先清除
|
||||||
|
if (pollingIntervalsRef.current[chapterId]) {
|
||||||
|
clearInterval(pollingIntervalsRef.current[chapterId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const interval = window.setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/chapters/${chapterId}/analysis/status`);
|
||||||
|
if (!response.ok) return;
|
||||||
|
|
||||||
|
const task: AnalysisTask = await response.json();
|
||||||
|
|
||||||
|
setAnalysisTasksMap(prev => ({
|
||||||
|
...prev,
|
||||||
|
[chapterId]: task
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 任务完成或失败,停止轮询
|
||||||
|
if (task.status === 'completed' || task.status === 'failed') {
|
||||||
|
clearInterval(pollingIntervalsRef.current[chapterId]);
|
||||||
|
delete pollingIntervalsRef.current[chapterId];
|
||||||
|
|
||||||
|
if (task.status === 'completed') {
|
||||||
|
message.success(`章节分析完成`);
|
||||||
|
} else if (task.status === 'failed') {
|
||||||
|
message.error(`章节分析失败: ${task.error_message || '未知错误'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('轮询分析任务失败:', error);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
pollingIntervalsRef.current[chapterId] = interval;
|
||||||
|
|
||||||
|
// 5分钟超时
|
||||||
|
setTimeout(() => {
|
||||||
|
if (pollingIntervalsRef.current[chapterId]) {
|
||||||
|
clearInterval(pollingIntervalsRef.current[chapterId]);
|
||||||
|
delete pollingIntervalsRef.current[chapterId];
|
||||||
|
}
|
||||||
|
}, 300000);
|
||||||
|
};
|
||||||
|
|
||||||
const loadWritingStyles = async () => {
|
const loadWritingStyles = async () => {
|
||||||
if (!currentProject?.id) return;
|
if (!currentProject?.id) return;
|
||||||
|
|
||||||
@@ -162,7 +251,7 @@ export default function Chapters() {
|
|||||||
setIsContinuing(true);
|
setIsContinuing(true);
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
|
|
||||||
await generateChapterContentStream(editingId, (content) => {
|
const result = await generateChapterContentStream(editingId, (content) => {
|
||||||
editorForm.setFieldsValue({ content });
|
editorForm.setFieldsValue({ content });
|
||||||
|
|
||||||
if (contentTextAreaRef.current) {
|
if (contentTextAreaRef.current) {
|
||||||
@@ -173,7 +262,24 @@ export default function Chapters() {
|
|||||||
}
|
}
|
||||||
}, selectedStyleId, targetWordCount);
|
}, selectedStyleId, targetWordCount);
|
||||||
|
|
||||||
message.success('AI创作成功');
|
message.success('AI创作成功,正在分析章节内容...');
|
||||||
|
|
||||||
|
// 如果返回了分析任务ID,启动轮询
|
||||||
|
if (result?.analysis_task_id) {
|
||||||
|
const taskId = result.analysis_task_id;
|
||||||
|
setAnalysisTasksMap(prev => ({
|
||||||
|
...prev,
|
||||||
|
[editingId]: {
|
||||||
|
task_id: taskId,
|
||||||
|
chapter_id: editingId,
|
||||||
|
status: 'pending',
|
||||||
|
progress: 0
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 启动轮询
|
||||||
|
startPollingTask(editingId);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const apiError = error as ApiError;
|
const apiError = error as ApiError;
|
||||||
message.error('AI创作失败:' + (apiError.response?.data?.detail || apiError.message || '未知错误'));
|
message.error('AI创作失败:' + (apiError.response?.data?.detail || apiError.message || '未知错误'));
|
||||||
@@ -330,6 +436,46 @@ export default function Chapters() {
|
|||||||
setAnalysisVisible(true);
|
setAnalysisVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 渲染分析状态标签
|
||||||
|
const renderAnalysisStatus = (chapterId: string) => {
|
||||||
|
const task = analysisTasksMap[chapterId];
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (task.status) {
|
||||||
|
case 'pending':
|
||||||
|
return (
|
||||||
|
<Tag icon={<SyncOutlined spin />} color="processing">
|
||||||
|
等待分析
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
case 'running':
|
||||||
|
return (
|
||||||
|
<Tag icon={<SyncOutlined spin />} color="processing">
|
||||||
|
分析中 {task.progress}%
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
case 'completed':
|
||||||
|
return (
|
||||||
|
<Tag icon={<CheckCircleOutlined />} color="success">
|
||||||
|
已分析
|
||||||
|
</Tag>
|
||||||
|
);
|
||||||
|
case 'failed':
|
||||||
|
return (
|
||||||
|
<Tooltip title={task.error_message}>
|
||||||
|
<Tag icon={<CloseCircleOutlined />} color="error">
|
||||||
|
分析失败
|
||||||
|
</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
<div style={{
|
<div style={{
|
||||||
@@ -385,15 +531,30 @@ export default function Chapters() {
|
|||||||
>
|
>
|
||||||
编辑内容
|
编辑内容
|
||||||
</Button>,
|
</Button>,
|
||||||
<Tooltip title={!item.content || item.content.trim() === '' ? '请先生成章节内容' : ''}>
|
(() => {
|
||||||
<Button
|
const task = analysisTasksMap[item.id];
|
||||||
icon={<FundOutlined />}
|
const isAnalyzing = task && (task.status === 'pending' || task.status === 'running');
|
||||||
onClick={() => handleShowAnalysis(item.id)}
|
const hasContent = item.content && item.content.trim() !== '';
|
||||||
disabled={!item.content || item.content.trim() === ''}
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
!hasContent ? '请先生成章节内容' :
|
||||||
|
isAnalyzing ? '分析进行中,请稍候...' :
|
||||||
|
''
|
||||||
|
}
|
||||||
>
|
>
|
||||||
查看分析
|
<Button
|
||||||
|
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
|
||||||
|
onClick={() => handleShowAnalysis(item.id)}
|
||||||
|
disabled={!hasContent || isAnalyzing}
|
||||||
|
loading={isAnalyzing}
|
||||||
|
>
|
||||||
|
{isAnalyzing ? '分析中' : '查看分析'}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>,
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})(),
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<SettingOutlined />}
|
icon={<SettingOutlined />}
|
||||||
@@ -411,6 +572,7 @@ export default function Chapters() {
|
|||||||
<span>第{item.chapter_number}章:{item.title}</span>
|
<span>第{item.chapter_number}章:{item.title}</span>
|
||||||
<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)}
|
||||||
{!canGenerateChapter(item) && (
|
{!canGenerateChapter(item) && (
|
||||||
<Tooltip title={getGenerateDisabledReason(item)}>
|
<Tooltip title={getGenerateDisabledReason(item)}>
|
||||||
<Tag icon={<LockOutlined />} color="warning">
|
<Tag icon={<LockOutlined />} color="warning">
|
||||||
@@ -441,15 +603,30 @@ export default function Chapters() {
|
|||||||
size="small"
|
size="small"
|
||||||
title="编辑内容"
|
title="编辑内容"
|
||||||
/>
|
/>
|
||||||
<Tooltip title={!item.content || item.content.trim() === '' ? '请先生成章节内容' : '查看分析'}>
|
{(() => {
|
||||||
|
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
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<FundOutlined />}
|
icon={isAnalyzing ? <SyncOutlined spin /> : <FundOutlined />}
|
||||||
onClick={() => handleShowAnalysis(item.id)}
|
onClick={() => handleShowAnalysis(item.id)}
|
||||||
size="small"
|
size="small"
|
||||||
disabled={!item.content || item.content.trim() === ''}
|
disabled={!hasContent || isAnalyzing}
|
||||||
|
loading={isAnalyzing}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
icon={<SettingOutlined />}
|
icon={<SettingOutlined />}
|
||||||
@@ -686,6 +863,53 @@ export default function Chapters() {
|
|||||||
visible={analysisVisible}
|
visible={analysisVisible}
|
||||||
onClose={() => {
|
onClose={() => {
|
||||||
setAnalysisVisible(false);
|
setAnalysisVisible(false);
|
||||||
|
|
||||||
|
// 延迟500ms后刷新该章节的分析状态,给后端足够时间完成数据库写入
|
||||||
|
if (analysisChapterId) {
|
||||||
|
const chapterIdToRefresh = analysisChapterId;
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
fetch(`/api/chapters/${chapterIdToRefresh}/analysis/status`)
|
||||||
|
.then(response => {
|
||||||
|
if (response.ok) {
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
throw new Error('获取状态失败');
|
||||||
|
})
|
||||||
|
.then((task: AnalysisTask) => {
|
||||||
|
setAnalysisTasksMap(prev => ({
|
||||||
|
...prev,
|
||||||
|
[chapterIdToRefresh]: task
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 如果任务正在运行,启动轮询
|
||||||
|
if (task.status === 'pending' || task.status === 'running') {
|
||||||
|
startPollingTask(chapterIdToRefresh);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('刷新分析状态失败:', error);
|
||||||
|
// 如果查询失败,再延迟尝试一次
|
||||||
|
setTimeout(() => {
|
||||||
|
fetch(`/api/chapters/${chapterIdToRefresh}/analysis/status`)
|
||||||
|
.then(response => response.ok ? response.json() : null)
|
||||||
|
.then((task: AnalysisTask | null) => {
|
||||||
|
if (task) {
|
||||||
|
setAnalysisTasksMap(prev => ({
|
||||||
|
...prev,
|
||||||
|
[chapterIdToRefresh]: task
|
||||||
|
}));
|
||||||
|
if (task.status === 'pending' || task.status === 'running') {
|
||||||
|
startPollingTask(chapterIdToRefresh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => console.error('第二次刷新失败:', err));
|
||||||
|
}, 1000);
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
setAnalysisChapterId(null);
|
setAnalysisChapterId(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -331,6 +331,7 @@ export function useChapterSync() {
|
|||||||
|
|
||||||
let buffer = '';
|
let buffer = '';
|
||||||
let fullContent = '';
|
let fullContent = '';
|
||||||
|
let analysisTaskId: string | undefined;
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const { done, value } = await reader.read();
|
const { done, value } = await reader.read();
|
||||||
@@ -363,9 +364,13 @@ export function useChapterSync() {
|
|||||||
} else if (message.type === 'error') {
|
} else if (message.type === 'error') {
|
||||||
throw new Error(message.error || '生成失败');
|
throw new Error(message.error || '生成失败');
|
||||||
} else if (message.type === 'done') {
|
} else if (message.type === 'done') {
|
||||||
|
// 生成完成,保存分析任务ID
|
||||||
|
analysisTaskId = message.analysis_task_id;
|
||||||
// 生成完成,刷新章节数据
|
// 生成完成,刷新章节数据
|
||||||
await refreshChapters();
|
await refreshChapters();
|
||||||
return { content: fullContent, word_count: message.word_count };
|
} else if (message.type === 'analysis_queued') {
|
||||||
|
// 分析任务已加入队列
|
||||||
|
analysisTaskId = message.task_id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -374,7 +379,10 @@ export function useChapterSync() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { content: fullContent };
|
return {
|
||||||
|
content: fullContent,
|
||||||
|
analysis_task_id: analysisTaskId
|
||||||
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('AI流式生成章节内容失败:', error);
|
console.error('AI流式生成章节内容失败:', error);
|
||||||
throw error;
|
throw error;
|
||||||
|
|||||||
Reference in New Issue
Block a user