update:1.更新mcp插件功能,目前只支持remote调用
This commit is contained in:
@@ -823,12 +823,14 @@ async def generate_chapter_content_stream(
|
||||
请求体参数:
|
||||
- style_id: 可选,指定使用的写作风格ID。不提供则不使用任何风格
|
||||
- target_word_count: 可选,目标字数,默认3000字,范围500-10000字
|
||||
- enable_mcp: 可选,是否启用MCP工具增强,默认True
|
||||
|
||||
注意:此函数不使用依赖注入的db,而是在生成器内部创建独立的数据库会话
|
||||
以避免流式响应期间的连接泄漏问题
|
||||
"""
|
||||
style_id = generate_request.style_id
|
||||
target_word_count = generate_request.target_word_count or 3000
|
||||
enable_mcp = generate_request.enable_mcp if hasattr(generate_request, 'enable_mcp') else True
|
||||
# 预先验证章节存在性(使用临时会话)
|
||||
async for temp_db in get_db(request):
|
||||
try:
|
||||
@@ -1002,7 +1004,60 @@ async def generate_chapter_content_stream(
|
||||
# 发送开始事件
|
||||
yield f"data: {json.dumps({'type': 'start', 'message': '开始AI创作...'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# 根据是否有前置内容选择不同的提示词,并应用写作风格和记忆增强
|
||||
# 🔧 MCP工具增强:收集章节参考资料
|
||||
mcp_reference_materials = ""
|
||||
if enable_mcp and current_user_id:
|
||||
try:
|
||||
yield f"data: {json.dumps({'type': 'progress', 'message': '🔍 尝试使用MCP工具收集参考资料...', 'progress': 28}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# 构建资料收集提示词
|
||||
planning_prompt = f"""你正在为小说《{project.title}》创作第{current_chapter.chapter_number}章《{current_chapter.title}》。
|
||||
|
||||
【章节大纲】
|
||||
{outline.content if outline else current_chapter.summary or '暂无大纲'}
|
||||
|
||||
【小说信息】
|
||||
- 题材:{project.genre or '未设定'}
|
||||
- 主题:{project.theme or '未设定'}
|
||||
- 时代背景:{project.world_time_period or '未设定'}
|
||||
- 地理位置:{project.world_location or '未设定'}
|
||||
|
||||
【任务】
|
||||
请使用可用工具搜索相关背景资料,帮助创作更真实、更有深度的章节内容。
|
||||
你可以查询:
|
||||
1. 该章节涉及的历史事件或时代背景
|
||||
2. 地理环境和场景描写参考
|
||||
3. 相关领域的专业知识(如武术、科技、魔法等)
|
||||
4. 文化习俗和生活细节
|
||||
|
||||
请根据章节内容,有针对性地查询1-2个最关键的问题。"""
|
||||
|
||||
# 调用MCP增强的AI(非流式,最多2轮工具调用)
|
||||
planning_result = await user_ai_service.generate_text_with_mcp(
|
||||
prompt=planning_prompt,
|
||||
user_id=current_user_id,
|
||||
db_session=db_session,
|
||||
enable_mcp=True,
|
||||
max_tool_rounds=2,
|
||||
tool_choice="auto",
|
||||
provider=None,
|
||||
model=None
|
||||
)
|
||||
|
||||
# 提取参考资料
|
||||
if planning_result.get("tool_calls_made", 0) > 0:
|
||||
tool_count = planning_result["tool_calls_made"]
|
||||
yield f"data: {json.dumps({'type': 'progress', 'message': f'✅ MCP工具调用成功({tool_count}次)', 'progress': 32}, ensure_ascii=False)}\n\n"
|
||||
mcp_reference_materials = planning_result.get("content", "")
|
||||
logger.info(f"📚 MCP工具收集参考资料:{len(mcp_reference_materials)} 字符")
|
||||
else:
|
||||
yield f"data: {json.dumps({'type': 'progress', 'message': 'ℹ️ 未使用MCP工具(无可用工具或不需要)', 'progress': 32}, ensure_ascii=False)}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"MCP工具调用失败(降级处理): {e}")
|
||||
yield f"data: {json.dumps({'type': 'progress', 'message': '⚠️ MCP工具暂时不可用,使用基础模式', 'progress': 32}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# 根据是否有前置内容选择不同的提示词,并应用写作风格、记忆增强和MCP参考资料
|
||||
if previous_content:
|
||||
prompt = prompt_service.get_chapter_generation_with_context_prompt(
|
||||
title=project.title,
|
||||
@@ -1021,7 +1076,8 @@ async def generate_chapter_content_stream(
|
||||
chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲',
|
||||
style_content=style_content,
|
||||
target_word_count=target_word_count,
|
||||
memory_context=memory_context
|
||||
memory_context=memory_context,
|
||||
mcp_references=mcp_reference_materials
|
||||
)
|
||||
else:
|
||||
prompt = prompt_service.get_chapter_generation_prompt(
|
||||
@@ -1040,9 +1096,13 @@ async def generate_chapter_content_stream(
|
||||
chapter_outline=outline.content if outline else current_chapter.summary or '暂无大纲',
|
||||
style_content=style_content,
|
||||
target_word_count=target_word_count,
|
||||
memory_context=memory_context
|
||||
memory_context=memory_context,
|
||||
mcp_references=mcp_reference_materials
|
||||
)
|
||||
|
||||
if mcp_reference_materials:
|
||||
logger.info(f"📖 已整合MCP参考资料({len(mcp_reference_materials)}字符)到章节生成提示词")
|
||||
|
||||
logger.info(f"开始AI流式创作章节 {chapter_id}")
|
||||
|
||||
# 流式生成内容
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""角色管理API"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func
|
||||
import json
|
||||
@@ -221,6 +221,7 @@ async def delete_character(
|
||||
@router.post("/generate", response_model=CharacterResponse, summary="AI生成角色")
|
||||
async def generate_character(
|
||||
request: CharacterGenerateRequest,
|
||||
http_request: Request,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user_ai_service: AIService = Depends(get_user_ai_service)
|
||||
):
|
||||
@@ -294,18 +295,42 @@ async def generate_character(
|
||||
user_input=user_input
|
||||
)
|
||||
|
||||
# 调用AI生成角色
|
||||
logger.info(f"🎯 开始为项目 {request.project_id} 生成角色")
|
||||
# 获取user_id用于MCP工具调用
|
||||
user_id = http_request.state.user_id if hasattr(http_request.state, 'user_id') else 'default_user'
|
||||
|
||||
# 调用AI生成角色(支持MCP工具)
|
||||
logger.info(f"🎯 开始为项目 {request.project_id} 生成角色(启用MCP)")
|
||||
logger.info(f" - 角色名:{request.name or 'AI生成'}")
|
||||
logger.info(f" - 角色定位:{request.role_type}")
|
||||
logger.info(f" - 背景设定:{request.background or '无'}")
|
||||
logger.info(f" - AI提供商:{user_ai_service.api_provider}")
|
||||
logger.info(f" - AI模型:{user_ai_service.default_model}")
|
||||
logger.info(f" - Prompt长度:{len(prompt)} 字符")
|
||||
logger.info(f" - 用户ID:{user_id}")
|
||||
|
||||
try:
|
||||
ai_response = await user_ai_service.generate_text(prompt=prompt)
|
||||
logger.info(f"✅ AI响应接收完成,长度:{len(ai_response) if ai_response else 0} 字符")
|
||||
# 使用支持MCP的生成方法
|
||||
result = await user_ai_service.generate_text_with_mcp(
|
||||
prompt=prompt,
|
||||
user_id=user_id,
|
||||
db_session=db,
|
||||
enable_mcp=True,
|
||||
max_tool_rounds=2,
|
||||
tool_choice="auto",
|
||||
provider=None, # 使用AIService初始化时的配置
|
||||
model=None # 使用AIService初始化时的配置
|
||||
)
|
||||
|
||||
# 提取内容
|
||||
if isinstance(result, dict):
|
||||
ai_response = result.get('content', '')
|
||||
logger.info(f"✅ AI响应接收完成(MCP增强),长度:{len(ai_response)} 字符")
|
||||
if result.get('tool_calls'):
|
||||
logger.info(f" - 工具调用:{len(result['tool_calls'])} 次")
|
||||
else:
|
||||
ai_response = result
|
||||
logger.info(f"✅ AI响应接收完成,长度:{len(ai_response) if ai_response else 0} 字符")
|
||||
|
||||
except Exception as ai_error:
|
||||
logger.error(f"❌ AI服务调用异常:{str(ai_error)}")
|
||||
raise HTTPException(
|
||||
@@ -559,7 +584,7 @@ async def generate_character(
|
||||
history = GenerationHistory(
|
||||
project_id=request.project_id,
|
||||
prompt=prompt,
|
||||
generated_content=ai_response,
|
||||
generated_content=json.dumps(result, ensure_ascii=False) if isinstance(result, dict) else ai_response,
|
||||
model=user_ai_service.default_model
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
@@ -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)}")
|
||||
@@ -38,6 +38,7 @@ class OrganizationGenerateRequest(BaseModel):
|
||||
organization_type: Optional[str] = Field(None, description="组织类型")
|
||||
background: Optional[str] = Field(None, description="组织背景")
|
||||
requirements: Optional[str] = Field(None, description="特殊要求")
|
||||
enable_mcp: bool = Field(True, description="是否启用MCP工具增强(搜索组织架构参考)")
|
||||
|
||||
|
||||
@router.get("/project/{project_id}", response_model=List[OrganizationDetailResponse], summary="获取项目的所有组织")
|
||||
|
||||
+255
-23
@@ -404,8 +404,8 @@ async def _generate_new_outline(
|
||||
db: AsyncSession,
|
||||
user_ai_service: AIService
|
||||
) -> OutlineListResponse:
|
||||
"""全新生成大纲"""
|
||||
logger.info(f"全新生成大纲 - 项目: {project.id}, keep_existing: {request.keep_existing}")
|
||||
"""全新生成大纲(MCP增强版)"""
|
||||
logger.info(f"全新生成大纲 - 项目: {project.id}, enable_mcp: {request.enable_mcp}")
|
||||
|
||||
# 获取角色信息
|
||||
characters_result = await db.execute(
|
||||
@@ -418,7 +418,59 @@ async def _generate_new_outline(
|
||||
for char in characters
|
||||
])
|
||||
|
||||
# 使用完整提示词
|
||||
# 🔍 MCP工具增强:收集情节设计参考资料
|
||||
mcp_reference_materials = ""
|
||||
if request.enable_mcp:
|
||||
try:
|
||||
logger.info(f"🔍 尝试使用MCP工具收集大纲设计参考资料...")
|
||||
|
||||
# 构建资料收集查询
|
||||
planning_query = f"""你正在为小说《{project.title}》设计完整大纲。
|
||||
项目信息:
|
||||
- 主题:{request.theme or project.theme}
|
||||
- 类型:{request.genre or project.genre}
|
||||
- 章节数:{request.chapter_count}
|
||||
- 叙事视角:{request.narrative_perspective}
|
||||
- 目标字数:{request.target_words}
|
||||
|
||||
世界观设定:
|
||||
- 时间背景:{project.world_time_period or '未设定'}
|
||||
- 地理位置:{project.world_location or '未设定'}
|
||||
- 氛围基调:{project.world_atmosphere or '未设定'}
|
||||
|
||||
角色信息:
|
||||
{characters_info or '暂无角色'}
|
||||
|
||||
请搜索:
|
||||
1. 该类型小说的经典情节结构和套路
|
||||
2. 适合该主题的冲突设计思路
|
||||
3. 符合世界观的情节元素和场景设计灵感
|
||||
|
||||
请有针对性地查询1-2个最关键的问题。"""
|
||||
|
||||
# 调用MCP增强的AI(非流式,最多2轮工具调用)
|
||||
planning_result = await user_ai_service.generate_text_with_mcp(
|
||||
prompt=planning_query,
|
||||
user_id="system", # 全新生成时可能没有用户上下文
|
||||
db_session=db,
|
||||
enable_mcp=True,
|
||||
max_tool_rounds=2,
|
||||
tool_choice="auto",
|
||||
provider=None,
|
||||
model=None
|
||||
)
|
||||
|
||||
# 提取参考资料
|
||||
if planning_result.get("tool_calls_made", 0) > 0:
|
||||
mcp_reference_materials = planning_result.get("content", "")
|
||||
logger.info(f"📚 MCP工具收集参考资料:{len(mcp_reference_materials)} 字符")
|
||||
else:
|
||||
logger.info(f"ℹ️ MCP工具未进行调用,继续正常生成")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ MCP工具调用失败,继续使用常规模式: {str(e)}")
|
||||
mcp_reference_materials = ""
|
||||
|
||||
# 使用完整提示词(插入MCP参考资料)
|
||||
prompt = prompt_service.get_complete_outline_prompt(
|
||||
title=project.title,
|
||||
theme=request.theme or project.theme or "未设定",
|
||||
@@ -431,18 +483,22 @@ async def _generate_new_outline(
|
||||
atmosphere=project.world_atmosphere or "未设定",
|
||||
rules=project.world_rules or "未设定",
|
||||
characters_info=characters_info or "暂无角色信息",
|
||||
requirements=request.requirements or ""
|
||||
requirements=request.requirements or "",
|
||||
mcp_references=mcp_reference_materials
|
||||
)
|
||||
|
||||
# 调用AI
|
||||
# 调用AI生成大纲
|
||||
ai_response = await user_ai_service.generate_text(
|
||||
prompt=prompt,
|
||||
provider=request.provider,
|
||||
model=request.model
|
||||
)
|
||||
|
||||
# 提取内容(generate_text返回字典)
|
||||
ai_content = ai_response.get("content", "") if isinstance(ai_response, dict) else ai_response
|
||||
|
||||
# 解析响应
|
||||
outline_data = _parse_ai_response(ai_response)
|
||||
outline_data = _parse_ai_response(ai_content)
|
||||
|
||||
# 全新生成模式:必须删除旧大纲和章节
|
||||
# 注意:这是"new"模式的核心逻辑,应该始终删除旧数据
|
||||
@@ -463,7 +519,7 @@ async def _generate_new_outline(
|
||||
history = GenerationHistory(
|
||||
project_id=project.id,
|
||||
prompt=prompt,
|
||||
generated_content=ai_response,
|
||||
generated_content=json.dumps(ai_response, ensure_ascii=False) if isinstance(ai_response, dict) else ai_response,
|
||||
model=request.model or "default"
|
||||
)
|
||||
db.add(history)
|
||||
@@ -571,8 +627,8 @@ async def _continue_outline(
|
||||
user_ai_service: AIService,
|
||||
user_id: str = "system"
|
||||
) -> OutlineListResponse:
|
||||
"""续写大纲 - 分批生成,每批5章(记忆增强版)"""
|
||||
logger.info(f"续写大纲 - 项目: {project.id}, 已有: {len(existing_outlines)} 章")
|
||||
"""续写大纲 - 分批生成,每批5章(记忆+MCP增强版)"""
|
||||
logger.info(f"续写大纲 - 项目: {project.id}, 已有: {len(existing_outlines)} 章, enable_mcp: {request.enable_mcp}")
|
||||
|
||||
# 分析已有大纲
|
||||
current_chapter_count = len(existing_outlines)
|
||||
@@ -664,7 +720,57 @@ async def _continue_outline(
|
||||
logger.warning(f"⚠️ 记忆上下文构建失败,继续不使用记忆: {str(e)}")
|
||||
memory_context = None
|
||||
|
||||
# 使用标准续写提示词模板(支持记忆增强)
|
||||
# 🔍 MCP工具增强:收集续写参考资料
|
||||
mcp_reference_materials = ""
|
||||
if request.enable_mcp:
|
||||
try:
|
||||
logger.info(f"🔍 第{batch_num + 1}批:尝试使用MCP工具收集续写参考资料...")
|
||||
|
||||
# 构建资料收集查询
|
||||
latest_summary = latest_outlines[-1].content if latest_outlines else ""
|
||||
planning_query = f"""你正在为小说《{project.title}》续写大纲。
|
||||
当前进度:已有{len(latest_outlines)}章,即将续写第{current_start_chapter}-{current_start_chapter + current_batch_size - 1}章
|
||||
|
||||
项目信息:
|
||||
- 主题:{request.theme or project.theme}
|
||||
- 类型:{request.genre or project.genre}
|
||||
- 叙事视角:{request.narrative_perspective}
|
||||
- 情节阶段:{request.plot_stage}
|
||||
- 故事发展方向:{request.story_direction or '自然延续'}
|
||||
|
||||
最近章节概要:
|
||||
{latest_summary[:200]}
|
||||
|
||||
请搜索:
|
||||
1. 该情节阶段的经典处理手法和技巧
|
||||
2. 适合该发展方向的情节转折和冲突设计
|
||||
3. 符合类型特点的场景设计和剧情元素
|
||||
|
||||
请有针对性地查询1-2个最关键的问题。"""
|
||||
|
||||
# 调用MCP增强的AI(非流式,最多2轮工具调用)
|
||||
planning_result = await user_ai_service.generate_text_with_mcp(
|
||||
prompt=planning_query,
|
||||
user_id=user_id,
|
||||
db_session=db,
|
||||
enable_mcp=True,
|
||||
max_tool_rounds=2,
|
||||
tool_choice="auto",
|
||||
provider=None,
|
||||
model=None
|
||||
)
|
||||
|
||||
# 提取参考资料
|
||||
if planning_result.get("tool_calls_made", 0) > 0:
|
||||
mcp_reference_materials = planning_result.get("content", "")
|
||||
logger.info(f"📚 第{batch_num + 1}批MCP工具收集参考资料:{len(mcp_reference_materials)} 字符")
|
||||
else:
|
||||
logger.info(f"ℹ️ 第{batch_num + 1}批MCP工具未进行调用,继续正常生成")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 第{batch_num + 1}批MCP工具调用失败,继续使用常规模式: {str(e)}")
|
||||
mcp_reference_materials = ""
|
||||
|
||||
# 使用标准续写提示词模板(支持记忆+MCP增强)
|
||||
prompt = prompt_service.get_outline_continue_prompt(
|
||||
title=project.title,
|
||||
theme=request.theme or project.theme or "未设定",
|
||||
@@ -683,7 +789,8 @@ async def _continue_outline(
|
||||
start_chapter=current_start_chapter,
|
||||
story_direction=request.story_direction or "自然延续",
|
||||
requirements=request.requirements or "",
|
||||
memory_context=memory_context
|
||||
memory_context=memory_context,
|
||||
mcp_references=mcp_reference_materials
|
||||
)
|
||||
|
||||
# 调用AI生成当前批次
|
||||
@@ -694,8 +801,11 @@ async def _continue_outline(
|
||||
model=request.model
|
||||
)
|
||||
|
||||
# 提取内容(generate_text返回字典)
|
||||
ai_content = ai_response.get("content", "") if isinstance(ai_response, dict) else ai_response
|
||||
|
||||
# 解析响应
|
||||
outline_data = _parse_ai_response(ai_response)
|
||||
outline_data = _parse_ai_response(ai_content)
|
||||
|
||||
# 保存当前批次的大纲
|
||||
batch_outlines = await _save_outlines(
|
||||
@@ -706,7 +816,7 @@ async def _continue_outline(
|
||||
history = GenerationHistory(
|
||||
project_id=project.id,
|
||||
prompt=f"[批次{batch_num + 1}/{total_batches}] {str(prompt)[:500]}",
|
||||
generated_content=ai_response,
|
||||
generated_content=json.dumps(ai_response, ensure_ascii=False) if isinstance(ai_response, dict) else ai_response,
|
||||
model=request.model or "default"
|
||||
)
|
||||
db.add(history)
|
||||
@@ -820,7 +930,7 @@ async def new_outline_generator(
|
||||
db: AsyncSession,
|
||||
user_ai_service: AIService
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""全新生成大纲SSE生成器"""
|
||||
"""全新生成大纲SSE生成器(MCP增强版)"""
|
||||
db_committed = False
|
||||
try:
|
||||
yield await SSEResponse.send_progress("开始生成大纲...", 5)
|
||||
@@ -828,6 +938,7 @@ async def new_outline_generator(
|
||||
project_id = data.get("project_id")
|
||||
# 确保chapter_count是整数(前端可能传字符串)
|
||||
chapter_count = int(data.get("chapter_count", 10))
|
||||
enable_mcp = data.get("enable_mcp", True)
|
||||
|
||||
# 验证项目
|
||||
yield await SSEResponse.send_progress("加载项目信息...", 10)
|
||||
@@ -852,7 +963,61 @@ async def new_outline_generator(
|
||||
for char in characters
|
||||
])
|
||||
|
||||
# 使用完整提示词
|
||||
# 🔍 MCP工具增强:收集情节设计参考资料
|
||||
mcp_reference_materials = ""
|
||||
if enable_mcp:
|
||||
try:
|
||||
yield await SSEResponse.send_progress("🔍 使用MCP工具收集参考资料...", 18)
|
||||
logger.info(f"🔍 尝试使用MCP工具收集大纲设计参考资料...")
|
||||
|
||||
# 构建资料收集查询
|
||||
planning_query = f"""你正在为小说《{project.title}》设计完整大纲。
|
||||
项目信息:
|
||||
- 主题:{data.get('theme') or project.theme}
|
||||
- 类型:{data.get('genre') or project.genre}
|
||||
- 章节数:{chapter_count}
|
||||
- 叙事视角:{data.get('narrative_perspective') or '第三人称'}
|
||||
- 目标字数:{data.get('target_words') or project.target_words or 100000}
|
||||
|
||||
世界观设定:
|
||||
- 时间背景:{project.world_time_period or '未设定'}
|
||||
- 地理位置:{project.world_location or '未设定'}
|
||||
- 氛围基调:{project.world_atmosphere or '未设定'}
|
||||
|
||||
角色信息:
|
||||
{characters_info or '暂无角色'}
|
||||
|
||||
请搜索:
|
||||
1. 该类型小说的经典情节结构和套路
|
||||
2. 适合该主题的冲突设计思路
|
||||
3. 符合世界观的情节元素和场景设计灵感
|
||||
|
||||
请有针对性地查询1-2个最关键的问题。"""
|
||||
|
||||
# 调用MCP增强的AI(非流式,最多2轮工具调用)
|
||||
planning_result = await user_ai_service.generate_text_with_mcp(
|
||||
prompt=planning_query,
|
||||
user_id="system",
|
||||
db_session=db,
|
||||
enable_mcp=True,
|
||||
max_tool_rounds=2,
|
||||
tool_choice="auto",
|
||||
provider=None,
|
||||
model=None
|
||||
)
|
||||
|
||||
# 提取参考资料
|
||||
if planning_result.get("tool_calls_made", 0) > 0:
|
||||
mcp_reference_materials = planning_result.get("content", "")
|
||||
logger.info(f"📚 MCP工具收集参考资料:{len(mcp_reference_materials)} 字符")
|
||||
yield await SSEResponse.send_progress(f"📚 MCP收集到参考资料 ({len(mcp_reference_materials)}字符)", 19)
|
||||
else:
|
||||
logger.info(f"ℹ️ MCP工具未进行调用,继续正常生成")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ MCP工具调用失败,继续使用常规模式: {str(e)}")
|
||||
mcp_reference_materials = ""
|
||||
|
||||
# 使用完整提示词(插入MCP参考资料)
|
||||
yield await SSEResponse.send_progress("准备AI提示词...", 20)
|
||||
prompt = prompt_service.get_complete_outline_prompt(
|
||||
title=project.title,
|
||||
@@ -866,7 +1031,8 @@ async def new_outline_generator(
|
||||
atmosphere=project.world_atmosphere or "未设定",
|
||||
rules=project.world_rules or "未设定",
|
||||
characters_info=characters_info or "暂无角色信息",
|
||||
requirements=data.get("requirements") or ""
|
||||
requirements=data.get("requirements") or "",
|
||||
mcp_references=mcp_reference_materials
|
||||
)
|
||||
|
||||
# 调用AI
|
||||
@@ -879,8 +1045,11 @@ async def new_outline_generator(
|
||||
|
||||
yield await SSEResponse.send_progress("✅ AI生成完成,正在解析...", 70)
|
||||
|
||||
# 提取内容(generate_text返回字典)
|
||||
ai_content = ai_response.get("content", "") if isinstance(ai_response, dict) else ai_response
|
||||
|
||||
# 解析响应
|
||||
outline_data = _parse_ai_response(ai_response)
|
||||
outline_data = _parse_ai_response(ai_content)
|
||||
|
||||
# 删除旧大纲和章节
|
||||
yield await SSEResponse.send_progress("清理旧数据...", 75)
|
||||
@@ -902,7 +1071,7 @@ async def new_outline_generator(
|
||||
history = GenerationHistory(
|
||||
project_id=project_id,
|
||||
prompt=prompt,
|
||||
generated_content=ai_response,
|
||||
generated_content=json.dumps(ai_response, ensure_ascii=False) if isinstance(ai_response, dict) else ai_response,
|
||||
model=data.get("model") or "default"
|
||||
)
|
||||
db.add(history)
|
||||
@@ -957,7 +1126,7 @@ async def continue_outline_generator(
|
||||
user_ai_service: AIService,
|
||||
user_id: str = "system"
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""大纲续写SSE生成器 - 分批生成,推送进度(记忆增强版)"""
|
||||
"""大纲续写SSE生成器 - 分批生成,推送进度(记忆+MCP增强版)"""
|
||||
db_committed = False
|
||||
try:
|
||||
yield await SSEResponse.send_progress("开始续写大纲...", 5)
|
||||
@@ -1090,13 +1259,72 @@ async def continue_outline_generator(
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 记忆上下文构建失败: {str(e)}")
|
||||
memory_context = None
|
||||
# 🔍 MCP工具增强:收集续写参考资料
|
||||
mcp_reference_materials = ""
|
||||
enable_mcp = data.get("enable_mcp", True)
|
||||
if enable_mcp:
|
||||
try:
|
||||
yield await SSEResponse.send_progress(
|
||||
f"🔍 第{str(batch_num + 1)}批:使用MCP工具收集参考资料...",
|
||||
batch_progress + 4
|
||||
)
|
||||
logger.info(f"🔍 第{batch_num + 1}批:尝试使用MCP工具收集续写参考资料...")
|
||||
|
||||
# 构建资料收集查询
|
||||
latest_summary = latest_outlines[-1].content if latest_outlines else ""
|
||||
planning_query = f"""你正在为小说《{project.title}》续写大纲。
|
||||
当前进度:已有{len(latest_outlines)}章,即将续写第{current_start_chapter}-{current_start_chapter + current_batch_size - 1}章
|
||||
|
||||
项目信息:
|
||||
- 主题:{data.get('theme') or project.theme}
|
||||
- 类型:{data.get('genre') or project.genre}
|
||||
- 叙事视角:{data.get('narrative_perspective') or project.narrative_perspective or '第三人称'}
|
||||
- 情节阶段:{data.get('plot_stage', 'development')}
|
||||
- 故事发展方向:{data.get('story_direction', '自然延续')}
|
||||
|
||||
最近章节概要:
|
||||
{latest_summary[:200]}
|
||||
|
||||
请搜索:
|
||||
1. 该情节阶段的经典处理手法和技巧
|
||||
2. 适合该发展方向的情节转折和冲突设计
|
||||
3. 符合类型特点的场景设计和剧情元素
|
||||
|
||||
请有针对性地查询1-2个最关键的问题。"""
|
||||
|
||||
# 调用MCP增强的AI(非流式,最多2轮工具调用)
|
||||
planning_result = await user_ai_service.generate_text_with_mcp(
|
||||
prompt=planning_query,
|
||||
user_id=user_id,
|
||||
db_session=db,
|
||||
enable_mcp=True,
|
||||
max_tool_rounds=2,
|
||||
tool_choice="auto",
|
||||
provider=None,
|
||||
model=None
|
||||
)
|
||||
|
||||
# 提取参考资料
|
||||
if planning_result.get("tool_calls_made", 0) > 0:
|
||||
mcp_reference_materials = planning_result.get("content", "")
|
||||
logger.info(f"📚 第{batch_num + 1}批MCP工具收集参考资料:{len(mcp_reference_materials)} 字符")
|
||||
yield await SSEResponse.send_progress(
|
||||
f"📚 第{str(batch_num + 1)}批收集到参考资料 ({len(mcp_reference_materials)}字符)",
|
||||
batch_progress + 4.5
|
||||
)
|
||||
else:
|
||||
logger.info(f"ℹ️ 第{batch_num + 1}批MCP工具未进行调用,继续正常生成")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ 第{batch_num + 1}批MCP工具调用失败,继续使用常规模式: {str(e)}")
|
||||
mcp_reference_materials = ""
|
||||
|
||||
|
||||
yield await SSEResponse.send_progress(
|
||||
f" 调用AI生成第{str(batch_num + 1)}批...",
|
||||
batch_progress + 5
|
||||
)
|
||||
|
||||
# 使用标准续写提示词模板(支持记忆增强)
|
||||
# 使用标准续写提示词模板(支持记忆+MCP增强)
|
||||
prompt = prompt_service.get_outline_continue_prompt(
|
||||
title=project.title,
|
||||
theme=data.get("theme") or project.theme or "未设定",
|
||||
@@ -1115,7 +1343,8 @@ async def continue_outline_generator(
|
||||
start_chapter=current_start_chapter,
|
||||
story_direction=data.get("story_direction", "自然延续"),
|
||||
requirements=data.get("requirements", ""),
|
||||
memory_context=memory_context
|
||||
memory_context=memory_context,
|
||||
mcp_references=mcp_reference_materials
|
||||
)
|
||||
|
||||
# 调用AI生成当前批次
|
||||
@@ -1130,8 +1359,11 @@ async def continue_outline_generator(
|
||||
batch_progress + 10
|
||||
)
|
||||
|
||||
# 提取内容(generate_text返回字典)
|
||||
ai_content = ai_response.get("content", "") if isinstance(ai_response, dict) else ai_response
|
||||
|
||||
# 解析响应
|
||||
outline_data = _parse_ai_response(ai_response)
|
||||
outline_data = _parse_ai_response(ai_content)
|
||||
|
||||
# 保存当前批次的大纲
|
||||
batch_outlines = await _save_outlines(
|
||||
@@ -1142,7 +1374,7 @@ async def continue_outline_generator(
|
||||
history = GenerationHistory(
|
||||
project_id=project_id,
|
||||
prompt=f"[续写批次{batch_num + 1}/{total_batches}] {str(prompt)[:500]}",
|
||||
generated_content=ai_response,
|
||||
generated_content=json.dumps(ai_response, ensure_ascii=False) if isinstance(ai_response, dict) else ai_response,
|
||||
model=data.get("model") or "default"
|
||||
)
|
||||
db.add(history)
|
||||
|
||||
@@ -359,7 +359,10 @@ async def test_api_connection(data: ApiTestRequest):
|
||||
|
||||
logger.info(f"✅ API 测试成功")
|
||||
logger.info(f" - 响应时间: {response_time}ms")
|
||||
logger.info(f" - 响应内容: {response[:100] if response else 'N/A'}")
|
||||
|
||||
# 安全地处理响应内容(确保是字符串)
|
||||
response_str = str(response) if response else 'N/A'
|
||||
logger.info(f" - 响应内容: {response_str[:100]}")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
@@ -367,7 +370,7 @@ async def test_api_connection(data: ApiTestRequest):
|
||||
"response_time_ms": response_time,
|
||||
"provider": provider,
|
||||
"model": llm_model,
|
||||
"response_preview": response[:100] if response and len(response) > 100 else response,
|
||||
"response_preview": response_str[:100] if len(response_str) > 100 else response_str,
|
||||
"details": {
|
||||
"api_available": True,
|
||||
"model_accessible": True,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""项目创建向导流式API - 使用SSE避免超时"""
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
from typing import Dict, Any, AsyncGenerator
|
||||
@@ -15,6 +15,7 @@ from app.models.relationship import CharacterRelationship, Organization, Organiz
|
||||
from app.models.writing_style import WritingStyle
|
||||
from app.models.project_default_style import ProjectDefaultStyle
|
||||
from app.services.ai_service import AIService
|
||||
from app.services.mcp_tool_service import MCPToolService
|
||||
from app.services.prompt_service import prompt_service
|
||||
from app.logger import get_logger
|
||||
from app.utils.sse_response import SSEResponse, create_sse_response
|
||||
@@ -29,7 +30,7 @@ async def world_building_generator(
|
||||
db: AsyncSession,
|
||||
user_ai_service: AIService
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""世界构建流式生成器"""
|
||||
"""世界构建流式生成器 - 支持MCP工具增强"""
|
||||
# 标记数据库会话是否已提交
|
||||
db_committed = False
|
||||
try:
|
||||
@@ -47,27 +48,94 @@ async def world_building_generator(
|
||||
character_count = data.get("character_count")
|
||||
provider = data.get("provider")
|
||||
model = data.get("model")
|
||||
enable_mcp = data.get("enable_mcp", True) # 默认启用MCP
|
||||
user_id = data.get("user_id") # 从中间件注入
|
||||
|
||||
if not title or not description or not theme or not genre:
|
||||
yield await SSEResponse.send_error("title、description、theme 和 genre 是必需的参数", 400)
|
||||
return
|
||||
|
||||
# 获取提示词
|
||||
yield await SSEResponse.send_progress("准备AI提示词...", 20)
|
||||
prompt = prompt_service.get_world_building_prompt(
|
||||
# 获取基础提示词
|
||||
yield await SSEResponse.send_progress("准备AI提示词...", 15)
|
||||
base_prompt = prompt_service.get_world_building_prompt(
|
||||
title=title,
|
||||
theme=theme,
|
||||
genre=genre
|
||||
)
|
||||
|
||||
# 流式调用AI
|
||||
yield await SSEResponse.send_progress("正在调用AI生成...", 30)
|
||||
# MCP工具增强:收集参考资料
|
||||
reference_materials = ""
|
||||
if enable_mcp and user_id:
|
||||
try:
|
||||
yield await SSEResponse.send_progress("🔍 尝试使用MCP工具收集参考资料...", 18)
|
||||
|
||||
# 直接调用MCP增强的AI,内部会自动检查和加载工具
|
||||
# 构建资料收集提示词
|
||||
planning_prompt = f"""你正在为小说《{title}》设计世界观。
|
||||
|
||||
【小说信息】
|
||||
- 题材:{genre}
|
||||
- 主题:{theme}
|
||||
- 简介:{description}
|
||||
|
||||
【任务】
|
||||
请使用可用工具搜索相关背景资料,帮助构建更真实、更有深度的世界观设定。
|
||||
你可以查询:
|
||||
1. 历史背景(如果是历史题材)
|
||||
2. 地理环境和文化特征
|
||||
3. 相关领域的专业知识
|
||||
4. 类似作品的设定参考
|
||||
|
||||
请根据题材特点,有针对性地查询2-3个关键问题。"""
|
||||
|
||||
# 调用MCP增强的AI(非流式,最多2轮工具调用)
|
||||
planning_result = await user_ai_service.generate_text_with_mcp(
|
||||
prompt=planning_prompt,
|
||||
user_id=user_id,
|
||||
db_session=db,
|
||||
enable_mcp=True,
|
||||
max_tool_rounds=2,
|
||||
tool_choice="auto",
|
||||
provider=None,
|
||||
model=None
|
||||
)
|
||||
|
||||
# 提取参考资料
|
||||
if planning_result.get("tool_calls_made", 0) > 0:
|
||||
yield await SSEResponse.send_progress(
|
||||
f"✅ MCP工具调用成功({planning_result['tool_calls_made']}次)",
|
||||
25
|
||||
)
|
||||
reference_materials = planning_result.get("content", "")
|
||||
else:
|
||||
yield await SSEResponse.send_progress("ℹ️ 未使用MCP工具(无可用工具或不需要)", 25)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"MCP工具调用失败(降级处理): {e}")
|
||||
yield await SSEResponse.send_progress("⚠️ MCP工具暂时不可用,使用基础模式", 25)
|
||||
|
||||
# 构建增强提示词
|
||||
if reference_materials:
|
||||
enhanced_prompt = f"""{base_prompt}
|
||||
|
||||
【参考资料】
|
||||
以下是通过MCP工具收集的真实背景资料,请参考这些信息构建更真实的世界观:
|
||||
|
||||
{reference_materials}
|
||||
|
||||
请结合上述资料,生成符合历史/现实的世界观设定。"""
|
||||
final_prompt = enhanced_prompt
|
||||
yield await SSEResponse.send_progress("💡 已整合参考资料,开始生成世界观...", 30)
|
||||
else:
|
||||
final_prompt = base_prompt
|
||||
yield await SSEResponse.send_progress("正在调用AI生成...", 30)
|
||||
|
||||
# 流式生成世界观
|
||||
accumulated_text = ""
|
||||
chunk_count = 0
|
||||
|
||||
async for chunk in user_ai_service.generate_text_stream(
|
||||
prompt=prompt,
|
||||
prompt=final_prompt,
|
||||
provider=provider,
|
||||
model=model
|
||||
):
|
||||
@@ -190,6 +258,7 @@ async def world_building_generator(
|
||||
|
||||
@router.post("/world-building", summary="流式生成世界构建")
|
||||
async def generate_world_building_stream(
|
||||
request: Request,
|
||||
data: Dict[str, Any],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user_ai_service: AIService = Depends(get_user_ai_service)
|
||||
@@ -198,6 +267,10 @@ async def generate_world_building_stream(
|
||||
使用SSE流式生成世界构建,避免超时
|
||||
前端使用EventSource接收实时进度和结果
|
||||
"""
|
||||
# 从中间件注入user_id到data中
|
||||
if hasattr(request.state, 'user_id'):
|
||||
data['user_id'] = request.state.user_id
|
||||
|
||||
return create_sse_response(world_building_generator(data, db, user_ai_service))
|
||||
|
||||
|
||||
@@ -206,7 +279,7 @@ async def characters_generator(
|
||||
db: AsyncSession,
|
||||
user_ai_service: AIService
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""角色批量生成流式生成器 - 优化版:分批+重试"""
|
||||
"""角色批量生成流式生成器 - 优化版:分批+重试+MCP工具增强"""
|
||||
db_committed = False
|
||||
try:
|
||||
yield await SSEResponse.send_progress("开始生成角色...", 5)
|
||||
@@ -219,6 +292,8 @@ async def characters_generator(
|
||||
requirements = data.get("requirements", "")
|
||||
provider = data.get("provider")
|
||||
model = data.get("model")
|
||||
enable_mcp = data.get("enable_mcp", True) # 默认启用MCP
|
||||
user_id = data.get("user_id") # 从中间件注入
|
||||
|
||||
# 验证项目
|
||||
yield await SSEResponse.send_progress("验证项目...", 10)
|
||||
@@ -239,6 +314,57 @@ async def characters_generator(
|
||||
"rules": project.world_rules or "未设定"
|
||||
}
|
||||
|
||||
# MCP工具增强:收集角色参考资料
|
||||
character_reference_materials = ""
|
||||
if enable_mcp and user_id:
|
||||
try:
|
||||
yield await SSEResponse.send_progress("🔍 尝试使用MCP工具收集角色参考资料...", 8)
|
||||
|
||||
# 构建角色资料收集提示词
|
||||
planning_prompt = f"""你正在为小说《{project.title}》设计角色。
|
||||
|
||||
【小说信息】
|
||||
- 题材:{genre or project.genre}
|
||||
- 主题:{theme or project.theme}
|
||||
- 时代背景:{world_context.get('time_period', '未设定')}
|
||||
- 地理位置:{world_context.get('location', '未设定')}
|
||||
|
||||
【任务】
|
||||
请使用可用工具搜索相关参考资料,帮助设计更真实、更有深度的角色。
|
||||
你可以查询:
|
||||
1. 该时代/地域的真实历史人物特征
|
||||
2. 文化背景和社会习俗
|
||||
3. 职业特点和生活方式
|
||||
4. 相关领域的人物原型
|
||||
|
||||
请根据题材特点,有针对性地查询1-2个关键问题。"""
|
||||
|
||||
# 调用MCP增强的AI(非流式,最多2轮工具调用)
|
||||
planning_result = await user_ai_service.generate_text_with_mcp(
|
||||
prompt=planning_prompt,
|
||||
user_id=user_id,
|
||||
db_session=db,
|
||||
enable_mcp=True,
|
||||
max_tool_rounds=2,
|
||||
tool_choice="auto",
|
||||
provider=None,
|
||||
model=None
|
||||
)
|
||||
|
||||
# 提取参考资料
|
||||
if planning_result.get("tool_calls_made", 0) > 0:
|
||||
yield await SSEResponse.send_progress(
|
||||
f"✅ MCP工具调用成功({planning_result['tool_calls_made']}次)",
|
||||
12
|
||||
)
|
||||
character_reference_materials = planning_result.get("content", "")
|
||||
else:
|
||||
yield await SSEResponse.send_progress("ℹ️ 未使用MCP工具(无可用工具或不需要)", 12)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"MCP工具调用失败(降级处理): {e}")
|
||||
yield await SSEResponse.send_progress("⚠️ MCP工具暂时不可用,使用基础模式", 12)
|
||||
|
||||
# 优化的分批策略:每批生成3个,平衡效率和成功率
|
||||
BATCH_SIZE = 3 # 每批生成3个角色
|
||||
MAX_RETRIES = 3 # 每批最多重试3次
|
||||
@@ -291,7 +417,8 @@ async def characters_generator(
|
||||
else:
|
||||
batch_requirements += "\n主要是配角(supporting)和反派(antagonist)"
|
||||
|
||||
prompt = prompt_service.get_characters_batch_prompt(
|
||||
# 构建基础提示词
|
||||
base_prompt = prompt_service.get_characters_batch_prompt(
|
||||
count=current_batch_size, # 传递精确数量
|
||||
time_period=world_context.get("time_period", ""),
|
||||
location=world_context.get("location", ""),
|
||||
@@ -302,6 +429,19 @@ async def characters_generator(
|
||||
requirements=batch_requirements
|
||||
)
|
||||
|
||||
# 如果有MCP参考资料,增强提示词
|
||||
if character_reference_materials:
|
||||
prompt = f"""{base_prompt}
|
||||
|
||||
【参考资料】
|
||||
以下是通过MCP工具收集的真实背景资料,请参考这些信息设计更真实的角色:
|
||||
|
||||
{character_reference_materials}
|
||||
|
||||
请结合上述资料,设计符合历史/文化背景的角色。"""
|
||||
else:
|
||||
prompt = base_prompt
|
||||
|
||||
# 流式生成
|
||||
accumulated_text = ""
|
||||
async for chunk in user_ai_service.generate_text_stream(
|
||||
@@ -708,13 +848,19 @@ async def characters_generator(
|
||||
|
||||
@router.post("/characters", summary="流式批量生成角色")
|
||||
async def generate_characters_stream(
|
||||
request: Request,
|
||||
data: Dict[str, Any],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user_ai_service: AIService = Depends(get_user_ai_service)
|
||||
):
|
||||
"""
|
||||
使用SSE流式批量生成角色,避免超时
|
||||
支持MCP工具增强
|
||||
"""
|
||||
# 从中间件注入user_id到data中
|
||||
if hasattr(request.state, 'user_id'):
|
||||
data['user_id'] = request.state.user_id
|
||||
|
||||
return create_sse_response(characters_generator(data, db, user_ai_service))
|
||||
|
||||
|
||||
@@ -1071,7 +1217,7 @@ async def regenerate_world_building_generator(
|
||||
db: AsyncSession,
|
||||
user_ai_service: AIService
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""重新生成世界观流式生成器"""
|
||||
"""重新生成世界观流式生成器 - 支持MCP工具增强"""
|
||||
db_committed = False
|
||||
try:
|
||||
yield await SSEResponse.send_progress("开始重新生成世界观...", 10)
|
||||
@@ -1087,23 +1233,89 @@ async def regenerate_world_building_generator(
|
||||
|
||||
provider = data.get("provider")
|
||||
model = data.get("model")
|
||||
enable_mcp = data.get("enable_mcp", True) # 默认启用MCP
|
||||
user_id = data.get("user_id") # 从中间件注入
|
||||
|
||||
# 获取世界构建提示词
|
||||
yield await SSEResponse.send_progress("准备AI提示词...", 20)
|
||||
prompt = prompt_service.get_world_building_prompt(
|
||||
# 获取基础提示词
|
||||
yield await SSEResponse.send_progress("准备AI提示词...", 15)
|
||||
base_prompt = prompt_service.get_world_building_prompt(
|
||||
title=project.title,
|
||||
theme=project.theme or "",
|
||||
genre=project.genre or ""
|
||||
)
|
||||
|
||||
# 流式调用AI
|
||||
yield await SSEResponse.send_progress("正在调用AI生成...", 30)
|
||||
# MCP工具增强:收集参考资料
|
||||
reference_materials = ""
|
||||
if enable_mcp and user_id:
|
||||
try:
|
||||
yield await SSEResponse.send_progress("🔍 尝试使用MCP工具收集参考资料...", 18)
|
||||
|
||||
# 直接调用MCP增强的AI,内部会自动检查和加载工具
|
||||
# 构建资料收集提示词
|
||||
planning_prompt = f"""你正在为小说《{project.title}》重新设计世界观。
|
||||
|
||||
【小说信息】
|
||||
- 题材:{project.genre or '未设定'}
|
||||
- 主题:{project.theme or '未设定'}
|
||||
|
||||
【任务】
|
||||
请使用可用工具搜索相关背景资料,帮助构建更真实、更有深度的世界观设定。
|
||||
你可以查询:
|
||||
1. 历史背景(如果是历史题材)
|
||||
2. 地理环境和文化特征
|
||||
3. 相关领域的专业知识
|
||||
4. 类似作品的设定参考
|
||||
|
||||
请根据题材特点,有针对性地查询2-3个关键问题。"""
|
||||
|
||||
# 调用MCP增强的AI(非流式,最多2轮工具调用)
|
||||
planning_result = await user_ai_service.generate_text_with_mcp(
|
||||
prompt=planning_prompt,
|
||||
user_id=user_id,
|
||||
db_session=db,
|
||||
enable_mcp=True,
|
||||
max_tool_rounds=2,
|
||||
tool_choice="auto",
|
||||
provider=None,
|
||||
model=None
|
||||
)
|
||||
|
||||
# 提取参考资料
|
||||
if planning_result.get("tool_calls_made", 0) > 0:
|
||||
yield await SSEResponse.send_progress(
|
||||
f"✅ MCP工具调用成功({planning_result['tool_calls_made']}次)",
|
||||
25
|
||||
)
|
||||
reference_materials = planning_result.get("content", "")
|
||||
else:
|
||||
yield await SSEResponse.send_progress("ℹ️ 未使用MCP工具(无可用工具或不需要)", 25)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"MCP工具调用失败(降级处理): {e}")
|
||||
yield await SSEResponse.send_progress("⚠️ MCP工具暂时不可用,使用基础模式", 25)
|
||||
|
||||
# 构建增强提示词
|
||||
if reference_materials:
|
||||
enhanced_prompt = f"""{base_prompt}
|
||||
|
||||
【参考资料】
|
||||
以下是通过MCP工具收集的真实背景资料,请参考这些信息构建更真实的世界观:
|
||||
|
||||
{reference_materials}
|
||||
|
||||
请结合上述资料,生成符合历史/现实的世界观设定。"""
|
||||
final_prompt = enhanced_prompt
|
||||
yield await SSEResponse.send_progress("💡 已整合参考资料,开始重新生成世界观...", 30)
|
||||
else:
|
||||
final_prompt = base_prompt
|
||||
yield await SSEResponse.send_progress("正在调用AI生成...", 30)
|
||||
|
||||
# 流式生成世界观
|
||||
accumulated_text = ""
|
||||
chunk_count = 0
|
||||
|
||||
async for chunk in user_ai_service.generate_text_stream(
|
||||
prompt=prompt,
|
||||
prompt=final_prompt,
|
||||
provider=provider,
|
||||
model=model
|
||||
):
|
||||
@@ -1187,6 +1399,7 @@ async def regenerate_world_building_generator(
|
||||
|
||||
@router.post("/world-building/{project_id}/regenerate", summary="流式重新生成世界观")
|
||||
async def regenerate_world_building_stream(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
data: Dict[str, Any],
|
||||
db: AsyncSession = Depends(get_db),
|
||||
@@ -1200,6 +1413,10 @@ async def regenerate_world_building_stream(
|
||||
"model": "模型名称(可选)"
|
||||
}
|
||||
"""
|
||||
# 从中间件注入user_id到data中
|
||||
if hasattr(request.state, 'user_id'):
|
||||
data['user_id'] = request.state.user_id
|
||||
|
||||
return create_sse_response(regenerate_world_building_generator(project_id, data, db, user_ai_service))
|
||||
|
||||
|
||||
|
||||
+9
-1
@@ -28,7 +28,13 @@ async def lifespan(app: FastAPI):
|
||||
"""应用生命周期管理"""
|
||||
logger.info("应用启动,等待用户登录...")
|
||||
|
||||
# 导入MCP注册表
|
||||
from app.mcp.registry import mcp_registry
|
||||
|
||||
yield
|
||||
|
||||
# 清理MCP插件
|
||||
await mcp_registry.cleanup_all()
|
||||
await close_db()
|
||||
logger.info("应用已关闭")
|
||||
|
||||
@@ -114,7 +120,8 @@ async def db_session_stats():
|
||||
from app.api import (
|
||||
projects, outlines, characters, chapters,
|
||||
wizard_stream, relationships, organizations,
|
||||
auth, users, settings, writing_styles, memories
|
||||
auth, users, settings, writing_styles, memories,
|
||||
mcp_plugins
|
||||
)
|
||||
|
||||
app.include_router(auth.router, prefix="/api")
|
||||
@@ -130,6 +137,7 @@ app.include_router(relationships.router, prefix="/api")
|
||||
app.include_router(organizations.router, prefix="/api")
|
||||
app.include_router(writing_styles.router, prefix="/api")
|
||||
app.include_router(memories.router) # 记忆管理API (已包含/api前缀)
|
||||
app.include_router(mcp_plugins.router, prefix="/api") # MCP插件管理API
|
||||
|
||||
static_dir = Path(__file__).parent.parent / "static"
|
||||
if static_dir.exists():
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
"""MCP插件系统"""
|
||||
from .registry import mcp_registry
|
||||
|
||||
__all__ = ["mcp_registry"]
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -1,37 +1,34 @@
|
||||
"""数据库模型"""
|
||||
"""数据模型导出"""
|
||||
from app.models.project import Project
|
||||
from app.models.outline import Outline
|
||||
from app.models.character import Character
|
||||
from app.models.chapter import Chapter
|
||||
from app.models.character import Character
|
||||
from app.models.relationship import CharacterRelationship, Organization, OrganizationMember, RelationshipType
|
||||
from app.models.generation_history import GenerationHistory
|
||||
from app.models.settings import Settings
|
||||
from app.models.writing_style import WritingStyle
|
||||
from app.models.project_default_style import ProjectDefaultStyle
|
||||
from app.models.relationship import (
|
||||
RelationshipType,
|
||||
CharacterRelationship,
|
||||
Organization,
|
||||
OrganizationMember
|
||||
)
|
||||
from app.models.memory import StoryMemory, PlotAnalysis
|
||||
from app.models.analysis_task import AnalysisTask
|
||||
from app.models.batch_generation_task import BatchGenerationTask
|
||||
from app.models.settings import Settings
|
||||
from app.models.memory import StoryMemory, PlotAnalysis
|
||||
from app.models.writing_style import WritingStyle
|
||||
from app.models.project_default_style import ProjectDefaultStyle
|
||||
from app.models.mcp_plugin import MCPPlugin
|
||||
|
||||
__all__ = [
|
||||
"Project",
|
||||
"Outline",
|
||||
"Character",
|
||||
"Chapter",
|
||||
"GenerationHistory",
|
||||
"Settings",
|
||||
"WritingStyle",
|
||||
"ProjectDefaultStyle",
|
||||
"RelationshipType",
|
||||
"Character",
|
||||
"CharacterRelationship",
|
||||
"Organization",
|
||||
"OrganizationMember",
|
||||
"StoryMemory",
|
||||
"PlotAnalysis",
|
||||
"RelationshipType",
|
||||
"GenerationHistory",
|
||||
"AnalysisTask",
|
||||
"BatchGenerationTask",
|
||||
"Settings",
|
||||
"StoryMemory",
|
||||
"PlotAnalysis",
|
||||
"WritingStyle",
|
||||
"ProjectDefaultStyle",
|
||||
"MCPPlugin"
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""MCP插件配置数据模型"""
|
||||
from sqlalchemy import Column, String, Text, Boolean, Integer, DateTime, Index, JSON
|
||||
from sqlalchemy.sql import func
|
||||
from app.database import Base
|
||||
import uuid
|
||||
|
||||
|
||||
class MCPPlugin(Base):
|
||||
"""MCP插件配置表"""
|
||||
__tablename__ = "mcp_plugins"
|
||||
|
||||
id = Column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
|
||||
user_id = Column(String(50), nullable=False, index=True, comment="用户ID")
|
||||
|
||||
# 插件基本信息
|
||||
plugin_name = Column(String(100), nullable=False, comment="插件名称(唯一标识)")
|
||||
display_name = Column(String(200), nullable=False, comment="显示名称")
|
||||
description = Column(Text, comment="插件描述")
|
||||
plugin_type = Column(String(50), default="http", comment="插件类型:http/stdio")
|
||||
|
||||
# 连接配置
|
||||
server_url = Column(String(500), comment="服务器URL(HTTP类型)")
|
||||
command = Column(String(500), comment="启动命令(stdio类型)")
|
||||
args = Column(JSON, comment="命令参数(stdio类型)")
|
||||
env = Column(JSON, comment="环境变量")
|
||||
headers = Column(JSON, comment="HTTP请求头")
|
||||
|
||||
# 插件配置
|
||||
config = Column(JSON, comment="插件特定配置(JSON)")
|
||||
tools = Column(JSON, comment="提供的工具列表")
|
||||
|
||||
# 状态管理
|
||||
enabled = Column(Boolean, default=True, comment="是否启用")
|
||||
status = Column(String(50), default="inactive", comment="状态:active/inactive/error")
|
||||
last_error = Column(Text, comment="最后错误信息")
|
||||
last_test_at = Column(DateTime, comment="最后测试时间")
|
||||
|
||||
# 排序和分组
|
||||
category = Column(String(100), default="general", comment="分类")
|
||||
sort_order = Column(Integer, default=0, comment="排序顺序")
|
||||
|
||||
# 时间戳
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间")
|
||||
|
||||
__table_args__ = (
|
||||
Index('idx_user_plugin', 'user_id', 'plugin_name', unique=True),
|
||||
Index('idx_user_enabled', 'user_id', 'enabled'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<MCPPlugin(id={self.id}, name={self.plugin_name}, enabled={self.enabled})>"
|
||||
@@ -66,6 +66,7 @@ class ChapterGenerateRequest(BaseModel):
|
||||
ge=500, # 最小500字
|
||||
le=10000 # 最大10000字
|
||||
)
|
||||
enable_mcp: bool = Field(True, description="是否启用MCP工具增强(搜索参考资料)")
|
||||
|
||||
|
||||
class BatchGenerateRequest(BaseModel):
|
||||
@@ -80,6 +81,7 @@ class BatchGenerateRequest(BaseModel):
|
||||
le=10000
|
||||
)
|
||||
enable_analysis: bool = Field(False, description="是否启用同步分析")
|
||||
enable_mcp: bool = Field(True, description="是否启用MCP工具增强(搜索参考资料)")
|
||||
max_retries: int = Field(3, description="每个章节的最大重试次数", ge=0, le=5)
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ class CharacterGenerateRequest(BaseModel):
|
||||
role_type: Optional[str] = Field(None, description="角色类型")
|
||||
background: Optional[str] = Field(None, description="角色背景")
|
||||
requirements: Optional[str] = Field(None, description="特殊要求")
|
||||
enable_mcp: bool = Field(True, description="是否启用MCP工具增强(搜索人物原型参考)")
|
||||
|
||||
|
||||
class CharacterListResponse(BaseModel):
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""MCP插件Pydantic模式"""
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class MCPToolSchema(BaseModel):
|
||||
"""MCP工具定义"""
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
inputSchema: Optional[Dict[str, Any]] = None
|
||||
category: Optional[str] = None
|
||||
|
||||
|
||||
class MCPPluginBase(BaseModel):
|
||||
"""插件基础模式"""
|
||||
plugin_name: str = Field(..., description="插件唯一标识")
|
||||
display_name: Optional[str] = Field(None, description="显示名称")
|
||||
description: Optional[str] = Field(None, description="插件描述")
|
||||
plugin_type: str = Field(default="http", description="插件类型:http/stdio")
|
||||
category: str = Field(default="general", description="分类")
|
||||
sort_order: int = Field(default=0, description="排序顺序")
|
||||
|
||||
|
||||
class MCPPluginCreate(MCPPluginBase):
|
||||
"""创建插件"""
|
||||
server_url: Optional[str] = Field(None, description="服务器URL(HTTP类型)")
|
||||
command: Optional[str] = Field(None, description="启动命令(stdio类型)")
|
||||
args: Optional[List[str]] = Field(None, description="命令参数")
|
||||
env: Optional[Dict[str, str]] = Field(None, description="环境变量")
|
||||
headers: Optional[Dict[str, str]] = Field(None, description="HTTP请求头")
|
||||
config: Optional[Dict[str, Any]] = Field(None, description="插件特定配置")
|
||||
enabled: bool = Field(default=True, description="是否启用")
|
||||
|
||||
|
||||
class MCPPluginSimpleCreate(BaseModel):
|
||||
"""简化的插件创建(通过标准MCP配置JSON)"""
|
||||
config_json: str = Field(..., description="标准MCP配置JSON字符串")
|
||||
enabled: bool = Field(default=True, description="是否启用")
|
||||
category: str = Field(default="general", description="插件分类")
|
||||
|
||||
|
||||
class MCPPluginUpdate(BaseModel):
|
||||
"""更新插件"""
|
||||
display_name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
server_url: Optional[str] = None
|
||||
command: Optional[str] = None
|
||||
args: Optional[List[str]] = None
|
||||
env: Optional[Dict[str, str]] = None
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
enabled: Optional[bool] = None
|
||||
category: Optional[str] = None
|
||||
sort_order: Optional[int] = None
|
||||
|
||||
|
||||
class MCPPluginResponse(BaseModel):
|
||||
"""插件响应 - 优化后只返回必要字段"""
|
||||
id: str
|
||||
plugin_name: str
|
||||
display_name: str
|
||||
description: Optional[str] = None
|
||||
plugin_type: str
|
||||
category: str
|
||||
|
||||
# HTTP类型字段
|
||||
server_url: Optional[str] = None
|
||||
headers: Optional[Dict[str, str]] = None
|
||||
|
||||
# Stdio类型字段
|
||||
command: Optional[str] = None
|
||||
args: Optional[List[str]] = None
|
||||
env: Optional[Dict[str, str]] = None
|
||||
|
||||
# 状态字段
|
||||
enabled: bool
|
||||
status: str
|
||||
last_error: Optional[str] = None
|
||||
last_test_at: Optional[datetime] = None
|
||||
|
||||
# 时间戳
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class MCPToolCall(BaseModel):
|
||||
"""工具调用请求"""
|
||||
plugin_id: str = Field(..., description="插件ID")
|
||||
tool_name: str = Field(..., description="工具名称")
|
||||
arguments: Dict[str, Any] = Field(default_factory=dict, description="工具参数")
|
||||
|
||||
|
||||
class MCPTestResult(BaseModel):
|
||||
"""测试结果"""
|
||||
success: bool
|
||||
message: str
|
||||
response_time_ms: Optional[float] = None
|
||||
tools_count: Optional[int] = None
|
||||
tools: Optional[List[MCPToolSchema]] = None
|
||||
error: Optional[str] = None
|
||||
error_type: Optional[str] = None
|
||||
suggestions: Optional[List[str]] = None
|
||||
@@ -61,6 +61,7 @@ class OutlineGenerateRequest(BaseModel):
|
||||
story_direction: Optional[str] = Field(None, description="故事发展方向提示(续写时使用)")
|
||||
plot_stage: str = Field("development", description="情节阶段: development(发展), climax(高潮), ending(结局)")
|
||||
keep_existing: bool = Field(False, description="是否保留现有大纲(续写时)")
|
||||
enable_mcp: bool = Field(True, description="是否启用MCP工具增强(搜索情节设计参考)")
|
||||
|
||||
|
||||
class ChapterOutlineGenerateRequest(BaseModel):
|
||||
|
||||
@@ -5,6 +5,7 @@ from anthropic import AsyncAnthropic
|
||||
from app.config import settings as app_settings
|
||||
from app.logger import get_logger
|
||||
import httpx
|
||||
import json
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@@ -126,10 +127,12 @@ class AIService:
|
||||
model: Optional[str] = None,
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
system_prompt: Optional[str] = None
|
||||
) -> str:
|
||||
system_prompt: Optional[str] = None,
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
生成文本
|
||||
生成文本(支持工具调用)
|
||||
|
||||
Args:
|
||||
prompt: 用户提示词
|
||||
@@ -138,9 +141,14 @@ class AIService:
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大token数
|
||||
system_prompt: 系统提示词
|
||||
tools: 可用工具列表(MCP工具格式)
|
||||
tool_choice: 工具选择策略 (auto/required/none)
|
||||
|
||||
Returns:
|
||||
生成的文本
|
||||
Dict包含:
|
||||
- content: 文本内容(如果没有工具调用)
|
||||
- tool_calls: 工具调用列表(如果AI决定调用工具)
|
||||
- finish_reason: 完成原因
|
||||
"""
|
||||
provider = provider or self.api_provider
|
||||
model = model or self.default_model
|
||||
@@ -148,12 +156,12 @@ class AIService:
|
||||
max_tokens = max_tokens or self.default_max_tokens
|
||||
|
||||
if provider == "openai":
|
||||
return await self._generate_openai(
|
||||
prompt, model, temperature, max_tokens, system_prompt
|
||||
return await self._generate_openai_with_tools(
|
||||
prompt, model, temperature, max_tokens, system_prompt, tools, tool_choice
|
||||
)
|
||||
elif provider == "anthropic":
|
||||
return await self._generate_anthropic(
|
||||
prompt, model, temperature, max_tokens, system_prompt
|
||||
return await self._generate_anthropic_with_tools(
|
||||
prompt, model, temperature, max_tokens, system_prompt, tools, tool_choice
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"不支持的AI提供商: {provider}")
|
||||
@@ -247,6 +255,7 @@ class AIService:
|
||||
logger.info(f"✅ OpenAI API调用成功")
|
||||
logger.info(f" - 响应ID: {data.get('id', 'N/A')}")
|
||||
logger.info(f" - 选项数量: {len(data.get('choices', []))}")
|
||||
logger.debug(f" - 完整API响应: {data}")
|
||||
|
||||
if not data.get('choices'):
|
||||
logger.error("❌ OpenAI返回的choices为空")
|
||||
@@ -294,6 +303,173 @@ class AIService:
|
||||
logger.error(f" - 模型: {model}")
|
||||
raise
|
||||
|
||||
|
||||
async def _generate_openai_with_tools(
|
||||
self,
|
||||
prompt: str,
|
||||
model: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
system_prompt: Optional[str],
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""使用OpenAI生成文本(支持工具调用)"""
|
||||
if not self.openai_http_client:
|
||||
raise ValueError("OpenAI客户端未初始化,请检查API key配置")
|
||||
|
||||
messages = []
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
try:
|
||||
logger.info(f"🔵 开始调用OpenAI API(支持工具调用)")
|
||||
logger.info(f" - 模型: {model}")
|
||||
logger.info(f" - 工具数量: {len(tools) if tools else 0}")
|
||||
|
||||
url = f"{self.openai_base_url}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {self.openai_api_key}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens
|
||||
}
|
||||
|
||||
# 添加工具参数
|
||||
if tools:
|
||||
payload["tools"] = tools
|
||||
if tool_choice:
|
||||
if tool_choice == "required":
|
||||
payload["tool_choice"] = "required"
|
||||
elif tool_choice == "auto":
|
||||
payload["tool_choice"] = "auto"
|
||||
elif tool_choice == "none":
|
||||
payload["tool_choice"] = "none"
|
||||
|
||||
response = await self.openai_http_client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
|
||||
logger.info(f"✅ OpenAI API调用成功")
|
||||
logger.debug(f" - 完整API响应: {data}")
|
||||
|
||||
if not data.get('choices'):
|
||||
logger.error(f"❌ API返回的choices为空")
|
||||
logger.error(f" - 完整响应: {data}")
|
||||
logger.error(f" - 响应键: {list(data.keys())}")
|
||||
raise ValueError(f"API返回的响应格式错误:choices字段为空。完整响应: {data}")
|
||||
|
||||
choice = data['choices'][0]
|
||||
message = choice.get('message', {})
|
||||
finish_reason = choice.get('finish_reason')
|
||||
|
||||
# 检查是否有工具调用
|
||||
tool_calls = message.get('tool_calls')
|
||||
if tool_calls:
|
||||
logger.info(f"🔧 AI请求调用 {len(tool_calls)} 个工具")
|
||||
return {
|
||||
"tool_calls": tool_calls,
|
||||
"content": message.get('content', ''),
|
||||
"finish_reason": finish_reason
|
||||
}
|
||||
|
||||
# 没有工具调用,返回普通内容
|
||||
content = message.get('content', '')
|
||||
if content:
|
||||
return {
|
||||
"content": content,
|
||||
"finish_reason": finish_reason
|
||||
}
|
||||
else:
|
||||
raise ValueError(f"AI返回了空内容(finish_reason: {finish_reason})")
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
logger.error(f"❌ OpenAI API调用失败 (HTTP {e.response.status_code})")
|
||||
logger.error(f" - 错误信息: {e.response.text}")
|
||||
raise Exception(f"API返回错误 ({e.response.status_code}): {e.response.text}")
|
||||
except Exception as e:
|
||||
logger.error(f"❌ OpenAI API调用失败: {str(e)}")
|
||||
raise
|
||||
|
||||
async def _generate_anthropic_with_tools(
|
||||
self,
|
||||
prompt: str,
|
||||
model: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
system_prompt: Optional[str],
|
||||
tools: Optional[List[Dict[str, Any]]] = None,
|
||||
tool_choice: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""使用Anthropic生成文本(支持工具调用)"""
|
||||
if not self.anthropic_client:
|
||||
raise ValueError("Anthropic客户端未初始化,请检查API key配置")
|
||||
|
||||
try:
|
||||
logger.info(f"🔵 开始调用Anthropic API(支持工具调用)")
|
||||
logger.info(f" - 模型: {model}")
|
||||
logger.info(f" - 工具数量: {len(tools) if tools else 0}")
|
||||
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
"messages": [{"role": "user", "content": prompt}]
|
||||
}
|
||||
|
||||
if system_prompt:
|
||||
kwargs["system"] = system_prompt
|
||||
|
||||
# 添加工具参数
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
if tool_choice == "required":
|
||||
kwargs["tool_choice"] = {"type": "any"}
|
||||
elif tool_choice == "auto":
|
||||
kwargs["tool_choice"] = {"type": "auto"}
|
||||
|
||||
response = await self.anthropic_client.messages.create(**kwargs)
|
||||
|
||||
# 检查是否有工具调用
|
||||
tool_calls = []
|
||||
content_text = ""
|
||||
|
||||
for block in response.content:
|
||||
if block.type == "tool_use":
|
||||
tool_calls.append({
|
||||
"id": block.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": block.name,
|
||||
"arguments": block.input
|
||||
}
|
||||
})
|
||||
elif block.type == "text":
|
||||
content_text += block.text
|
||||
|
||||
if tool_calls:
|
||||
logger.info(f"🔧 AI请求调用 {len(tool_calls)} 个工具")
|
||||
return {
|
||||
"tool_calls": tool_calls,
|
||||
"content": content_text,
|
||||
"finish_reason": response.stop_reason
|
||||
}
|
||||
|
||||
return {
|
||||
"content": content_text,
|
||||
"finish_reason": response.stop_reason
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Anthropic API调用失败: {str(e)}")
|
||||
raise
|
||||
|
||||
async def _generate_openai_stream(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -457,6 +633,232 @@ class AIService:
|
||||
logger.error(f" - 错误类型: {type(e).__name__}")
|
||||
raise
|
||||
|
||||
async def generate_text_with_mcp(
|
||||
self,
|
||||
prompt: str,
|
||||
user_id: str,
|
||||
db_session,
|
||||
enable_mcp: bool = True,
|
||||
max_tool_rounds: int = 3,
|
||||
tool_choice: str = "auto",
|
||||
**kwargs
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
支持MCP工具的AI文本生成(非流式)
|
||||
|
||||
Args:
|
||||
prompt: 用户提示词
|
||||
user_id: 用户ID,用于获取MCP工具
|
||||
db_session: 数据库会话
|
||||
enable_mcp: 是否启用MCP增强
|
||||
max_tool_rounds: 最大工具调用轮次
|
||||
tool_choice: 工具选择策略(auto/required/none)
|
||||
**kwargs: 其他AI参数(provider, model, temperature等)
|
||||
|
||||
Returns:
|
||||
{
|
||||
"content": "AI生成的最终文本",
|
||||
"tool_calls_made": 2, # 实际调用的工具次数
|
||||
"tools_used": ["exa_search", "filesystem_read"],
|
||||
"finish_reason": "stop",
|
||||
"mcp_enhanced": True
|
||||
}
|
||||
"""
|
||||
from app.services.mcp_tool_service import mcp_tool_service, MCPToolServiceError
|
||||
|
||||
# 初始化返回结果
|
||||
result = {
|
||||
"content": "",
|
||||
"tool_calls_made": 0,
|
||||
"tools_used": [],
|
||||
"finish_reason": "",
|
||||
"mcp_enhanced": False
|
||||
}
|
||||
|
||||
# 1. 获取MCP工具(如果启用)
|
||||
tools = None
|
||||
if enable_mcp:
|
||||
try:
|
||||
tools = await mcp_tool_service.get_user_enabled_tools(
|
||||
user_id=user_id,
|
||||
db_session=db_session
|
||||
)
|
||||
if tools:
|
||||
logger.info(f"MCP增强: 加载了 {len(tools)} 个工具")
|
||||
result["mcp_enhanced"] = True
|
||||
except MCPToolServiceError as e:
|
||||
logger.error(f"获取MCP工具失败,降级为普通生成: {e}")
|
||||
tools = None
|
||||
|
||||
# 2. 工具调用循环
|
||||
conversation_history = [
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
|
||||
for round_num in range(max_tool_rounds):
|
||||
logger.info(f"MCP工具调用轮次: {round_num + 1}/{max_tool_rounds}")
|
||||
|
||||
# 调用AI
|
||||
ai_response = await self.generate_text(
|
||||
prompt=conversation_history[-1]["content"],
|
||||
tools=tools if round_num == 0 else None, # 只在第一轮传递工具
|
||||
tool_choice=tool_choice if round_num == 0 else None,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# 检查是否有工具调用
|
||||
tool_calls = ai_response.get("tool_calls", [])
|
||||
|
||||
if not tool_calls:
|
||||
# AI返回最终内容
|
||||
result["content"] = ai_response.get("content", "")
|
||||
result["finish_reason"] = ai_response.get("finish_reason", "stop")
|
||||
break
|
||||
|
||||
# 3. 执行工具调用
|
||||
logger.info(f"AI请求调用 {len(tool_calls)} 个工具")
|
||||
|
||||
try:
|
||||
tool_results = await mcp_tool_service.execute_tool_calls(
|
||||
user_id=user_id,
|
||||
tool_calls=tool_calls,
|
||||
db_session=db_session
|
||||
)
|
||||
|
||||
# 记录使用的工具
|
||||
for tool_call in tool_calls:
|
||||
tool_name = tool_call["function"]["name"]
|
||||
if tool_name not in result["tools_used"]:
|
||||
result["tools_used"].append(tool_name)
|
||||
|
||||
result["tool_calls_made"] += len(tool_calls)
|
||||
|
||||
# 4. 构建工具上下文
|
||||
tool_context = await mcp_tool_service.build_tool_context(
|
||||
tool_results,
|
||||
format="markdown"
|
||||
)
|
||||
|
||||
# 5. 更新对话历史
|
||||
conversation_history.append({
|
||||
"role": "assistant",
|
||||
"content": ai_response.get("content", ""),
|
||||
"tool_calls": tool_calls
|
||||
})
|
||||
|
||||
for tool_result in tool_results:
|
||||
conversation_history.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_result["tool_call_id"],
|
||||
"content": tool_result["content"]
|
||||
})
|
||||
|
||||
# 6. 构建下一轮提示
|
||||
next_prompt = (
|
||||
f"{prompt}\n\n"
|
||||
f"{tool_context}\n\n"
|
||||
f"请基于以上工具查询结果,继续完成任务。"
|
||||
)
|
||||
conversation_history.append({
|
||||
"role": "user",
|
||||
"content": next_prompt
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"执行MCP工具失败: {e}", exc_info=True)
|
||||
# 降级:返回当前AI响应
|
||||
result["content"] = ai_response.get("content", "")
|
||||
result["finish_reason"] = "tool_error"
|
||||
break
|
||||
|
||||
else:
|
||||
# 达到最大轮次
|
||||
logger.warning(f"达到MCP最大调用轮次 {max_tool_rounds}")
|
||||
result["content"] = conversation_history[-1].get("content", "")
|
||||
result["finish_reason"] = "max_rounds"
|
||||
|
||||
return result
|
||||
|
||||
async def generate_text_stream_with_mcp(
|
||||
self,
|
||||
prompt: str,
|
||||
user_id: str,
|
||||
db_session,
|
||||
enable_mcp: bool = True,
|
||||
mcp_planning_prompt: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> AsyncGenerator[str, None]:
|
||||
"""
|
||||
支持MCP工具的AI流式文本生成(两阶段模式)
|
||||
|
||||
Args:
|
||||
prompt: 用户提示词
|
||||
user_id: 用户ID
|
||||
db_session: 数据库会话
|
||||
enable_mcp: 是否启用MCP增强
|
||||
mcp_planning_prompt: MCP规划阶段的提示词(可选)
|
||||
**kwargs: 其他AI参数
|
||||
|
||||
Yields:
|
||||
流式文本chunk
|
||||
"""
|
||||
from app.services.mcp_tool_service import mcp_tool_service
|
||||
|
||||
# 阶段1: 工具调用阶段(非流式)
|
||||
enhanced_prompt = prompt
|
||||
|
||||
if enable_mcp:
|
||||
try:
|
||||
# 获取MCP工具
|
||||
tools = await mcp_tool_service.get_user_enabled_tools(
|
||||
user_id=user_id,
|
||||
db_session=db_session
|
||||
)
|
||||
|
||||
if tools:
|
||||
logger.info(f"MCP增强(流式): 加载了 {len(tools)} 个工具")
|
||||
|
||||
# 使用规划提示让AI决定需要查询什么
|
||||
if not mcp_planning_prompt:
|
||||
mcp_planning_prompt = (
|
||||
f"任务: {prompt}\n\n"
|
||||
f"请分析这个任务,决定是否需要查询外部信息。"
|
||||
f"如果需要,请调用相应的工具获取信息。"
|
||||
)
|
||||
|
||||
# 非流式调用获取工具结果
|
||||
planning_result = await self.generate_text_with_mcp(
|
||||
prompt=mcp_planning_prompt,
|
||||
user_id=user_id,
|
||||
db_session=db_session,
|
||||
enable_mcp=True,
|
||||
max_tool_rounds=2,
|
||||
tool_choice="auto",
|
||||
**kwargs
|
||||
)
|
||||
|
||||
# 如果有工具调用,将结果融入提示
|
||||
if planning_result["tool_calls_made"] > 0:
|
||||
enhanced_prompt = (
|
||||
f"{prompt}\n\n"
|
||||
f"【参考资料】\n"
|
||||
f"{planning_result.get('content', '')}"
|
||||
)
|
||||
logger.info(
|
||||
f"MCP工具规划完成,调用了 "
|
||||
f"{planning_result['tool_calls_made']} 次工具"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"MCP工具规划失败,使用原始提示: {e}")
|
||||
|
||||
# 阶段2: 内容生成阶段(流式)
|
||||
async for chunk in self.generate_text_stream(
|
||||
prompt=enhanced_prompt,
|
||||
**kwargs
|
||||
):
|
||||
yield chunk
|
||||
|
||||
|
||||
# 创建全局AI服务实例
|
||||
ai_service = AIService()
|
||||
|
||||
@@ -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()
|
||||
@@ -225,8 +225,22 @@ class PlotAnalyzer:
|
||||
temperature=0.3 # 降低温度以获得更稳定的JSON输出
|
||||
)
|
||||
|
||||
# 🔍 添加调试日志:查看AI返回的原始内容
|
||||
logger.info(f"🔍 AI返回类型: {type(response)}")
|
||||
logger.info(f"🔍 AI返回内容(前500字符): {str(response)}")
|
||||
|
||||
# 从返回的字典中提取content字段
|
||||
if isinstance(response, dict):
|
||||
response_text = response.get('content', '')
|
||||
if not response_text:
|
||||
logger.error("❌ AI返回的字典中没有content字段或content为空")
|
||||
return None
|
||||
else:
|
||||
# 兼容旧的字符串返回格式
|
||||
response_text = response
|
||||
|
||||
# 解析JSON结果
|
||||
analysis_result = self._parse_analysis_response(response)
|
||||
analysis_result = self._parse_analysis_response(response_text)
|
||||
|
||||
if analysis_result:
|
||||
logger.info(f"✅ 第{chapter_number}章分析完成")
|
||||
|
||||
@@ -282,6 +282,8 @@ class PromptService:
|
||||
角色信息:
|
||||
{characters_info}
|
||||
|
||||
{mcp_references}
|
||||
|
||||
其他要求:{requirements}
|
||||
|
||||
整体要求:
|
||||
@@ -356,6 +358,8 @@ class PromptService:
|
||||
|
||||
{memory_context}
|
||||
|
||||
{mcp_references}
|
||||
|
||||
【续写指导】
|
||||
- 当前情节阶段:{plot_stage_instruction}
|
||||
- 起始章节编号:第{start_chapter}章
|
||||
@@ -836,8 +840,17 @@ class PromptService:
|
||||
chapter_count: int, narrative_perspective: str,
|
||||
target_words: int, time_period: str, location: str,
|
||||
atmosphere: str, rules: str, characters_info: str,
|
||||
requirements: str = "") -> str:
|
||||
"""获取向导大纲生成提示词"""
|
||||
requirements: str = "",
|
||||
mcp_references: str = "") -> str:
|
||||
"""获取向导大纲生成提示词(支持MCP增强)"""
|
||||
# 格式化MCP参考资料
|
||||
mcp_text = ""
|
||||
if mcp_references:
|
||||
mcp_text = "【📚 MCP工具搜索 - 情节设计参考】\n"
|
||||
mcp_text += "以下是通过MCP工具搜索到的情节设计参考资料,可用于设计大纲结构和情节发展:\n\n"
|
||||
mcp_text += mcp_references
|
||||
mcp_text += "\n"
|
||||
|
||||
return cls.format_prompt(
|
||||
cls.COMPLETE_OUTLINE_GENERATION,
|
||||
title=title,
|
||||
@@ -851,6 +864,7 @@ class PromptService:
|
||||
atmosphere=atmosphere,
|
||||
rules=rules,
|
||||
characters_info=characters_info,
|
||||
mcp_references=mcp_text,
|
||||
requirements=requirements or "无特殊要求"
|
||||
)
|
||||
|
||||
@@ -862,7 +876,8 @@ class PromptService:
|
||||
chapter_number: int, chapter_title: str,
|
||||
chapter_outline: str, style_content: str = "",
|
||||
target_word_count: int = 3000,
|
||||
memory_context: dict = None) -> str:
|
||||
memory_context: dict = None,
|
||||
mcp_references: str = "") -> str:
|
||||
"""
|
||||
获取章节完整创作提示词
|
||||
|
||||
@@ -870,6 +885,7 @@ class PromptService:
|
||||
style_content: 写作风格要求内容,如果提供则会追加到提示词中
|
||||
target_word_count: 目标字数,默认3000字
|
||||
memory_context: 记忆上下文(可选)
|
||||
mcp_references: MCP工具搜索的参考资料(可选)
|
||||
"""
|
||||
# 计算最大字数(目标字数+1000)
|
||||
max_word_count = target_word_count + 1000
|
||||
@@ -884,6 +900,14 @@ class PromptService:
|
||||
memory_text += "\n" + memory_context.get('character_states', '')
|
||||
memory_text += "\n" + memory_context.get('plot_points', '')
|
||||
|
||||
# 格式化MCP参考资料
|
||||
mcp_text = ""
|
||||
if mcp_references:
|
||||
mcp_text = "\n【📚 MCP工具搜索 - 参考资料】\n"
|
||||
mcp_text += "以下是通过MCP工具搜索到的相关参考资料,可用于丰富情节和细节:\n\n"
|
||||
mcp_text += mcp_references
|
||||
mcp_text += "\n"
|
||||
|
||||
base_prompt = cls.format_prompt(
|
||||
cls.CHAPTER_GENERATION,
|
||||
title=title,
|
||||
@@ -903,11 +927,17 @@ class PromptService:
|
||||
max_word_count=max_word_count
|
||||
)
|
||||
|
||||
# 插入记忆上下文
|
||||
# 插入记忆上下文和MCP参考资料
|
||||
insert_text = ""
|
||||
if memory_text:
|
||||
insert_text += memory_text
|
||||
if mcp_text:
|
||||
insert_text += mcp_text
|
||||
|
||||
if insert_text:
|
||||
base_prompt = base_prompt.replace(
|
||||
"本章信息:",
|
||||
memory_text + "\n\n本章信息:"
|
||||
insert_text + "\n\n本章信息:"
|
||||
)
|
||||
|
||||
# 如果有风格要求,应用到提示词中
|
||||
@@ -925,7 +955,8 @@ class PromptService:
|
||||
chapter_title: str, chapter_outline: str,
|
||||
style_content: str = "",
|
||||
target_word_count: int = 3000,
|
||||
memory_context: dict = None) -> str:
|
||||
memory_context: dict = None,
|
||||
mcp_references: str = "") -> str:
|
||||
"""
|
||||
获取章节完整创作提示词(带前置章节上下文和记忆增强)
|
||||
|
||||
@@ -933,6 +964,7 @@ class PromptService:
|
||||
style_content: 写作风格要求内容,如果提供则会追加到提示词中
|
||||
target_word_count: 目标字数,默认3000字
|
||||
memory_context: 记忆上下文(可选)
|
||||
mcp_references: MCP工具搜索的参考资料(可选)
|
||||
"""
|
||||
# 计算最大字数(目标字数+1000)
|
||||
max_word_count = target_word_count + 1000
|
||||
@@ -948,6 +980,12 @@ class PromptService:
|
||||
else:
|
||||
memory_text = "暂无相关记忆"
|
||||
|
||||
# 格式化MCP参考资料
|
||||
if mcp_references:
|
||||
memory_text += "\n\n【📚 MCP工具搜索 - 参考资料】\n"
|
||||
memory_text += "以下是通过MCP工具搜索到的相关参考资料,可用于丰富情节和细节:\n\n"
|
||||
memory_text += mcp_references
|
||||
|
||||
base_prompt = cls.format_prompt(
|
||||
cls.CHAPTER_GENERATION_WITH_CONTEXT,
|
||||
title=title,
|
||||
@@ -996,8 +1034,9 @@ class PromptService:
|
||||
recent_plot: str, plot_stage_instruction: str,
|
||||
start_chapter: int, story_direction: str,
|
||||
requirements: str = "",
|
||||
memory_context: dict = None) -> str:
|
||||
"""获取大纲续写提示词(支持记忆增强)"""
|
||||
memory_context: dict = None,
|
||||
mcp_references: str = "") -> str:
|
||||
"""获取大纲续写提示词(支持记忆+MCP增强)"""
|
||||
end_chapter = start_chapter + chapter_count - 1
|
||||
|
||||
# 格式化记忆上下文
|
||||
@@ -1011,6 +1050,14 @@ class PromptService:
|
||||
else:
|
||||
memory_text = "暂无相关记忆(可能是首次续写或记忆库为空)"
|
||||
|
||||
# 格式化MCP参考资料
|
||||
mcp_text = ""
|
||||
if mcp_references:
|
||||
mcp_text = "\n\n【📚 MCP工具搜索 - 续写参考资料】\n"
|
||||
mcp_text += "以下是通过MCP工具搜索到的续写参考资料,可用于丰富情节发展和冲突设计:\n\n"
|
||||
mcp_text += mcp_references
|
||||
mcp_text += "\n"
|
||||
|
||||
return cls.format_prompt(
|
||||
cls.OUTLINE_CONTINUE_GENERATION,
|
||||
title=title,
|
||||
@@ -1031,7 +1078,8 @@ class PromptService:
|
||||
end_chapter=end_chapter,
|
||||
story_direction=story_direction,
|
||||
requirements=requirements or "无特殊要求",
|
||||
memory_context=memory_text
|
||||
memory_context=memory_text,
|
||||
mcp_references=mcp_text
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -14,6 +14,7 @@ import ChapterReader from './pages/ChapterReader';
|
||||
import ChapterAnalysis from './pages/ChapterAnalysis';
|
||||
import WritingStyles from './pages/WritingStyles';
|
||||
import Settings from './pages/Settings';
|
||||
import MCPPlugins from './pages/MCPPlugins';
|
||||
// import Polish from './pages/Polish';
|
||||
import Login from './pages/Login';
|
||||
import AuthCallback from './pages/AuthCallback';
|
||||
@@ -36,6 +37,7 @@ function App() {
|
||||
<Route path="/" element={<ProtectedRoute><ProjectList /></ProtectedRoute>} />
|
||||
<Route path="/wizard" element={<ProtectedRoute><ProjectWizardNew /></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="/project/:projectId" element={<ProtectedRoute><ProjectDetail /></ProtectedRoute>}>
|
||||
<Route index element={<Navigate to="world-setting" replace />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
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 { EditOutlined, DeleteOutlined, BookOutlined, RocketOutlined, CalendarOutlined, FileTextOutlined, TrophyOutlined, FireOutlined, SettingOutlined, InfoCircleOutlined, CloseOutlined, UploadOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
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, ApiOutlined, MoreOutlined } from '@ant-design/icons';
|
||||
import { projectApi } from '../services/api';
|
||||
import { useStore } from '../store';
|
||||
import { useProjectSync } from '../store/hooks';
|
||||
@@ -388,10 +388,25 @@ export default function ProjectList() {
|
||||
>
|
||||
导入
|
||||
</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>
|
||||
) : (
|
||||
// PC端:原有布局
|
||||
// PC端:优化后的布局 - 主要按钮 + 下拉菜单
|
||||
<Space size={12} style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -407,51 +422,6 @@ export default function ProjectList() {
|
||||
>
|
||||
向导创建
|
||||
</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
|
||||
type="default"
|
||||
size="large"
|
||||
@@ -476,6 +446,58 @@ export default function ProjectList() {
|
||||
>
|
||||
API设置
|
||||
</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 />
|
||||
</Space>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import axios from 'axios';
|
||||
|
||||
interface MCPPluginSimpleCreate {
|
||||
config_json: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
import { message } from 'antd';
|
||||
import { ssePost } from '../utils/sseClient';
|
||||
import type { SSEClientOptions } from '../utils/sseClient';
|
||||
@@ -30,6 +35,13 @@ import type {
|
||||
WritingStyleUpdate,
|
||||
PresetStyle,
|
||||
WritingStyleListResponse,
|
||||
MCPPlugin,
|
||||
MCPPluginCreate,
|
||||
MCPPluginUpdate,
|
||||
MCPTestResult,
|
||||
MCPTool,
|
||||
MCPToolCallRequest,
|
||||
MCPToolCallResponse,
|
||||
} from '../types';
|
||||
|
||||
const api = axios.create({
|
||||
@@ -429,3 +441,45 @@ export const wizardStreamApi = {
|
||||
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),
|
||||
};
|
||||
@@ -523,3 +523,84 @@ export interface TriggerAnalysisResponse {
|
||||
status: 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;
|
||||
}
|
||||
Reference in New Issue
Block a user