diff --git a/backend/.env.example b/backend/.env.example index 0f50deb..5380f46 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,7 +8,7 @@ # 应用配置 # ========================================== APP_NAME=MuMuAINovel -APP_VERSION=1.2.3 +APP_VERSION=1.2.4 APP_HOST=0.0.0.0 APP_PORT=8000 DEBUG=false diff --git a/backend/app/api/inspiration.py b/backend/app/api/inspiration.py index 456d674..ad42c39 100644 --- a/backend/app/api/inspiration.py +++ b/backend/app/api/inspiration.py @@ -7,7 +7,7 @@ import json from app.database import get_db from app.services.ai_service import AIService from app.api.settings import get_user_ai_service -from app.services.prompt_service import prompt_service, PromptService +from app.services.prompt_service import PromptService from app.logger import get_logger router = APIRouter(prefix="/inspiration", tags=["灵感模式"]) @@ -441,17 +441,13 @@ async def quick_generate( existing_text = "\n".join(existing_info) if existing_info else "暂无信息" # 获取自定义提示词模板 - prompt_template_str = await PromptService.get_template("INSPIRATION_QUICK_COMPLETE", user_id, db) + system_template = await PromptService.get_template("INSPIRATION_QUICK_COMPLETE", user_id, db) # 格式化提示词 - try: - prompts = json.loads(prompt_template_str) - # 格式化参数 - prompts["system"] = prompts["system"].replace("{existing}", existing_text) - prompts["user"] = prompts["user"].replace("{existing}", existing_text) - except (json.JSONDecodeError, KeyError): - # 降级使用原有方法 - prompts = prompt_service.get_inspiration_quick_complete_prompt(existing=existing_text) + prompts = { + "system": PromptService.format_prompt(system_template, existing=existing_text), + "user": "请补全小说信息" + } # 调用AI - 流式生成并累积文本 accumulated_text = "" diff --git a/backend/app/api/outlines.py b/backend/app/api/outlines.py index baa24c9..799ddb1 100644 --- a/backend/app/api/outlines.py +++ b/backend/app/api/outlines.py @@ -1052,26 +1052,49 @@ async def _continue_outline( mcp_references=mcp_reference_materials ) - # 调用AI生成当前批次 + # 调用AI生成当前批次(带重试机制) logger.info(f"正在调用AI流式生成第{batch_num + 1}批...") - accumulated_text = "" - chunk_count = 0 - async for chunk in user_ai_service.generate_text_stream( - prompt=prompt, - provider=request.provider, - model=request.model - ): - chunk_count += 1 - accumulated_text += chunk + max_retries = 2 + retry_count = 0 + outline_data = None + + while retry_count <= max_retries: + accumulated_text = "" + chunk_count = 0 - # 这里是非SSE接口,不需要发送chunk - - ai_content = accumulated_text - ai_response = {"content": ai_content} - - # 解析响应 - outline_data = _parse_ai_response(ai_content) + # 第一次使用原始prompt,重试时添加格式强调 + current_prompt = prompt if retry_count == 0 else ( + prompt + "\n\n【重要提醒】请确保返回完整的JSON数组,不要截断。每个章节对象必须包含完整的title、summary等字段。" + ) + + async for chunk in user_ai_service.generate_text_stream( + prompt=current_prompt, + provider=request.provider, + model=request.model + ): + chunk_count += 1 + accumulated_text += chunk + + # 这里是非SSE接口,不需要发送chunk + + ai_content = accumulated_text + ai_response = {"content": ai_content} + + # 解析响应 + try: + outline_data = _parse_ai_response(ai_content, raise_on_error=True) + break # 解析成功,跳出循环 + + except JSONParseError as e: + retry_count += 1 + if retry_count > max_retries: + # 超过最大重试次数,使用fallback数据 + logger.error(f"❌ 第{batch_num + 1}批解析失败,已达最大重试次数({max_retries}),使用fallback数据") + outline_data = _parse_ai_response(ai_content, raise_on_error=False) + break + + logger.warning(f"⚠️ 第{batch_num + 1}批JSON解析失败(第{retry_count}次),正在重试...") # 保存当前批次的大纲 batch_outlines = await _save_outlines( @@ -1111,8 +1134,27 @@ async def _continue_outline( return OutlineListResponse(total=len(all_outlines), items=all_outlines) -def _parse_ai_response(ai_response: str) -> list: - """解析AI响应为章节数据列表(使用统一的JSON清洗方法)""" +class JSONParseError(Exception): + """JSON解析失败异常,用于触发重试""" + def __init__(self, message: str, original_content: str = ""): + super().__init__(message) + self.original_content = original_content + + +def _parse_ai_response(ai_response: str, raise_on_error: bool = False) -> list: + """ + 解析AI响应为章节数据列表(使用统一的JSON清洗方法) + + Args: + ai_response: AI返回的原始文本 + raise_on_error: 如果为True,解析失败时抛出异常而不是返回fallback数据 + + Returns: + 解析后的章节数据列表 + + Raises: + JSONParseError: 当raise_on_error=True且解析失败时抛出 + """ try: # 使用统一的JSON清洗方法(从AIService导入) from app.services.ai_service import AIService @@ -1129,19 +1171,49 @@ def _parse_ai_response(ai_response: str) -> list: else: outline_data = [outline_data] - logger.info(f"✅ 成功解析 {len(outline_data)} 个章节数据") - return outline_data + # 验证解析结果是否有效(至少有一个有效章节) + valid_chapters = [ + ch for ch in outline_data + if isinstance(ch, dict) and (ch.get("title") or ch.get("summary") or ch.get("content")) + ] + + if not valid_chapters: + error_msg = "解析结果无效:未找到有效的章节数据" + logger.error(f"❌ {error_msg}") + if raise_on_error: + raise JSONParseError(error_msg, ai_response) + return [{ + "title": "AI生成的大纲", + "content": ai_response[:1000], + "summary": ai_response[:1000] + }] + + logger.info(f"✅ 成功解析 {len(valid_chapters)} 个章节数据") + return valid_chapters except json.JSONDecodeError as e: + error_msg = f"JSON解析失败: {e}" logger.error(f"❌ AI响应解析失败: {e}") + + if raise_on_error: + raise JSONParseError(error_msg, ai_response) + # 返回一个包含原始内容的章节 return [{ "title": "AI生成的大纲", "content": ai_response[:1000], "summary": ai_response[:1000] }] + except JSONParseError: + # 重新抛出JSONParseError + raise except Exception as e: - logger.error(f"❌ 解析异常: {str(e)}") + error_msg = f"解析异常: {str(e)}" + logger.error(f"❌ {error_msg}") + + if raise_on_error: + raise JSONParseError(error_msg, ai_response) + return [{ "title": "解析异常的大纲", "content": "系统错误", @@ -1389,8 +1461,60 @@ async def new_outline_generator( ai_content = accumulated_text ai_response = {"content": ai_content} - # 解析响应 - outline_data = _parse_ai_response(ai_content) + # 解析响应(带重试机制) + max_retries = 2 + retry_count = 0 + outline_data = None + + while retry_count <= max_retries: + try: + # 使用 raise_on_error=True,解析失败时抛出异常 + outline_data = _parse_ai_response(ai_content, raise_on_error=True) + break # 解析成功,跳出循环 + + except JSONParseError as e: + retry_count += 1 + if retry_count > max_retries: + # 超过最大重试次数,使用fallback数据 + logger.error(f"❌ 大纲解析失败,已达最大重试次数({max_retries}),使用fallback数据") + yield await SSEResponse.send_progress( + f"⚠️ 解析失败,使用备用数据", + 96.5 + ) + outline_data = _parse_ai_response(ai_content, raise_on_error=False) + break + + logger.warning(f"⚠️ JSON解析失败(第{retry_count}次),正在重试...") + yield await SSEResponse.send_progress( + f"⚠️ 解析失败,正在重试({retry_count}/{max_retries})...", + 96 + ) + + # 重新调用AI生成 + accumulated_text = "" + chunk_count = 0 + + # 在prompt中添加格式强调 + retry_prompt = prompt + "\n\n【重要提醒】请确保返回完整的JSON数组,不要截断。每个章节对象必须包含完整的title、summary等字段。" + + async for chunk in user_ai_service.generate_text_stream( + prompt=retry_prompt, + provider=provider_param, + model=model_param + ): + chunk_count += 1 + accumulated_text += chunk + + # 发送内容块 + yield await SSEResponse.send_chunk(chunk) + + # 每20个块发送心跳 + if chunk_count % 20 == 0: + yield await SSEResponse.send_heartbeat() + + ai_content = accumulated_text + ai_response = {"content": ai_content} + logger.info(f"🔄 重试生成完成,累计{len(ai_content)}字符") # 全新生成模式:删除旧大纲和关联的所有章节 yield await SSEResponse.send_progress("清理旧大纲和章节...", 97) @@ -1919,8 +2043,60 @@ async def continue_outline_generator( ai_content = accumulated_text ai_response = {"content": ai_content} - # 解析响应 - outline_data = _parse_ai_response(ai_content) + # 解析响应(带重试机制) + max_retries = 2 + retry_count = 0 + outline_data = None + + while retry_count <= max_retries: + try: + # 使用 raise_on_error=True,解析失败时抛出异常 + outline_data = _parse_ai_response(ai_content, raise_on_error=True) + break # 解析成功,跳出循环 + + except JSONParseError as e: + retry_count += 1 + if retry_count > max_retries: + # 超过最大重试次数,使用fallback数据 + logger.error(f"❌ 第{batch_num + 1}批解析失败,已达最大重试次数({max_retries}),使用fallback数据") + yield await SSEResponse.send_progress( + f"⚠️ 第{str(batch_num + 1)}批解析失败,使用备用数据", + batch_progress + 11 + ) + outline_data = _parse_ai_response(ai_content, raise_on_error=False) + break + + logger.warning(f"⚠️ 第{batch_num + 1}批JSON解析失败(第{retry_count}次),正在重试...") + yield await SSEResponse.send_progress( + f"⚠️ 第{str(batch_num + 1)}批解析失败,正在重试({retry_count}/{max_retries})...", + batch_progress + 10.5 + ) + + # 重新调用AI生成 + accumulated_text = "" + chunk_count = 0 + + # 在prompt中添加格式强调 + retry_prompt = prompt + "\n\n【重要提醒】请确保返回完整的JSON数组,不要截断。每个章节对象必须包含完整的title、summary等字段。" + + async for chunk in user_ai_service.generate_text_stream( + prompt=retry_prompt, + provider=provider_param, + model=model_param + ): + chunk_count += 1 + accumulated_text += chunk + + # 发送内容块 + yield await SSEResponse.send_chunk(chunk) + + # 每20个块发送心跳 + if chunk_count % 20 == 0: + yield await SSEResponse.send_heartbeat() + + ai_content = accumulated_text + ai_response = {"content": ai_content} + logger.info(f"🔄 第{batch_num + 1}批重试生成完成,累计{len(ai_content)}字符") # 保存当前批次的大纲 batch_outlines = await _save_outlines( diff --git a/backend/app/services/chapter_regenerator.py b/backend/app/services/chapter_regenerator.py index 6bdd20f..3d0a299 100644 --- a/backend/app/services/chapter_regenerator.py +++ b/backend/app/services/chapter_regenerator.py @@ -71,12 +71,24 @@ class ChapterRegenerator: logger.info(f"🎯 提示词构建完成,开始AI生成") yield {'type': 'progress', 'progress': 15, 'message': '开始AI生成内容...'} - # 3. 流式生成新内容,同时跟踪进度 + # 3. 构建系统提示词(注入写作风格) + system_prompt_with_style = None + if style_content: + system_prompt_with_style = f"""【🎨 写作风格要求 - 最高优先级】 + +{style_content} + +⚠️ 请严格遵循上述写作风格要求进行重写,这是最重要的指令! +确保在整个章节重写过程中始终保持风格的一致性。""" + logger.info(f"✅ 已将写作风格注入系统提示词({len(style_content)}字符)") + + # 4. 流式生成新内容,同时跟踪进度 target_word_count = regenerate_request.target_word_count accumulated_length = 0 async for chunk in self.ai_service.generate_text_stream( prompt=full_prompt, + system_prompt=system_prompt_with_style, temperature=0.7 ): # 发送内容块 diff --git a/frontend/package.json b/frontend/package.json index 4f2ae72..af79f31 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "1.2.3", + "version": "1.2.4", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/public/99.png b/frontend/public/99.png new file mode 100644 index 0000000..f299be2 Binary files /dev/null and b/frontend/public/99.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2900cf6..c0ae24e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -50,7 +50,7 @@ function App() { } /> } /> }> - } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/AnnotatedText.tsx b/frontend/src/components/AnnotatedText.tsx index a6a8a39..8d7ada3 100644 --- a/frontend/src/components/AnnotatedText.tsx +++ b/frontend/src/components/AnnotatedText.tsx @@ -1,5 +1,4 @@ import React, { useMemo, useEffect, useRef } from 'react'; -import { Tooltip } from 'antd'; // 标注数据类型 export interface MemoryAnnotation { @@ -219,113 +218,54 @@ const AnnotatedText: React.FC = ({ const icon = TYPE_ICONS[annotation.type]; const isActive = activeAnnotationId === annotation.id; - // 🔧 工具提示内容:如果有多个标注,显示所有标注信息 - const tooltipContent = ( -
- {annotations && annotations.length > 1 ? ( - // 多个标注 -
-
- 📍 此处有 {annotations.length} 个标注 -
- {annotations.map((ann, idx) => ( -
-
- {TYPE_ICONS[ann.type]} {ann.title} -
-
- {ann.content.slice(0, 80)} - {ann.content.length > 80 ? '...' : ''} -
-
- 重要性: {(ann.importance * 10).toFixed(1)}/10 -
-
- ))} -
- ) : ( - // 单个标注 -
-
- {icon} {annotation.title} -
-
- {annotation.content.slice(0, 100)} - {annotation.content.length > 100 ? '...' : ''} -
-
- 重要性: {(annotation.importance * 10).toFixed(1)}/10 -
- {annotation.tags && annotation.tags.length > 0 && ( -
- {annotation.tags.map((tag, i) => ( - - {tag} - - ))} -
- )} -
- )} -
- ); + // 简化工具提示内容,不再使用复杂的React元素,改为纯文本或移除Tooltip + const tooltipText = annotations && annotations.length > 1 + ? `此处有 ${annotations.length} 个标注` + : `${annotation.title}: ${annotation.content.slice(0, 100)}${annotation.content.length > 100 ? '...' : ''}`; return ( - + { + if (annotation) { + annotationRefs.current[annotation.id] = el; + } + }} + data-annotation-id={annotation?.id} + className={`annotated-text ${isActive ? 'active' : ''}`} + style={{ + position: 'relative', + borderBottom: `2px solid ${color}`, + cursor: 'pointer', + backgroundColor: isActive ? `${color}22` : 'transparent', + transition: 'all 0.2s', + padding: '2px 0', + }} + onClick={() => onAnnotationClick?.(annotation)} + onMouseEnter={(e) => { + e.currentTarget.style.backgroundColor = `${color}33`; + }} + onMouseLeave={(e) => { + e.currentTarget.style.backgroundColor = isActive + ? `${color}22` + : 'transparent'; + }} + > + {segment.content} { - if (annotation) { - annotationRefs.current[annotation.id] = el; - } - }} - data-annotation-id={annotation?.id} - className={`annotated-text ${isActive ? 'active' : ''}`} style={{ - position: 'relative', - borderBottom: `2px solid ${color}`, - cursor: 'pointer', - backgroundColor: isActive ? `${color}22` : 'transparent', - transition: 'all 0.2s', - padding: '2px 0', - }} - onClick={() => onAnnotationClick?.(annotation)} - onMouseEnter={(e) => { - e.currentTarget.style.backgroundColor = `${color}33`; - }} - onMouseLeave={(e) => { - e.currentTarget.style.backgroundColor = isActive - ? `${color}22` - : 'transparent'; + position: 'absolute', + top: -20, + left: '50%', + transform: 'translateX(-50%)', + fontSize: 14, + pointerEvents: 'none', }} > - {segment.content} - - {icon} - + {icon} - + ); }; diff --git a/frontend/src/components/AppFooter.tsx b/frontend/src/components/AppFooter.tsx index f8b1dcb..f3bda37 100644 --- a/frontend/src/components/AppFooter.tsx +++ b/frontend/src/components/AppFooter.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from 'react'; -import { Typography, Space, Divider, Badge, Tooltip, Button } from 'antd'; +import { Typography, Space, Divider, Badge, Button } from 'antd'; import { GithubOutlined, CopyrightOutlined, HeartFilled, ClockCircleOutlined, GiftOutlined } from '@ant-design/icons'; import { VERSION_INFO, getVersionString } from '../config/version'; import { checkLatestVersion } from '../services/versionService'; @@ -70,22 +70,21 @@ export default function AppFooter() { flexWrap: 'wrap' }}> - - - {VERSION_INFO.projectName} - {getVersionString()} - - + + {VERSION_INFO.projectName} + {getVersionString()} + + + + 第{chapter.chapter_number}章:{chapter.title} + + + + +
+
{chapter.word_count || 0} 字
+ {navigation && ( +
+ {navigation.previous ? `← ${navigation.previous.title}` : '已是第一章'} + {' | '} + {navigation.next ? `${navigation.next.title} →` : '已是最后一章'} +
+ )} +
+ + + + + ); +} \ No newline at end of file diff --git a/frontend/src/pages/MCPPlugins.tsx b/frontend/src/pages/MCPPlugins.tsx index 56a6a9b..b1348c8 100644 --- a/frontend/src/pages/MCPPlugins.tsx +++ b/frontend/src/pages/MCPPlugins.tsx @@ -12,7 +12,6 @@ import { Select, message, Tag, - Tooltip, Spin, Empty, Alert, @@ -307,9 +306,7 @@ export default function MCPPluginsPage() { return }>运行中; case 'error': return ( - - }>错误 - + } title={plugin.last_error}>错误 ); default: return 未激活; @@ -553,50 +550,45 @@ export default function MCPPluginsPage() { - - handleToggle(plugin, checked)} - size={isMobile ? 'small' : 'default'} - style={{ - flexShrink: 0, - height: isMobile ? 16 : 22, - minHeight: isMobile ? 16 : 22, - lineHeight: isMobile ? '16px' : '22px' - }} - /> - - - {outlines.length > 0 && currentProject?.outline_mode === 'one-to-many' && ( - - - + )} @@ -1965,16 +1964,16 @@ export default function Outline() { }} actions={isMobile ? undefined : [ ...(currentProject?.outline_mode === 'one-to-many' ? [ - - - + ] : []), // 一对一模式:不显示任何展开/创建按钮 ), - - - , + , - + - - - + - - - + {!record.is_admin && ( @@ -398,16 +391,14 @@ export default function UserManagement() { cancelText="取消" okButtonProps={{ danger: true }} > - - - + )} diff --git a/frontend/src/pages/WritingStyles.tsx b/frontend/src/pages/WritingStyles.tsx index 1372ef5..5652f67 100644 --- a/frontend/src/pages/WritingStyles.tsx +++ b/frontend/src/pages/WritingStyles.tsx @@ -12,8 +12,7 @@ import { Empty, Typography, Row, - Col, - Tooltip + Col } from 'antd'; import { PlusOutlined, @@ -232,28 +231,26 @@ export default function WritingStyles() { padding: '16px', }} actions={[ - - !style.is_default && handleSetDefault(style.id)} - style={{ cursor: style.is_default ? 'default' : 'pointer' }} - > - {style.is_default ? ( - - ) : ( - - )} - - , - - style.user_id !== null && handleEdit(style)} - style={{ - fontSize: 18, - cursor: style.user_id === null ? 'not-allowed' : 'pointer', - color: style.user_id === null ? '#ccc' : undefined - }} - /> - , + !style.is_default && handleSetDefault(style.id)} + style={{ cursor: style.is_default ? 'default' : 'pointer' }} + > + {style.is_default ? ( + + ) : ( + + )} + , + style.user_id !== null && handleEdit(style)} + style={{ + fontSize: 18, + cursor: style.user_id === null ? 'not-allowed' : 'pointer', + color: style.user_id === null ? '#ccc' : undefined + }} + />, - - - + , ]} >