From fba6922a5c7da3d2f109079aca365ca457d6a4fc Mon Sep 17 00:00:00 2001 From: xiamuceer Date: Wed, 31 Dec 2025 12:02:36 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=A4=9A=E4=B8=AA?= =?UTF-8?q?=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JSON解析器字符串状态追踪修复 - AI客户端流式响应异常处理 - 写作风格MultipleResultsFound错误 - 职业stages字段类型处理 - 章节分析任务状态同步 - 后台任务返回值修复 --- backend/app/api/careers.py | 7 +- backend/app/api/chapters.py | 6 +- backend/app/api/outlines.py | 2 +- backend/app/api/wizard_stream.py | 4 +- backend/app/api/writing_styles.py | 12 ++-- .../services/ai_clients/anthropic_client.py | 21 +++++- .../app/services/ai_clients/gemini_client.py | 50 ++++++++++----- .../app/services/ai_clients/openai_client.py | 53 ++++++++++----- backend/app/services/json_helper.py | 64 +++++++++++-------- frontend/src/pages/Chapters.tsx | 18 ++++-- 10 files changed, 155 insertions(+), 82 deletions(-) diff --git a/backend/app/api/careers.py b/backend/app/api/careers.py index a11d4af..b355360 100644 --- a/backend/app/api/careers.py +++ b/backend/app/api/careers.py @@ -483,7 +483,12 @@ async def update_career( for field, value in update_data.items(): if field == "stages" and value is not None: # 转换为JSON字符串 - setattr(career, field, json.dumps([stage.model_dump() for stage in value], ensure_ascii=False)) + # model_dump() 已经将嵌套模型转换为字典,所以 value 中的元素已经是 dict + stages_list = [ + stage if isinstance(stage, dict) else stage.model_dump() + for stage in value + ] + setattr(career, field, json.dumps(stages_list, ensure_ascii=False)) elif field == "attribute_bonuses" and value is not None: # 转换为JSON字符串 setattr(career, field, json.dumps(value, ensure_ascii=False)) diff --git a/backend/app/api/chapters.py b/backend/app/api/chapters.py index 6c040c3..edbda2c 100644 --- a/backend/app/api/chapters.py +++ b/backend/app/api/chapters.py @@ -865,7 +865,7 @@ async def analyze_chapter_background( if not task: logger.error(f"❌ 任务不存在: {task_id}") - return + return False # 更新任务状态(写操作,需要锁) async with write_lock: @@ -886,7 +886,7 @@ async def analyze_chapter_background( task.completed_at = datetime.now() await db_session.commit() logger.error(f"❌ 章节不存在或内容为空: {chapter_id}") - return + return False async with write_lock: task.progress = 20 @@ -908,7 +908,7 @@ async def analyze_chapter_background( task.completed_at = datetime.now() await db_session.commit() logger.error(f"❌ AI分析失败: {chapter_id}") - return + return False async with write_lock: task.progress = 60 diff --git a/backend/app/api/outlines.py b/backend/app/api/outlines.py index 6a922dd..baa24c9 100644 --- a/backend/app/api/outlines.py +++ b/backend/app/api/outlines.py @@ -845,7 +845,7 @@ async def _continue_outline( try: from app.services.auto_character_service import get_auto_character_service - logger.info(f"🔮 【预测模式】在生成大纲前预测是否需要新角色(需要用户确认)") + logger.info(f"🔮 【预测模式】在生成大纲前预测是否需要新角色") # 构建已有章节概览 all_chapters_brief_for_analysis = "" diff --git a/backend/app/api/wizard_stream.py b/backend/app/api/wizard_stream.py index ce11231..2bb851a 100644 --- a/backend/app/api/wizard_stream.py +++ b/backend/app/api/wizard_stream.py @@ -1310,7 +1310,7 @@ async def outline_generator( ]) # 第一阶段:生成3个粗粒度大纲节点 - yield await SSEResponse.send_progress(f"生成{outline_count}个大纲节点...", 20) + yield await SSEResponse.send_progress(f"生成{outline_count}个大纲节点...", 10) outline_requirements = f"{requirements}\n\n【重要说明】这是小说的开局部分,请生成{outline_count}个大纲节点,重点关注:\n" outline_requirements += "1. 引入主要角色和世界观设定\n" @@ -1355,7 +1355,7 @@ async def outline_generator( # 定期更新进度和字数(5-95%,AI生成占90%) if chunk_count % 5 == 0: - progress = min(5 + (chunk_count // 3), 95) + progress = min(10 + (chunk_count // 3), 90) yield await SSEResponse.send_progress( f"生成大纲中... ({len(accumulated_text)}字符)", progress diff --git a/backend/app/api/writing_styles.py b/backend/app/api/writing_styles.py index d275ac7..045b9cd 100644 --- a/backend/app/api/writing_styles.py +++ b/backend/app/api/writing_styles.py @@ -285,11 +285,11 @@ async def get_writing_style( if not style: raise HTTPException(status_code=404, detail="写作风格不存在") - # 检查是否有项目将其设置为默认风格 + # 检查是否有项目将其设置为默认风格(一个风格可能被多个项目使用,使用 first() 避免 MultipleResultsFound) result = await db.execute( select(ProjectDefaultStyle).where(ProjectDefaultStyle.style_id == style_id) ) - is_default = result.scalar_one_or_none() is not None + is_default = result.scalars().first() is not None # 返回包含 is_default 字段的字典 return { @@ -351,11 +351,11 @@ async def update_writing_style( await db.commit() await db.refresh(style) - # 检查是否有项目将其设置为默认风格 + # 检查是否有项目将其设置为默认风格(一个风格可能被多个项目使用,使用 first() 避免 MultipleResultsFound) result = await db.execute( select(ProjectDefaultStyle).where(ProjectDefaultStyle.style_id == style_id) ) - is_default = result.scalar_one_or_none() is not None + is_default = result.scalars().first() is not None # 返回包含 is_default 字段的字典 return { @@ -405,11 +405,11 @@ async def delete_writing_style( if style.user_id != user_id: raise HTTPException(status_code=403, detail="无权删除其他用户的风格") - # 检查是否有项目将其设置为默认风格 + # 检查是否有项目将其设置为默认风格(一个风格可能被多个项目使用,使用 first() 避免 MultipleResultsFound) result = await db.execute( select(ProjectDefaultStyle).where(ProjectDefaultStyle.style_id == style_id) ) - default_relation = result.scalar_one_or_none() + default_relation = result.scalars().first() if default_relation: raise HTTPException( status_code=400, diff --git a/backend/app/services/ai_clients/anthropic_client.py b/backend/app/services/ai_clients/anthropic_client.py index 11c3125..396f234 100644 --- a/backend/app/services/ai_clients/anthropic_client.py +++ b/backend/app/services/ai_clients/anthropic_client.py @@ -81,6 +81,21 @@ class AnthropicClient: if system_prompt: kwargs["system"] = system_prompt - async with self.client.messages.stream(**kwargs) as stream: - async for text in stream.text_stream: - yield text \ No newline at end of file + try: + async with self.client.messages.stream(**kwargs) as stream: + try: + async for text in stream.text_stream: + yield text + except GeneratorExit: + # 生成器被关闭,这是正常的清理过程 + logger.debug("Anthropic 流式响应生成器被关闭(GeneratorExit)") + raise + except Exception as iter_error: + logger.error(f"Anthropic 流式响应迭代出错: {str(iter_error)}") + raise + except GeneratorExit: + # 重新抛出GeneratorExit,让调用方处理 + raise + except Exception as e: + logger.error(f"Anthropic 流式请求出错: {str(e)}") + raise \ No newline at end of file diff --git a/backend/app/services/ai_clients/gemini_client.py b/backend/app/services/ai_clients/gemini_client.py index 33d6cb4..1354767 100644 --- a/backend/app/services/ai_clients/gemini_client.py +++ b/backend/app/services/ai_clients/gemini_client.py @@ -2,6 +2,9 @@ from typing import Any, AsyncGenerator, Dict, List, Optional import httpx from app.services.ai_config import AIClientConfig, default_config +from app.logger import get_logger + +logger = get_logger(__name__) class GeminiClient: @@ -123,19 +126,34 @@ class GeminiClient: if system_prompt: payload["systemInstruction"] = {"parts": [{"text": system_prompt}]} - async with self.client.stream("POST", url, json=payload) as response: - response.raise_for_status() - async for line in response.aiter_lines(): - if line.startswith("data: "): - import json - try: - data = json.loads(line[6:]) - candidates = data.get("candidates", []) - if candidates and len(candidates) > 0: - parts = candidates[0].get("content", {}).get("parts", []) - if parts and len(parts) > 0: - text = parts[0].get("text", "") - if text: - yield text - except: - continue \ No newline at end of file + try: + async with self.client.stream("POST", url, json=payload) as response: + response.raise_for_status() + try: + async for line in response.aiter_lines(): + if line.startswith("data: "): + import json + try: + data = json.loads(line[6:]) + candidates = data.get("candidates", []) + if candidates and len(candidates) > 0: + parts = candidates[0].get("content", {}).get("parts", []) + if parts and len(parts) > 0: + text = parts[0].get("text", "") + if text: + yield text + except json.JSONDecodeError: + continue + except GeneratorExit: + # 生成器被关闭,这是正常的清理过程 + logger.debug("Gemini 流式响应生成器被关闭(GeneratorExit)") + raise + except Exception as iter_error: + logger.error(f"Gemini 流式响应迭代出错: {str(iter_error)}") + raise + except GeneratorExit: + # 重新抛出GeneratorExit,让调用方处理 + raise + except Exception as e: + logger.error(f"Gemini 流式请求出错: {str(e)}") + raise \ No newline at end of file diff --git a/backend/app/services/ai_clients/openai_client.py b/backend/app/services/ai_clients/openai_client.py index c32657d..b1be00d 100644 --- a/backend/app/services/ai_clients/openai_client.py +++ b/backend/app/services/ai_clients/openai_client.py @@ -60,7 +60,13 @@ class OpenAIClient(BaseAIClient): tool_choice: Optional[str] = None, ) -> Dict[str, Any]: payload = self._build_payload(messages, model, temperature, max_tokens, tools, tool_choice) + + logger.debug(f"📤 OpenAI 请求 payload: {json.dumps(payload, ensure_ascii=False, indent=2)}") + data = await self._request_with_retry("POST", "/chat/completions", payload) + + # 调试日志:输出原始响应 + logger.debug(f"📥 OpenAI 原始响应: {json.dumps(data, ensure_ascii=False, indent=2)}") choices = data.get("choices", []) if not choices or len(choices) == 0: @@ -83,19 +89,34 @@ class OpenAIClient(BaseAIClient): ) -> AsyncGenerator[str, None]: payload = self._build_payload(messages, model, temperature, max_tokens, stream=True) - async with await self._request_with_retry("POST", "/chat/completions", payload, stream=True) as response: - response.raise_for_status() - async for line in response.aiter_lines(): - if line.startswith("data: "): - data_str = line[6:] - if data_str.strip() == "[DONE]": - break - try: - data = json.loads(data_str) - choices = data.get("choices", []) - if choices and len(choices) > 0: - content = choices[0].get("delta", {}).get("content", "") - if content: - yield content - except json.JSONDecodeError: - continue \ No newline at end of file + try: + async with await self._request_with_retry("POST", "/chat/completions", payload, stream=True) as response: + response.raise_for_status() + try: + async for line in response.aiter_lines(): + if line.startswith("data: "): + data_str = line[6:] + if data_str.strip() == "[DONE]": + break + try: + data = json.loads(data_str) + choices = data.get("choices", []) + if choices and len(choices) > 0: + content = choices[0].get("delta", {}).get("content", "") + if content: + yield content + except json.JSONDecodeError: + continue + except GeneratorExit: + # 生成器被关闭,这是正常的清理过程 + logger.debug("流式响应生成器被关闭(GeneratorExit)") + raise + except Exception as iter_error: + logger.error(f"流式响应迭代出错: {str(iter_error)}") + raise + except GeneratorExit: + # 重新抛出GeneratorExit,让调用方处理 + raise + except Exception as e: + logger.error(f"流式请求出错: {str(e)}") + raise \ No newline at end of file diff --git a/backend/app/services/json_helper.py b/backend/app/services/json_helper.py index 2662235..01bd2ab 100644 --- a/backend/app/services/json_helper.py +++ b/backend/app/services/json_helper.py @@ -54,37 +54,35 @@ def clean_json_response(text: str) -> str: stack = [] i = 0 end = -1 + in_string = False while i < len(text): c = text[i] - # 处理字符串(关键:正确处理转义) + # 处理字符串状态 if c == '"': - # 计算前面有多少个连续的反斜杠 - num_backslashes = 0 - j = i - 1 - while j >= 0 and text[j] == '\\': - num_backslashes += 1 - j -= 1 + if not in_string: + # 进入字符串 + in_string = True + else: + # 检查是否是转义的引号 + num_backslashes = 0 + j = i - 1 + while j >= 0 and text[j] == '\\': + num_backslashes += 1 + j -= 1 + + # 偶数个反斜杠表示引号未被转义,字符串结束 + if num_backslashes % 2 == 0: + in_string = False - # 偶数个反斜杠(包括0)表示引号未被转义 - if num_backslashes % 2 == 0: - # 这是字符串边界,跳过整个字符串 - i += 1 - while i < len(text): - if text[i] == '"': - # 再次检查转义 - num_backslashes = 0 - j = i - 1 - while j >= 0 and text[j] == '\\': - num_backslashes += 1 - j -= 1 - if num_backslashes % 2 == 0: - # 字符串结束 - break - i += 1 - i += 1 - continue + i += 1 + continue + + # 在字符串内部,跳过所有字符 + if in_string: + i += 1 + continue # 处理括号(只有在字符串外部才有效) if c == '{' or c == '[': @@ -96,8 +94,12 @@ def clean_json_response(text: str) -> str: end = i + 1 logger.debug(f"✅ 找到JSON结束位置: {end}") break + elif len(stack) > 0: + # 括号不匹配,可能是损坏的JSON,尝试继续 + logger.warning(f"⚠️ 括号不匹配:遇到 }} 但栈顶是 {stack[-1]}") else: - logger.warning(f"⚠️ 括号不匹配:遇到 }} 但栈顶是 {stack[-1] if stack else 'empty'}") + # 栈为空遇到 },忽略多余的闭合括号 + logger.warning(f"⚠️ 遇到多余的 }},忽略") elif c == ']': if len(stack) > 0 and stack[-1] == '[': stack.pop() @@ -105,11 +107,19 @@ def clean_json_response(text: str) -> str: end = i + 1 logger.debug(f"✅ 找到JSON结束位置: {end}") break + elif len(stack) > 0: + # 括号不匹配,可能是损坏的JSON,尝试继续 + logger.warning(f"⚠️ 括号不匹配:遇到 ] 但栈顶是 {stack[-1]}") else: - logger.warning(f"⚠️ 括号不匹配:遇到 ] 但栈顶是 {stack[-1] if stack else 'empty'}") + # 栈为空遇到 ],忽略多余的闭合括号 + logger.warning(f"⚠️ 遇到多余的 ],忽略") i += 1 + # 检查未闭合的字符串 + if in_string: + logger.warning(f"⚠️ 字符串未闭合,JSON可能不完整") + # 提取结果 if end > 0: result = text[:end] diff --git a/frontend/src/pages/Chapters.tsx b/frontend/src/pages/Chapters.tsx index ee2dc18..96786bb 100644 --- a/frontend/src/pages/Chapters.tsx +++ b/frontend/src/pages/Chapters.tsx @@ -101,12 +101,14 @@ export default function Chapters() { }, []); // 加载所有章节的分析任务状态 - const loadAnalysisTasks = async () => { - if (!chapters || chapters.length === 0) return; + // 接受可选的 chaptersToLoad 参数,解决 React 状态更新延迟导致的问题 + const loadAnalysisTasks = async (chaptersToLoad?: typeof chapters) => { + const targetChapters = chaptersToLoad || chapters; + if (!targetChapters || targetChapters.length === 0) return; const tasksMap: Record = {}; - for (const chapter of chapters) { + for (const chapter of targetChapters) { // 只查询有内容的章节 if (chapter.content && chapter.content.trim() !== '') { try { @@ -805,9 +807,10 @@ export default function Chapters() { }); // 每次轮询时刷新章节列表和分析状态,实时显示新生成的章节和分析进度 + // 使用 await 确保获取最新章节列表后再加载分析任务状态 if (status.completed > 0) { - refreshChapters(); - loadAnalysisTasks(); + const latestChapters = await refreshChapters(); + await loadAnalysisTasks(latestChapters); // 刷新项目信息以实时更新总字数统计 if (currentProject?.id) { @@ -826,8 +829,9 @@ export default function Chapters() { setBatchGenerating(false); // 立即刷新章节列表和分析任务状态(在显示消息前) - await refreshChapters(); - await loadAnalysisTasks(); + // 使用 refreshChapters 返回的最新章节列表传递给 loadAnalysisTasks + const finalChapters = await refreshChapters(); + await loadAnalysisTasks(finalChapters); // 刷新项目信息以更新总字数统计 if (currentProject?.id) {