diff --git a/backend/app/api/wizard_stream.py b/backend/app/api/wizard_stream.py index 1100cca..037b466 100644 --- a/backend/app/api/wizard_stream.py +++ b/backend/app/api/wizard_stream.py @@ -95,15 +95,15 @@ async def world_building_generator( 3. 相关领域的专业知识 4. 类似作品的设定参考 -请根据题材特点,有针对性地查询2-3个关键问题。""" +请查询最关键的1个问题(不要超过1个)。""" - # 调用MCP增强的AI(非流式,最多2轮工具调用) + # 调用MCP增强的AI(非流式,最多1轮工具调用,避免超时) planning_result = await user_ai_service.generate_text_with_mcp( prompt=planning_prompt, user_id=user_id, db_session=db, enable_mcp=True, - max_tool_rounds=2, + max_tool_rounds=1, tool_choice="auto", provider=None, model=None @@ -365,15 +365,15 @@ async def characters_generator( 3. 职业特点和生活方式 4. 相关领域的人物原型 -请根据题材特点,有针对性地查询1-2个关键问题。""" +请查询最关键的1个问题(不要超过1个)。""" - # 调用MCP增强的AI(非流式,最多2轮工具调用) + # 调用MCP增强的AI(非流式,最多1轮工具调用,避免超时) planning_result = await user_ai_service.generate_text_with_mcp( prompt=planning_prompt, user_id=user_id, db_session=db, enable_mcp=True, - max_tool_rounds=2, + max_tool_rounds=1, # ✅ 优化: 从2轮减少到1轮 tool_choice="auto", provider=None, model=None diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index 49888ce..656902c 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -73,10 +73,10 @@ def _get_or_create_http_client( client = httpx.AsyncClient( timeout=httpx.Timeout( - connect=60.0, # 连接超时 - read=180.0, # 读取超时 - write=60.0, # 写入超时 - pool=60.0 # 连接池超时 + connect=90.0, # 连接超时 + read=300.0, # 读取超时 + write=90.0, # 写入超时 + pool=90.0 # 连接池超时 ), limits=limits, headers={ @@ -959,7 +959,7 @@ class AIService: else: # 达到最大轮次 - logger.warning(f"达到MCP最大调用轮次 {max_tool_rounds}") + logger.info(f"达到MCP最大调用轮次 {max_tool_rounds}") result["content"] = conversation_history[-1].get("content", "") result["finish_reason"] = "max_rounds" diff --git a/backend/app/services/mcp_tool_service.py b/backend/app/services/mcp_tool_service.py index 49d8f6c..aa7cde6 100644 --- a/backend/app/services/mcp_tool_service.py +++ b/backend/app/services/mcp_tool_service.py @@ -291,16 +291,18 @@ class MCPToolService: user_id: str, tool_calls: List[Dict[str, Any]], db_session: AsyncSession, - timeout: Optional[float] = None + timeout: Optional[float] = None, + max_concurrent: int = 2 ) -> List[Dict[str, Any]]: """ - 批量执行AI请求的工具调用(并行执行) + 批量执行AI请求的工具调用(限制并发数,避免超时) Args: user_id: 用户ID tool_calls: AI返回的工具调用列表 db_session: 数据库会话 timeout: 单个工具调用的超时时间(秒,默认使用配置) + max_concurrent: 最大并发工具调用数(默认2) Returns: 工具调用结果列表 @@ -311,41 +313,54 @@ class MCPToolService: # 使用配置的默认超时 actual_timeout = timeout or mcp_config.TOOL_CALL_TIMEOUT_SECONDS - logger.info(f"开始执行 {len(tool_calls)} 个工具调用 (超时={actual_timeout}s)") + logger.info(f"开始执行 {len(tool_calls)} 个工具调用 (超时={actual_timeout}s, 最大并发={max_concurrent})") - # 创建异步任务列表 - tasks = [ - self._execute_single_tool( - user_id=user_id, - tool_call=tool_call, - db_session=db_session, - timeout=actual_timeout - ) - for tool_call in tool_calls - ] - - # 并行执行所有工具调用 - results = await asyncio.gather(*tasks, return_exceptions=True) - - # 处理结果 - formatted_results = [] - for i, result in enumerate(results): - tool_call = tool_calls[i] + # ✅ 分批执行,每批最多max_concurrent个 + all_results = [] + for i in range(0, len(tool_calls), max_concurrent): + batch = tool_calls[i:i+max_concurrent] + batch_num = i // max_concurrent + 1 + total_batches = (len(tool_calls) + max_concurrent - 1) // max_concurrent - if isinstance(result, Exception): - # 工具调用异常 - formatted_results.append({ - "tool_call_id": tool_call.get("id", f"call_{i}"), - "role": "tool", - "name": tool_call["function"]["name"], - "content": f"工具调用失败: {str(result)}", - "success": False, - "error": str(result) - }) - else: - formatted_results.append(result) + logger.info(f"执行工具批次 {batch_num}/{total_batches}, 数量: {len(batch)}") + + # 创建当前批次的异步任务 + tasks = [ + self._execute_single_tool( + user_id=user_id, + tool_call=tool_call, + db_session=db_session, + timeout=actual_timeout + ) + for tool_call in batch + ] + + # 并行执行当前批次 + batch_results = await asyncio.gather(*tasks, return_exceptions=True) + + # 处理批次结果 + for j, result in enumerate(batch_results): + tool_call = batch[j] + + if isinstance(result, Exception): + # 工具调用异常 + all_results.append({ + "tool_call_id": tool_call.get("id", f"call_{i+j}"), + "role": "tool", + "name": tool_call["function"]["name"], + "content": f"工具调用失败: {str(result)}", + "success": False, + "error": str(result) + }) + else: + all_results.append(result) + + # 批次间增加短暂延迟,避免API限流 + if i + max_concurrent < len(tool_calls): + await asyncio.sleep(0.5) + logger.debug(f"批次间延迟 0.5 秒...") - return formatted_results + return all_results async def _execute_single_tool( self, diff --git a/backend/app/services/prompt_service.py b/backend/app/services/prompt_service.py index e7ecedc..e9a98cc 100644 --- a/backend/app/services/prompt_service.py +++ b/backend/app/services/prompt_service.py @@ -119,27 +119,38 @@ class PromptService: 主题:{theme} 类型:{genre} -# 2. 世界构建框架 -请生成包含以下四个核心板块的世界构建框架。请确保所有板块都围绕【核心概念】展开,并且板块之间【互为因果】。 +# 2. 核心指令(CRITICAL) +* **去标签化**:严禁使用通用的“XX纪元”、“XX时代”、“XX年”作为时间背景的开头或核心描述。请直接描述世界所处的状态、技术水平或生存现状。 +* **动态演绎**:所有设定必须直接由输入的主题衍生而来。例如,如果是赛博朋克,不要只写“高科技”,要写“义体技术如何导致了贫民窟的特定生活方式”。 +* **拒绝陈词滥调**:避免使用宏大的空洞词汇,专注于具体的、可感知的细节。 -1. **时间背景 (time_period)**: - * 具体的时代设定(例如:星际航行晚期、黑铁时代)。 - * 重要的【历史转折事件】(是什么导致了当前的世界面貌?)。 - * 当前的主要【社会矛盾】或【时代议题】。 -2. **地理/空间 (location)**: - * 主要舞台(如城市、星球、位面)的【地理环境特征】。 - * 这些特征如何影响了【文明】的形态和【资源】分布? - * 独特的【空间布局】或【奇观】。 -3. **氛围基调 (atmosphere)**: - * 整体的【情感色彩】(例如:压抑、荒诞、史诗、诡异)。 - * 【视觉风格】(例如:赛博霓虹、蒸汽朋克、哥特式)。 - * 普通居民在日常生活中最常【感受】到什么? -4. **世界规则 (rules)**: - * 【物理法则】或【超自然力量】(如魔法、科技)的【具体运作方式】和【代价】。 - * 【社会规则】和【权力结构】(谁在统治?基于什么?)。 - * 最严重的【社会禁忌】是什么?违反了会怎样? +# 3. 世界构建框架 +请生成包含以下四个核心板块的JSON。请确保所有板块互为因果,逻辑严密。 -# 3. 严格格式要求 +**重要说明:每个字段的value必须是一个完整的文本字符串,将以下所有要点整合成连贯的段落描述,不要使用嵌套的JSON对象或数组。** + +1. **time_period (时间线与文明阶段)**: + 请将以下内容整合为一段完整的文字描述(300-500字): + * 描述当前世界处于什么**发展阶段**(是毁灭边缘、新生萌芽、还是停滞不前?),**不要给这个阶段起名字**,而是描述其**特征**。 + * **历史转折点**:具体的事件(战争、发明、灾难),它如何直接导致了现在的局面? + * **当下的核心矛盾**:时间流逝带来的具体焦虑是什么?(例如:资源枯竭的倒计时、某种信仰的崩塌)。 +2. **location (空间与生态环境)**: + 请将以下内容整合为一段完整的文字描述(300-500字): + * **舞台特征**:描述主要故事发生的地理或空间环境(如:悬浮的破碎岛屿、被真菌覆盖的地铁网络)。 + * **环境与生存**:地理环境如何强迫居民改变了生活方式?(例如:因为引力失衡,建筑都是倒挂的)。 + * **标志性奇观**:一个能代表这个世界独特性的具体场景或建筑。 +3. **atmosphere (感官体验与基调)**: + 请将以下内容整合为一段完整的文字描述(300-500字): + * **感官细节**:如果站在这个世界的街头,会**闻**到什么?**听**到什么?(不要只写"压抑",要写"空气中弥漫着铁锈和合成营养膏的酸味")。 + * **视觉美学**:描述具体的色彩倾向和光影质感。 + * **居民心态**:普通人普遍的心理状态(是麻木、狂热、还是某种特定的恐惧)。 +4. **rules (运作逻辑与禁忌)**: + 请将以下内容整合为一段完整的文字描述(300-500字): + * **核心法则**:这个世界运行的底层逻辑(物理、魔法或科技)。**重点描述代价**(使用力量需要支付什么?)。 + * **权力架构**:谁掌握资源?他们通过什么手段维持控制(暴力、技术垄断、宗教洗脑)? + * **红线禁忌**:这个社会绝对不能触碰的具体底线,以及违反后的直接后果。 + +# 4. 严格格式要求 1. **绝对纯净JSON**:你的[唯一]输出必须是一个完整的JSON对象。输出必须以左花括号开始,并以右花括号结束。 2. **禁止额外字符**:不要在JSON对象之前或之后包含任何说明文字、Markdown标记(如三个反引号加json)、注释或任何其他非JSON字符。 3. **JSON内部文本规则**:在JSON的value字符串内部: @@ -147,13 +158,14 @@ class PromptService: * 所有【专有名词】(如地点、人物、组织)应使用【】包裹。 * 所有《作品》或《特殊概念》的标题应使用《》包裹。 4. **JSON结构**:严格遵守`"key": "value"`的英文双引号结构,并使用下面指定的key。 -5. **内容密度**:每个字段的描述都必须【深入且详实】,提供至少5-7个具体的设定点或细节。 +5. **内容密度**:每个字段的描述都必须【深入且详实】,提供至少5-7个具体的设定点或细节,整合为连贯的段落文本。 +6. **禁止嵌套结构**:value必须是纯文本字符串,绝对不能是JSON对象或数组,所有信息都要整合在一个字符串中。 {{ - "time_period": "(此处填写时间背景的详细描述)", - "location": "(此处填写地理/空间的详细描述)", - "atmosphere": "(此处填写氛围基调的详细描述)", - "rules": "(此处填写世界规则的详细描述)" + "time_period": "(此处填写一段完整的文字描述,包含发展阶段特征、历史转折点、核心矛盾等内容,300-500字)", + "location": "(此处填写一段完整的文字描述,包含舞台特征、环境与生存、标志性奇观等内容,300-500字)", + "atmosphere": "(此处填写一段完整的文字描述,包含感官细节、视觉美学、居民心态等内容,300-500字)", + "rules": "(此处填写一段完整的文字描述,包含核心法则、权力架构、红线禁忌等内容,300-500字)" }}""" # 批量角色生成提示词 diff --git a/frontend/INSTALLATION.md b/frontend/INSTALLATION.md deleted file mode 100644 index 1633dd2..0000000 --- a/frontend/INSTALLATION.md +++ /dev/null @@ -1,156 +0,0 @@ -# 安装和测试指南 - -## 1. 安装前端依赖 - -在完成所有代码更改后,需要安装新添加的npm包: - -```bash -cd frontend -npm install -``` - -这将安装以下新依赖: -- `react-diff-viewer-continued`: 用于版本对比的diff查看器 - -## 2. 重启后端服务 - -由于修改了数据模型,需要重启后端服务以加载新的模型定义: - -```bash -# 在项目根目录 -cd backend -python -m uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 -``` - -## 3. 启动前端开发服务器 - -```bash -cd frontend -npm run dev -``` - -## 4. 测试功能流程 - -### 4.1 基本流程测试 - -1. **打开章节列表** - - 进入任意项目 - - 查看章节列表 - -2. **分析章节** - - 点击某个章节的"分析"按钮 - - 等待AI分析完成 - - 查看分析结果和改进建议 - -3. **重新生成章节** - - 在分析结果页面,点击"根据建议重新生成" - - 选择要应用的建议 - - 可以添加自定义修改要求 - - 配置生成参数(字数、保留元素等) - - 勾选"保存为版本历史"(不勾选自动应用) - - 点击"开始重新生成" - - 观察流式生成过程 - -4. **查看版本对比** - - 生成完成后,点击"查看版本对比"按钮 - - 进入版本管理器界面 - -5. **版本管理操作** - - **版本列表**: 查看所有历史版本 - - **预览版本**: 点击"预览"查看某个版本的完整内容 - - **对比版本**: - - 点击第一个版本的"对比"按钮 - - 再点击第二个版本的"对比"按钮 - - 自动切换到"对比"标签页 - - 查看并排diff对比 - - **恢复版本**: 点击"恢复"将章节内容还原到该版本 - - **删除版本**: 删除不需要的历史版本(当前激活版本不能删除) - -### 4.2 测试场景 - -#### 场景1:优化情感描写 -1. 分析一个章节 -2. 查看建议中关于情感的建议 -3. 重新生成时选择情感相关建议 -4. 设置重点优化方向为"情感渲染" -5. 生成后对比新旧版本的差异 - -#### 场景2:调整节奏 -1. 选择节奏问题的建议 -2. 添加自定义指令:"加快前半部分节奏,增强紧张感" -3. 设置目标字数适当减少(如从3000减到2500) -4. 生成后查看结构变化 - -#### 场景3:版本管理 -1. 对同一章节重新生成多次(使用不同建议) -2. 在版本管理器中浏览所有版本 -3. 对比不同版本的差异 -4. 选择最满意的版本恢复 - -## 5. 验证清单 - -- [ ] 依赖安装无错误 -- [ ] 前后端服务正常启动 -- [ ] 章节分析功能正常 -- [ ] 重新生成功能正常 -- [ ] 流式生成显示正常 -- [ ] 版本保存成功 -- [ ] 版本列表显示正确 -- [ ] 版本预览功能正常 -- [ ] 版本对比diff显示正确 -- [ ] 版本恢复功能正常 -- [ ] 版本删除功能正常 -- [ ] 移动端适配正常 - -## 6. 常见问题 - -### Q1: 依赖安装失败 -```bash -# 清除缓存重试 -npm cache clean --force -npm install -``` - -### Q2: 后端启动报错 -- 检查是否运行了数据库迁移脚本 -- 确认模型定义与数据库表结构一致 - -### Q3: 版本对比不显示 -- 检查浏览器控制台是否有JavaScript错误 -- 确认react-diff-viewer-continued正确安装 - -### Q4: 重新生成后看不到新内容 -- 检查是否勾选了"自动应用" -- 查看版本管理器中是否有新版本记录 - -## 7. 性能优化建议 - -1. **首次加载优化** - - react-diff-viewer-continued是较大的依赖 - - 可以考虑代码分割(lazy loading) - -2. **版本列表优化** - - 如果版本过多,考虑分页加载 - - 添加版本数量限制提示 - -3. **diff计算优化** - - 对于超长文本,可以限制diff行数 - - 添加加载提示 - -## 8. 下一步优化方向 - -1. **AI质量评分对比** - - 在版本对比时显示质量分数变化 - - 自动标注改进/退步的指标 - -2. **批量操作** - - 支持批量删除历史版本 - - 支持版本导出/导入 - -3. **协作功能** - - 版本评论和讨论 - - 多人协作编辑 - -4. **智能推荐** - - 基于历史生成结果推荐最佳配置 - - 学习用户偏好自动调整参数 \ No newline at end of file diff --git a/frontend/src/components/AnnotatedText.tsx b/frontend/src/components/AnnotatedText.tsx index 979e880..a6a8a39 100644 --- a/frontend/src/components/AnnotatedText.tsx +++ b/frontend/src/components/AnnotatedText.tsx @@ -24,6 +24,7 @@ interface TextSegment { type: 'text' | 'annotated'; content: string; annotation?: MemoryAnnotation; + annotations?: MemoryAnnotation[]; // 🔧 支持多个标注 } interface AnnotatedTextProps { @@ -108,30 +109,89 @@ const AnnotatedText: React.FC = ({ const result: TextSegment[] = []; let lastPos = 0; + // 🔧 智能分组:检测重叠和相邻的标注 + const annotationRanges: Array<{ + start: number; + end: number; + annotations: MemoryAnnotation[]; + }> = []; + for (const annotation of processedAnnotations) { const { position, length } = annotation; - - // 添加普通文本片段 - if (position > lastPos) { + const actualLength = length > 0 ? length : 30; + const start = position; + const end = position + actualLength; + + // 查找是否有重叠或紧邻的范围 + const overlappingRange = annotationRanges.find( + (range) => + (start >= range.start && start <= range.end) || // 起始点在范围内 + (end >= range.start && end <= range.end) || // 结束点在范围内 + (start <= range.start && end >= range.end) || // 完全包含 + Math.abs(start - range.end) <= 5 || // 紧邻(容差5字符) + Math.abs(end - range.start) <= 5 + ); + + if (overlappingRange) { + // 合并到现有范围 + overlappingRange.start = Math.min(overlappingRange.start, start); + overlappingRange.end = Math.max(overlappingRange.end, end); + overlappingRange.annotations.push(annotation); + } else { + // 创建新范围 + annotationRanges.push({ + start, + end, + annotations: [annotation], + }); + } + } + + // 按起始位置排序 + annotationRanges.sort((a, b) => a.start - b.start); + + // 🔧 智能分片:将重叠区域分成多个小片段 + for (const range of annotationRanges) { + // 添加前面的普通文本 + if (range.start > lastPos) { result.push({ type: 'text', - content: content.slice(lastPos, position), + content: content.slice(lastPos, range.start), }); } - // 添加标注片段 - const annotatedContent = content.slice( - position, - position + (length > 0 ? length : 30) // 如果没有长度,默认30字符 - ); - - result.push({ - type: 'annotated', - content: annotatedContent, - annotation, - }); + if (range.annotations.length === 1) { + // 单个标注,直接添加 + result.push({ + type: 'annotated', + content: content.slice(range.start, range.end), + annotation: range.annotations[0], + annotations: range.annotations, + }); + } else { + // 🔧 多个标注:将文本分成多个小片段 + const totalLength = range.end - range.start; + const segmentLength = Math.max(1, Math.floor(totalLength / range.annotations.length)); - lastPos = position + (length > 0 ? length : 30); + // 按重要性排序标注 + const sortedAnnotations = [...range.annotations].sort((a, b) => b.importance - a.importance); + + for (let i = 0; i < sortedAnnotations.length; i++) { + const segmentStart = range.start + i * segmentLength; + const segmentEnd = i === sortedAnnotations.length - 1 + ? range.end + : range.start + (i + 1) * segmentLength; + + result.push({ + type: 'annotated', + content: content.slice(segmentStart, segmentEnd), + annotation: sortedAnnotations[i], + annotations: sortedAnnotations, // 保留所有标注信息 + }); + } + } + + lastPos = range.end; } // 添加剩余文本 @@ -142,6 +202,7 @@ const AnnotatedText: React.FC = ({ }); } + console.log(`AnnotatedText: 处理${processedAnnotations.length}个标注,生成${result.length}个片段`); return result; }, [content, processedAnnotations]); @@ -151,43 +212,73 @@ const AnnotatedText: React.FC = ({ return {segment.content}; } - const { annotation } = segment; + const { annotation, annotations } = segment; if (!annotation) return null; const color = TYPE_COLORS[annotation.type]; const icon = TYPE_ICONS[annotation.type]; const isActive = activeAnnotationId === annotation.id; - // 工具提示内容 + // 🔧 工具提示内容:如果有多个标注,显示所有标注信息 const tooltipContent = ( -
-
- {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} - +
+ {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} + + ))} +
+ )} +
)}
); diff --git a/frontend/src/components/ChapterAnalysis.tsx b/frontend/src/components/ChapterAnalysis.tsx index 37faa60..32337a7 100644 --- a/frontend/src/components/ChapterAnalysis.tsx +++ b/frontend/src/components/ChapterAnalysis.tsx @@ -57,12 +57,9 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter }; }, [visible, chapterId]); - const fetchAnalysisStatus = async () => { + // 🔧 新增:独立的章节信息加载函数 + const loadChapterInfo = async () => { try { - setLoading(true); - setError(null); - - // 同时获取章节信息 const chapterResponse = await fetch(`/api/chapters/${chapterId}`); if (chapterResponse.ok) { const chapterData = await chapterResponse.json(); @@ -71,7 +68,20 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter chapter_number: chapterData.chapter_number, content: chapterData.content || '' }); + console.log('✅ 已刷新章节内容,字数:', chapterData.content?.length || 0); } + } catch (error) { + console.error('❌ 加载章节信息失败:', error); + } + }; + + const fetchAnalysisStatus = async () => { + try { + setLoading(true); + setError(null); + + // 🔧 使用独立的章节加载函数 + await loadChapterInfo(); const response = await fetch(`/api/chapters/${chapterId}/analysis/status`); @@ -134,6 +144,8 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter if (taskData.status === 'completed') { clearInterval(pollInterval); await fetchAnalysisResult(); + // 🔧 分析完成后刷新章节内容,确保显示最新内容 + await loadChapterInfo(); } else if (taskData.status === 'failed') { clearInterval(pollInterval); setError(taskData.error_message || '分析失败'); @@ -152,6 +164,9 @@ export default function ChapterAnalysis({ chapterId, visible, onClose }: Chapter setLoading(true); setError(null); + // 🔧 触发分析前先刷新章节内容,确保分析的是最新内容 + await loadChapterInfo(); + const response = await fetch(`/api/chapters/${chapterId}/analyze`, { method: 'POST' });