fix:优化章节分析并发问题

This commit is contained in:
xiamuceer
2025-11-05 00:11:27 +08:00
parent e62286eab1
commit 7e9781477b
5 changed files with 478 additions and 340 deletions
+63 -41
View File
@@ -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": "分析任务已创建并开始执行"
} }
+23 -19
View File
@@ -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>
}
/> />
)} )}
+2 -122
View File
@@ -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
View File
@@ -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);
}} }}
/> />
+10 -2
View File
@@ -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;