From 88115a45c5b6e46e1e783f9d8cf8c4d9c020d253 Mon Sep 17 00:00:00 2001 From: xiamuceer Date: Fri, 7 Nov 2025 22:14:20 +0800 Subject: [PATCH] =?UTF-8?q?update:1.=E6=9B=B4=E6=96=B0mcp=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=8A=9F=E8=83=BD=EF=BC=8C=E7=9B=AE=E5=89=8D=E5=8F=AA?= =?UTF-8?q?=E6=94=AF=E6=8C=81remote=E8=B0=83=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/chapters.py | 66 +- backend/app/api/characters.py | 37 +- backend/app/api/mcp_plugins.py | 862 +++++++++++++++++++++++ backend/app/api/organizations.py | 1 + backend/app/api/outlines.py | 278 +++++++- backend/app/api/settings.py | 7 +- backend/app/api/wizard_stream.py | 251 ++++++- backend/app/main.py | 10 +- backend/app/mcp/__init__.py | 4 + backend/app/mcp/http_client.py | 345 +++++++++ backend/app/mcp/registry.py | 349 +++++++++ backend/app/models/__init__.py | 37 +- backend/app/models/mcp_plugin.py | 52 ++ backend/app/schemas/chapter.py | 2 + backend/app/schemas/character.py | 1 + backend/app/schemas/mcp_plugin.py | 105 +++ backend/app/schemas/outline.py | 1 + backend/app/services/ai_service.py | 418 ++++++++++- backend/app/services/mcp_tool_service.py | 355 ++++++++++ backend/app/services/plot_analyzer.py | 16 +- backend/app/services/prompt_service.py | 66 +- frontend/src/App.tsx | 2 + frontend/src/pages/MCPPlugins.tsx | 708 +++++++++++++++++++ frontend/src/pages/ProjectList.tsx | 118 ++-- frontend/src/services/api.ts | 54 ++ frontend/src/types/index.ts | 81 +++ 26 files changed, 4088 insertions(+), 138 deletions(-) create mode 100644 backend/app/api/mcp_plugins.py create mode 100644 backend/app/mcp/__init__.py create mode 100644 backend/app/mcp/http_client.py create mode 100644 backend/app/mcp/registry.py create mode 100644 backend/app/models/mcp_plugin.py create mode 100644 backend/app/schemas/mcp_plugin.py create mode 100644 backend/app/services/mcp_tool_service.py create mode 100644 frontend/src/pages/MCPPlugins.tsx diff --git a/backend/app/api/chapters.py b/backend/app/api/chapters.py index f192a29..ffd399c 100644 --- a/backend/app/api/chapters.py +++ b/backend/app/api/chapters.py @@ -823,12 +823,14 @@ async def generate_chapter_content_stream( 请求体参数: - style_id: 可选,指定使用的写作风格ID。不提供则不使用任何风格 - target_word_count: 可选,目标字数,默认3000字,范围500-10000字 + - enable_mcp: 可选,是否启用MCP工具增强,默认True 注意:此函数不使用依赖注入的db,而是在生成器内部创建独立的数据库会话 以避免流式响应期间的连接泄漏问题 """ style_id = generate_request.style_id target_word_count = generate_request.target_word_count or 3000 + enable_mcp = generate_request.enable_mcp if hasattr(generate_request, 'enable_mcp') else True # 预先验证章节存在性(使用临时会话) async for temp_db in get_db(request): try: @@ -1002,7 +1004,60 @@ async def generate_chapter_content_stream( # 发送开始事件 yield f"data: {json.dumps({'type': 'start', 'message': '开始AI创作...'}, ensure_ascii=False)}\n\n" - # 根据是否有前置内容选择不同的提示词,并应用写作风格和记忆增强 + # 🔧 MCP工具增强:收集章节参考资料 + mcp_reference_materials = "" + if enable_mcp and current_user_id: + try: + yield f"data: {json.dumps({'type': 'progress', 'message': '🔍 尝试使用MCP工具收集参考资料...', 'progress': 28}, ensure_ascii=False)}\n\n" + + # 构建资料收集提示词 + planning_prompt = f"""你正在为小说《{project.title}》创作第{current_chapter.chapter_number}章《{current_chapter.title}》。 + +【章节大纲】 +{outline.content if outline else current_chapter.summary or '暂无大纲'} + +【小说信息】 +- 题材:{project.genre or '未设定'} +- 主题:{project.theme or '未设定'} +- 时代背景:{project.world_time_period or '未设定'} +- 地理位置:{project.world_location or '未设定'} + +【任务】 +请使用可用工具搜索相关背景资料,帮助创作更真实、更有深度的章节内容。 +你可以查询: +1. 该章节涉及的历史事件或时代背景 +2. 地理环境和场景描写参考 +3. 相关领域的专业知识(如武术、科技、魔法等) +4. 文化习俗和生活细节 + +请根据章节内容,有针对性地查询1-2个最关键的问题。""" + + # 调用MCP增强的AI(非流式,最多2轮工具调用) + planning_result = await user_ai_service.generate_text_with_mcp( + prompt=planning_prompt, + user_id=current_user_id, + db_session=db_session, + enable_mcp=True, + max_tool_rounds=2, + tool_choice="auto", + provider=None, + model=None + ) + + # 提取参考资料 + if planning_result.get("tool_calls_made", 0) > 0: + tool_count = planning_result["tool_calls_made"] + yield f"data: {json.dumps({'type': 'progress', 'message': f'✅ MCP工具调用成功({tool_count}次)', 'progress': 32}, ensure_ascii=False)}\n\n" + mcp_reference_materials = planning_result.get("content", "") + logger.info(f"📚 MCP工具收集参考资料:{len(mcp_reference_materials)} 字符") + else: + yield f"data: {json.dumps({'type': 'progress', 'message': 'ℹ️ 未使用MCP工具(无可用工具或不需要)', 'progress': 32}, ensure_ascii=False)}\n\n" + + except Exception as e: + logger.warning(f"MCP工具调用失败(降级处理): {e}") + yield f"data: {json.dumps({'type': 'progress', 'message': '⚠️ MCP工具暂时不可用,使用基础模式', 'progress': 32}, ensure_ascii=False)}\n\n" + + # 根据是否有前置内容选择不同的提示词,并应用写作风格、记忆增强和MCP参考资料 if previous_content: prompt = prompt_service.get_chapter_generation_with_context_prompt( title=project.title, @@ -1021,7 +1076,8 @@ async def generate_chapter_content_stream( chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲', style_content=style_content, target_word_count=target_word_count, - memory_context=memory_context + memory_context=memory_context, + mcp_references=mcp_reference_materials ) else: prompt = prompt_service.get_chapter_generation_prompt( @@ -1040,9 +1096,13 @@ async def generate_chapter_content_stream( chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲', style_content=style_content, target_word_count=target_word_count, - memory_context=memory_context + memory_context=memory_context, + mcp_references=mcp_reference_materials ) + if mcp_reference_materials: + logger.info(f"📖 已整合MCP参考资料({len(mcp_reference_materials)}字符)到章节生成提示词") + logger.info(f"开始AI流式创作章节 {chapter_id}") # 流式生成内容 diff --git a/backend/app/api/characters.py b/backend/app/api/characters.py index 1893635..85f6446 100644 --- a/backend/app/api/characters.py +++ b/backend/app/api/characters.py @@ -1,5 +1,5 @@ """角色管理API""" -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, func import json @@ -221,6 +221,7 @@ async def delete_character( @router.post("/generate", response_model=CharacterResponse, summary="AI生成角色") async def generate_character( request: CharacterGenerateRequest, + http_request: Request, db: AsyncSession = Depends(get_db), user_ai_service: AIService = Depends(get_user_ai_service) ): @@ -294,18 +295,42 @@ async def generate_character( user_input=user_input ) - # 调用AI生成角色 - logger.info(f"🎯 开始为项目 {request.project_id} 生成角色") + # 获取user_id用于MCP工具调用 + user_id = http_request.state.user_id if hasattr(http_request.state, 'user_id') else 'default_user' + + # 调用AI生成角色(支持MCP工具) + logger.info(f"🎯 开始为项目 {request.project_id} 生成角色(启用MCP)") logger.info(f" - 角色名:{request.name or 'AI生成'}") logger.info(f" - 角色定位:{request.role_type}") logger.info(f" - 背景设定:{request.background or '无'}") logger.info(f" - AI提供商:{user_ai_service.api_provider}") logger.info(f" - AI模型:{user_ai_service.default_model}") logger.info(f" - Prompt长度:{len(prompt)} 字符") + logger.info(f" - 用户ID:{user_id}") try: - ai_response = await user_ai_service.generate_text(prompt=prompt) - logger.info(f"✅ AI响应接收完成,长度:{len(ai_response) if ai_response else 0} 字符") + # 使用支持MCP的生成方法 + result = await user_ai_service.generate_text_with_mcp( + prompt=prompt, + user_id=user_id, + db_session=db, + enable_mcp=True, + max_tool_rounds=2, + tool_choice="auto", + provider=None, # 使用AIService初始化时的配置 + model=None # 使用AIService初始化时的配置 + ) + + # 提取内容 + if isinstance(result, dict): + ai_response = result.get('content', '') + logger.info(f"✅ AI响应接收完成(MCP增强),长度:{len(ai_response)} 字符") + if result.get('tool_calls'): + logger.info(f" - 工具调用:{len(result['tool_calls'])} 次") + else: + ai_response = result + logger.info(f"✅ AI响应接收完成,长度:{len(ai_response) if ai_response else 0} 字符") + except Exception as ai_error: logger.error(f"❌ AI服务调用异常:{str(ai_error)}") raise HTTPException( @@ -559,7 +584,7 @@ async def generate_character( history = GenerationHistory( project_id=request.project_id, prompt=prompt, - generated_content=ai_response, + generated_content=json.dumps(result, ensure_ascii=False) if isinstance(result, dict) else ai_response, model=user_ai_service.default_model ) db.add(history) diff --git a/backend/app/api/mcp_plugins.py b/backend/app/api/mcp_plugins.py new file mode 100644 index 0000000..f9f463d --- /dev/null +++ b/backend/app/api/mcp_plugins.py @@ -0,0 +1,862 @@ +"""MCP插件管理API""" +from fastapi import APIRouter, HTTPException, Depends, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from typing import List, Optional +from datetime import datetime + +from app.database import get_db +from app.models.mcp_plugin import MCPPlugin +from app.schemas.mcp_plugin import ( + MCPPluginCreate, + MCPPluginSimpleCreate, + MCPPluginUpdate, + MCPPluginResponse, + MCPToolCall, + MCPTestResult +) +import json +from app.user_manager import User +from app.mcp.registry import mcp_registry +from app.logger import get_logger +from app.services.ai_service import create_user_ai_service +from app.models.settings import Settings as UserSettings + +logger = get_logger(__name__) + +router = APIRouter(prefix="/mcp/plugins", tags=["MCP插件管理"]) + + +def require_login(request: Request) -> User: + """依赖:要求用户已登录""" + if not hasattr(request.state, "user") or not request.state.user: + raise HTTPException(status_code=401, detail="需要登录") + return request.state.user + + +@router.get("", response_model=List[MCPPluginResponse]) +async def list_plugins( + enabled_only: bool = Query(False, description="只返回启用的插件"), + category: Optional[str] = Query(None, description="按分类筛选"), + user: User = Depends(require_login), + db: AsyncSession = Depends(get_db) +): + """ + 获取用户的所有MCP插件 + """ + query = select(MCPPlugin).where(MCPPlugin.user_id == user.user_id) + + if enabled_only: + query = query.where(MCPPlugin.enabled == True) + + if category: + query = query.where(MCPPlugin.category == category) + + query = query.order_by(MCPPlugin.sort_order, MCPPlugin.created_at) + + result = await db.execute(query) + plugins = result.scalars().all() + + logger.info(f"用户 {user.user_id} 查询插件列表,共 {len(plugins)} 个") + return plugins + + +@router.post("", response_model=MCPPluginResponse) +async def create_plugin( + data: MCPPluginCreate, + user: User = Depends(require_login), + db: AsyncSession = Depends(get_db) +): + """ + 创建新的MCP插件 + """ + # 检查插件名是否已存在 + result = await db.execute( + select(MCPPlugin).where( + MCPPlugin.user_id == user.user_id, + MCPPlugin.plugin_name == data.plugin_name + ) + ) + existing = result.scalar_one_or_none() + + if existing: + raise HTTPException(status_code=400, detail=f"插件名已存在: {data.plugin_name}") + + # 创建插件数据 + plugin_data = data.model_dump() + + # 如果没有提供display_name,使用plugin_name作为默认值 + if not plugin_data.get("display_name"): + plugin_data["display_name"] = plugin_data["plugin_name"] + + # 创建插件 + plugin = MCPPlugin( + user_id=user.user_id, + **plugin_data + ) + + db.add(plugin) + await db.commit() + await db.refresh(plugin) + + # 如果启用,加载到注册表 + if plugin.enabled: + success = await mcp_registry.load_plugin(plugin) + if success: + plugin.status = "active" + else: + plugin.status = "error" + plugin.last_error = "加载失败" + await db.commit() + await db.refresh(plugin) + + logger.info(f"用户 {user.user_id} 创建插件: {plugin.plugin_name}") + return plugin + + +@router.post("/simple", response_model=MCPPluginResponse) +async def create_plugin_simple( + data: MCPPluginSimpleCreate, + user: User = Depends(require_login), + db: AsyncSession = Depends(get_db) +): + """ + 通过标准MCP配置JSON创建或更新插件(简化版) + + 接受格式: + { + "config_json": '{"mcpServers": {"exa": {"type": "http", "url": "...", "headers": {}}}}', + "category": "search" + } + + 自动从mcpServers中提取插件名称(取第一个键) + 如果插件已存在,则更新;否则创建新插件 + """ + try: + # 解析配置JSON + config = json.loads(data.config_json) + + # 验证格式 + if "mcpServers" not in config: + raise HTTPException(status_code=400, detail="配置JSON必须包含mcpServers字段") + + servers = config["mcpServers"] + if not servers or len(servers) == 0: + raise HTTPException(status_code=400, detail="mcpServers不能为空") + + # 自动提取第一个插件名称 + plugin_name = list(servers.keys())[0] + server_config = servers[plugin_name] + + logger.info(f"从配置中提取插件名称: {plugin_name}") + + # 提取配置 + server_type = server_config.get("type", "http") + + if server_type not in ["http", "stdio"]: + raise HTTPException(status_code=400, detail=f"不支持的服务器类型: {server_type}") + + # 检查插件名是否已存在 + result = await db.execute( + select(MCPPlugin).where( + MCPPlugin.user_id == user.user_id, + MCPPlugin.plugin_name == plugin_name + ) + ) + existing = result.scalar_one_or_none() + + # 构建插件数据 + plugin_data = { + "plugin_name": plugin_name, + "display_name": plugin_name, + "plugin_type": server_type, + "enabled": data.enabled, + "category": data.category, + "sort_order": 0 + } + + if server_type == "http": + plugin_data["server_url"] = server_config.get("url") + plugin_data["headers"] = server_config.get("headers", {}) + + if not plugin_data["server_url"]: + raise HTTPException(status_code=400, detail="HTTP类型插件必须提供url字段") + + elif server_type == "stdio": + plugin_data["command"] = server_config.get("command") + plugin_data["args"] = server_config.get("args", []) + plugin_data["env"] = server_config.get("env", {}) + + if not plugin_data["command"]: + raise HTTPException(status_code=400, detail="Stdio类型插件必须提供command字段") + + if existing: + # 更新现有插件 + logger.info(f"插件 {plugin_name} 已存在,执行更新操作") + + # 先卸载旧插件 + if existing.enabled: + await mcp_registry.unload_plugin(user.user_id, existing.plugin_name) + + # 更新字段 + for key, value in plugin_data.items(): + setattr(existing, key, value) + + plugin = existing + await db.commit() + await db.refresh(plugin) + + # 如果启用,重新加载 + if plugin.enabled: + success = await mcp_registry.load_plugin(plugin) + if success: + plugin.status = "active" + plugin.last_error = None + else: + plugin.status = "error" + plugin.last_error = "加载失败" + await db.commit() + await db.refresh(plugin) + + logger.info(f"用户 {user.user_id} 更新插件: {plugin_name}") + else: + # 创建新插件 + plugin = MCPPlugin( + user_id=user.user_id, + **plugin_data + ) + + db.add(plugin) + await db.commit() + await db.refresh(plugin) + + # 如果启用,加载到注册表 + if plugin.enabled: + success = await mcp_registry.load_plugin(plugin) + if success: + plugin.status = "active" + else: + plugin.status = "error" + plugin.last_error = "加载失败" + await db.commit() + await db.refresh(plugin) + + logger.info(f"用户 {user.user_id} 通过简化配置创建插件: {plugin_name}") + + return plugin + + except json.JSONDecodeError as e: + raise HTTPException(status_code=400, detail=f"配置JSON格式错误: {str(e)}") + except HTTPException: + raise + except Exception as e: + logger.error(f"创建插件失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"创建插件失败: {str(e)}") + + +@router.get("/{plugin_id}", response_model=MCPPluginResponse) +async def get_plugin( + plugin_id: str, + user: User = Depends(require_login), + db: AsyncSession = Depends(get_db) +): + """ + 获取插件详情 + """ + result = await db.execute( + select(MCPPlugin).where( + MCPPlugin.id == plugin_id, + MCPPlugin.user_id == user.user_id + ) + ) + plugin = result.scalar_one_or_none() + + if not plugin: + raise HTTPException(status_code=404, detail="插件不存在") + + return plugin + + +@router.put("/{plugin_id}", response_model=MCPPluginResponse) +async def update_plugin( + plugin_id: str, + data: MCPPluginUpdate, + user: User = Depends(require_login), + db: AsyncSession = Depends(get_db) +): + """ + 更新插件配置 + """ + result = await db.execute( + select(MCPPlugin).where( + MCPPlugin.id == plugin_id, + MCPPlugin.user_id == user.user_id + ) + ) + plugin = result.scalar_one_or_none() + + if not plugin: + raise HTTPException(status_code=404, detail="插件不存在") + + # 更新字段 + update_data = data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(plugin, key, value) + + await db.commit() + await db.refresh(plugin) + + # 如果插件已启用,重新加载 + if plugin.enabled: + await mcp_registry.reload_plugin(plugin) + + logger.info(f"用户 {user.user_id} 更新插件: {plugin.plugin_name}") + return plugin + + +@router.delete("/{plugin_id}") +async def delete_plugin( + plugin_id: str, + user: User = Depends(require_login), + db: AsyncSession = Depends(get_db) +): + """ + 删除插件 + """ + result = await db.execute( + select(MCPPlugin).where( + MCPPlugin.id == plugin_id, + MCPPlugin.user_id == user.user_id + ) + ) + plugin = result.scalar_one_or_none() + + if not plugin: + raise HTTPException(status_code=404, detail="插件不存在") + + # 从注册表卸载 + await mcp_registry.unload_plugin(user.user_id, plugin.plugin_name) + + # 删除数据库记录 + await db.delete(plugin) + await db.commit() + + logger.info(f"用户 {user.user_id} 删除插件: {plugin.plugin_name}") + return {"message": "插件已删除", "plugin_name": plugin.plugin_name} + + +@router.post("/{plugin_id}/toggle", response_model=MCPPluginResponse) +async def toggle_plugin( + plugin_id: str, + enabled: bool = Query(..., description="启用或禁用"), + user: User = Depends(require_login), + db: AsyncSession = Depends(get_db) +): + """ + 启用或禁用插件 + """ + result = await db.execute( + select(MCPPlugin).where( + MCPPlugin.id == plugin_id, + MCPPlugin.user_id == user.user_id + ) + ) + plugin = result.scalar_one_or_none() + + if not plugin: + raise HTTPException(status_code=404, detail="插件不存在") + + plugin.enabled = enabled + + if enabled: + # 启用:加载到注册表 + success = await mcp_registry.load_plugin(plugin) + if success: + plugin.status = "active" + plugin.last_error = None + else: + plugin.status = "error" + plugin.last_error = "加载失败" + else: + # 禁用:从注册表卸载 + await mcp_registry.unload_plugin(user.user_id, plugin.plugin_name) + plugin.status = "inactive" + + await db.commit() + await db.refresh(plugin) + + action = "启用" if enabled else "禁用" + logger.info(f"用户 {user.user_id} {action}插件: {plugin.plugin_name}") + return plugin + + +@router.post("/{plugin_id}/test", response_model=MCPTestResult) +async def test_plugin( + plugin_id: str, + user: User = Depends(require_login), + db: AsyncSession = Depends(get_db) +): + """ + 测试插件连接并调用工具验证功能 + + 测试流程: + 1. 测试MCP服务器连接 + 2. 获取工具列表 + 3. 自动选择一个工具进行实际调用测试 + 4. 返回完整测试结果 + """ + import time + + result = await db.execute( + select(MCPPlugin).where( + MCPPlugin.id == plugin_id, + MCPPlugin.user_id == user.user_id + ) + ) + plugin = result.scalar_one_or_none() + + if not plugin: + raise HTTPException(status_code=404, detail="插件不存在") + + if not plugin.enabled: + return MCPTestResult( + success=False, + message="插件未启用", + error="请先启用插件", + suggestions=["点击开关按钮启用插件"] + ) + + start_time = time.time() + + try: + # 1. 确保插件已加载 + if not mcp_registry.get_client(user.user_id, plugin.plugin_name): + success = await mcp_registry.load_plugin(plugin) + if not success: + return MCPTestResult( + success=False, + message="插件加载失败", + error="无法创建MCP客户端", + suggestions=["请检查插件配置", "请确认服务器URL正确"] + ) + + # 2. 测试连接并获取工具列表 + test_result = await mcp_registry.test_plugin(user.user_id, plugin.plugin_name) + + if not test_result["success"]: + plugin.status = "error" + plugin.last_error = test_result.get("error", "连接测试失败") + plugin.last_test_at = datetime.now() + await db.commit() + return MCPTestResult(**test_result) + + tools = test_result.get("tools", []) + + if not tools: + plugin.status = "error" + plugin.last_error = "插件没有提供任何工具" + plugin.last_test_at = datetime.now() + await db.commit() + + return MCPTestResult( + success=False, + message="插件没有提供任何工具", + error="工具列表为空", + response_time_ms=test_result.get("response_time_ms"), + suggestions=["请检查插件配置", "请确认MCP服务器正常运行"] + ) + + # 3. 使用AI智能选择工具并生成测试参数 + logger.info(f"使用AI分析工具并生成测试计划...") + + # 获取用户的AI设置 + settings_result = await db.execute( + select(UserSettings).where(UserSettings.user_id == user.user_id) + ) + user_settings = settings_result.scalar_one_or_none() + + if not user_settings or not user_settings.api_key: + # 如果没有AI配置,回退到简单测试 + logger.warning("用户未配置AI服务,使用简单连接测试") + plugin.status = "active" + plugin.last_error = None + plugin.last_test_at = datetime.now() + plugin.tools = tools + await db.commit() + + return MCPTestResult( + success=True, + message=f"✅ 连接测试成功(未配置AI,跳过工具调用测试)", + response_time_ms=test_result.get("response_time_ms"), + tools_count=len(tools), + suggestions=[ + f"连接测试: 成功", + f"可用工具数: {len(tools)}", + "提示: 配置AI服务后可进行智能工具调用测试" + ] + ) + + # 使用AI的标准Function Calling机制选择工具 + ai_service = create_user_ai_service( + api_provider=user_settings.api_provider, + api_key=user_settings.api_key, + api_base_url=user_settings.api_base_url, + model_name=user_settings.llm_model, + temperature=0.3, + max_tokens=1000 + ) + + # 将MCP工具格式转换为OpenAI Function Calling格式 + openai_tools = [] + for tool in tools: + openai_tool = { + "type": "function", + "function": { + "name": tool["name"], + "description": tool.get("description", ""), + } + } + # 将 inputSchema 转换为 parameters + if "inputSchema" in tool: + openai_tool["function"]["parameters"] = tool["inputSchema"] + + openai_tools.append(openai_tool) + + logger.info(f"转换了 {len(openai_tools)} 个MCP工具为OpenAI格式") + logger.info(f"工具列表: {[t['function']['name'] for t in openai_tools]}") + + # 使用标准的Function Calling,将转换后的工具传递给AI + prompt = f"""你是MCP插件测试助手,需要测试插件 '{plugin.plugin_name}' 的功能。 + +请选择一个合适的工具进行测试,优先选择搜索、查询类工具。 +生成真实有效的测试参数(例如搜索"人工智能最新进展"而不是"test")。 + +现在开始测试这个插件。""" + + system_prompt = "你是专业的API测试工具。当给定工具列表时,选择一个工具并使用合适的参数调用它。" + + # 调用AI的Function Calling + logger.info(f"📞 准备调用AI Function Calling") + logger.info(f" - Provider: {user_settings.api_provider}") + logger.info(f" - Model: {user_settings.llm_model}") + logger.info(f" - Tools count: {len(openai_tools)}") + logger.debug(f" - Tools: {json.dumps(openai_tools, ensure_ascii=False, indent=2)}") + + ai_response = await ai_service.generate_text( + prompt=prompt, + system_prompt=system_prompt, + tools=openai_tools, # 传递转换后的OpenAI格式工具 + tool_choice="required" # 要求AI必须选择一个工具 + ) + + logger.info(f"📥 收到AI响应") + logger.info(f" - Response keys: {list(ai_response.keys())}") + logger.debug(f" - Full response: {json.dumps(ai_response, ensure_ascii=False, indent=2)}") + + # 检查AI是否请求调用工具 + if not ai_response.get("tool_calls"): + # AI未调用工具,记录详细信息 + logger.error(f"❌ AI未返回工具调用") + logger.error(f" - Response: {ai_response}") + logger.error(f" - Content: {ai_response.get('content', 'N/A')}") + logger.error(f" - Finish reason: {ai_response.get('finish_reason', 'N/A')}") + + plugin.status = "error" + plugin.last_error = "AI未返回工具调用请求" + plugin.last_test_at = datetime.now() + await db.commit() + + return MCPTestResult( + success=False, + message="❌ AI Function Calling失败", + error=f"AI未返回工具调用请求。响应: {ai_response.get('content', 'N/A')[:200]}", + tools_count=len(tools), + suggestions=[ + "请确认使用的AI模型支持Function Calling", + "OpenAI: 需要gpt-4, gpt-3.5-turbo等模型", + "Anthropic: 需要claude-3系列模型", + f"当前Provider: {user_settings.api_provider}", + f"当前模型: {user_settings.llm_model}", + f"AI返回内容: {ai_response.get('content', 'N/A')[:100]}" + ] + ) + + # 获取第一个工具调用 + tool_call = ai_response["tool_calls"][0] + function = tool_call["function"] + tool_name = function["name"] + test_arguments = function["arguments"] + + # AI返回的arguments可能是JSON字符串,需要解析 + if isinstance(test_arguments, str): + try: + test_arguments = json.loads(test_arguments) + logger.info(f"✅ 解析AI返回的JSON字符串参数") + except json.JSONDecodeError as e: + logger.error(f"❌ 解析AI参数失败: {e}") + return MCPTestResult( + success=False, + message="❌ AI返回的参数格式错误", + error=f"无法解析参数JSON: {str(e)}", + tools_count=len(tools), + suggestions=["AI返回的参数不是有效的JSON格式"] + ) + + logger.info(f"🤖 AI通过Function Calling选择的工具: {tool_name}") + logger.info(f"📝 AI生成的参数: {test_arguments}") + logger.info(f"📝 参数类型: {type(test_arguments).__name__}") + + # 4. 使用AI选择的工具和参数调用MCP工具 + call_start = time.time() + try: + tool_result = await mcp_registry.call_tool( + user.user_id, + plugin.plugin_name, + tool_name, + test_arguments + ) + + call_end = time.time() + call_time = round((call_end - call_start) * 1000, 2) + total_time = round((call_end - start_time) * 1000, 2) + + # 6. 测试成功,更新插件状态 + plugin.status = "active" + plugin.last_error = None + plugin.last_test_at = datetime.now() + plugin.tools = tools # 缓存工具列表 + await db.commit() + + # 格式化工具结果用于显示 + result_str = str(tool_result) + + # 如果结果太长,截取前800字符 + if len(result_str) > 800: + result_preview = result_str[:800] + "\n...(结果已截断,完整结果请查看日志)" + else: + result_preview = result_str + + return MCPTestResult( + success=True, + message=f"✅ Function Calling测试成功!工具 '{tool_name}' 调用正常", + response_time_ms=total_time, + tools_count=len(tools), + suggestions=[ + f"🤖 AI (Function Calling) 选择: {tool_name}", + f"📝 AI生成的参数: {json.dumps(test_arguments, ensure_ascii=False)}", + f"⏱️ 调用耗时: {call_time}ms", + f"📊 返回结果:\n{result_preview}" + ] + ) + + except Exception as call_error: + call_end = time.time() + total_time = round((call_end - start_time) * 1000, 2) + + logger.warning(f"工具调用失败: {tool_name}, 错误: {call_error}") + + # 工具调用失败,但连接成功 + plugin.status = "active" # 仍标记为active,因为连接是成功的 + plugin.last_error = f"工具调用测试失败: {str(call_error)}" + plugin.last_test_at = datetime.now() + plugin.tools = tools + await db.commit() + + return MCPTestResult( + success=True, # 连接成功就算测试通过 + message=f"⚠️ 连接成功,但工具调用失败", + response_time_ms=total_time, + tools_count=len(tools), + error=f"工具 '{tool_name}' 调用失败: {str(call_error)}", + suggestions=[ + f"✅ 连接测试: 成功", + f"❌ 工具调用测试: 失败", + f"🤖 AI (Function Calling) 选择: {tool_name}", + f"📝 AI生成的参数: {json.dumps(test_arguments, ensure_ascii=False)}", + f"❌ 错误: {str(call_error)}", + "💡 可能原因: API Key无效、参数错误或服务限制" + ] + ) + + except Exception as e: + end_time = time.time() + total_time = round((end_time - start_time) * 1000, 2) + + logger.error(f"测试插件失败: {plugin.plugin_name}, 错误: {e}") + + plugin.status = "error" + plugin.last_error = str(e) + plugin.last_test_at = datetime.now() + await db.commit() + + return MCPTestResult( + success=False, + message="❌ 测试失败", + response_time_ms=total_time, + error=str(e), + error_type=type(e).__name__, + suggestions=["请检查服务器是否在线", "请确认配置正确", "请检查API Key是否有效"] + ) + + +def _build_test_arguments(tool_name: str, input_schema: dict, plugin_name: str) -> dict: + """ + 根据工具schema智能构造测试参数 + + Args: + tool_name: 工具名称 + input_schema: 输入schema + plugin_name: 插件名称 + + Returns: + 测试参数字典 + """ + # 针对常见MCP工具的默认测试参数 + test_cases = { + # Exa搜索工具 + "search": { + "query": "AI technology", + "num_results": 3 + }, + "search_and_contents": { + "query": "artificial intelligence", + "num_results": 2 + }, + # Brave搜索 + "brave_web_search": { + "query": "AI news", + "count": 3 + }, + # Filesystem工具 + "read_file": { + "path": "README.md" + }, + "list_directory": { + "path": "." + }, + } + + # 如果有针对特定工具的测试用例,使用它 + if tool_name in test_cases: + logger.info(f"使用预定义测试参数: {test_cases[tool_name]}") + return test_cases[tool_name] + + # 否则根据schema自动构造 + properties = input_schema.get("properties", {}) + required = input_schema.get("required", []) + + test_args = {} + + for prop_name, prop_schema in properties.items(): + # 只填充必需的参数 + if prop_name not in required: + continue + + prop_type = prop_schema.get("type", "string") + + # 根据参数名称和类型猜测合适的测试值 + if "query" in prop_name.lower() or "search" in prop_name.lower(): + test_args[prop_name] = "test query" + elif "url" in prop_name.lower(): + test_args[prop_name] = "https://example.com" + elif "path" in prop_name.lower(): + test_args[prop_name] = "." + elif "count" in prop_name.lower() or "limit" in prop_name.lower() or "num" in prop_name.lower(): + test_args[prop_name] = 3 + elif prop_type == "string": + test_args[prop_name] = "test" + elif prop_type == "number" or prop_type == "integer": + test_args[prop_name] = 1 + elif prop_type == "boolean": + test_args[prop_name] = True + elif prop_type == "array": + test_args[prop_name] = [] + elif prop_type == "object": + test_args[prop_name] = {} + + logger.info(f"自动构造测试参数: {test_args}") + return test_args + + +@router.get("/{plugin_id}/tools") +async def get_plugin_tools( + plugin_id: str, + user: User = Depends(require_login), + db: AsyncSession = Depends(get_db) +): + """ + 获取插件提供的工具列表 + """ + result = await db.execute( + select(MCPPlugin).where( + MCPPlugin.id == plugin_id, + MCPPlugin.user_id == user.user_id + ) + ) + plugin = result.scalar_one_or_none() + + if not plugin: + raise HTTPException(status_code=404, detail="插件不存在") + + if not plugin.enabled: + raise HTTPException(status_code=400, detail="插件未启用") + + try: + tools = await mcp_registry.get_plugin_tools(user.user_id, plugin.plugin_name) + + # 更新缓存 + plugin.tools = tools + await db.commit() + + return { + "plugin_name": plugin.plugin_name, + "tools": tools, + "count": len(tools) + } + except Exception as e: + logger.error(f"获取工具列表失败: {plugin.plugin_name}, 错误: {e}") + raise HTTPException(status_code=500, detail=f"获取工具列表失败: {str(e)}") + + +@router.post("/call") +async def call_mcp_tool( + data: MCPToolCall, + user: User = Depends(require_login), + db: AsyncSession = Depends(get_db) +): + """ + 调用MCP工具 + """ + # 获取插件 + result = await db.execute( + select(MCPPlugin).where( + MCPPlugin.id == data.plugin_id, + MCPPlugin.user_id == user.user_id + ) + ) + plugin = result.scalar_one_or_none() + + if not plugin: + raise HTTPException(status_code=404, detail="插件不存在") + + if not plugin.enabled: + raise HTTPException(status_code=400, detail="插件未启用") + + try: + # 调用工具 + result = await mcp_registry.call_tool( + user.user_id, + plugin.plugin_name, + data.tool_name, + data.arguments + ) + + return { + "success": True, + "plugin_name": plugin.plugin_name, + "tool_name": data.tool_name, + "result": result + } + except Exception as e: + logger.error(f"调用工具失败: {plugin.plugin_name}.{data.tool_name}, 错误: {e}") + raise HTTPException(status_code=500, detail=f"工具调用失败: {str(e)}") \ No newline at end of file diff --git a/backend/app/api/organizations.py b/backend/app/api/organizations.py index f7a45cf..f0eee77 100644 --- a/backend/app/api/organizations.py +++ b/backend/app/api/organizations.py @@ -38,6 +38,7 @@ class OrganizationGenerateRequest(BaseModel): organization_type: Optional[str] = Field(None, description="组织类型") background: Optional[str] = Field(None, description="组织背景") requirements: Optional[str] = Field(None, description="特殊要求") + enable_mcp: bool = Field(True, description="是否启用MCP工具增强(搜索组织架构参考)") @router.get("/project/{project_id}", response_model=List[OrganizationDetailResponse], summary="获取项目的所有组织") diff --git a/backend/app/api/outlines.py b/backend/app/api/outlines.py index 2ff646b..a7dcced 100644 --- a/backend/app/api/outlines.py +++ b/backend/app/api/outlines.py @@ -404,8 +404,8 @@ async def _generate_new_outline( db: AsyncSession, user_ai_service: AIService ) -> OutlineListResponse: - """全新生成大纲""" - logger.info(f"全新生成大纲 - 项目: {project.id}, keep_existing: {request.keep_existing}") + """全新生成大纲(MCP增强版)""" + logger.info(f"全新生成大纲 - 项目: {project.id}, enable_mcp: {request.enable_mcp}") # 获取角色信息 characters_result = await db.execute( @@ -418,7 +418,59 @@ async def _generate_new_outline( for char in characters ]) - # 使用完整提示词 + # 🔍 MCP工具增强:收集情节设计参考资料 + mcp_reference_materials = "" + if request.enable_mcp: + try: + logger.info(f"🔍 尝试使用MCP工具收集大纲设计参考资料...") + + # 构建资料收集查询 + planning_query = f"""你正在为小说《{project.title}》设计完整大纲。 +项目信息: +- 主题:{request.theme or project.theme} +- 类型:{request.genre or project.genre} +- 章节数:{request.chapter_count} +- 叙事视角:{request.narrative_perspective} +- 目标字数:{request.target_words} + +世界观设定: +- 时间背景:{project.world_time_period or '未设定'} +- 地理位置:{project.world_location or '未设定'} +- 氛围基调:{project.world_atmosphere or '未设定'} + +角色信息: +{characters_info or '暂无角色'} + +请搜索: +1. 该类型小说的经典情节结构和套路 +2. 适合该主题的冲突设计思路 +3. 符合世界观的情节元素和场景设计灵感 + +请有针对性地查询1-2个最关键的问题。""" + + # 调用MCP增强的AI(非流式,最多2轮工具调用) + planning_result = await user_ai_service.generate_text_with_mcp( + prompt=planning_query, + user_id="system", # 全新生成时可能没有用户上下文 + db_session=db, + enable_mcp=True, + max_tool_rounds=2, + tool_choice="auto", + provider=None, + model=None + ) + + # 提取参考资料 + if planning_result.get("tool_calls_made", 0) > 0: + mcp_reference_materials = planning_result.get("content", "") + logger.info(f"📚 MCP工具收集参考资料:{len(mcp_reference_materials)} 字符") + else: + logger.info(f"ℹ️ MCP工具未进行调用,继续正常生成") + except Exception as e: + logger.warning(f"⚠️ MCP工具调用失败,继续使用常规模式: {str(e)}") + mcp_reference_materials = "" + + # 使用完整提示词(插入MCP参考资料) prompt = prompt_service.get_complete_outline_prompt( title=project.title, theme=request.theme or project.theme or "未设定", @@ -431,18 +483,22 @@ async def _generate_new_outline( atmosphere=project.world_atmosphere or "未设定", rules=project.world_rules or "未设定", characters_info=characters_info or "暂无角色信息", - requirements=request.requirements or "" + requirements=request.requirements or "", + mcp_references=mcp_reference_materials ) - # 调用AI + # 调用AI生成大纲 ai_response = await user_ai_service.generate_text( prompt=prompt, provider=request.provider, model=request.model ) + # 提取内容(generate_text返回字典) + ai_content = ai_response.get("content", "") if isinstance(ai_response, dict) else ai_response + # 解析响应 - outline_data = _parse_ai_response(ai_response) + outline_data = _parse_ai_response(ai_content) # 全新生成模式:必须删除旧大纲和章节 # 注意:这是"new"模式的核心逻辑,应该始终删除旧数据 @@ -463,7 +519,7 @@ async def _generate_new_outline( history = GenerationHistory( project_id=project.id, prompt=prompt, - generated_content=ai_response, + generated_content=json.dumps(ai_response, ensure_ascii=False) if isinstance(ai_response, dict) else ai_response, model=request.model or "default" ) db.add(history) @@ -571,8 +627,8 @@ async def _continue_outline( user_ai_service: AIService, user_id: str = "system" ) -> OutlineListResponse: - """续写大纲 - 分批生成,每批5章(记忆增强版)""" - logger.info(f"续写大纲 - 项目: {project.id}, 已有: {len(existing_outlines)} 章") + """续写大纲 - 分批生成,每批5章(记忆+MCP增强版)""" + logger.info(f"续写大纲 - 项目: {project.id}, 已有: {len(existing_outlines)} 章, enable_mcp: {request.enable_mcp}") # 分析已有大纲 current_chapter_count = len(existing_outlines) @@ -664,7 +720,57 @@ async def _continue_outline( logger.warning(f"⚠️ 记忆上下文构建失败,继续不使用记忆: {str(e)}") memory_context = None - # 使用标准续写提示词模板(支持记忆增强) + # 🔍 MCP工具增强:收集续写参考资料 + mcp_reference_materials = "" + if request.enable_mcp: + try: + logger.info(f"🔍 第{batch_num + 1}批:尝试使用MCP工具收集续写参考资料...") + + # 构建资料收集查询 + latest_summary = latest_outlines[-1].content if latest_outlines else "" + planning_query = f"""你正在为小说《{project.title}》续写大纲。 +当前进度:已有{len(latest_outlines)}章,即将续写第{current_start_chapter}-{current_start_chapter + current_batch_size - 1}章 + +项目信息: +- 主题:{request.theme or project.theme} +- 类型:{request.genre or project.genre} +- 叙事视角:{request.narrative_perspective} +- 情节阶段:{request.plot_stage} +- 故事发展方向:{request.story_direction or '自然延续'} + +最近章节概要: +{latest_summary[:200]} + +请搜索: +1. 该情节阶段的经典处理手法和技巧 +2. 适合该发展方向的情节转折和冲突设计 +3. 符合类型特点的场景设计和剧情元素 + +请有针对性地查询1-2个最关键的问题。""" + + # 调用MCP增强的AI(非流式,最多2轮工具调用) + planning_result = await user_ai_service.generate_text_with_mcp( + prompt=planning_query, + user_id=user_id, + db_session=db, + enable_mcp=True, + max_tool_rounds=2, + tool_choice="auto", + provider=None, + model=None + ) + + # 提取参考资料 + if planning_result.get("tool_calls_made", 0) > 0: + mcp_reference_materials = planning_result.get("content", "") + logger.info(f"📚 第{batch_num + 1}批MCP工具收集参考资料:{len(mcp_reference_materials)} 字符") + else: + logger.info(f"ℹ️ 第{batch_num + 1}批MCP工具未进行调用,继续正常生成") + except Exception as e: + logger.warning(f"⚠️ 第{batch_num + 1}批MCP工具调用失败,继续使用常规模式: {str(e)}") + mcp_reference_materials = "" + + # 使用标准续写提示词模板(支持记忆+MCP增强) prompt = prompt_service.get_outline_continue_prompt( title=project.title, theme=request.theme or project.theme or "未设定", @@ -683,7 +789,8 @@ async def _continue_outline( start_chapter=current_start_chapter, story_direction=request.story_direction or "自然延续", requirements=request.requirements or "", - memory_context=memory_context + memory_context=memory_context, + mcp_references=mcp_reference_materials ) # 调用AI生成当前批次 @@ -694,8 +801,11 @@ async def _continue_outline( model=request.model ) + # 提取内容(generate_text返回字典) + ai_content = ai_response.get("content", "") if isinstance(ai_response, dict) else ai_response + # 解析响应 - outline_data = _parse_ai_response(ai_response) + outline_data = _parse_ai_response(ai_content) # 保存当前批次的大纲 batch_outlines = await _save_outlines( @@ -706,7 +816,7 @@ async def _continue_outline( history = GenerationHistory( project_id=project.id, prompt=f"[批次{batch_num + 1}/{total_batches}] {str(prompt)[:500]}", - generated_content=ai_response, + generated_content=json.dumps(ai_response, ensure_ascii=False) if isinstance(ai_response, dict) else ai_response, model=request.model or "default" ) db.add(history) @@ -820,7 +930,7 @@ async def new_outline_generator( db: AsyncSession, user_ai_service: AIService ) -> AsyncGenerator[str, None]: - """全新生成大纲SSE生成器""" + """全新生成大纲SSE生成器(MCP增强版)""" db_committed = False try: yield await SSEResponse.send_progress("开始生成大纲...", 5) @@ -828,6 +938,7 @@ async def new_outline_generator( project_id = data.get("project_id") # 确保chapter_count是整数(前端可能传字符串) chapter_count = int(data.get("chapter_count", 10)) + enable_mcp = data.get("enable_mcp", True) # 验证项目 yield await SSEResponse.send_progress("加载项目信息...", 10) @@ -852,7 +963,61 @@ async def new_outline_generator( for char in characters ]) - # 使用完整提示词 + # 🔍 MCP工具增强:收集情节设计参考资料 + mcp_reference_materials = "" + if enable_mcp: + try: + yield await SSEResponse.send_progress("🔍 使用MCP工具收集参考资料...", 18) + logger.info(f"🔍 尝试使用MCP工具收集大纲设计参考资料...") + + # 构建资料收集查询 + planning_query = f"""你正在为小说《{project.title}》设计完整大纲。 +项目信息: +- 主题:{data.get('theme') or project.theme} +- 类型:{data.get('genre') or project.genre} +- 章节数:{chapter_count} +- 叙事视角:{data.get('narrative_perspective') or '第三人称'} +- 目标字数:{data.get('target_words') or project.target_words or 100000} + +世界观设定: +- 时间背景:{project.world_time_period or '未设定'} +- 地理位置:{project.world_location or '未设定'} +- 氛围基调:{project.world_atmosphere or '未设定'} + +角色信息: +{characters_info or '暂无角色'} + +请搜索: +1. 该类型小说的经典情节结构和套路 +2. 适合该主题的冲突设计思路 +3. 符合世界观的情节元素和场景设计灵感 + +请有针对性地查询1-2个最关键的问题。""" + + # 调用MCP增强的AI(非流式,最多2轮工具调用) + planning_result = await user_ai_service.generate_text_with_mcp( + prompt=planning_query, + user_id="system", + db_session=db, + enable_mcp=True, + max_tool_rounds=2, + tool_choice="auto", + provider=None, + model=None + ) + + # 提取参考资料 + if planning_result.get("tool_calls_made", 0) > 0: + mcp_reference_materials = planning_result.get("content", "") + logger.info(f"📚 MCP工具收集参考资料:{len(mcp_reference_materials)} 字符") + yield await SSEResponse.send_progress(f"📚 MCP收集到参考资料 ({len(mcp_reference_materials)}字符)", 19) + else: + logger.info(f"ℹ️ MCP工具未进行调用,继续正常生成") + except Exception as e: + logger.warning(f"⚠️ MCP工具调用失败,继续使用常规模式: {str(e)}") + mcp_reference_materials = "" + + # 使用完整提示词(插入MCP参考资料) yield await SSEResponse.send_progress("准备AI提示词...", 20) prompt = prompt_service.get_complete_outline_prompt( title=project.title, @@ -866,7 +1031,8 @@ async def new_outline_generator( atmosphere=project.world_atmosphere or "未设定", rules=project.world_rules or "未设定", characters_info=characters_info or "暂无角色信息", - requirements=data.get("requirements") or "" + requirements=data.get("requirements") or "", + mcp_references=mcp_reference_materials ) # 调用AI @@ -879,8 +1045,11 @@ async def new_outline_generator( yield await SSEResponse.send_progress("✅ AI生成完成,正在解析...", 70) + # 提取内容(generate_text返回字典) + ai_content = ai_response.get("content", "") if isinstance(ai_response, dict) else ai_response + # 解析响应 - outline_data = _parse_ai_response(ai_response) + outline_data = _parse_ai_response(ai_content) # 删除旧大纲和章节 yield await SSEResponse.send_progress("清理旧数据...", 75) @@ -902,7 +1071,7 @@ async def new_outline_generator( history = GenerationHistory( project_id=project_id, prompt=prompt, - generated_content=ai_response, + generated_content=json.dumps(ai_response, ensure_ascii=False) if isinstance(ai_response, dict) else ai_response, model=data.get("model") or "default" ) db.add(history) @@ -957,7 +1126,7 @@ async def continue_outline_generator( user_ai_service: AIService, user_id: str = "system" ) -> AsyncGenerator[str, None]: - """大纲续写SSE生成器 - 分批生成,推送进度(记忆增强版)""" + """大纲续写SSE生成器 - 分批生成,推送进度(记忆+MCP增强版)""" db_committed = False try: yield await SSEResponse.send_progress("开始续写大纲...", 5) @@ -1090,13 +1259,72 @@ async def continue_outline_generator( except Exception as e: logger.warning(f"⚠️ 记忆上下文构建失败: {str(e)}") memory_context = None + # 🔍 MCP工具增强:收集续写参考资料 + mcp_reference_materials = "" + enable_mcp = data.get("enable_mcp", True) + if enable_mcp: + try: + yield await SSEResponse.send_progress( + f"🔍 第{str(batch_num + 1)}批:使用MCP工具收集参考资料...", + batch_progress + 4 + ) + logger.info(f"🔍 第{batch_num + 1}批:尝试使用MCP工具收集续写参考资料...") + + # 构建资料收集查询 + latest_summary = latest_outlines[-1].content if latest_outlines else "" + planning_query = f"""你正在为小说《{project.title}》续写大纲。 +当前进度:已有{len(latest_outlines)}章,即将续写第{current_start_chapter}-{current_start_chapter + current_batch_size - 1}章 + +项目信息: +- 主题:{data.get('theme') or project.theme} +- 类型:{data.get('genre') or project.genre} +- 叙事视角:{data.get('narrative_perspective') or project.narrative_perspective or '第三人称'} +- 情节阶段:{data.get('plot_stage', 'development')} +- 故事发展方向:{data.get('story_direction', '自然延续')} + +最近章节概要: +{latest_summary[:200]} + +请搜索: +1. 该情节阶段的经典处理手法和技巧 +2. 适合该发展方向的情节转折和冲突设计 +3. 符合类型特点的场景设计和剧情元素 + +请有针对性地查询1-2个最关键的问题。""" + + # 调用MCP增强的AI(非流式,最多2轮工具调用) + planning_result = await user_ai_service.generate_text_with_mcp( + prompt=planning_query, + user_id=user_id, + db_session=db, + enable_mcp=True, + max_tool_rounds=2, + tool_choice="auto", + provider=None, + model=None + ) + + # 提取参考资料 + if planning_result.get("tool_calls_made", 0) > 0: + mcp_reference_materials = planning_result.get("content", "") + logger.info(f"📚 第{batch_num + 1}批MCP工具收集参考资料:{len(mcp_reference_materials)} 字符") + yield await SSEResponse.send_progress( + f"📚 第{str(batch_num + 1)}批收集到参考资料 ({len(mcp_reference_materials)}字符)", + batch_progress + 4.5 + ) + else: + logger.info(f"ℹ️ 第{batch_num + 1}批MCP工具未进行调用,继续正常生成") + except Exception as e: + logger.warning(f"⚠️ 第{batch_num + 1}批MCP工具调用失败,继续使用常规模式: {str(e)}") + mcp_reference_materials = "" + yield await SSEResponse.send_progress( f" 调用AI生成第{str(batch_num + 1)}批...", batch_progress + 5 ) - # 使用标准续写提示词模板(支持记忆增强) + # 使用标准续写提示词模板(支持记忆+MCP增强) prompt = prompt_service.get_outline_continue_prompt( title=project.title, theme=data.get("theme") or project.theme or "未设定", @@ -1115,7 +1343,8 @@ async def continue_outline_generator( start_chapter=current_start_chapter, story_direction=data.get("story_direction", "自然延续"), requirements=data.get("requirements", ""), - memory_context=memory_context + memory_context=memory_context, + mcp_references=mcp_reference_materials ) # 调用AI生成当前批次 @@ -1130,8 +1359,11 @@ async def continue_outline_generator( batch_progress + 10 ) + # 提取内容(generate_text返回字典) + ai_content = ai_response.get("content", "") if isinstance(ai_response, dict) else ai_response + # 解析响应 - outline_data = _parse_ai_response(ai_response) + outline_data = _parse_ai_response(ai_content) # 保存当前批次的大纲 batch_outlines = await _save_outlines( @@ -1142,7 +1374,7 @@ async def continue_outline_generator( history = GenerationHistory( project_id=project_id, prompt=f"[续写批次{batch_num + 1}/{total_batches}] {str(prompt)[:500]}", - generated_content=ai_response, + generated_content=json.dumps(ai_response, ensure_ascii=False) if isinstance(ai_response, dict) else ai_response, model=data.get("model") or "default" ) db.add(history) diff --git a/backend/app/api/settings.py b/backend/app/api/settings.py index 0f3bf20..1ac0320 100644 --- a/backend/app/api/settings.py +++ b/backend/app/api/settings.py @@ -359,7 +359,10 @@ async def test_api_connection(data: ApiTestRequest): logger.info(f"✅ API 测试成功") logger.info(f" - 响应时间: {response_time}ms") - logger.info(f" - 响应内容: {response[:100] if response else 'N/A'}") + + # 安全地处理响应内容(确保是字符串) + response_str = str(response) if response else 'N/A' + logger.info(f" - 响应内容: {response_str[:100]}") return { "success": True, @@ -367,7 +370,7 @@ async def test_api_connection(data: ApiTestRequest): "response_time_ms": response_time, "provider": provider, "model": llm_model, - "response_preview": response[:100] if response and len(response) > 100 else response, + "response_preview": response_str[:100] if len(response_str) > 100 else response_str, "details": { "api_available": True, "model_accessible": True, diff --git a/backend/app/api/wizard_stream.py b/backend/app/api/wizard_stream.py index 7c39d46..33826c2 100644 --- a/backend/app/api/wizard_stream.py +++ b/backend/app/api/wizard_stream.py @@ -1,5 +1,5 @@ """项目创建向导流式API - 使用SSE避免超时""" -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from typing import Dict, Any, AsyncGenerator @@ -15,6 +15,7 @@ from app.models.relationship import CharacterRelationship, Organization, Organiz from app.models.writing_style import WritingStyle from app.models.project_default_style import ProjectDefaultStyle from app.services.ai_service import AIService +from app.services.mcp_tool_service import MCPToolService from app.services.prompt_service import prompt_service from app.logger import get_logger from app.utils.sse_response import SSEResponse, create_sse_response @@ -29,7 +30,7 @@ async def world_building_generator( db: AsyncSession, user_ai_service: AIService ) -> AsyncGenerator[str, None]: - """世界构建流式生成器""" + """世界构建流式生成器 - 支持MCP工具增强""" # 标记数据库会话是否已提交 db_committed = False try: @@ -47,27 +48,94 @@ async def world_building_generator( character_count = data.get("character_count") provider = data.get("provider") model = data.get("model") + enable_mcp = data.get("enable_mcp", True) # 默认启用MCP + user_id = data.get("user_id") # 从中间件注入 if not title or not description or not theme or not genre: yield await SSEResponse.send_error("title、description、theme 和 genre 是必需的参数", 400) return - # 获取提示词 - yield await SSEResponse.send_progress("准备AI提示词...", 20) - prompt = prompt_service.get_world_building_prompt( + # 获取基础提示词 + yield await SSEResponse.send_progress("准备AI提示词...", 15) + base_prompt = prompt_service.get_world_building_prompt( title=title, theme=theme, genre=genre ) - # 流式调用AI - yield await SSEResponse.send_progress("正在调用AI生成...", 30) + # MCP工具增强:收集参考资料 + reference_materials = "" + if enable_mcp and user_id: + try: + yield await SSEResponse.send_progress("🔍 尝试使用MCP工具收集参考资料...", 18) + + # 直接调用MCP增强的AI,内部会自动检查和加载工具 + # 构建资料收集提示词 + planning_prompt = f"""你正在为小说《{title}》设计世界观。 + +【小说信息】 +- 题材:{genre} +- 主题:{theme} +- 简介:{description} + +【任务】 +请使用可用工具搜索相关背景资料,帮助构建更真实、更有深度的世界观设定。 +你可以查询: +1. 历史背景(如果是历史题材) +2. 地理环境和文化特征 +3. 相关领域的专业知识 +4. 类似作品的设定参考 + +请根据题材特点,有针对性地查询2-3个关键问题。""" + + # 调用MCP增强的AI(非流式,最多2轮工具调用) + 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, + tool_choice="auto", + provider=None, + model=None + ) + + # 提取参考资料 + if planning_result.get("tool_calls_made", 0) > 0: + yield await SSEResponse.send_progress( + f"✅ MCP工具调用成功({planning_result['tool_calls_made']}次)", + 25 + ) + reference_materials = planning_result.get("content", "") + else: + yield await SSEResponse.send_progress("ℹ️ 未使用MCP工具(无可用工具或不需要)", 25) + + except Exception as e: + logger.warning(f"MCP工具调用失败(降级处理): {e}") + yield await SSEResponse.send_progress("⚠️ MCP工具暂时不可用,使用基础模式", 25) + # 构建增强提示词 + if reference_materials: + enhanced_prompt = f"""{base_prompt} + +【参考资料】 +以下是通过MCP工具收集的真实背景资料,请参考这些信息构建更真实的世界观: + +{reference_materials} + +请结合上述资料,生成符合历史/现实的世界观设定。""" + final_prompt = enhanced_prompt + yield await SSEResponse.send_progress("💡 已整合参考资料,开始生成世界观...", 30) + else: + final_prompt = base_prompt + yield await SSEResponse.send_progress("正在调用AI生成...", 30) + + # 流式生成世界观 accumulated_text = "" chunk_count = 0 async for chunk in user_ai_service.generate_text_stream( - prompt=prompt, + prompt=final_prompt, provider=provider, model=model ): @@ -190,6 +258,7 @@ async def world_building_generator( @router.post("/world-building", summary="流式生成世界构建") async def generate_world_building_stream( + request: Request, data: Dict[str, Any], db: AsyncSession = Depends(get_db), user_ai_service: AIService = Depends(get_user_ai_service) @@ -198,6 +267,10 @@ async def generate_world_building_stream( 使用SSE流式生成世界构建,避免超时 前端使用EventSource接收实时进度和结果 """ + # 从中间件注入user_id到data中 + if hasattr(request.state, 'user_id'): + data['user_id'] = request.state.user_id + return create_sse_response(world_building_generator(data, db, user_ai_service)) @@ -206,7 +279,7 @@ async def characters_generator( db: AsyncSession, user_ai_service: AIService ) -> AsyncGenerator[str, None]: - """角色批量生成流式生成器 - 优化版:分批+重试""" + """角色批量生成流式生成器 - 优化版:分批+重试+MCP工具增强""" db_committed = False try: yield await SSEResponse.send_progress("开始生成角色...", 5) @@ -219,6 +292,8 @@ async def characters_generator( requirements = data.get("requirements", "") provider = data.get("provider") model = data.get("model") + enable_mcp = data.get("enable_mcp", True) # 默认启用MCP + user_id = data.get("user_id") # 从中间件注入 # 验证项目 yield await SSEResponse.send_progress("验证项目...", 10) @@ -239,6 +314,57 @@ async def characters_generator( "rules": project.world_rules or "未设定" } + # MCP工具增强:收集角色参考资料 + character_reference_materials = "" + if enable_mcp and user_id: + try: + yield await SSEResponse.send_progress("🔍 尝试使用MCP工具收集角色参考资料...", 8) + + # 构建角色资料收集提示词 + planning_prompt = f"""你正在为小说《{project.title}》设计角色。 + +【小说信息】 +- 题材:{genre or project.genre} +- 主题:{theme or project.theme} +- 时代背景:{world_context.get('time_period', '未设定')} +- 地理位置:{world_context.get('location', '未设定')} + +【任务】 +请使用可用工具搜索相关参考资料,帮助设计更真实、更有深度的角色。 +你可以查询: +1. 该时代/地域的真实历史人物特征 +2. 文化背景和社会习俗 +3. 职业特点和生活方式 +4. 相关领域的人物原型 + +请根据题材特点,有针对性地查询1-2个关键问题。""" + + # 调用MCP增强的AI(非流式,最多2轮工具调用) + 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, + tool_choice="auto", + provider=None, + model=None + ) + + # 提取参考资料 + if planning_result.get("tool_calls_made", 0) > 0: + yield await SSEResponse.send_progress( + f"✅ MCP工具调用成功({planning_result['tool_calls_made']}次)", + 12 + ) + character_reference_materials = planning_result.get("content", "") + else: + yield await SSEResponse.send_progress("ℹ️ 未使用MCP工具(无可用工具或不需要)", 12) + + except Exception as e: + logger.warning(f"MCP工具调用失败(降级处理): {e}") + yield await SSEResponse.send_progress("⚠️ MCP工具暂时不可用,使用基础模式", 12) + # 优化的分批策略:每批生成3个,平衡效率和成功率 BATCH_SIZE = 3 # 每批生成3个角色 MAX_RETRIES = 3 # 每批最多重试3次 @@ -291,7 +417,8 @@ async def characters_generator( else: batch_requirements += "\n主要是配角(supporting)和反派(antagonist)" - prompt = prompt_service.get_characters_batch_prompt( + # 构建基础提示词 + base_prompt = prompt_service.get_characters_batch_prompt( count=current_batch_size, # 传递精确数量 time_period=world_context.get("time_period", ""), location=world_context.get("location", ""), @@ -302,6 +429,19 @@ async def characters_generator( requirements=batch_requirements ) + # 如果有MCP参考资料,增强提示词 + if character_reference_materials: + prompt = f"""{base_prompt} + +【参考资料】 +以下是通过MCP工具收集的真实背景资料,请参考这些信息设计更真实的角色: + +{character_reference_materials} + +请结合上述资料,设计符合历史/文化背景的角色。""" + else: + prompt = base_prompt + # 流式生成 accumulated_text = "" async for chunk in user_ai_service.generate_text_stream( @@ -708,13 +848,19 @@ async def characters_generator( @router.post("/characters", summary="流式批量生成角色") async def generate_characters_stream( + request: Request, data: Dict[str, Any], db: AsyncSession = Depends(get_db), user_ai_service: AIService = Depends(get_user_ai_service) ): """ 使用SSE流式批量生成角色,避免超时 + 支持MCP工具增强 """ + # 从中间件注入user_id到data中 + if hasattr(request.state, 'user_id'): + data['user_id'] = request.state.user_id + return create_sse_response(characters_generator(data, db, user_ai_service)) @@ -1071,7 +1217,7 @@ async def regenerate_world_building_generator( db: AsyncSession, user_ai_service: AIService ) -> AsyncGenerator[str, None]: - """重新生成世界观流式生成器""" + """重新生成世界观流式生成器 - 支持MCP工具增强""" db_committed = False try: yield await SSEResponse.send_progress("开始重新生成世界观...", 10) @@ -1087,23 +1233,89 @@ async def regenerate_world_building_generator( provider = data.get("provider") model = data.get("model") + enable_mcp = data.get("enable_mcp", True) # 默认启用MCP + user_id = data.get("user_id") # 从中间件注入 - # 获取世界构建提示词 - yield await SSEResponse.send_progress("准备AI提示词...", 20) - prompt = prompt_service.get_world_building_prompt( + # 获取基础提示词 + yield await SSEResponse.send_progress("准备AI提示词...", 15) + base_prompt = prompt_service.get_world_building_prompt( title=project.title, theme=project.theme or "", genre=project.genre or "" ) - # 流式调用AI - yield await SSEResponse.send_progress("正在调用AI生成...", 30) + # MCP工具增强:收集参考资料 + reference_materials = "" + if enable_mcp and user_id: + try: + yield await SSEResponse.send_progress("🔍 尝试使用MCP工具收集参考资料...", 18) + + # 直接调用MCP增强的AI,内部会自动检查和加载工具 + # 构建资料收集提示词 + planning_prompt = f"""你正在为小说《{project.title}》重新设计世界观。 + +【小说信息】 +- 题材:{project.genre or '未设定'} +- 主题:{project.theme or '未设定'} + +【任务】 +请使用可用工具搜索相关背景资料,帮助构建更真实、更有深度的世界观设定。 +你可以查询: +1. 历史背景(如果是历史题材) +2. 地理环境和文化特征 +3. 相关领域的专业知识 +4. 类似作品的设定参考 + +请根据题材特点,有针对性地查询2-3个关键问题。""" + + # 调用MCP增强的AI(非流式,最多2轮工具调用) + 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, + tool_choice="auto", + provider=None, + model=None + ) + + # 提取参考资料 + if planning_result.get("tool_calls_made", 0) > 0: + yield await SSEResponse.send_progress( + f"✅ MCP工具调用成功({planning_result['tool_calls_made']}次)", + 25 + ) + reference_materials = planning_result.get("content", "") + else: + yield await SSEResponse.send_progress("ℹ️ 未使用MCP工具(无可用工具或不需要)", 25) + + except Exception as e: + logger.warning(f"MCP工具调用失败(降级处理): {e}") + yield await SSEResponse.send_progress("⚠️ MCP工具暂时不可用,使用基础模式", 25) + # 构建增强提示词 + if reference_materials: + enhanced_prompt = f"""{base_prompt} + +【参考资料】 +以下是通过MCP工具收集的真实背景资料,请参考这些信息构建更真实的世界观: + +{reference_materials} + +请结合上述资料,生成符合历史/现实的世界观设定。""" + final_prompt = enhanced_prompt + yield await SSEResponse.send_progress("💡 已整合参考资料,开始重新生成世界观...", 30) + else: + final_prompt = base_prompt + yield await SSEResponse.send_progress("正在调用AI生成...", 30) + + # 流式生成世界观 accumulated_text = "" chunk_count = 0 async for chunk in user_ai_service.generate_text_stream( - prompt=prompt, + prompt=final_prompt, provider=provider, model=model ): @@ -1187,6 +1399,7 @@ async def regenerate_world_building_generator( @router.post("/world-building/{project_id}/regenerate", summary="流式重新生成世界观") async def regenerate_world_building_stream( + request: Request, project_id: str, data: Dict[str, Any], db: AsyncSession = Depends(get_db), @@ -1200,6 +1413,10 @@ async def regenerate_world_building_stream( "model": "模型名称(可选)" } """ + # 从中间件注入user_id到data中 + if hasattr(request.state, 'user_id'): + data['user_id'] = request.state.user_id + return create_sse_response(regenerate_world_building_generator(project_id, data, db, user_ai_service)) diff --git a/backend/app/main.py b/backend/app/main.py index 719f7e4..1efd48e 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -28,7 +28,13 @@ async def lifespan(app: FastAPI): """应用生命周期管理""" logger.info("应用启动,等待用户登录...") + # 导入MCP注册表 + from app.mcp.registry import mcp_registry + yield + + # 清理MCP插件 + await mcp_registry.cleanup_all() await close_db() logger.info("应用已关闭") @@ -114,7 +120,8 @@ async def db_session_stats(): from app.api import ( projects, outlines, characters, chapters, wizard_stream, relationships, organizations, - auth, users, settings, writing_styles, memories + auth, users, settings, writing_styles, memories, + mcp_plugins ) app.include_router(auth.router, prefix="/api") @@ -130,6 +137,7 @@ app.include_router(relationships.router, prefix="/api") app.include_router(organizations.router, prefix="/api") app.include_router(writing_styles.router, prefix="/api") app.include_router(memories.router) # 记忆管理API (已包含/api前缀) +app.include_router(mcp_plugins.router, prefix="/api") # MCP插件管理API static_dir = Path(__file__).parent.parent / "static" if static_dir.exists(): diff --git a/backend/app/mcp/__init__.py b/backend/app/mcp/__init__.py new file mode 100644 index 0000000..1a8b35f --- /dev/null +++ b/backend/app/mcp/__init__.py @@ -0,0 +1,4 @@ +"""MCP插件系统""" +from .registry import mcp_registry + +__all__ = ["mcp_registry"] \ No newline at end of file diff --git a/backend/app/mcp/http_client.py b/backend/app/mcp/http_client.py new file mode 100644 index 0000000..7160ec1 --- /dev/null +++ b/backend/app/mcp/http_client.py @@ -0,0 +1,345 @@ +"""HTTP MCP客户端 - 实现JSON-RPC 2.0协议""" +import httpx +from typing import Dict, Any, List, Optional +from app.logger import get_logger +import time + +logger = get_logger(__name__) + + +class MCPError(Exception): + """MCP错误""" + pass + + +class HTTPMCPClient: + """HTTP模式MCP客户端(类似Cursor/Claude Code实现)""" + + def __init__( + self, + url: str, + headers: Optional[Dict[str, str]] = None, + env: Optional[Dict[str, str]] = None, + timeout: float = 60.0, + http_client: Optional[httpx.AsyncClient] = None + ): + """ + 初始化HTTP MCP客户端 + + Args: + url: MCP服务器URL + headers: HTTP请求头 + env: 环境变量(用于API Key等) + timeout: 超时时间(秒) + http_client: 可选的共享HTTP客户端(用于连接池复用) + """ + self.url = url.rstrip('/') + self.headers = headers or {} + self.env = env or {} + self.timeout = timeout + + # 设置MCP必需的Accept头 + # MCP服务器要求客户端必须接受 application/json 和 text/event-stream + if 'Accept' not in self.headers: + self.headers['Accept'] = 'application/json, text/event-stream' + + # 设置Content-Type + if 'Content-Type' not in self.headers: + self.headers['Content-Type'] = 'application/json' + + # 如果env中有API Key,添加到headers + if 'API_KEY' in self.env: + self.headers['Authorization'] = f'Bearer {self.env["API_KEY"]}' + + # 使用共享客户端或创建新客户端 + self._owns_client = http_client is None + if http_client: + self.client = http_client + else: + self.client = httpx.AsyncClient( + timeout=httpx.Timeout(timeout), + headers=self.headers + ) + self._request_id = 0 + + def _next_request_id(self) -> int: + """获取下一个请求ID""" + self._request_id += 1 + return self._request_id + + async def _call_jsonrpc( + self, + method: str, + params: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """ + 调用JSON-RPC 2.0方法 + + Args: + method: 方法名 + params: 参数 + + Returns: + 响应结果 + + Raises: + MCPError: 调用失败时抛出 + """ + request_id = self._next_request_id() + + payload = { + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params or {} + } + + try: + logger.debug(f"MCP请求: {method} -> {self.url}") + + response = await self.client.post( + self.url, + json=payload, + headers=self.headers # 显式传递headers(对于共享客户端很重要) + ) + + response.raise_for_status() + + # 获取响应内容 + response_text = response.text + content_type = response.headers.get('content-type', '') + + # 如果是空响应 + if not response_text or response_text.strip() == '': + raise MCPError("服务器返回空响应") + + # 处理SSE格式响应 + if 'text/event-stream' in content_type or response_text.startswith('event:'): + logger.debug("检测到SSE格式响应,开始解析") + data = self._parse_sse_response(response_text) + else: + # 标准JSON响应 + try: + data = response.json() + except ValueError as e: + logger.error(f"JSON解析失败,响应内容: {response_text[:500]}") + raise MCPError(f"无法解析JSON响应: {str(e)}") + + # 检查JSON-RPC错误 + if "error" in data: + error = data["error"] + error_msg = error.get("message", "Unknown error") + error_code = error.get("code", -1) + logger.error(f"MCP错误 [{error_code}]: {error_msg}") + raise MCPError(f"[{error_code}] {error_msg}") + + if "result" not in data: + raise MCPError("响应中缺少result字段") + + return data["result"] + + except httpx.HTTPStatusError as e: + logger.error(f"HTTP错误 {e.response.status_code}: {e.response.text}") + raise MCPError(f"HTTP错误 {e.response.status_code}: {e.response.text}") + except httpx.RequestError as e: + logger.error(f"请求错误: {str(e)}") + raise MCPError(f"请求错误: {str(e)}") + except MCPError: + raise + except Exception as e: + logger.error(f"未知错误: {str(e)}") + raise MCPError(f"未知错误: {str(e)}") + + def _parse_sse_response(self, sse_text: str) -> Dict[str, Any]: + """ + 解析SSE格式的响应 + + SSE格式示例: + event: message + data: {"result": {...}} + + Args: + sse_text: SSE格式的文本 + + Returns: + 解析后的JSON数据 + """ + import json + + lines = sse_text.strip().split('\n') + data_lines = [] + + for line in lines: + line = line.strip() + if line.startswith('data:'): + # 提取data后面的内容 + data_content = line[5:].strip() + data_lines.append(data_content) + + if not data_lines: + raise MCPError("SSE响应中没有找到data字段") + + # 合并所有data行(某些SSE可能分多行) + full_data = ''.join(data_lines) + + try: + return json.loads(full_data) + except json.JSONDecodeError as e: + logger.error(f"解析SSE data失败: {full_data[:200]}") + raise MCPError(f"SSE data不是有效的JSON: {str(e)}") + + async def list_tools(self) -> List[Dict[str, Any]]: + """ + 列举可用工具 + + Returns: + 工具列表 + """ + try: + result = await self._call_jsonrpc("tools/list") + tools = result.get("tools", []) + logger.info(f"获取到 {len(tools)} 个工具") + return tools + except Exception as e: + logger.error(f"获取工具列表失败: {e}") + raise + + async def call_tool( + self, + tool_name: str, + arguments: Dict[str, Any] + ) -> Any: + """ + 调用工具 + + Args: + tool_name: 工具名称 + arguments: 工具参数 + + Returns: + 工具执行结果 + """ + try: + logger.info(f"调用工具: {tool_name}") + logger.debug(f"参数: {arguments}") + + result = await self._call_jsonrpc( + "tools/call", + { + "name": tool_name, + "arguments": arguments + } + ) + + # MCP返回的result通常包含content数组 + if isinstance(result, dict) and "content" in result: + content = result["content"] + if isinstance(content, list) and len(content) > 0: + # 提取第一个content项的text + first_content = content[0] + if isinstance(first_content, dict) and "text" in first_content: + return first_content["text"] + return first_content + return content + + return result + + except Exception as e: + logger.error(f"调用工具失败: {tool_name}, 错误: {e}") + raise + + async def list_resources(self) -> List[Dict[str, Any]]: + """ + 列举可用资源 + + Returns: + 资源列表 + """ + try: + result = await self._call_jsonrpc("resources/list") + resources = result.get("resources", []) + logger.info(f"获取到 {len(resources)} 个资源") + return resources + except Exception as e: + logger.error(f"获取资源列表失败: {e}") + raise + + async def read_resource(self, uri: str) -> Any: + """ + 读取资源 + + Args: + uri: 资源URI + + Returns: + 资源内容 + """ + try: + result = await self._call_jsonrpc( + "resources/read", + {"uri": uri} + ) + return result + except Exception as e: + logger.error(f"读取资源失败: {uri}, 错误: {e}") + raise + + async def test_connection(self) -> Dict[str, Any]: + """ + 测试连接 + + Returns: + 测试结果 + """ + start_time = time.time() + + try: + # 尝试列举工具来测试连接 + tools = await self.list_tools() + + end_time = time.time() + response_time = round((end_time - start_time) * 1000, 2) + + return { + "success": True, + "message": "连接测试成功", + "response_time_ms": response_time, + "tools_count": len(tools), + "tools": tools + } + except MCPError as e: + end_time = time.time() + response_time = round((end_time - start_time) * 1000, 2) + + return { + "success": False, + "message": "连接测试失败", + "response_time_ms": response_time, + "error": str(e), + "error_type": "MCPError", + "suggestions": [ + "请检查服务器URL是否正确", + "请确认API Key是否有效", + "请检查网络连接" + ] + } + except Exception as e: + end_time = time.time() + response_time = round((end_time - start_time) * 1000, 2) + + return { + "success": False, + "message": "连接测试失败", + "response_time_ms": response_time, + "error": str(e), + "error_type": type(e).__name__, + "suggestions": [ + "请检查服务器是否在线", + "请确认配置是否正确" + ] + } + + async def close(self): + """关闭客户端(仅在拥有客户端所有权时关闭)""" + if self._owns_client and self.client: + await self.client.aclose() \ No newline at end of file diff --git a/backend/app/mcp/registry.py b/backend/app/mcp/registry.py new file mode 100644 index 0000000..b1c7511 --- /dev/null +++ b/backend/app/mcp/registry.py @@ -0,0 +1,349 @@ +"""MCP插件注册表 - 管理运行时插件实例""" +import asyncio +import time +import httpx +from typing import Dict, Optional, Any, List, Tuple +from collections import OrderedDict +from app.mcp.http_client import HTTPMCPClient, MCPError +from app.models.mcp_plugin import MCPPlugin +from app.logger import get_logger + +logger = get_logger(__name__) + + +class MCPPluginRegistry: + """MCP插件注册表 - 管理运行时插件实例(多用户优化版)""" + + def __init__(self, max_clients: int = 1000, client_ttl: int = 3600): + """ + 初始化注册表 + + Args: + max_clients: 最大缓存客户端数量 + client_ttl: 客户端过期时间(秒),默认1小时 + """ + # 存储格式: {plugin_id: (client, last_access_time)} + self._clients: OrderedDict[str, Tuple[HTTPMCPClient, float]] = OrderedDict() + + # 细粒度锁:每个用户一个锁 + self._user_locks: Dict[str, asyncio.Lock] = {} + self._locks_lock = asyncio.Lock() # 保护locks字典本身 + + # 配置参数 + self._max_clients = max_clients + self._client_ttl = client_ttl + + # 共享HTTP客户端池(用于所有MCP HTTP请求) + self._shared_http_client = httpx.AsyncClient( + limits=httpx.Limits( + max_keepalive_connections=100, + max_connections=200, + keepalive_expiry=30.0 + ), + timeout=httpx.Timeout(connect=10.0, read=60.0, write=10.0, pool=5.0), + headers={ + "User-Agent": "MuMuAINovel-MCP-Client/1.0" + } + ) + + # 启动后台清理任务 + self._cleanup_task = None + self._start_cleanup_task() + + def _start_cleanup_task(self): + """启动后台清理任务""" + if self._cleanup_task is None: + self._cleanup_task = asyncio.create_task(self._cleanup_loop()) + logger.info("✅ MCP插件注册表后台清理任务已启动") + + async def _cleanup_loop(self): + """后台清理过期客户端""" + while True: + try: + await asyncio.sleep(300) # 每5分钟清理一次 + await self._cleanup_expired_clients() + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"清理任务异常: {e}") + + async def _cleanup_expired_clients(self): + """清理过期的客户端""" + now = time.time() + expired_ids = [] + + # 收集过期的plugin_id + for plugin_id, (client, last_access) in list(self._clients.items()): + if now - last_access > self._client_ttl: + expired_ids.append(plugin_id) + + if expired_ids: + logger.info(f"🧹 清理 {len(expired_ids)} 个过期的MCP客户端") + for plugin_id in expired_ids: + # 提取user_id来获取对应的锁 + user_id = plugin_id.split(':', 1)[0] + user_lock = await self._get_user_lock(user_id) + + async with user_lock: + if plugin_id in self._clients: + await self._unload_plugin_unsafe(plugin_id) + + async def _get_user_lock(self, user_id: str) -> asyncio.Lock: + """ + 获取用户专属的锁(细粒度锁) + + Args: + user_id: 用户ID + + Returns: + 该用户的锁对象 + """ + async with self._locks_lock: + if user_id not in self._user_locks: + self._user_locks[user_id] = asyncio.Lock() + return self._user_locks[user_id] + + def _touch_client(self, plugin_id: str): + """ + 更新客户端的最后访问时间(LRU) + + Args: + plugin_id: 插件ID + """ + if plugin_id in self._clients: + client, _ = self._clients[plugin_id] + self._clients[plugin_id] = (client, time.time()) + # 移到末尾(LRU) + self._clients.move_to_end(plugin_id) + + async def _evict_lru_client(self): + """驱逐最久未使用的客户端(当达到max_clients限制时)""" + if len(self._clients) >= self._max_clients: + # 获取最旧的plugin_id + oldest_id = next(iter(self._clients)) + logger.info(f"📤 达到最大客户端数量限制,驱逐: {oldest_id}") + await self._unload_plugin_unsafe(oldest_id) + + async def load_plugin(self, plugin: MCPPlugin) -> bool: + """ + 从配置加载插件 + + Args: + plugin: 插件配置 + + Returns: + 是否加载成功 + """ + # 使用细粒度锁(只锁定当前用户) + user_lock = await self._get_user_lock(plugin.user_id) + async with user_lock: + try: + plugin_id = f"{plugin.user_id}:{plugin.plugin_name}" + + # 如果已加载,先卸载 + if plugin_id in self._clients: + await self._unload_plugin_unsafe(plugin_id) + + # 检查是否需要驱逐LRU客户端 + await self._evict_lru_client() + + # 目前只支持HTTP类型 + if plugin.plugin_type == "http": + if not plugin.server_url: + logger.error(f"HTTP插件缺少server_url: {plugin.plugin_name}") + return False + + # 使用共享HTTP连接池创建客户端 + client = HTTPMCPClient( + url=plugin.server_url, + headers=plugin.headers or {}, + env=plugin.env or {}, + timeout=plugin.config.get('timeout', 60.0) if plugin.config else 60.0, + http_client=self._shared_http_client # 传入共享连接池 + ) + + # 存储客户端和当前时间戳 + self._clients[plugin_id] = (client, time.time()) + logger.info(f"✅ 加载MCP插件: {plugin_id}") + return True + else: + logger.warning(f"暂不支持的插件类型: {plugin.plugin_type}") + return False + + except Exception as e: + logger.error(f"加载插件失败 {plugin.plugin_name}: {e}") + return False + + async def unload_plugin(self, user_id: str, plugin_name: str): + """ + 卸载插件 + + Args: + user_id: 用户ID + plugin_name: 插件名称 + """ + # 使用细粒度锁(只锁定当前用户) + user_lock = await self._get_user_lock(user_id) + async with user_lock: + plugin_id = f"{user_id}:{plugin_name}" + await self._unload_plugin_unsafe(plugin_id) + + async def _unload_plugin_unsafe(self, plugin_id: str): + """卸载插件(不加锁,内部使用)""" + if plugin_id in self._clients: + client, _ = self._clients[plugin_id] # 解包 (client, timestamp) + try: + await client.close() + except Exception as e: + logger.error(f"关闭插件客户端失败 {plugin_id}: {e}") + + del self._clients[plugin_id] + logger.info(f"卸载MCP插件: {plugin_id}") + + async def reload_plugin(self, plugin: MCPPlugin) -> bool: + """ + 重新加载插件 + + Args: + plugin: 插件配置 + + Returns: + 是否重载成功 + """ + await self.unload_plugin(plugin.user_id, plugin.plugin_name) + return await self.load_plugin(plugin) + + def get_client(self, user_id: str, plugin_name: str) -> Optional[HTTPMCPClient]: + """ + 获取插件客户端(支持LRU访问时间更新) + + Args: + user_id: 用户ID + plugin_name: 插件名称 + + Returns: + 客户端实例或None + """ + plugin_id = f"{user_id}:{plugin_name}" + entry = self._clients.get(plugin_id) + if entry: + # 更新访问时间(LRU) + self._touch_client(plugin_id) + return entry[0] # 返回客户端对象 + return None + + async def call_tool( + self, + user_id: str, + plugin_name: str, + tool_name: str, + arguments: Dict[str, Any] + ) -> Any: + """ + 调用插件工具 + + Args: + user_id: 用户ID + plugin_name: 插件名称 + tool_name: 工具名称 + arguments: 工具参数 + + Returns: + 工具执行结果 + + Raises: + ValueError: 插件不存在或未启用 + MCPError: 工具调用失败 + """ + client = self.get_client(user_id, plugin_name) + + if not client: + raise ValueError(f"插件未加载: {plugin_name}") + + try: + result = await client.call_tool(tool_name, arguments) + logger.info(f"✅ 工具调用成功: {plugin_name}.{tool_name}") + # logger.info(f"✅ 工具返回内容: {result}") + return result + except Exception as e: + logger.error(f"❌ 工具调用失败: {plugin_name}.{tool_name}, 错误: {e}") + raise + + async def get_plugin_tools( + self, + user_id: str, + plugin_name: str + ) -> List[Dict[str, Any]]: + """ + 获取插件的工具列表 + + Args: + user_id: 用户ID + plugin_name: 插件名称 + + Returns: + 工具列表 + """ + client = self.get_client(user_id, plugin_name) + + if not client: + raise ValueError(f"插件未加载: {plugin_name}") + + try: + tools = await client.list_tools() + return tools + except Exception as e: + logger.error(f"获取工具列表失败: {plugin_name}, 错误: {e}") + raise + + async def test_plugin( + self, + user_id: str, + plugin_name: str + ) -> Dict[str, Any]: + """ + 测试插件连接 + + Args: + user_id: 用户ID + plugin_name: 插件名称 + + Returns: + 测试结果 + """ + client = self.get_client(user_id, plugin_name) + + if not client: + raise ValueError(f"插件未加载: {plugin_name}") + + return await client.test_connection() + + async def cleanup_all(self): + """清理所有插件和资源""" + # 停止后台清理任务 + if self._cleanup_task: + self._cleanup_task.cancel() + try: + await self._cleanup_task + except asyncio.CancelledError: + pass + + # 清理所有客户端 + plugin_ids = list(self._clients.keys()) + for plugin_id in plugin_ids: + user_id = plugin_id.split(':', 1)[0] + user_lock = await self._get_user_lock(user_id) + async with user_lock: + await self._unload_plugin_unsafe(plugin_id) + + # 关闭共享HTTP客户端 + try: + await self._shared_http_client.aclose() + except Exception as e: + logger.error(f"关闭共享HTTP客户端失败: {e}") + + logger.info("✅ 已清理所有MCP插件和资源") + + +# 全局注册表实例 +mcp_registry = MCPPluginRegistry() \ No newline at end of file diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 396099c..e06a9f7 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -1,37 +1,34 @@ -"""数据库模型""" +"""数据模型导出""" from app.models.project import Project from app.models.outline import Outline -from app.models.character import Character from app.models.chapter import Chapter +from app.models.character import Character +from app.models.relationship import CharacterRelationship, Organization, OrganizationMember, RelationshipType from app.models.generation_history import GenerationHistory -from app.models.settings import Settings -from app.models.writing_style import WritingStyle -from app.models.project_default_style import ProjectDefaultStyle -from app.models.relationship import ( - RelationshipType, - CharacterRelationship, - Organization, - OrganizationMember -) -from app.models.memory import StoryMemory, PlotAnalysis from app.models.analysis_task import AnalysisTask from app.models.batch_generation_task import BatchGenerationTask +from app.models.settings import Settings +from app.models.memory import StoryMemory, PlotAnalysis +from app.models.writing_style import WritingStyle +from app.models.project_default_style import ProjectDefaultStyle +from app.models.mcp_plugin import MCPPlugin __all__ = [ "Project", "Outline", - "Character", "Chapter", - "GenerationHistory", - "Settings", - "WritingStyle", - "ProjectDefaultStyle", - "RelationshipType", + "Character", "CharacterRelationship", "Organization", "OrganizationMember", - "StoryMemory", - "PlotAnalysis", + "RelationshipType", + "GenerationHistory", "AnalysisTask", "BatchGenerationTask", + "Settings", + "StoryMemory", + "PlotAnalysis", + "WritingStyle", + "ProjectDefaultStyle", + "MCPPlugin" ] \ No newline at end of file diff --git a/backend/app/models/mcp_plugin.py b/backend/app/models/mcp_plugin.py new file mode 100644 index 0000000..4a45393 --- /dev/null +++ b/backend/app/models/mcp_plugin.py @@ -0,0 +1,52 @@ +"""MCP插件配置数据模型""" +from sqlalchemy import Column, String, Text, Boolean, Integer, DateTime, Index, JSON +from sqlalchemy.sql import func +from app.database import Base +import uuid + + +class MCPPlugin(Base): + """MCP插件配置表""" + __tablename__ = "mcp_plugins" + + id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4())) + user_id = Column(String(50), nullable=False, index=True, comment="用户ID") + + # 插件基本信息 + plugin_name = Column(String(100), nullable=False, comment="插件名称(唯一标识)") + display_name = Column(String(200), nullable=False, comment="显示名称") + description = Column(Text, comment="插件描述") + plugin_type = Column(String(50), default="http", comment="插件类型:http/stdio") + + # 连接配置 + server_url = Column(String(500), comment="服务器URL(HTTP类型)") + command = Column(String(500), comment="启动命令(stdio类型)") + args = Column(JSON, comment="命令参数(stdio类型)") + env = Column(JSON, comment="环境变量") + headers = Column(JSON, comment="HTTP请求头") + + # 插件配置 + config = Column(JSON, comment="插件特定配置(JSON)") + tools = Column(JSON, comment="提供的工具列表") + + # 状态管理 + enabled = Column(Boolean, default=True, comment="是否启用") + status = Column(String(50), default="inactive", comment="状态:active/inactive/error") + last_error = Column(Text, comment="最后错误信息") + last_test_at = Column(DateTime, comment="最后测试时间") + + # 排序和分组 + category = Column(String(100), default="general", comment="分类") + sort_order = Column(Integer, default=0, comment="排序顺序") + + # 时间戳 + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + __table_args__ = ( + Index('idx_user_plugin', 'user_id', 'plugin_name', unique=True), + Index('idx_user_enabled', 'user_id', 'enabled'), + ) + + def __repr__(self): + return f"" \ No newline at end of file diff --git a/backend/app/schemas/chapter.py b/backend/app/schemas/chapter.py index 52e9d7f..71b3082 100644 --- a/backend/app/schemas/chapter.py +++ b/backend/app/schemas/chapter.py @@ -66,6 +66,7 @@ class ChapterGenerateRequest(BaseModel): ge=500, # 最小500字 le=10000 # 最大10000字 ) + enable_mcp: bool = Field(True, description="是否启用MCP工具增强(搜索参考资料)") class BatchGenerateRequest(BaseModel): @@ -80,6 +81,7 @@ class BatchGenerateRequest(BaseModel): le=10000 ) enable_analysis: bool = Field(False, description="是否启用同步分析") + enable_mcp: bool = Field(True, description="是否启用MCP工具增强(搜索参考资料)") max_retries: int = Field(3, description="每个章节的最大重试次数", ge=0, le=5) diff --git a/backend/app/schemas/character.py b/backend/app/schemas/character.py index a61ded1..498c811 100644 --- a/backend/app/schemas/character.py +++ b/backend/app/schemas/character.py @@ -63,6 +63,7 @@ class CharacterGenerateRequest(BaseModel): role_type: Optional[str] = Field(None, description="角色类型") background: Optional[str] = Field(None, description="角色背景") requirements: Optional[str] = Field(None, description="特殊要求") + enable_mcp: bool = Field(True, description="是否启用MCP工具增强(搜索人物原型参考)") class CharacterListResponse(BaseModel): diff --git a/backend/app/schemas/mcp_plugin.py b/backend/app/schemas/mcp_plugin.py new file mode 100644 index 0000000..4c22bdb --- /dev/null +++ b/backend/app/schemas/mcp_plugin.py @@ -0,0 +1,105 @@ +"""MCP插件Pydantic模式""" +from pydantic import BaseModel, Field +from typing import Optional, Dict, Any, List +from datetime import datetime + + +class MCPToolSchema(BaseModel): + """MCP工具定义""" + name: str + description: Optional[str] = None + inputSchema: Optional[Dict[str, Any]] = None + category: Optional[str] = None + + +class MCPPluginBase(BaseModel): + """插件基础模式""" + plugin_name: str = Field(..., description="插件唯一标识") + display_name: Optional[str] = Field(None, description="显示名称") + description: Optional[str] = Field(None, description="插件描述") + plugin_type: str = Field(default="http", description="插件类型:http/stdio") + category: str = Field(default="general", description="分类") + sort_order: int = Field(default=0, description="排序顺序") + + +class MCPPluginCreate(MCPPluginBase): + """创建插件""" + server_url: Optional[str] = Field(None, description="服务器URL(HTTP类型)") + command: Optional[str] = Field(None, description="启动命令(stdio类型)") + args: Optional[List[str]] = Field(None, description="命令参数") + env: Optional[Dict[str, str]] = Field(None, description="环境变量") + headers: Optional[Dict[str, str]] = Field(None, description="HTTP请求头") + config: Optional[Dict[str, Any]] = Field(None, description="插件特定配置") + enabled: bool = Field(default=True, description="是否启用") + + +class MCPPluginSimpleCreate(BaseModel): + """简化的插件创建(通过标准MCP配置JSON)""" + config_json: str = Field(..., description="标准MCP配置JSON字符串") + enabled: bool = Field(default=True, description="是否启用") + category: str = Field(default="general", description="插件分类") + + +class MCPPluginUpdate(BaseModel): + """更新插件""" + display_name: Optional[str] = None + description: Optional[str] = None + server_url: Optional[str] = None + command: Optional[str] = None + args: Optional[List[str]] = None + env: Optional[Dict[str, str]] = None + headers: Optional[Dict[str, str]] = None + config: Optional[Dict[str, Any]] = None + enabled: Optional[bool] = None + category: Optional[str] = None + sort_order: Optional[int] = None + + +class MCPPluginResponse(BaseModel): + """插件响应 - 优化后只返回必要字段""" + id: str + plugin_name: str + display_name: str + description: Optional[str] = None + plugin_type: str + category: str + + # HTTP类型字段 + server_url: Optional[str] = None + headers: Optional[Dict[str, str]] = None + + # Stdio类型字段 + command: Optional[str] = None + args: Optional[List[str]] = None + env: Optional[Dict[str, str]] = None + + # 状态字段 + enabled: bool + status: str + last_error: Optional[str] = None + last_test_at: Optional[datetime] = None + + # 时间戳 + created_at: datetime + + class Config: + from_attributes = True + + +class MCPToolCall(BaseModel): + """工具调用请求""" + plugin_id: str = Field(..., description="插件ID") + tool_name: str = Field(..., description="工具名称") + arguments: Dict[str, Any] = Field(default_factory=dict, description="工具参数") + + +class MCPTestResult(BaseModel): + """测试结果""" + success: bool + message: str + response_time_ms: Optional[float] = None + tools_count: Optional[int] = None + tools: Optional[List[MCPToolSchema]] = None + error: Optional[str] = None + error_type: Optional[str] = None + suggestions: Optional[List[str]] = None \ No newline at end of file diff --git a/backend/app/schemas/outline.py b/backend/app/schemas/outline.py index 2ff9619..a471415 100644 --- a/backend/app/schemas/outline.py +++ b/backend/app/schemas/outline.py @@ -61,6 +61,7 @@ class OutlineGenerateRequest(BaseModel): story_direction: Optional[str] = Field(None, description="故事发展方向提示(续写时使用)") plot_stage: str = Field("development", description="情节阶段: development(发展), climax(高潮), ending(结局)") keep_existing: bool = Field(False, description="是否保留现有大纲(续写时)") + enable_mcp: bool = Field(True, description="是否启用MCP工具增强(搜索情节设计参考)") class ChapterOutlineGenerateRequest(BaseModel): diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index 5fe53af..4bfeb97 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -5,6 +5,7 @@ from anthropic import AsyncAnthropic from app.config import settings as app_settings from app.logger import get_logger import httpx +import json logger = get_logger(__name__) @@ -126,10 +127,12 @@ class AIService: model: Optional[str] = None, temperature: Optional[float] = None, max_tokens: Optional[int] = None, - system_prompt: Optional[str] = None - ) -> str: + system_prompt: Optional[str] = None, + tools: Optional[List[Dict[str, Any]]] = None, + tool_choice: Optional[str] = None + ) -> Dict[str, Any]: """ - 生成文本 + 生成文本(支持工具调用) Args: prompt: 用户提示词 @@ -138,9 +141,14 @@ class AIService: temperature: 温度参数 max_tokens: 最大token数 system_prompt: 系统提示词 + tools: 可用工具列表(MCP工具格式) + tool_choice: 工具选择策略 (auto/required/none) Returns: - 生成的文本 + Dict包含: + - content: 文本内容(如果没有工具调用) + - tool_calls: 工具调用列表(如果AI决定调用工具) + - finish_reason: 完成原因 """ provider = provider or self.api_provider model = model or self.default_model @@ -148,12 +156,12 @@ class AIService: max_tokens = max_tokens or self.default_max_tokens if provider == "openai": - return await self._generate_openai( - prompt, model, temperature, max_tokens, system_prompt + return await self._generate_openai_with_tools( + prompt, model, temperature, max_tokens, system_prompt, tools, tool_choice ) elif provider == "anthropic": - return await self._generate_anthropic( - prompt, model, temperature, max_tokens, system_prompt + return await self._generate_anthropic_with_tools( + prompt, model, temperature, max_tokens, system_prompt, tools, tool_choice ) else: raise ValueError(f"不支持的AI提供商: {provider}") @@ -247,6 +255,7 @@ class AIService: logger.info(f"✅ OpenAI API调用成功") logger.info(f" - 响应ID: {data.get('id', 'N/A')}") logger.info(f" - 选项数量: {len(data.get('choices', []))}") + logger.debug(f" - 完整API响应: {data}") if not data.get('choices'): logger.error("❌ OpenAI返回的choices为空") @@ -294,6 +303,173 @@ class AIService: logger.error(f" - 模型: {model}") raise + + async def _generate_openai_with_tools( + self, + prompt: str, + model: str, + temperature: float, + max_tokens: int, + system_prompt: Optional[str], + tools: Optional[List[Dict[str, Any]]] = None, + tool_choice: Optional[str] = None + ) -> Dict[str, Any]: + """使用OpenAI生成文本(支持工具调用)""" + if not self.openai_http_client: + raise ValueError("OpenAI客户端未初始化,请检查API key配置") + + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + try: + logger.info(f"🔵 开始调用OpenAI API(支持工具调用)") + logger.info(f" - 模型: {model}") + logger.info(f" - 工具数量: {len(tools) if tools else 0}") + + url = f"{self.openai_base_url}/chat/completions" + headers = { + "Authorization": f"Bearer {self.openai_api_key}", + "Content-Type": "application/json" + } + payload = { + "model": model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens + } + + # 添加工具参数 + if tools: + payload["tools"] = tools + if tool_choice: + if tool_choice == "required": + payload["tool_choice"] = "required" + elif tool_choice == "auto": + payload["tool_choice"] = "auto" + elif tool_choice == "none": + payload["tool_choice"] = "none" + + response = await self.openai_http_client.post(url, headers=headers, json=payload) + response.raise_for_status() + + data = response.json() + + logger.info(f"✅ OpenAI API调用成功") + logger.debug(f" - 完整API响应: {data}") + + if not data.get('choices'): + logger.error(f"❌ API返回的choices为空") + logger.error(f" - 完整响应: {data}") + logger.error(f" - 响应键: {list(data.keys())}") + raise ValueError(f"API返回的响应格式错误:choices字段为空。完整响应: {data}") + + choice = data['choices'][0] + message = choice.get('message', {}) + finish_reason = choice.get('finish_reason') + + # 检查是否有工具调用 + tool_calls = message.get('tool_calls') + if tool_calls: + logger.info(f"🔧 AI请求调用 {len(tool_calls)} 个工具") + return { + "tool_calls": tool_calls, + "content": message.get('content', ''), + "finish_reason": finish_reason + } + + # 没有工具调用,返回普通内容 + content = message.get('content', '') + if content: + return { + "content": content, + "finish_reason": finish_reason + } + else: + raise ValueError(f"AI返回了空内容(finish_reason: {finish_reason})") + + except httpx.HTTPStatusError as e: + logger.error(f"❌ OpenAI API调用失败 (HTTP {e.response.status_code})") + logger.error(f" - 错误信息: {e.response.text}") + raise Exception(f"API返回错误 ({e.response.status_code}): {e.response.text}") + except Exception as e: + logger.error(f"❌ OpenAI API调用失败: {str(e)}") + raise + + async def _generate_anthropic_with_tools( + self, + prompt: str, + model: str, + temperature: float, + max_tokens: int, + system_prompt: Optional[str], + tools: Optional[List[Dict[str, Any]]] = None, + tool_choice: Optional[str] = None + ) -> Dict[str, Any]: + """使用Anthropic生成文本(支持工具调用)""" + if not self.anthropic_client: + raise ValueError("Anthropic客户端未初始化,请检查API key配置") + + try: + logger.info(f"🔵 开始调用Anthropic API(支持工具调用)") + logger.info(f" - 模型: {model}") + logger.info(f" - 工具数量: {len(tools) if tools else 0}") + + kwargs = { + "model": model, + "max_tokens": max_tokens, + "temperature": temperature, + "messages": [{"role": "user", "content": prompt}] + } + + if system_prompt: + kwargs["system"] = system_prompt + + # 添加工具参数 + if tools: + kwargs["tools"] = tools + if tool_choice == "required": + kwargs["tool_choice"] = {"type": "any"} + elif tool_choice == "auto": + kwargs["tool_choice"] = {"type": "auto"} + + response = await self.anthropic_client.messages.create(**kwargs) + + # 检查是否有工具调用 + tool_calls = [] + content_text = "" + + for block in response.content: + if block.type == "tool_use": + tool_calls.append({ + "id": block.id, + "type": "function", + "function": { + "name": block.name, + "arguments": block.input + } + }) + elif block.type == "text": + content_text += block.text + + if tool_calls: + logger.info(f"🔧 AI请求调用 {len(tool_calls)} 个工具") + return { + "tool_calls": tool_calls, + "content": content_text, + "finish_reason": response.stop_reason + } + + return { + "content": content_text, + "finish_reason": response.stop_reason + } + + except Exception as e: + logger.error(f"❌ Anthropic API调用失败: {str(e)}") + raise + async def _generate_openai_stream( self, prompt: str, @@ -456,6 +632,232 @@ class AIService: logger.error(f"❌ Anthropic流式API调用失败: {str(e)}") logger.error(f" - 错误类型: {type(e).__name__}") raise + + async def generate_text_with_mcp( + self, + prompt: str, + user_id: str, + db_session, + enable_mcp: bool = True, + max_tool_rounds: int = 3, + tool_choice: str = "auto", + **kwargs + ) -> Dict[str, Any]: + """ + 支持MCP工具的AI文本生成(非流式) + + Args: + prompt: 用户提示词 + user_id: 用户ID,用于获取MCP工具 + db_session: 数据库会话 + enable_mcp: 是否启用MCP增强 + max_tool_rounds: 最大工具调用轮次 + tool_choice: 工具选择策略(auto/required/none) + **kwargs: 其他AI参数(provider, model, temperature等) + + Returns: + { + "content": "AI生成的最终文本", + "tool_calls_made": 2, # 实际调用的工具次数 + "tools_used": ["exa_search", "filesystem_read"], + "finish_reason": "stop", + "mcp_enhanced": True + } + """ + from app.services.mcp_tool_service import mcp_tool_service, MCPToolServiceError + + # 初始化返回结果 + result = { + "content": "", + "tool_calls_made": 0, + "tools_used": [], + "finish_reason": "", + "mcp_enhanced": False + } + + # 1. 获取MCP工具(如果启用) + tools = None + if enable_mcp: + try: + tools = await mcp_tool_service.get_user_enabled_tools( + user_id=user_id, + db_session=db_session + ) + if tools: + logger.info(f"MCP增强: 加载了 {len(tools)} 个工具") + result["mcp_enhanced"] = True + except MCPToolServiceError as e: + logger.error(f"获取MCP工具失败,降级为普通生成: {e}") + tools = None + + # 2. 工具调用循环 + conversation_history = [ + {"role": "user", "content": prompt} + ] + + for round_num in range(max_tool_rounds): + logger.info(f"MCP工具调用轮次: {round_num + 1}/{max_tool_rounds}") + + # 调用AI + ai_response = await self.generate_text( + prompt=conversation_history[-1]["content"], + tools=tools if round_num == 0 else None, # 只在第一轮传递工具 + tool_choice=tool_choice if round_num == 0 else None, + **kwargs + ) + + # 检查是否有工具调用 + tool_calls = ai_response.get("tool_calls", []) + + if not tool_calls: + # AI返回最终内容 + result["content"] = ai_response.get("content", "") + result["finish_reason"] = ai_response.get("finish_reason", "stop") + break + + # 3. 执行工具调用 + logger.info(f"AI请求调用 {len(tool_calls)} 个工具") + + try: + tool_results = await mcp_tool_service.execute_tool_calls( + user_id=user_id, + tool_calls=tool_calls, + db_session=db_session + ) + + # 记录使用的工具 + for tool_call in tool_calls: + tool_name = tool_call["function"]["name"] + if tool_name not in result["tools_used"]: + result["tools_used"].append(tool_name) + + result["tool_calls_made"] += len(tool_calls) + + # 4. 构建工具上下文 + tool_context = await mcp_tool_service.build_tool_context( + tool_results, + format="markdown" + ) + + # 5. 更新对话历史 + conversation_history.append({ + "role": "assistant", + "content": ai_response.get("content", ""), + "tool_calls": tool_calls + }) + + for tool_result in tool_results: + conversation_history.append({ + "role": "tool", + "tool_call_id": tool_result["tool_call_id"], + "content": tool_result["content"] + }) + + # 6. 构建下一轮提示 + next_prompt = ( + f"{prompt}\n\n" + f"{tool_context}\n\n" + f"请基于以上工具查询结果,继续完成任务。" + ) + conversation_history.append({ + "role": "user", + "content": next_prompt + }) + + except Exception as e: + logger.error(f"执行MCP工具失败: {e}", exc_info=True) + # 降级:返回当前AI响应 + result["content"] = ai_response.get("content", "") + result["finish_reason"] = "tool_error" + break + + else: + # 达到最大轮次 + logger.warning(f"达到MCP最大调用轮次 {max_tool_rounds}") + result["content"] = conversation_history[-1].get("content", "") + result["finish_reason"] = "max_rounds" + + return result + + async def generate_text_stream_with_mcp( + self, + prompt: str, + user_id: str, + db_session, + enable_mcp: bool = True, + mcp_planning_prompt: Optional[str] = None, + **kwargs + ) -> AsyncGenerator[str, None]: + """ + 支持MCP工具的AI流式文本生成(两阶段模式) + + Args: + prompt: 用户提示词 + user_id: 用户ID + db_session: 数据库会话 + enable_mcp: 是否启用MCP增强 + mcp_planning_prompt: MCP规划阶段的提示词(可选) + **kwargs: 其他AI参数 + + Yields: + 流式文本chunk + """ + from app.services.mcp_tool_service import mcp_tool_service + + # 阶段1: 工具调用阶段(非流式) + enhanced_prompt = prompt + + if enable_mcp: + try: + # 获取MCP工具 + tools = await mcp_tool_service.get_user_enabled_tools( + user_id=user_id, + db_session=db_session + ) + + if tools: + logger.info(f"MCP增强(流式): 加载了 {len(tools)} 个工具") + + # 使用规划提示让AI决定需要查询什么 + if not mcp_planning_prompt: + mcp_planning_prompt = ( + f"任务: {prompt}\n\n" + f"请分析这个任务,决定是否需要查询外部信息。" + f"如果需要,请调用相应的工具获取信息。" + ) + + # 非流式调用获取工具结果 + planning_result = await self.generate_text_with_mcp( + prompt=mcp_planning_prompt, + user_id=user_id, + db_session=db_session, + enable_mcp=True, + max_tool_rounds=2, + tool_choice="auto", + **kwargs + ) + + # 如果有工具调用,将结果融入提示 + if planning_result["tool_calls_made"] > 0: + enhanced_prompt = ( + f"{prompt}\n\n" + f"【参考资料】\n" + f"{planning_result.get('content', '')}" + ) + logger.info( + f"MCP工具规划完成,调用了 " + f"{planning_result['tool_calls_made']} 次工具" + ) + + except Exception as e: + logger.error(f"MCP工具规划失败,使用原始提示: {e}") + + # 阶段2: 内容生成阶段(流式) + async for chunk in self.generate_text_stream( + prompt=enhanced_prompt, + **kwargs + ): + yield chunk # 创建全局AI服务实例 diff --git a/backend/app/services/mcp_tool_service.py b/backend/app/services/mcp_tool_service.py new file mode 100644 index 0000000..bdf3fa8 --- /dev/null +++ b/backend/app/services/mcp_tool_service.py @@ -0,0 +1,355 @@ +"""MCP工具服务 - 统一管理MCP工具的注入和执行""" + +from typing import List, Dict, Any, Optional +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +import asyncio +import json +from datetime import datetime + +from app.models.mcp_plugin import MCPPlugin +from app.mcp.registry import mcp_registry +from app.logger import get_logger + +logger = get_logger(__name__) + + +class MCPToolServiceError(Exception): + """MCP工具服务异常""" + pass + + +class MCPToolService: + """MCP工具服务 - 统一管理MCP工具的注入和执行""" + + def __init__(self): + self._tool_cache = {} # 工具定义缓存 + self._result_cache = {} # 工具结果缓存(可选) + + async def get_user_enabled_tools( + self, + user_id: str, + db_session: AsyncSession, + category: Optional[str] = None + ) -> List[Dict[str, Any]]: + """ + 获取用户启用的MCP工具列表 + + Args: + user_id: 用户ID + db_session: 数据库会话 + category: 工具类别筛选(search/analysis/filesystem等) + + Returns: + 工具定义列表,格式符合OpenAI Function Calling规范 + """ + try: + # 1. 查询用户启用的插件(enabled=True即可,不强制要求status=active) + # 因为新启用的插件status可能还是inactive,需要给它机会被调用 + query = select(MCPPlugin).where( + MCPPlugin.user_id == user_id, + MCPPlugin.enabled == True + ) + + if category: + query = query.where(MCPPlugin.category == category) + + result = await db_session.execute(query) + plugins = result.scalars().all() + + if not plugins: + logger.info(f"用户 {user_id} 没有启用的MCP插件") + return [] + + # 2. 获取所有工具定义 + all_tools = [] + for plugin in plugins: + try: + # 确保插件已加载到注册表 + if not mcp_registry.get_client(user_id, plugin.plugin_name): + logger.info(f"插件 {plugin.plugin_name} 未加载,尝试加载...") + success = await mcp_registry.load_plugin(plugin) + if not success: + logger.warning(f"插件 {plugin.plugin_name} 加载失败,跳过") + continue + + # 从registry获取该插件的工具列表 + plugin_tools = await mcp_registry.get_plugin_tools( + user_id=user_id, + plugin_name=plugin.plugin_name + ) + + # 格式化为Function Calling格式 + formatted_tools = self._format_tools_for_ai( + plugin_tools, + plugin.plugin_name # ✅ 修复:使用正确的属性名plugin_name + ) + all_tools.extend(formatted_tools) + + logger.info( + f"从插件 {plugin.plugin_name} 加载了 " + f"{len(formatted_tools)} 个工具" + ) + + except Exception as e: + logger.error( + f"获取插件 {plugin.plugin_name} 的工具失败: {e}", + exc_info=True + ) + continue + + logger.info(f"用户 {user_id} 共加载 {len(all_tools)} 个MCP工具") + return all_tools + + except Exception as e: + logger.error(f"获取用户MCP工具失败: {e}", exc_info=True) + raise MCPToolServiceError(f"获取MCP工具失败: {str(e)}") + + def _format_tools_for_ai( + self, + plugin_tools: List[Dict[str, Any]], + plugin_name: str + ) -> List[Dict[str, Any]]: + """ + 将MCP工具定义格式化为AI Function Calling格式 + + Args: + plugin_tools: MCP插件的工具列表 + plugin_name: 插件名称 + + Returns: + 格式化后的工具列表 + """ + formatted_tools = [] + + for tool in plugin_tools: + formatted_tool = { + "type": "function", + "function": { + "name": f"{plugin_name}_{tool['name']}", # 加插件前缀避免冲突 + "description": tool.get("description", ""), + "parameters": tool.get("inputSchema", { + "type": "object", + "properties": {}, + "required": [] + }) + } + } + formatted_tools.append(formatted_tool) + + return formatted_tools + + async def execute_tool_calls( + self, + user_id: str, + tool_calls: List[Dict[str, Any]], + db_session: AsyncSession, + timeout: float = 60.0 + ) -> List[Dict[str, Any]]: + """ + 批量执行AI请求的工具调用(并行执行) + + Args: + user_id: 用户ID + tool_calls: AI返回的工具调用列表 + db_session: 数据库会话 + timeout: 单个工具调用的超时时间(秒,默认30秒) + + Returns: + 工具调用结果列表 + """ + if not tool_calls: + return [] + + logger.info(f"开始执行 {len(tool_calls)} 个工具调用") + + # 创建异步任务列表 + tasks = [ + self._execute_single_tool( + user_id=user_id, + tool_call=tool_call, + db_session=db_session, + timeout=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] + + 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) + + return formatted_results + + async def _execute_single_tool( + self, + user_id: str, + tool_call: Dict[str, Any], + db_session: AsyncSession, + timeout: float + ) -> Dict[str, Any]: + """ + 执行单个工具调用 + + Args: + user_id: 用户ID + tool_call: 工具调用信息 + db_session: 数据库会话 + timeout: 超时时间 + + Returns: + 工具调用结果 + """ + tool_call_id = tool_call.get("id", "unknown") + function_name = tool_call["function"]["name"] + + try: + # 解析插件名和工具名 + if "_" in function_name: + plugin_name, tool_name = function_name.split("_", 1) + else: + raise ValueError(f"无效的工具名称格式: {function_name}") + + # 解析参数 + arguments_str = tool_call["function"]["arguments"] + if isinstance(arguments_str, str): + arguments = json.loads(arguments_str) + else: + arguments = arguments_str + + logger.info( + f"执行工具: {plugin_name}.{tool_name}, " + f"参数: {arguments}" + ) + + # 设置超时 + try: + result = await asyncio.wait_for( + mcp_registry.call_tool( + user_id=user_id, + plugin_name=plugin_name, + tool_name=tool_name, + arguments=arguments + ), + timeout=timeout + ) + + # 成功返回 + return { + "tool_call_id": tool_call_id, + "role": "tool", + "name": function_name, + "content": json.dumps(result, ensure_ascii=False), + "success": True, + "error": None + } + + except asyncio.TimeoutError: + raise MCPToolServiceError( + f"工具调用超时(>{timeout}秒)" + ) + + except Exception as e: + logger.error( + f"工具 {function_name} 调用失败: {e}", + exc_info=True + ) + return { + "tool_call_id": tool_call_id, + "role": "tool", + "name": function_name, + "content": f"工具调用失败: {str(e)}", + "success": False, + "error": str(e) + } + + async def build_tool_context( + self, + tool_results: List[Dict[str, Any]], + format: str = "markdown" + ) -> str: + """ + 将工具调用结果格式化为上下文文本 + + Args: + tool_results: 工具调用结果列表 + format: 输出格式(markdown/json/plain) + + Returns: + 格式化的上下文字符串 + """ + if not tool_results: + return "" + + if format == "markdown": + return self._build_markdown_context(tool_results) + elif format == "json": + return json.dumps(tool_results, ensure_ascii=False, indent=2) + else: # plain + return self._build_plain_context(tool_results) + + def _build_markdown_context( + self, + tool_results: List[Dict[str, Any]] + ) -> str: + """构建Markdown格式的工具上下文""" + lines = ["## 🔧 工具调用结果\n"] + + for i, result in enumerate(tool_results, 1): + tool_name = result.get("name", "unknown") + success = result.get("success", False) + content = result.get("content", "") + + status_emoji = "✅" if success else "❌" + lines.append(f"### {status_emoji} {i}. {tool_name}\n") + + if success: + # 尝试美化JSON内容 + try: + content_obj = json.loads(content) + content = json.dumps(content_obj, ensure_ascii=False, indent=2) + except: + pass + lines.append(f"```json\n{content}\n```\n") + else: + lines.append(f"**错误**: {content}\n") + + return "\n".join(lines) + + def _build_plain_context( + self, + tool_results: List[Dict[str, Any]] + ) -> str: + """构建纯文本格式的工具上下文""" + lines = ["=== 工具调用结果 ===\n"] + + for i, result in enumerate(tool_results, 1): + tool_name = result.get("name", "unknown") + success = result.get("success", False) + content = result.get("content", "") + + status = "成功" if success else "失败" + lines.append(f"{i}. {tool_name} - {status}") + lines.append(f" 结果: {content}\n") + + return "\n".join(lines) + + +# 全局单例 +mcp_tool_service = MCPToolService() \ No newline at end of file diff --git a/backend/app/services/plot_analyzer.py b/backend/app/services/plot_analyzer.py index 29e9636..ef9f45f 100644 --- a/backend/app/services/plot_analyzer.py +++ b/backend/app/services/plot_analyzer.py @@ -225,8 +225,22 @@ class PlotAnalyzer: temperature=0.3 # 降低温度以获得更稳定的JSON输出 ) + # 🔍 添加调试日志:查看AI返回的原始内容 + logger.info(f"🔍 AI返回类型: {type(response)}") + logger.info(f"🔍 AI返回内容(前500字符): {str(response)}") + + # 从返回的字典中提取content字段 + if isinstance(response, dict): + response_text = response.get('content', '') + if not response_text: + logger.error("❌ AI返回的字典中没有content字段或content为空") + return None + else: + # 兼容旧的字符串返回格式 + response_text = response + # 解析JSON结果 - analysis_result = self._parse_analysis_response(response) + analysis_result = self._parse_analysis_response(response_text) if analysis_result: logger.info(f"✅ 第{chapter_number}章分析完成") diff --git a/backend/app/services/prompt_service.py b/backend/app/services/prompt_service.py index d80c41d..b0add89 100644 --- a/backend/app/services/prompt_service.py +++ b/backend/app/services/prompt_service.py @@ -282,6 +282,8 @@ class PromptService: 角色信息: {characters_info} +{mcp_references} + 其他要求:{requirements} 整体要求: @@ -356,6 +358,8 @@ class PromptService: {memory_context} +{mcp_references} + 【续写指导】 - 当前情节阶段:{plot_stage_instruction} - 起始章节编号:第{start_chapter}章 @@ -836,8 +840,17 @@ class PromptService: chapter_count: int, narrative_perspective: str, target_words: int, time_period: str, location: str, atmosphere: str, rules: str, characters_info: str, - requirements: str = "") -> str: - """获取向导大纲生成提示词""" + requirements: str = "", + mcp_references: str = "") -> str: + """获取向导大纲生成提示词(支持MCP增强)""" + # 格式化MCP参考资料 + mcp_text = "" + if mcp_references: + mcp_text = "【📚 MCP工具搜索 - 情节设计参考】\n" + mcp_text += "以下是通过MCP工具搜索到的情节设计参考资料,可用于设计大纲结构和情节发展:\n\n" + mcp_text += mcp_references + mcp_text += "\n" + return cls.format_prompt( cls.COMPLETE_OUTLINE_GENERATION, title=title, @@ -851,6 +864,7 @@ class PromptService: atmosphere=atmosphere, rules=rules, characters_info=characters_info, + mcp_references=mcp_text, requirements=requirements or "无特殊要求" ) @@ -862,7 +876,8 @@ class PromptService: chapter_number: int, chapter_title: str, chapter_outline: str, style_content: str = "", target_word_count: int = 3000, - memory_context: dict = None) -> str: + memory_context: dict = None, + mcp_references: str = "") -> str: """ 获取章节完整创作提示词 @@ -870,6 +885,7 @@ class PromptService: style_content: 写作风格要求内容,如果提供则会追加到提示词中 target_word_count: 目标字数,默认3000字 memory_context: 记忆上下文(可选) + mcp_references: MCP工具搜索的参考资料(可选) """ # 计算最大字数(目标字数+1000) max_word_count = target_word_count + 1000 @@ -884,6 +900,14 @@ class PromptService: memory_text += "\n" + memory_context.get('character_states', '') memory_text += "\n" + memory_context.get('plot_points', '') + # 格式化MCP参考资料 + mcp_text = "" + if mcp_references: + mcp_text = "\n【📚 MCP工具搜索 - 参考资料】\n" + mcp_text += "以下是通过MCP工具搜索到的相关参考资料,可用于丰富情节和细节:\n\n" + mcp_text += mcp_references + mcp_text += "\n" + base_prompt = cls.format_prompt( cls.CHAPTER_GENERATION, title=title, @@ -903,11 +927,17 @@ class PromptService: max_word_count=max_word_count ) - # 插入记忆上下文 + # 插入记忆上下文和MCP参考资料 + insert_text = "" if memory_text: + insert_text += memory_text + if mcp_text: + insert_text += mcp_text + + if insert_text: base_prompt = base_prompt.replace( "本章信息:", - memory_text + "\n\n本章信息:" + insert_text + "\n\n本章信息:" ) # 如果有风格要求,应用到提示词中 @@ -925,7 +955,8 @@ class PromptService: chapter_title: str, chapter_outline: str, style_content: str = "", target_word_count: int = 3000, - memory_context: dict = None) -> str: + memory_context: dict = None, + mcp_references: str = "") -> str: """ 获取章节完整创作提示词(带前置章节上下文和记忆增强) @@ -933,6 +964,7 @@ class PromptService: style_content: 写作风格要求内容,如果提供则会追加到提示词中 target_word_count: 目标字数,默认3000字 memory_context: 记忆上下文(可选) + mcp_references: MCP工具搜索的参考资料(可选) """ # 计算最大字数(目标字数+1000) max_word_count = target_word_count + 1000 @@ -948,6 +980,12 @@ class PromptService: else: memory_text = "暂无相关记忆" + # 格式化MCP参考资料 + if mcp_references: + memory_text += "\n\n【📚 MCP工具搜索 - 参考资料】\n" + memory_text += "以下是通过MCP工具搜索到的相关参考资料,可用于丰富情节和细节:\n\n" + memory_text += mcp_references + base_prompt = cls.format_prompt( cls.CHAPTER_GENERATION_WITH_CONTEXT, title=title, @@ -996,8 +1034,9 @@ class PromptService: recent_plot: str, plot_stage_instruction: str, start_chapter: int, story_direction: str, requirements: str = "", - memory_context: dict = None) -> str: - """获取大纲续写提示词(支持记忆增强)""" + memory_context: dict = None, + mcp_references: str = "") -> str: + """获取大纲续写提示词(支持记忆+MCP增强)""" end_chapter = start_chapter + chapter_count - 1 # 格式化记忆上下文 @@ -1011,6 +1050,14 @@ class PromptService: else: memory_text = "暂无相关记忆(可能是首次续写或记忆库为空)" + # 格式化MCP参考资料 + mcp_text = "" + if mcp_references: + mcp_text = "\n\n【📚 MCP工具搜索 - 续写参考资料】\n" + mcp_text += "以下是通过MCP工具搜索到的续写参考资料,可用于丰富情节发展和冲突设计:\n\n" + mcp_text += mcp_references + mcp_text += "\n" + return cls.format_prompt( cls.OUTLINE_CONTINUE_GENERATION, title=title, @@ -1031,7 +1078,8 @@ class PromptService: end_chapter=end_chapter, story_direction=story_direction, requirements=requirements or "无特殊要求", - memory_context=memory_text + memory_context=memory_text, + mcp_references=mcp_text ) @classmethod diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 383598e..0ca4de0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,6 +14,7 @@ import ChapterReader from './pages/ChapterReader'; import ChapterAnalysis from './pages/ChapterAnalysis'; import WritingStyles from './pages/WritingStyles'; import Settings from './pages/Settings'; +import MCPPlugins from './pages/MCPPlugins'; // import Polish from './pages/Polish'; import Login from './pages/Login'; import AuthCallback from './pages/AuthCallback'; @@ -36,6 +37,7 @@ function App() { } /> } /> } /> + } /> } /> }> } /> diff --git a/frontend/src/pages/MCPPlugins.tsx b/frontend/src/pages/MCPPlugins.tsx new file mode 100644 index 0000000..6db2e7d --- /dev/null +++ b/frontend/src/pages/MCPPlugins.tsx @@ -0,0 +1,708 @@ +import { useState, useEffect } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + Card, + Button, + Space, + Typography, + Modal, + Form, + Input, + Switch, + Select, + message, + Tag, + Tooltip, + Spin, + Empty, + Alert, + Descriptions, + Layout, +} from 'antd'; +import { + PlusOutlined, + EditOutlined, + DeleteOutlined, + CheckCircleOutlined, + CloseCircleOutlined, + ThunderboltOutlined, + InfoCircleOutlined, + ToolOutlined, + ArrowLeftOutlined, +} from '@ant-design/icons'; +import { mcpPluginApi } from '../services/api'; +import type { MCPPlugin, MCPTool } from '../types'; + +const { Paragraph, Text, Title } = Typography; +const { TextArea } = Input; +const { Header, Content } = Layout; + +export default function MCPPluginsPage() { + const navigate = useNavigate(); + const isMobile = window.innerWidth <= 768; + const [form] = Form.useForm(); + const [loading, setLoading] = useState(false); + const [plugins, setPlugins] = useState([]); + const [modalVisible, setModalVisible] = useState(false); + const [editingPlugin, setEditingPlugin] = useState(null); + const [testingPluginId, setTestingPluginId] = useState(null); + const [viewingTools, setViewingTools] = useState<{ pluginId: string; tools: MCPTool[] } | null>(null); + + useEffect(() => { + loadPlugins(); + }, []); + + const loadPlugins = async () => { + setLoading(true); + try { + const data = await mcpPluginApi.getPlugins(); + setPlugins(data); + } catch (error) { + message.error('加载插件列表失败'); + } finally { + setLoading(false); + } + }; + + const handleCreate = () => { + setEditingPlugin(null); + form.resetFields(); + form.setFieldsValue({ + enabled: true, + category: 'search', + config_json: `{ + "mcpServers": { + "exa": { + "type": "http", + "url": "https://mcp.exa.ai/mcp?exaApiKey=YOUR_API_KEY", + "headers": {} + } + } +}` + }); + setModalVisible(true); + }; + + const handleEdit = (plugin: MCPPlugin) => { + setEditingPlugin(plugin); + + // 重构为标准MCP配置格式 + const mcpConfig: any = { + mcpServers: { + [plugin.plugin_name]: { + type: plugin.plugin_type || 'http' + } + } + }; + + if (plugin.plugin_type === 'http') { + mcpConfig.mcpServers[plugin.plugin_name].url = plugin.server_url; + mcpConfig.mcpServers[plugin.plugin_name].headers = plugin.headers || {}; + } else { + mcpConfig.mcpServers[plugin.plugin_name].command = plugin.command; + mcpConfig.mcpServers[plugin.plugin_name].args = plugin.args || []; + mcpConfig.mcpServers[plugin.plugin_name].env = plugin.env || {}; + } + + form.setFieldsValue({ + config_json: JSON.stringify(mcpConfig, null, 2), + enabled: plugin.enabled, + category: plugin.category || 'general', + }); + setModalVisible(true); + }; + + const handleDelete = (plugin: MCPPlugin) => { + Modal.confirm({ + title: '删除插件', + content: `确定要删除插件 "${plugin.display_name || plugin.plugin_name}" 吗?`, + okText: '确定', + cancelText: '取消', + okType: 'danger', + onOk: async () => { + try { + await mcpPluginApi.deletePlugin(plugin.id); + message.success('插件已删除'); + loadPlugins(); + } catch (error) { + message.error('删除插件失败'); + } + }, + }); + }; + + const handleToggle = async (plugin: MCPPlugin, enabled: boolean) => { + try { + await mcpPluginApi.togglePlugin(plugin.id, enabled); + message.success(enabled ? '插件已启用' : '插件已禁用'); + loadPlugins(); + } catch (error) { + message.error('切换插件状态失败'); + } + }; + + const handleTest = async (pluginId: string) => { + setTestingPluginId(pluginId); + try { + const result = await mcpPluginApi.testPlugin(pluginId); + + // 测试完成后,无论成功失败都刷新插件列表以更新状态 + await loadPlugins(); + + if (result.success) { + Modal.success({ + title: '✅ 测试成功', + width: 700, + content: ( +
+

{result.message}

+ + {/* 显示详细的测试结果 */} + {result.suggestions && result.suggestions.length > 0 && ( +
+ 测试详情: +
+ {result.suggestions.map((suggestion: string, index: number) => ( +
+ {suggestion} +
+ ))} +
+
+ )} + + {/* 显示工具数量 */} + {result.tools_count !== undefined && ( +
+ 🔧 可用工具数: {result.tools_count} +
+ )} + + {/* 显示响应时间 */} + {result.response_time_ms !== undefined && ( +
+ ⏱️ 响应时间: {result.response_time_ms}ms +
+ )} + +
+ + ✓ 插件状态已自动更新为"运行中" + +
+
+ ), + }); + } else { + Modal.error({ + title: '❌ 测试失败', + width: 700, + content: ( +
+

{result.message}

+ + {/* 显示错误信息 */} + {result.error && ( +
+ 错误信息: +
+ {result.error} +
+
+ )} + + {/* 显示建议 */} + {result.suggestions && result.suggestions.length > 0 && ( +
+ 💡 建议: +
    + {result.suggestions.map((suggestion: string, index: number) => ( +
  • + {suggestion} +
  • + ))} +
+
+ )} + +
+ + ⚠️ 插件状态已更新,请检查配置后重试 + +
+
+ ), + }); + } + } catch (error: any) { + message.error('测试插件失败'); + } finally { + setTestingPluginId(null); + } + }; + + const handleViewTools = async (pluginId: string) => { + try { + const result = await mcpPluginApi.getPluginTools(pluginId); + setViewingTools({ pluginId, tools: result.tools }); + } catch (error) { + message.error('获取工具列表失败'); + } + }; + + const handleSubmit = async (values: any) => { + setLoading(true); + try { + // 验证JSON格式 + try { + JSON.parse(values.config_json); + } catch (e) { + message.error('配置JSON格式错误,请检查'); + setLoading(false); + return; + } + + const data = { + config_json: values.config_json, + enabled: values.enabled, + category: values.category || 'general', + }; + + // 统一使用简化API,后端会自动判断是创建还是更新 + await mcpPluginApi.createPluginSimple(data); + message.success(editingPlugin ? '插件已更新' : '插件已创建'); + + setModalVisible(false); + form.resetFields(); + loadPlugins(); + } catch (error: any) { + const errorMsg = error?.response?.data?.detail || '操作失败'; + message.error(errorMsg); + } finally { + setLoading(false); + } + }; + + const getStatusTag = (plugin: MCPPlugin) => { + if (!plugin.enabled) { + return 已禁用; + } + switch (plugin.status) { + case 'active': + return }>运行中; + case 'error': + return ( + + }>错误 + + ); + default: + return 未激活; + } + }; + + return ( + + {/* 顶部导航栏 */} +
+ + + + MCP插件管理 + + +
+ + {/* 主内容区 */} + + +
+ + 我的插件 + + +
+ + +

+ MCP (Model Context Protocol) 是一个标准化的协议,允许 AI 调用外部工具获取数据。 +

+

+ 通过添加 MCP 插件,AI 可以访问搜索引擎、数据库、API 等外部服务,增强创作能力。 +

+ + } + type="info" + showIcon + icon={} + style={{ marginBottom: isMobile ? 16 : 20 }} + /> +
+ + {/* 插件列表 */} + + {plugins.length === 0 ? ( + + + + ) : ( + + {plugins.map((plugin) => ( + +
+
+ +
+ + {plugin.display_name || plugin.plugin_name} + + {getStatusTag(plugin)} + + {plugin.plugin_type?.toUpperCase() || 'UNKNOWN'} + + {plugin.category && plugin.category !== 'general' && ( + {plugin.category} + )} +
+ {plugin.description && ( + + {plugin.description} + + )} + + {/* 只显示有值的URL或命令,脱敏处理敏感信息 */} + {plugin.plugin_type === 'http' && plugin.server_url && ( +
+ + {(() => { + // 脱敏处理:隐藏URL中的API Key + const url = plugin.server_url; + try { + const urlObj = new URL(url); + // 替换查询参数中的敏感信息 + const params = new URLSearchParams(urlObj.search); + let maskedUrl = `${urlObj.protocol}//${urlObj.host}${urlObj.pathname}`; + + const sensitiveKeys = ['apiKey', 'api_key', 'key', 'token', 'secret', 'password', 'auth']; + let hasParams = false; + + params.forEach((value, key) => { + const isSensitive = sensitiveKeys.some(k => key.toLowerCase().includes(k.toLowerCase())); + const maskedValue = isSensitive ? '***' : value; + maskedUrl += (hasParams ? '&' : '?') + `${key}=${maskedValue}`; + hasParams = true; + }); + + return maskedUrl; + } catch { + // 如果URL解析失败,尝试简单替换 + return url.replace(/([?&])(apiKey|api_key|key|token|secret|password|auth)=([^&]+)/gi, '$1$2=***'); + } + })()} + +
+ )} + + {plugin.plugin_type === 'stdio' && plugin.command && ( +
+ + {plugin.command} {plugin.args?.join(' ')} + +
+ )} + + {/* 显示最后错误信息 */} + {plugin.last_error && ( + + 错误: {plugin.last_error} + + )} +
+
+ + + + handleToggle(plugin, checked)} + size={isMobile ? 'small' : 'default'} + /> + + +
+
+ ))} +
+ )} +
+
+ + {/* 创建/编辑插件模态框 */} + { + setModalVisible(false); + form.resetFields(); + }} + onOk={() => form.submit()} + width={isMobile ? '100%' : 600} + confirmLoading={loading} + okText="保存" + cancelText="取消" + > +
+ +