update:1.更新mcp插件功能,目前只支持remote调用

This commit is contained in:
xiamuceer
2025-11-07 22:14:20 +08:00
parent 1e998920e3
commit 88115a45c5
26 changed files with 4088 additions and 138 deletions
+63 -3
View File
@@ -823,12 +823,14 @@ async def generate_chapter_content_stream(
请求体参数: 请求体参数:
- style_id: 可选,指定使用的写作风格ID。不提供则不使用任何风格 - style_id: 可选,指定使用的写作风格ID。不提供则不使用任何风格
- target_word_count: 可选,目标字数,默认3000字,范围500-10000字 - target_word_count: 可选,目标字数,默认3000字,范围500-10000字
- enable_mcp: 可选,是否启用MCP工具增强,默认True
注意:此函数不使用依赖注入的db,而是在生成器内部创建独立的数据库会话 注意:此函数不使用依赖注入的db,而是在生成器内部创建独立的数据库会话
以避免流式响应期间的连接泄漏问题 以避免流式响应期间的连接泄漏问题
""" """
style_id = generate_request.style_id style_id = generate_request.style_id
target_word_count = generate_request.target_word_count or 3000 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): async for temp_db in get_db(request):
try: 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" 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: if previous_content:
prompt = prompt_service.get_chapter_generation_with_context_prompt( prompt = prompt_service.get_chapter_generation_with_context_prompt(
title=project.title, title=project.title,
@@ -1021,7 +1076,8 @@ async def generate_chapter_content_stream(
chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲', chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲',
style_content=style_content, style_content=style_content,
target_word_count=target_word_count, target_word_count=target_word_count,
memory_context=memory_context memory_context=memory_context,
mcp_references=mcp_reference_materials
) )
else: else:
prompt = prompt_service.get_chapter_generation_prompt( 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 '暂无大纲', chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲',
style_content=style_content, style_content=style_content,
target_word_count=target_word_count, 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}") logger.info(f"开始AI流式创作章节 {chapter_id}")
# 流式生成内容 # 流式生成内容
+31 -6
View File
@@ -1,5 +1,5 @@
"""角色管理API""" """角色管理API"""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func from sqlalchemy import select, func
import json import json
@@ -221,6 +221,7 @@ async def delete_character(
@router.post("/generate", response_model=CharacterResponse, summary="AI生成角色") @router.post("/generate", response_model=CharacterResponse, summary="AI生成角色")
async def generate_character( async def generate_character(
request: CharacterGenerateRequest, request: CharacterGenerateRequest,
http_request: Request,
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user_ai_service: AIService = Depends(get_user_ai_service) user_ai_service: AIService = Depends(get_user_ai_service)
): ):
@@ -294,18 +295,42 @@ async def generate_character(
user_input=user_input user_input=user_input
) )
# 调用AI生成角色 # 获取user_id用于MCP工具调用
logger.info(f"🎯 开始为项目 {request.project_id} 生成角色") 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.name or 'AI生成'}")
logger.info(f" - 角色定位:{request.role_type}") logger.info(f" - 角色定位:{request.role_type}")
logger.info(f" - 背景设定:{request.background or ''}") logger.info(f" - 背景设定:{request.background or ''}")
logger.info(f" - AI提供商:{user_ai_service.api_provider}") logger.info(f" - AI提供商:{user_ai_service.api_provider}")
logger.info(f" - AI模型:{user_ai_service.default_model}") logger.info(f" - AI模型:{user_ai_service.default_model}")
logger.info(f" - Prompt长度:{len(prompt)} 字符") logger.info(f" - Prompt长度:{len(prompt)} 字符")
logger.info(f" - 用户ID{user_id}")
try: try:
ai_response = await user_ai_service.generate_text(prompt=prompt) # 使用支持MCP的生成方法
logger.info(f"✅ AI响应接收完成,长度:{len(ai_response) if ai_response else 0} 字符") 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: except Exception as ai_error:
logger.error(f"❌ AI服务调用异常:{str(ai_error)}") logger.error(f"❌ AI服务调用异常:{str(ai_error)}")
raise HTTPException( raise HTTPException(
@@ -559,7 +584,7 @@ async def generate_character(
history = GenerationHistory( history = GenerationHistory(
project_id=request.project_id, project_id=request.project_id,
prompt=prompt, 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 model=user_ai_service.default_model
) )
db.add(history) db.add(history)
+862
View File
@@ -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)}")
+1
View File
@@ -38,6 +38,7 @@ class OrganizationGenerateRequest(BaseModel):
organization_type: Optional[str] = Field(None, description="组织类型") organization_type: Optional[str] = Field(None, description="组织类型")
background: Optional[str] = Field(None, description="组织背景") background: Optional[str] = Field(None, description="组织背景")
requirements: 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="获取项目的所有组织") @router.get("/project/{project_id}", response_model=List[OrganizationDetailResponse], summary="获取项目的所有组织")
+255 -23
View File
@@ -404,8 +404,8 @@ async def _generate_new_outline(
db: AsyncSession, db: AsyncSession,
user_ai_service: AIService user_ai_service: AIService
) -> OutlineListResponse: ) -> OutlineListResponse:
"""全新生成大纲""" """全新生成大纲MCP增强版)"""
logger.info(f"全新生成大纲 - 项目: {project.id}, keep_existing: {request.keep_existing}") logger.info(f"全新生成大纲 - 项目: {project.id}, enable_mcp: {request.enable_mcp}")
# 获取角色信息 # 获取角色信息
characters_result = await db.execute( characters_result = await db.execute(
@@ -418,7 +418,59 @@ async def _generate_new_outline(
for char in characters 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( prompt = prompt_service.get_complete_outline_prompt(
title=project.title, title=project.title,
theme=request.theme or project.theme or "未设定", theme=request.theme or project.theme or "未设定",
@@ -431,18 +483,22 @@ async def _generate_new_outline(
atmosphere=project.world_atmosphere or "未设定", atmosphere=project.world_atmosphere or "未设定",
rules=project.world_rules or "未设定", rules=project.world_rules or "未设定",
characters_info=characters_info 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( ai_response = await user_ai_service.generate_text(
prompt=prompt, prompt=prompt,
provider=request.provider, provider=request.provider,
model=request.model 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"模式的核心逻辑,应该始终删除旧数据 # 注意:这是"new"模式的核心逻辑,应该始终删除旧数据
@@ -463,7 +519,7 @@ async def _generate_new_outline(
history = GenerationHistory( history = GenerationHistory(
project_id=project.id, project_id=project.id,
prompt=prompt, 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" model=request.model or "default"
) )
db.add(history) db.add(history)
@@ -571,8 +627,8 @@ async def _continue_outline(
user_ai_service: AIService, user_ai_service: AIService,
user_id: str = "system" user_id: str = "system"
) -> OutlineListResponse: ) -> OutlineListResponse:
"""续写大纲 - 分批生成,每批5章(记忆增强版)""" """续写大纲 - 分批生成,每批5章(记忆+MCP增强版)"""
logger.info(f"续写大纲 - 项目: {project.id}, 已有: {len(existing_outlines)}") logger.info(f"续写大纲 - 项目: {project.id}, 已有: {len(existing_outlines)}, enable_mcp: {request.enable_mcp}")
# 分析已有大纲 # 分析已有大纲
current_chapter_count = len(existing_outlines) current_chapter_count = len(existing_outlines)
@@ -664,7 +720,57 @@ async def _continue_outline(
logger.warning(f"⚠️ 记忆上下文构建失败,继续不使用记忆: {str(e)}") logger.warning(f"⚠️ 记忆上下文构建失败,继续不使用记忆: {str(e)}")
memory_context = None 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( prompt = prompt_service.get_outline_continue_prompt(
title=project.title, title=project.title,
theme=request.theme or project.theme or "未设定", theme=request.theme or project.theme or "未设定",
@@ -683,7 +789,8 @@ async def _continue_outline(
start_chapter=current_start_chapter, start_chapter=current_start_chapter,
story_direction=request.story_direction or "自然延续", story_direction=request.story_direction or "自然延续",
requirements=request.requirements or "", requirements=request.requirements or "",
memory_context=memory_context memory_context=memory_context,
mcp_references=mcp_reference_materials
) )
# 调用AI生成当前批次 # 调用AI生成当前批次
@@ -694,8 +801,11 @@ async def _continue_outline(
model=request.model 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( batch_outlines = await _save_outlines(
@@ -706,7 +816,7 @@ async def _continue_outline(
history = GenerationHistory( history = GenerationHistory(
project_id=project.id, project_id=project.id,
prompt=f"[批次{batch_num + 1}/{total_batches}] {str(prompt)[:500]}", 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" model=request.model or "default"
) )
db.add(history) db.add(history)
@@ -820,7 +930,7 @@ async def new_outline_generator(
db: AsyncSession, db: AsyncSession,
user_ai_service: AIService user_ai_service: AIService
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
"""全新生成大纲SSE生成器""" """全新生成大纲SSE生成器MCP增强版)"""
db_committed = False db_committed = False
try: try:
yield await SSEResponse.send_progress("开始生成大纲...", 5) yield await SSEResponse.send_progress("开始生成大纲...", 5)
@@ -828,6 +938,7 @@ async def new_outline_generator(
project_id = data.get("project_id") project_id = data.get("project_id")
# 确保chapter_count是整数(前端可能传字符串) # 确保chapter_count是整数(前端可能传字符串)
chapter_count = int(data.get("chapter_count", 10)) chapter_count = int(data.get("chapter_count", 10))
enable_mcp = data.get("enable_mcp", True)
# 验证项目 # 验证项目
yield await SSEResponse.send_progress("加载项目信息...", 10) yield await SSEResponse.send_progress("加载项目信息...", 10)
@@ -852,7 +963,61 @@ async def new_outline_generator(
for char in characters 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) yield await SSEResponse.send_progress("准备AI提示词...", 20)
prompt = prompt_service.get_complete_outline_prompt( prompt = prompt_service.get_complete_outline_prompt(
title=project.title, title=project.title,
@@ -866,7 +1031,8 @@ async def new_outline_generator(
atmosphere=project.world_atmosphere or "未设定", atmosphere=project.world_atmosphere or "未设定",
rules=project.world_rules or "未设定", rules=project.world_rules or "未设定",
characters_info=characters_info or "暂无角色信息", characters_info=characters_info or "暂无角色信息",
requirements=data.get("requirements") or "" requirements=data.get("requirements") or "",
mcp_references=mcp_reference_materials
) )
# 调用AI # 调用AI
@@ -879,8 +1045,11 @@ async def new_outline_generator(
yield await SSEResponse.send_progress("✅ AI生成完成,正在解析...", 70) 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) yield await SSEResponse.send_progress("清理旧数据...", 75)
@@ -902,7 +1071,7 @@ async def new_outline_generator(
history = GenerationHistory( history = GenerationHistory(
project_id=project_id, project_id=project_id,
prompt=prompt, 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" model=data.get("model") or "default"
) )
db.add(history) db.add(history)
@@ -957,7 +1126,7 @@ async def continue_outline_generator(
user_ai_service: AIService, user_ai_service: AIService,
user_id: str = "system" user_id: str = "system"
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
"""大纲续写SSE生成器 - 分批生成,推送进度(记忆增强版)""" """大纲续写SSE生成器 - 分批生成,推送进度(记忆+MCP增强版)"""
db_committed = False db_committed = False
try: try:
yield await SSEResponse.send_progress("开始续写大纲...", 5) yield await SSEResponse.send_progress("开始续写大纲...", 5)
@@ -1090,13 +1259,72 @@ async def continue_outline_generator(
except Exception as e: except Exception as e:
logger.warning(f"⚠️ 记忆上下文构建失败: {str(e)}") logger.warning(f"⚠️ 记忆上下文构建失败: {str(e)}")
memory_context = None 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( yield await SSEResponse.send_progress(
f" 调用AI生成第{str(batch_num + 1)}批...", f" 调用AI生成第{str(batch_num + 1)}批...",
batch_progress + 5 batch_progress + 5
) )
# 使用标准续写提示词模板(支持记忆增强) # 使用标准续写提示词模板(支持记忆+MCP增强)
prompt = prompt_service.get_outline_continue_prompt( prompt = prompt_service.get_outline_continue_prompt(
title=project.title, title=project.title,
theme=data.get("theme") or project.theme or "未设定", theme=data.get("theme") or project.theme or "未设定",
@@ -1115,7 +1343,8 @@ async def continue_outline_generator(
start_chapter=current_start_chapter, start_chapter=current_start_chapter,
story_direction=data.get("story_direction", "自然延续"), story_direction=data.get("story_direction", "自然延续"),
requirements=data.get("requirements", ""), requirements=data.get("requirements", ""),
memory_context=memory_context memory_context=memory_context,
mcp_references=mcp_reference_materials
) )
# 调用AI生成当前批次 # 调用AI生成当前批次
@@ -1130,8 +1359,11 @@ async def continue_outline_generator(
batch_progress + 10 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( batch_outlines = await _save_outlines(
@@ -1142,7 +1374,7 @@ async def continue_outline_generator(
history = GenerationHistory( history = GenerationHistory(
project_id=project_id, project_id=project_id,
prompt=f"[续写批次{batch_num + 1}/{total_batches}] {str(prompt)[:500]}", 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" model=data.get("model") or "default"
) )
db.add(history) db.add(history)
+5 -2
View File
@@ -359,7 +359,10 @@ async def test_api_connection(data: ApiTestRequest):
logger.info(f"✅ API 测试成功") logger.info(f"✅ API 测试成功")
logger.info(f" - 响应时间: {response_time}ms") 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 { return {
"success": True, "success": True,
@@ -367,7 +370,7 @@ async def test_api_connection(data: ApiTestRequest):
"response_time_ms": response_time, "response_time_ms": response_time,
"provider": provider, "provider": provider,
"model": llm_model, "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": { "details": {
"api_available": True, "api_available": True,
"model_accessible": True, "model_accessible": True,
+234 -17
View File
@@ -1,5 +1,5 @@
"""项目创建向导流式API - 使用SSE避免超时""" """项目创建向导流式API - 使用SSE避免超时"""
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
from typing import Dict, Any, AsyncGenerator 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.writing_style import WritingStyle
from app.models.project_default_style import ProjectDefaultStyle from app.models.project_default_style import ProjectDefaultStyle
from app.services.ai_service import AIService 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.services.prompt_service import prompt_service
from app.logger import get_logger from app.logger import get_logger
from app.utils.sse_response import SSEResponse, create_sse_response from app.utils.sse_response import SSEResponse, create_sse_response
@@ -29,7 +30,7 @@ async def world_building_generator(
db: AsyncSession, db: AsyncSession,
user_ai_service: AIService user_ai_service: AIService
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
"""世界构建流式生成器""" """世界构建流式生成器 - 支持MCP工具增强"""
# 标记数据库会话是否已提交 # 标记数据库会话是否已提交
db_committed = False db_committed = False
try: try:
@@ -47,27 +48,94 @@ async def world_building_generator(
character_count = data.get("character_count") character_count = data.get("character_count")
provider = data.get("provider") provider = data.get("provider")
model = data.get("model") 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: if not title or not description or not theme or not genre:
yield await SSEResponse.send_error("title、description、theme 和 genre 是必需的参数", 400) yield await SSEResponse.send_error("title、description、theme 和 genre 是必需的参数", 400)
return return
# 获取提示词 # 获取基础提示词
yield await SSEResponse.send_progress("准备AI提示词...", 20) yield await SSEResponse.send_progress("准备AI提示词...", 15)
prompt = prompt_service.get_world_building_prompt( base_prompt = prompt_service.get_world_building_prompt(
title=title, title=title,
theme=theme, theme=theme,
genre=genre genre=genre
) )
# 流式调用AI # MCP工具增强:收集参考资料
yield await SSEResponse.send_progress("正在调用AI生成...", 30) 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 = "" accumulated_text = ""
chunk_count = 0 chunk_count = 0
async for chunk in user_ai_service.generate_text_stream( async for chunk in user_ai_service.generate_text_stream(
prompt=prompt, prompt=final_prompt,
provider=provider, provider=provider,
model=model model=model
): ):
@@ -190,6 +258,7 @@ async def world_building_generator(
@router.post("/world-building", summary="流式生成世界构建") @router.post("/world-building", summary="流式生成世界构建")
async def generate_world_building_stream( async def generate_world_building_stream(
request: Request,
data: Dict[str, Any], data: Dict[str, Any],
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user_ai_service: AIService = Depends(get_user_ai_service) user_ai_service: AIService = Depends(get_user_ai_service)
@@ -198,6 +267,10 @@ async def generate_world_building_stream(
使用SSE流式生成世界构建,避免超时 使用SSE流式生成世界构建,避免超时
前端使用EventSource接收实时进度和结果 前端使用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)) return create_sse_response(world_building_generator(data, db, user_ai_service))
@@ -206,7 +279,7 @@ async def characters_generator(
db: AsyncSession, db: AsyncSession,
user_ai_service: AIService user_ai_service: AIService
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
"""角色批量生成流式生成器 - 优化版:分批+重试""" """角色批量生成流式生成器 - 优化版:分批+重试+MCP工具增强"""
db_committed = False db_committed = False
try: try:
yield await SSEResponse.send_progress("开始生成角色...", 5) yield await SSEResponse.send_progress("开始生成角色...", 5)
@@ -219,6 +292,8 @@ async def characters_generator(
requirements = data.get("requirements", "") requirements = data.get("requirements", "")
provider = data.get("provider") provider = data.get("provider")
model = data.get("model") model = data.get("model")
enable_mcp = data.get("enable_mcp", True) # 默认启用MCP
user_id = data.get("user_id") # 从中间件注入
# 验证项目 # 验证项目
yield await SSEResponse.send_progress("验证项目...", 10) yield await SSEResponse.send_progress("验证项目...", 10)
@@ -239,6 +314,57 @@ async def characters_generator(
"rules": project.world_rules or "未设定" "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个,平衡效率和成功率 # 优化的分批策略:每批生成3个,平衡效率和成功率
BATCH_SIZE = 3 # 每批生成3个角色 BATCH_SIZE = 3 # 每批生成3个角色
MAX_RETRIES = 3 # 每批最多重试3次 MAX_RETRIES = 3 # 每批最多重试3次
@@ -291,7 +417,8 @@ async def characters_generator(
else: else:
batch_requirements += "\n主要是配角(supporting)和反派(antagonist)" batch_requirements += "\n主要是配角(supporting)和反派(antagonist)"
prompt = prompt_service.get_characters_batch_prompt( # 构建基础提示词
base_prompt = prompt_service.get_characters_batch_prompt(
count=current_batch_size, # 传递精确数量 count=current_batch_size, # 传递精确数量
time_period=world_context.get("time_period", ""), time_period=world_context.get("time_period", ""),
location=world_context.get("location", ""), location=world_context.get("location", ""),
@@ -302,6 +429,19 @@ async def characters_generator(
requirements=batch_requirements requirements=batch_requirements
) )
# 如果有MCP参考资料,增强提示词
if character_reference_materials:
prompt = f"""{base_prompt}
【参考资料】
以下是通过MCP工具收集的真实背景资料,请参考这些信息设计更真实的角色:
{character_reference_materials}
请结合上述资料,设计符合历史/文化背景的角色。"""
else:
prompt = base_prompt
# 流式生成 # 流式生成
accumulated_text = "" accumulated_text = ""
async for chunk in user_ai_service.generate_text_stream( async for chunk in user_ai_service.generate_text_stream(
@@ -708,13 +848,19 @@ async def characters_generator(
@router.post("/characters", summary="流式批量生成角色") @router.post("/characters", summary="流式批量生成角色")
async def generate_characters_stream( async def generate_characters_stream(
request: Request,
data: Dict[str, Any], data: Dict[str, Any],
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
user_ai_service: AIService = Depends(get_user_ai_service) user_ai_service: AIService = Depends(get_user_ai_service)
): ):
""" """
使用SSE流式批量生成角色,避免超时 使用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)) return create_sse_response(characters_generator(data, db, user_ai_service))
@@ -1071,7 +1217,7 @@ async def regenerate_world_building_generator(
db: AsyncSession, db: AsyncSession,
user_ai_service: AIService user_ai_service: AIService
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
"""重新生成世界观流式生成器""" """重新生成世界观流式生成器 - 支持MCP工具增强"""
db_committed = False db_committed = False
try: try:
yield await SSEResponse.send_progress("开始重新生成世界观...", 10) yield await SSEResponse.send_progress("开始重新生成世界观...", 10)
@@ -1087,23 +1233,89 @@ async def regenerate_world_building_generator(
provider = data.get("provider") provider = data.get("provider")
model = data.get("model") 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) yield await SSEResponse.send_progress("准备AI提示词...", 15)
prompt = prompt_service.get_world_building_prompt( base_prompt = prompt_service.get_world_building_prompt(
title=project.title, title=project.title,
theme=project.theme or "", theme=project.theme or "",
genre=project.genre or "" genre=project.genre or ""
) )
# 流式调用AI # MCP工具增强:收集参考资料
yield await SSEResponse.send_progress("正在调用AI生成...", 30) 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 = "" accumulated_text = ""
chunk_count = 0 chunk_count = 0
async for chunk in user_ai_service.generate_text_stream( async for chunk in user_ai_service.generate_text_stream(
prompt=prompt, prompt=final_prompt,
provider=provider, provider=provider,
model=model model=model
): ):
@@ -1187,6 +1399,7 @@ async def regenerate_world_building_generator(
@router.post("/world-building/{project_id}/regenerate", summary="流式重新生成世界观") @router.post("/world-building/{project_id}/regenerate", summary="流式重新生成世界观")
async def regenerate_world_building_stream( async def regenerate_world_building_stream(
request: Request,
project_id: str, project_id: str,
data: Dict[str, Any], data: Dict[str, Any],
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
@@ -1200,6 +1413,10 @@ async def regenerate_world_building_stream(
"model": "模型名称(可选)" "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)) return create_sse_response(regenerate_world_building_generator(project_id, data, db, user_ai_service))
+9 -1
View File
@@ -28,7 +28,13 @@ async def lifespan(app: FastAPI):
"""应用生命周期管理""" """应用生命周期管理"""
logger.info("应用启动,等待用户登录...") logger.info("应用启动,等待用户登录...")
# 导入MCP注册表
from app.mcp.registry import mcp_registry
yield yield
# 清理MCP插件
await mcp_registry.cleanup_all()
await close_db() await close_db()
logger.info("应用已关闭") logger.info("应用已关闭")
@@ -114,7 +120,8 @@ async def db_session_stats():
from app.api import ( from app.api import (
projects, outlines, characters, chapters, projects, outlines, characters, chapters,
wizard_stream, relationships, organizations, 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") 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(organizations.router, prefix="/api")
app.include_router(writing_styles.router, prefix="/api") app.include_router(writing_styles.router, prefix="/api")
app.include_router(memories.router) # 记忆管理API (已包含/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" static_dir = Path(__file__).parent.parent / "static"
if static_dir.exists(): if static_dir.exists():
+4
View File
@@ -0,0 +1,4 @@
"""MCP插件系统"""
from .registry import mcp_registry
__all__ = ["mcp_registry"]
+345
View File
@@ -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()
+349
View File
@@ -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()
+17 -20
View File
@@ -1,37 +1,34 @@
"""数据模型""" """数据模型导出"""
from app.models.project import Project from app.models.project import Project
from app.models.outline import Outline from app.models.outline import Outline
from app.models.character import Character
from app.models.chapter import Chapter 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.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.analysis_task import AnalysisTask
from app.models.batch_generation_task import BatchGenerationTask 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__ = [ __all__ = [
"Project", "Project",
"Outline", "Outline",
"Character",
"Chapter", "Chapter",
"GenerationHistory", "Character",
"Settings",
"WritingStyle",
"ProjectDefaultStyle",
"RelationshipType",
"CharacterRelationship", "CharacterRelationship",
"Organization", "Organization",
"OrganizationMember", "OrganizationMember",
"StoryMemory", "RelationshipType",
"PlotAnalysis", "GenerationHistory",
"AnalysisTask", "AnalysisTask",
"BatchGenerationTask", "BatchGenerationTask",
"Settings",
"StoryMemory",
"PlotAnalysis",
"WritingStyle",
"ProjectDefaultStyle",
"MCPPlugin"
] ]
+52
View File
@@ -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="服务器URLHTTP类型)")
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"<MCPPlugin(id={self.id}, name={self.plugin_name}, enabled={self.enabled})>"
+2
View File
@@ -66,6 +66,7 @@ class ChapterGenerateRequest(BaseModel):
ge=500, # 最小500字 ge=500, # 最小500字
le=10000 # 最大10000字 le=10000 # 最大10000字
) )
enable_mcp: bool = Field(True, description="是否启用MCP工具增强(搜索参考资料)")
class BatchGenerateRequest(BaseModel): class BatchGenerateRequest(BaseModel):
@@ -80,6 +81,7 @@ class BatchGenerateRequest(BaseModel):
le=10000 le=10000
) )
enable_analysis: bool = Field(False, description="是否启用同步分析") enable_analysis: bool = Field(False, description="是否启用同步分析")
enable_mcp: bool = Field(True, description="是否启用MCP工具增强(搜索参考资料)")
max_retries: int = Field(3, description="每个章节的最大重试次数", ge=0, le=5) max_retries: int = Field(3, description="每个章节的最大重试次数", ge=0, le=5)
+1
View File
@@ -63,6 +63,7 @@ class CharacterGenerateRequest(BaseModel):
role_type: Optional[str] = Field(None, description="角色类型") role_type: Optional[str] = Field(None, description="角色类型")
background: Optional[str] = Field(None, description="角色背景") background: Optional[str] = Field(None, description="角色背景")
requirements: Optional[str] = Field(None, description="特殊要求") requirements: Optional[str] = Field(None, description="特殊要求")
enable_mcp: bool = Field(True, description="是否启用MCP工具增强(搜索人物原型参考)")
class CharacterListResponse(BaseModel): class CharacterListResponse(BaseModel):
+105
View File
@@ -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="服务器URLHTTP类型)")
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
+1
View File
@@ -61,6 +61,7 @@ class OutlineGenerateRequest(BaseModel):
story_direction: Optional[str] = Field(None, description="故事发展方向提示(续写时使用)") story_direction: Optional[str] = Field(None, description="故事发展方向提示(续写时使用)")
plot_stage: str = Field("development", description="情节阶段: development(发展), climax(高潮), ending(结局)") plot_stage: str = Field("development", description="情节阶段: development(发展), climax(高潮), ending(结局)")
keep_existing: bool = Field(False, description="是否保留现有大纲(续写时)") keep_existing: bool = Field(False, description="是否保留现有大纲(续写时)")
enable_mcp: bool = Field(True, description="是否启用MCP工具增强(搜索情节设计参考)")
class ChapterOutlineGenerateRequest(BaseModel): class ChapterOutlineGenerateRequest(BaseModel):
+410 -8
View File
@@ -5,6 +5,7 @@ from anthropic import AsyncAnthropic
from app.config import settings as app_settings from app.config import settings as app_settings
from app.logger import get_logger from app.logger import get_logger
import httpx import httpx
import json
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -126,10 +127,12 @@ class AIService:
model: Optional[str] = None, model: Optional[str] = None,
temperature: Optional[float] = None, temperature: Optional[float] = None,
max_tokens: Optional[int] = None, max_tokens: Optional[int] = None,
system_prompt: Optional[str] = None system_prompt: Optional[str] = None,
) -> str: tools: Optional[List[Dict[str, Any]]] = None,
tool_choice: Optional[str] = None
) -> Dict[str, Any]:
""" """
生成文本 生成文本(支持工具调用)
Args: Args:
prompt: 用户提示词 prompt: 用户提示词
@@ -138,9 +141,14 @@ class AIService:
temperature: 温度参数 temperature: 温度参数
max_tokens: 最大token数 max_tokens: 最大token数
system_prompt: 系统提示词 system_prompt: 系统提示词
tools: 可用工具列表(MCP工具格式)
tool_choice: 工具选择策略 (auto/required/none)
Returns: Returns:
生成的文本 Dict包含:
- content: 文本内容(如果没有工具调用)
- tool_calls: 工具调用列表(如果AI决定调用工具)
- finish_reason: 完成原因
""" """
provider = provider or self.api_provider provider = provider or self.api_provider
model = model or self.default_model model = model or self.default_model
@@ -148,12 +156,12 @@ class AIService:
max_tokens = max_tokens or self.default_max_tokens max_tokens = max_tokens or self.default_max_tokens
if provider == "openai": if provider == "openai":
return await self._generate_openai( return await self._generate_openai_with_tools(
prompt, model, temperature, max_tokens, system_prompt prompt, model, temperature, max_tokens, system_prompt, tools, tool_choice
) )
elif provider == "anthropic": elif provider == "anthropic":
return await self._generate_anthropic( return await self._generate_anthropic_with_tools(
prompt, model, temperature, max_tokens, system_prompt prompt, model, temperature, max_tokens, system_prompt, tools, tool_choice
) )
else: else:
raise ValueError(f"不支持的AI提供商: {provider}") raise ValueError(f"不支持的AI提供商: {provider}")
@@ -247,6 +255,7 @@ class AIService:
logger.info(f"✅ OpenAI API调用成功") logger.info(f"✅ OpenAI API调用成功")
logger.info(f" - 响应ID: {data.get('id', 'N/A')}") logger.info(f" - 响应ID: {data.get('id', 'N/A')}")
logger.info(f" - 选项数量: {len(data.get('choices', []))}") logger.info(f" - 选项数量: {len(data.get('choices', []))}")
logger.debug(f" - 完整API响应: {data}")
if not data.get('choices'): if not data.get('choices'):
logger.error("❌ OpenAI返回的choices为空") logger.error("❌ OpenAI返回的choices为空")
@@ -294,6 +303,173 @@ class AIService:
logger.error(f" - 模型: {model}") logger.error(f" - 模型: {model}")
raise 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( async def _generate_openai_stream(
self, self,
prompt: str, prompt: str,
@@ -456,6 +632,232 @@ class AIService:
logger.error(f"❌ Anthropic流式API调用失败: {str(e)}") logger.error(f"❌ Anthropic流式API调用失败: {str(e)}")
logger.error(f" - 错误类型: {type(e).__name__}") logger.error(f" - 错误类型: {type(e).__name__}")
raise 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服务实例 # 创建全局AI服务实例
+355
View File
@@ -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()
+15 -1
View File
@@ -225,8 +225,22 @@ class PlotAnalyzer:
temperature=0.3 # 降低温度以获得更稳定的JSON输出 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结果 # 解析JSON结果
analysis_result = self._parse_analysis_response(response) analysis_result = self._parse_analysis_response(response_text)
if analysis_result: if analysis_result:
logger.info(f"✅ 第{chapter_number}章分析完成") logger.info(f"✅ 第{chapter_number}章分析完成")
+57 -9
View File
@@ -282,6 +282,8 @@ class PromptService:
角色信息: 角色信息:
{characters_info} {characters_info}
{mcp_references}
其他要求:{requirements} 其他要求:{requirements}
整体要求: 整体要求:
@@ -356,6 +358,8 @@ class PromptService:
{memory_context} {memory_context}
{mcp_references}
【续写指导】 【续写指导】
- 当前情节阶段:{plot_stage_instruction} - 当前情节阶段:{plot_stage_instruction}
- 起始章节编号:第{start_chapter} - 起始章节编号:第{start_chapter}
@@ -836,8 +840,17 @@ class PromptService:
chapter_count: int, narrative_perspective: str, chapter_count: int, narrative_perspective: str,
target_words: int, time_period: str, location: str, target_words: int, time_period: str, location: str,
atmosphere: str, rules: str, characters_info: 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( return cls.format_prompt(
cls.COMPLETE_OUTLINE_GENERATION, cls.COMPLETE_OUTLINE_GENERATION,
title=title, title=title,
@@ -851,6 +864,7 @@ class PromptService:
atmosphere=atmosphere, atmosphere=atmosphere,
rules=rules, rules=rules,
characters_info=characters_info, characters_info=characters_info,
mcp_references=mcp_text,
requirements=requirements or "无特殊要求" requirements=requirements or "无特殊要求"
) )
@@ -862,7 +876,8 @@ class PromptService:
chapter_number: int, chapter_title: str, chapter_number: int, chapter_title: str,
chapter_outline: str, style_content: str = "", chapter_outline: str, style_content: str = "",
target_word_count: int = 3000, 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: 写作风格要求内容,如果提供则会追加到提示词中 style_content: 写作风格要求内容,如果提供则会追加到提示词中
target_word_count: 目标字数,默认3000字 target_word_count: 目标字数,默认3000字
memory_context: 记忆上下文(可选) memory_context: 记忆上下文(可选)
mcp_references: MCP工具搜索的参考资料(可选)
""" """
# 计算最大字数(目标字数+1000 # 计算最大字数(目标字数+1000
max_word_count = target_word_count + 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('character_states', '')
memory_text += "\n" + memory_context.get('plot_points', '') 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( base_prompt = cls.format_prompt(
cls.CHAPTER_GENERATION, cls.CHAPTER_GENERATION,
title=title, title=title,
@@ -903,11 +927,17 @@ class PromptService:
max_word_count=max_word_count max_word_count=max_word_count
) )
# 插入记忆上下文 # 插入记忆上下文和MCP参考资料
insert_text = ""
if memory_text: if memory_text:
insert_text += memory_text
if mcp_text:
insert_text += mcp_text
if insert_text:
base_prompt = base_prompt.replace( 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, chapter_title: str, chapter_outline: str,
style_content: str = "", style_content: str = "",
target_word_count: int = 3000, 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: 写作风格要求内容,如果提供则会追加到提示词中 style_content: 写作风格要求内容,如果提供则会追加到提示词中
target_word_count: 目标字数,默认3000字 target_word_count: 目标字数,默认3000字
memory_context: 记忆上下文(可选) memory_context: 记忆上下文(可选)
mcp_references: MCP工具搜索的参考资料(可选)
""" """
# 计算最大字数(目标字数+1000 # 计算最大字数(目标字数+1000
max_word_count = target_word_count + 1000 max_word_count = target_word_count + 1000
@@ -948,6 +980,12 @@ class PromptService:
else: else:
memory_text = "暂无相关记忆" 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( base_prompt = cls.format_prompt(
cls.CHAPTER_GENERATION_WITH_CONTEXT, cls.CHAPTER_GENERATION_WITH_CONTEXT,
title=title, title=title,
@@ -996,8 +1034,9 @@ class PromptService:
recent_plot: str, plot_stage_instruction: str, recent_plot: str, plot_stage_instruction: str,
start_chapter: int, story_direction: str, start_chapter: int, story_direction: str,
requirements: str = "", requirements: str = "",
memory_context: dict = None) -> str: memory_context: dict = None,
"""获取大纲续写提示词(支持记忆增强)""" mcp_references: str = "") -> str:
"""获取大纲续写提示词(支持记忆+MCP增强)"""
end_chapter = start_chapter + chapter_count - 1 end_chapter = start_chapter + chapter_count - 1
# 格式化记忆上下文 # 格式化记忆上下文
@@ -1011,6 +1050,14 @@ class PromptService:
else: else:
memory_text = "暂无相关记忆(可能是首次续写或记忆库为空)" 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( return cls.format_prompt(
cls.OUTLINE_CONTINUE_GENERATION, cls.OUTLINE_CONTINUE_GENERATION,
title=title, title=title,
@@ -1031,7 +1078,8 @@ class PromptService:
end_chapter=end_chapter, end_chapter=end_chapter,
story_direction=story_direction, story_direction=story_direction,
requirements=requirements or "无特殊要求", requirements=requirements or "无特殊要求",
memory_context=memory_text memory_context=memory_text,
mcp_references=mcp_text
) )
@classmethod @classmethod
+2
View File
@@ -14,6 +14,7 @@ import ChapterReader from './pages/ChapterReader';
import ChapterAnalysis from './pages/ChapterAnalysis'; import ChapterAnalysis from './pages/ChapterAnalysis';
import WritingStyles from './pages/WritingStyles'; import WritingStyles from './pages/WritingStyles';
import Settings from './pages/Settings'; import Settings from './pages/Settings';
import MCPPlugins from './pages/MCPPlugins';
// import Polish from './pages/Polish'; // import Polish from './pages/Polish';
import Login from './pages/Login'; import Login from './pages/Login';
import AuthCallback from './pages/AuthCallback'; import AuthCallback from './pages/AuthCallback';
@@ -36,6 +37,7 @@ function App() {
<Route path="/" element={<ProtectedRoute><ProjectList /></ProtectedRoute>} /> <Route path="/" element={<ProtectedRoute><ProjectList /></ProtectedRoute>} />
<Route path="/wizard" element={<ProtectedRoute><ProjectWizardNew /></ProtectedRoute>} /> <Route path="/wizard" element={<ProtectedRoute><ProjectWizardNew /></ProtectedRoute>} />
<Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} /> <Route path="/settings" element={<ProtectedRoute><Settings /></ProtectedRoute>} />
<Route path="/mcp-plugins" element={<ProtectedRoute><MCPPlugins /></ProtectedRoute>} />
<Route path="/chapters/:chapterId/reader" element={<ProtectedRoute><ChapterReader /></ProtectedRoute>} /> <Route path="/chapters/:chapterId/reader" element={<ProtectedRoute><ChapterReader /></ProtectedRoute>} />
<Route path="/project/:projectId" element={<ProtectedRoute><ProjectDetail /></ProtectedRoute>}> <Route path="/project/:projectId" element={<ProtectedRoute><ProjectDetail /></ProtectedRoute>}>
<Route index element={<Navigate to="world-setting" replace />} /> <Route index element={<Navigate to="world-setting" replace />} />
+708
View File
@@ -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<MCPPlugin[]>([]);
const [modalVisible, setModalVisible] = useState(false);
const [editingPlugin, setEditingPlugin] = useState<MCPPlugin | null>(null);
const [testingPluginId, setTestingPluginId] = useState<string | null>(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: (
<div>
<p style={{ marginBottom: 16, fontSize: '15px', fontWeight: 500 }}>{result.message}</p>
{/* 显示详细的测试结果 */}
{result.suggestions && result.suggestions.length > 0 && (
<div style={{ marginTop: 16 }}>
<Text strong style={{ fontSize: '14px' }}>:</Text>
<div style={{
marginTop: 8,
padding: 12,
background: '#f5f5f5',
borderRadius: 4,
fontSize: '13px',
fontFamily: 'monospace',
maxHeight: '400px',
overflowY: 'auto'
}}>
{result.suggestions.map((suggestion: string, index: number) => (
<div key={index} style={{
marginBottom: index < (result.suggestions?.length || 0) - 1 ? 8 : 0,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
lineHeight: 1.6
}}>
{suggestion}
</div>
))}
</div>
</div>
)}
{/* 显示工具数量 */}
{result.tools_count !== undefined && (
<div style={{ marginTop: 12 }}>
<Text type="secondary">🔧 : <strong>{result.tools_count}</strong></Text>
</div>
)}
{/* 显示响应时间 */}
{result.response_time_ms !== undefined && (
<div style={{ marginTop: 4 }}>
<Text type="secondary"> : <strong>{result.response_time_ms}ms</strong></Text>
</div>
)}
<div style={{
marginTop: 16,
padding: 12,
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: 4
}}>
<Text type="success" style={{ fontSize: '13px' }}>
"运行中"
</Text>
</div>
</div>
),
});
} else {
Modal.error({
title: '❌ 测试失败',
width: 700,
content: (
<div>
<p style={{ marginBottom: 16, fontSize: '15px', fontWeight: 500 }}>{result.message}</p>
{/* 显示错误信息 */}
{result.error && (
<div style={{ marginTop: 16 }}>
<Text strong style={{ fontSize: '14px' }}>:</Text>
<div style={{
marginTop: 8,
padding: 12,
background: '#fff2f0',
borderRadius: 4,
color: '#cf1322',
fontSize: '13px',
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: '300px',
overflowY: 'auto'
}}>
{result.error}
</div>
</div>
)}
{/* 显示建议 */}
{result.suggestions && result.suggestions.length > 0 && (
<div style={{ marginTop: 16 }}>
<Text strong style={{ fontSize: '14px' }}>💡 :</Text>
<ul style={{ marginTop: 8, marginBottom: 0, paddingLeft: 20 }}>
{result.suggestions.map((suggestion: string, index: number) => (
<li key={index} style={{ marginBottom: 6, fontSize: '13px' }}>
{suggestion}
</li>
))}
</ul>
</div>
)}
<div style={{
marginTop: 16,
padding: 12,
background: '#fff7e6',
border: '1px solid #ffd591',
borderRadius: 4
}}>
<Text style={{ fontSize: '13px', color: '#ad6800' }}>
</Text>
</div>
</div>
),
});
}
} 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 <Tag color="default"></Tag>;
}
switch (plugin.status) {
case 'active':
return <Tag color="success" icon={<CheckCircleOutlined />}></Tag>;
case 'error':
return (
<Tooltip title={plugin.last_error}>
<Tag color="error" icon={<CloseCircleOutlined />}></Tag>
</Tooltip>
);
default:
return <Tag color="default"></Tag>;
}
};
return (
<Layout style={{ minHeight: '100vh', background: '#f0f2f5' }}>
{/* 顶部导航栏 */}
<Header style={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
padding: isMobile ? '0 16px' : '0 24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
position: 'fixed',
top: 0,
left: 0,
right: 0,
zIndex: 1000,
height: isMobile ? 56 : 64
}}>
<Space size={isMobile ? 12 : 16}>
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/')}
style={{
color: '#fff',
fontSize: isMobile ? 16 : 18,
display: 'flex',
alignItems: 'center'
}}
>
{!isMobile && '返回'}
</Button>
<Title level={isMobile ? 4 : 3} style={{
margin: 0,
color: '#fff',
fontSize: isMobile ? 18 : 24
}}>
MCP插件管理
</Title>
</Space>
</Header>
{/* 主内容区 */}
<Content style={{
marginTop: isMobile ? 56 : 64,
padding: isMobile ? '16px' : '24px',
maxWidth: 1400,
width: '100%',
margin: `${isMobile ? 56 : 64}px auto 0`,
}}>
<Card
variant="borderless"
style={{
borderRadius: isMobile ? 8 : 12,
boxShadow: '0 2px 8px rgba(0,0,0,0.06)',
marginBottom: isMobile ? 16 : 24
}}
>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: isMobile ? 16 : 20
}}>
<Title level={isMobile ? 5 : 4} style={{ margin: 0 }}>
</Title>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleCreate}
size={isMobile ? 'middle' : 'large'}
style={{
borderRadius: 8,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
border: 'none'
}}
>
</Button>
</div>
<Alert
message="什么是 MCP 插件?"
description={
<div style={{ fontSize: isMobile ? '12px' : '14px' }}>
<p style={{ margin: '8px 0' }}>
MCP (Model Context Protocol) AI
</p>
<p style={{ margin: '8px 0 0 0' }}>
MCP AI 访API
</p>
</div>
}
type="info"
showIcon
icon={<InfoCircleOutlined />}
style={{ marginBottom: isMobile ? 16 : 20 }}
/>
</Card>
{/* 插件列表 */}
<Spin spinning={loading}>
{plugins.length === 0 ? (
<Empty
description="还没有添加任何插件"
image={Empty.PRESENTED_IMAGE_SIMPLE}
style={{ padding: isMobile ? '40px 0' : '60px 0' }}
>
<Button type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
</Button>
</Empty>
) : (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
{plugins.map((plugin) => (
<Card
key={plugin.id}
size="small"
style={{
borderRadius: 8,
border: '1px solid #f0f0f0',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '16px',
flexWrap: isMobile ? 'wrap' : 'nowrap',
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<Space direction="vertical" size="small" style={{ width: '100%' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }}>
<Text strong style={{ fontSize: isMobile ? '14px' : '16px' }}>
{plugin.display_name || plugin.plugin_name}
</Text>
{getStatusTag(plugin)}
<Tag color={plugin.plugin_type === 'http' ? 'blue' : 'cyan'}>
{plugin.plugin_type?.toUpperCase() || 'UNKNOWN'}
</Tag>
{plugin.category && plugin.category !== 'general' && (
<Tag color="purple">{plugin.category}</Tag>
)}
</div>
{plugin.description && (
<Paragraph
type="secondary"
style={{
margin: 0,
fontSize: isMobile ? '12px' : '13px',
}}
ellipsis={{ rows: 2 }}
>
{plugin.description}
</Paragraph>
)}
{/* 只显示有值的URL或命令,脱敏处理敏感信息 */}
{plugin.plugin_type === 'http' && plugin.server_url && (
<div style={{ fontSize: isMobile ? '11px' : '12px' }}>
<Text type="secondary" code>
{(() => {
// 脱敏处理:隐藏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=***');
}
})()}
</Text>
</div>
)}
{plugin.plugin_type === 'stdio' && plugin.command && (
<div style={{ fontSize: isMobile ? '11px' : '12px' }}>
<Text type="secondary" code>
{plugin.command} {plugin.args?.join(' ')}
</Text>
</div>
)}
{/* 显示最后错误信息 */}
{plugin.last_error && (
<Text type="danger" style={{ fontSize: isMobile ? '11px' : '12px' }}>
: {plugin.last_error}
</Text>
)}
</Space>
</div>
<Space size="small" wrap>
<Tooltip title={plugin.enabled ? '禁用插件' : '启用插件'}>
<Switch
checked={plugin.enabled}
onChange={(checked) => handleToggle(plugin, checked)}
size={isMobile ? 'small' : 'default'}
/>
</Tooltip>
<Tooltip title="测试连接">
<Button
icon={<ThunderboltOutlined />}
onClick={() => handleTest(plugin.id)}
loading={testingPluginId === plugin.id}
size={isMobile ? 'small' : 'middle'}
/>
</Tooltip>
<Tooltip title="查看工具">
<Button
icon={<ToolOutlined />}
onClick={() => handleViewTools(plugin.id)}
disabled={!plugin.enabled || plugin.status !== 'active'}
size={isMobile ? 'small' : 'middle'}
/>
</Tooltip>
<Tooltip title="编辑">
<Button
icon={<EditOutlined />}
onClick={() => handleEdit(plugin)}
size={isMobile ? 'small' : 'middle'}
/>
</Tooltip>
<Tooltip title="删除">
<Button
danger
icon={<DeleteOutlined />}
onClick={() => handleDelete(plugin)}
size={isMobile ? 'small' : 'middle'}
/>
</Tooltip>
</Space>
</div>
</Card>
))}
</Space>
)}
</Spin>
</Content>
{/* 创建/编辑插件模态框 */}
<Modal
title={editingPlugin ? '编辑插件' : '添加插件'}
open={modalVisible}
onCancel={() => {
setModalVisible(false);
form.resetFields();
}}
onOk={() => form.submit()}
width={isMobile ? '100%' : 600}
confirmLoading={loading}
okText="保存"
cancelText="取消"
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
<Form.Item
label="MCP配置JSON"
name="config_json"
rules={[{ required: true, message: '请输入配置JSON' }]}
extra="粘贴标准MCP配置,系统自动提取插件名称。支持HTTP和Stdio类型"
>
<TextArea
rows={16}
placeholder={`示例:
{
"mcpServers": {
"exa": {
"type": "http",
"url": "https://mcp.exa.ai/mcp?exaApiKey=YOUR_API_KEY",
"headers": {}
}
}
}`}
style={{ fontFamily: 'monospace', fontSize: '13px' }}
/>
</Form.Item>
<Form.Item
label="插件分类"
name="category"
rules={[{ required: true, message: '请选择插件分类' }]}
extra="选择插件的功能类别,用于AI智能匹配使用场景"
>
<Select placeholder="请选择分类">
<Select.Option value="search"> (Search) - </Select.Option>
<Select.Option value="analysis"> (Analysis) - </Select.Option>
<Select.Option value="filesystem"> (FileSystem) - </Select.Option>
<Select.Option value="database"> (Database) - </Select.Option>
<Select.Option value="api">API调用 (API) - </Select.Option>
<Select.Option value="generation"> (Generation) - </Select.Option>
<Select.Option value="general"> (General) - </Select.Option>
</Select>
</Form.Item>
</Form>
</Modal>
{/* 查看工具列表模态框 */}
<Modal
title="可用工具列表"
open={!!viewingTools}
onCancel={() => setViewingTools(null)}
footer={[
<Button key="close" onClick={() => setViewingTools(null)}>
</Button>,
]}
width={isMobile ? '100%' : 700}
>
{viewingTools && (
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
{viewingTools.tools.length === 0 ? (
<Empty description="该插件没有提供任何工具" />
) : (
viewingTools.tools.map((tool, index) => (
<Card key={index} size="small" style={{ borderRadius: 8 }}>
<Descriptions column={1} size="small">
<Descriptions.Item label="工具名称">
<Text code strong>
{tool.name}
</Text>
</Descriptions.Item>
{tool.description && (
<Descriptions.Item label="描述">{tool.description}</Descriptions.Item>
)}
{tool.inputSchema && (
<Descriptions.Item label="输入参数">
<pre
style={{
margin: 0,
padding: 8,
background: '#f5f5f5',
borderRadius: 4,
fontSize: isMobile ? '11px' : '12px',
overflow: 'auto',
}}
>
{JSON.stringify(tool.inputSchema, null, 2)}
</pre>
</Descriptions.Item>
)}
</Descriptions>
</Card>
))
)}
</Space>
)}
</Modal>
</Layout>
);
}
+70 -48
View File
@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Card, Button, Empty, Modal, message, Spin, Row, Col, Statistic, Space, Tag, Progress, Typography, Tooltip, Badge, Alert, Upload, Checkbox, Divider, Switch } from 'antd'; import { Card, Button, Empty, Modal, message, Spin, Row, Col, Statistic, Space, Tag, Progress, Typography, Tooltip, Badge, Alert, Upload, Checkbox, Divider, Switch, Dropdown } from 'antd';
import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined, SettingOutlined, InfoCircleOutlined, CloseOutlined, UploadOutlined, DownloadOutlined } from '@ant-design/icons'; import { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined, SettingOutlined, InfoCircleOutlined, CloseOutlined, UploadOutlined, DownloadOutlined, ApiOutlined, MoreOutlined } from '@ant-design/icons';
import { projectApi } from '../services/api'; import { projectApi } from '../services/api';
import { useStore } from '../store'; import { useStore } from '../store';
import { useProjectSync } from '../store/hooks'; import { useProjectSync } from '../store/hooks';
@@ -388,10 +388,25 @@ export default function ProjectList() {
> >
</Button> </Button>
<Button
type="default"
size="middle"
icon={<ApiOutlined />}
onClick={() => navigate('/mcp-plugins')}
style={{
flex: 1,
borderRadius: 8,
borderColor: '#722ed1',
color: '#722ed1',
boxShadow: '0 2px 8px rgba(114, 46, 209, 0.2)'
}}
>
MCP
</Button>
</Space> </Space>
</Space> </Space>
) : ( ) : (
// PC端:原有布局 // PC端:优化后的布局 - 主要按钮 + 下拉菜单
<Space size={12} style={{ display: 'flex', justifyContent: 'flex-end' }}> <Space size={12} style={{ display: 'flex', justifyContent: 'flex-end' }}>
<Button <Button
type="primary" type="primary"
@@ -407,51 +422,6 @@ export default function ProjectList() {
> >
</Button> </Button>
<Button
type="default"
size="large"
icon={<DownloadOutlined />}
onClick={handleOpenExportModal}
disabled={exportableProjects.length === 0}
style={{
borderRadius: 8,
borderColor: '#1890ff',
color: '#1890ff',
boxShadow: '0 2px 8px rgba(24, 144, 255, 0.2)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#1890ff';
e.currentTarget.style.background = '#e6f7ff';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#1890ff';
e.currentTarget.style.background = 'transparent';
}}
>
</Button>
<Button
type="default"
size="large"
icon={<UploadOutlined />}
onClick={() => setImportModalVisible(true)}
style={{
borderRadius: 8,
borderColor: '#52c41a',
color: '#52c41a',
boxShadow: '0 2px 8px rgba(82, 196, 26, 0.2)'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#52c41a';
e.currentTarget.style.background = '#f6ffed';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#52c41a';
e.currentTarget.style.background = 'transparent';
}}
>
</Button>
<Button <Button
type="default" type="default"
size="large" size="large"
@@ -476,6 +446,58 @@ export default function ProjectList() {
> >
API设置 API设置
</Button> </Button>
<Dropdown
menu={{
items: [
{
key: 'export',
label: '导出项目',
icon: <DownloadOutlined />,
onClick: handleOpenExportModal,
disabled: exportableProjects.length === 0
},
{
key: 'import',
label: '导入项目',
icon: <UploadOutlined />,
onClick: () => setImportModalVisible(true)
},
{
type: 'divider'
},
{
key: 'mcp',
label: 'MCP插件',
icon: <ApiOutlined />,
onClick: () => navigate('/mcp-plugins')
}
]
}}
placement="bottomRight"
>
<Button
size="large"
icon={<MoreOutlined />}
style={{
borderRadius: 8,
borderColor: '#d9d9d9',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
transition: 'all 0.3s ease'
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = '#1890ff';
e.currentTarget.style.color = '#1890ff';
e.currentTarget.style.boxShadow = '0 2px 12px rgba(24, 144, 255, 0.3)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = '#d9d9d9';
e.currentTarget.style.color = 'rgba(0, 0, 0, 0.88)';
e.currentTarget.style.boxShadow = '0 2px 8px rgba(0, 0, 0, 0.08)';
}}
>
</Button>
</Dropdown>
<UserMenu /> <UserMenu />
</Space> </Space>
)} )}
+54
View File
@@ -1,4 +1,9 @@
import axios from 'axios'; import axios from 'axios';
interface MCPPluginSimpleCreate {
config_json: string;
enabled: boolean;
}
import { message } from 'antd'; import { message } from 'antd';
import { ssePost } from '../utils/sseClient'; import { ssePost } from '../utils/sseClient';
import type { SSEClientOptions } from '../utils/sseClient'; import type { SSEClientOptions } from '../utils/sseClient';
@@ -30,6 +35,13 @@ import type {
WritingStyleUpdate, WritingStyleUpdate,
PresetStyle, PresetStyle,
WritingStyleListResponse, WritingStyleListResponse,
MCPPlugin,
MCPPluginCreate,
MCPPluginUpdate,
MCPTestResult,
MCPTool,
MCPToolCallRequest,
MCPToolCallResponse,
} from '../types'; } from '../types';
const api = axios.create({ const api = axios.create({
@@ -428,4 +440,46 @@ export const wizardStreamApi = {
{}, {},
options options
), ),
};
export const mcpPluginApi = {
// 获取所有插件
getPlugins: () =>
api.get<unknown, MCPPlugin[]>('/mcp/plugins'),
// 获取单个插件
getPlugin: (id: string) =>
api.get<unknown, MCPPlugin>(`/mcp/plugins/${id}`),
// 创建插件
createPlugin: (data: MCPPluginCreate) =>
api.post<unknown, MCPPlugin>('/mcp/plugins', data),
// 简化创建插件(通过标准MCP配置JSON)
createPluginSimple: (data: MCPPluginSimpleCreate) =>
api.post<unknown, MCPPlugin>('/mcp/plugins/simple', data),
// 更新插件
updatePlugin: (id: string, data: MCPPluginUpdate) =>
api.put<unknown, MCPPlugin>(`/mcp/plugins/${id}`, data),
// 删除插件
deletePlugin: (id: string) =>
api.delete<unknown, { message: string }>(`/mcp/plugins/${id}`),
// 启用/禁用插件
togglePlugin: (id: string, enabled: boolean) =>
api.post<unknown, MCPPlugin>(`/mcp/plugins/${id}/toggle`, null, { params: { enabled } }),
// 测试插件连接
testPlugin: (id: string) =>
api.post<unknown, MCPTestResult>(`/mcp/plugins/${id}/test`),
// 获取插件工具列表
getPluginTools: (id: string) =>
api.get<unknown, { tools: MCPTool[] }>(`/mcp/plugins/${id}/tools`),
// 调用工具
callTool: (data: MCPToolCallRequest) =>
api.post<unknown, MCPToolCallResponse>('/mcp/call', data),
}; };
+81
View File
@@ -522,4 +522,85 @@ export interface TriggerAnalysisResponse {
chapter_id: string; chapter_id: string;
status: string; status: string;
message: string; message: string;
}
// MCP 插件类型定义 - 优化后只包含必要字段
export interface MCPPlugin {
id: string;
plugin_name: string;
display_name: string;
description?: string;
plugin_type: 'http' | 'stdio';
category: string;
// HTTP类型字段
server_url?: string;
headers?: Record<string, string>;
// Stdio类型字段
command?: string;
args?: string[];
env?: Record<string, string>;
// 状态字段
enabled: boolean;
status: 'active' | 'inactive' | 'error';
last_error?: string;
last_test_at?: string;
// 时间戳
created_at: string;
}
export interface MCPPluginCreate {
plugin_name: string;
display_name?: string;
description?: string;
server_type: 'http' | 'stdio';
server_url?: string;
command?: string;
args?: string[];
env?: Record<string, string>;
headers?: Record<string, string>;
enabled?: boolean;
}
export interface MCPPluginUpdate {
display_name?: string;
description?: string;
server_url?: string;
command?: string;
args?: string[];
env?: Record<string, string>;
headers?: Record<string, string>;
enabled?: boolean;
}
export interface MCPTool {
name: string;
description?: string;
inputSchema?: Record<string, unknown>;
}
export interface MCPTestResult {
success: boolean;
message: string;
tools?: MCPTool[];
tools_count?: number;
response_time_ms?: number;
error?: string;
error_type?: string;
suggestions?: string[];
}
export interface MCPToolCallRequest {
plugin_id: string;
tool_name: string;
arguments: Record<string, unknown>;
}
export interface MCPToolCallResponse {
success: boolean;
result?: unknown;
error?: string;
} }