Merge pull request #844 from AraragiEro/mcp_adapt

支持 MCP 服务并优化函数调用流程
This commit is contained in:
Soulter
2025-03-23 22:35:35 +08:00
committed by GitHub
19 changed files with 2206 additions and 664 deletions
@@ -16,7 +16,13 @@ from astrbot.core.message.message_event_result import (
from astrbot.core.message.components import Image
from astrbot.core import logger
from astrbot.core.utils.metrics import Metric
from astrbot.core.provider.entites import ProviderRequest, LLMResponse
from astrbot.core.provider.entites import (
ProviderRequest,
LLMResponse,
ToolCallMessageSegment,
AssistantMessageSegment,
ToolCallsResult,
)
from astrbot.core.star.star_handler import star_handlers_registry, EventType
from astrbot.core.star.star import star_map
@@ -111,10 +117,18 @@ class LLMRequestSubStage(Stage):
req.contexts = json.loads(req.contexts)
try:
logger.debug(f"提供商请求 Payload: {req}")
if _nested:
req.func_tool = None # 暂时不支持递归工具调用
llm_response = await provider.text_chat(**req.__dict__) # 请求 LLM
need_loop = True
while need_loop:
need_loop = False
logger.debug(f"提供商请求 Payload: {req}")
llm_response = await provider.text_chat(**req.__dict__) # 请求 LLM
async for result in self._handle_llm_response(event, req, llm_response):
if isinstance(result, ProviderRequest):
# 有函数工具调用并且返回了结果,我们需要再次请求 LLM
req = result
need_loop = True
else:
yield
# 执行 LLM 响应后的事件钩子。
handlers = star_handlers_registry.get_handlers_by_event_type(
@@ -135,9 +149,6 @@ class LLMRequestSubStage(Stage):
)
return
# 保存到历史记录
await self._save_to_history(event, req, llm_response)
asyncio.create_task(
Metric.upload(
llm_tick=1,
@@ -146,72 +157,8 @@ class LLMRequestSubStage(Stage):
)
)
if llm_response.role == "assistant":
# text completion
if llm_response.result_chain:
event.set_result(
MessageEventResult(
chain=llm_response.result_chain.chain
).set_result_content_type(ResultContentType.LLM_RESULT)
)
else:
event.set_result(
MessageEventResult()
.message(llm_response.completion_text)
.set_result_content_type(ResultContentType.LLM_RESULT)
)
elif llm_response.role == "err":
event.set_result(
MessageEventResult().message(
f"AstrBot 请求失败。\n错误信息: {llm_response.completion_text}"
)
)
elif llm_response.role == "tool":
# function calling
function_calling_result = {}
logger.info(
f"触发 {len(llm_response.tools_call_name)} 个函数调用: {llm_response.tools_call_name}"
)
for func_tool_name, func_tool_args in zip(
llm_response.tools_call_name, llm_response.tools_call_args
):
func_tool = req.func_tool.get_func(func_tool_name)
logger.info(
f"调用工具函数:{func_tool_name},参数:{func_tool_args}"
)
try:
# 尝试调用工具函数
wrapper = self._call_handler(
self.ctx, event, func_tool.handler, **func_tool_args
)
async for resp in wrapper:
if resp is not None: # 有 return 返回
function_calling_result[func_tool_name] = resp
else:
yield # 有生成器返回
event.clear_result() # 清除上一个 handler 的结果
except BaseException as e:
logger.warning(traceback.format_exc())
function_calling_result[func_tool_name] = (
"When calling the function, an error occurred: " + str(e)
)
if function_calling_result:
# 工具返回 LLM 资源。比如 RAG、网页 得到的相关结果等。
# 我们重新执行一遍这个 stage
req.func_tool = None # 暂时不支持递归工具调用
extra_prompt = "\n\nSystem executed some external tools for this task and here are the results:\n"
for tool_name, tool_result in function_calling_result.items():
extra_prompt += (
f"Tool: {tool_name}\nTool Result: {tool_result}\n"
)
req.prompt += extra_prompt
async for _ in self.process(event, _nested=True):
yield
else:
if llm_response.completion_text:
event.set_result(
MessageEventResult().message(llm_response.completion_text)
)
# 保存到历史记录
await self._save_to_history(event, req, llm_response)
except BaseException as e:
logger.error(traceback.format_exc())
@@ -222,6 +169,116 @@ class LLMRequestSubStage(Stage):
)
return
async def _handle_llm_response(
self, event: AstrMessageEvent, req: ProviderRequest, llm_response: LLMResponse
) -> AsyncGenerator[None, None]:
"""处理 LLM 响应。
Returns:
bool: 是否需要继续调用 LLM
Yields:
Iterator[bool]: 将 event 交付给下一个 stage
"""
if llm_response.role == "assistant":
# text completion
if llm_response.result_chain:
event.set_result(
MessageEventResult(
chain=llm_response.result_chain.chain
).set_result_content_type(ResultContentType.LLM_RESULT)
)
else:
event.set_result(
MessageEventResult()
.message(llm_response.completion_text)
.set_result_content_type(ResultContentType.LLM_RESULT)
)
elif llm_response.role == "err":
event.set_result(
MessageEventResult().message(
f"AstrBot 请求失败。\n错误信息: {llm_response.completion_text}"
)
)
elif llm_response.role == "tool":
# function calling
tool_call_result: list[ToolCallMessageSegment] = []
logger.info(
f"触发 {len(llm_response.tools_call_name)} 个函数调用: {llm_response.tools_call_name}"
)
for func_tool_name, func_tool_args, func_tool_id in zip(
llm_response.tools_call_name,
llm_response.tools_call_args,
llm_response.tools_call_ids,
):
try:
func_tool = req.func_tool.get_func(func_tool_name)
if func_tool.origin == "mcp":
logger.info(
f"从 MCP 服务 {func_tool.mcp_server_name} 调用工具函数:{func_tool.name},参数:{func_tool_args}"
)
client = req.func_tool.mcp_client_dict[
func_tool.mcp_server_name
]
res = await client.session.call_tool(
func_tool.name, func_tool_args
)
if res:
# TODO content的类型可能包括list[TextContent | ImageContent | EmbeddedResource],这里只处理了TextContent。
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=res.content[0].text,
)
)
else:
logger.info(
f"调用工具函数:{func_tool_name},参数:{func_tool_args}"
)
# 尝试调用工具函数
wrapper = self._call_handler(
self.ctx, event, func_tool.handler, **func_tool_args
)
async for resp in wrapper:
if resp is not None: # 有 return 返回
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=resp,
)
)
else:
yield # 有生成器返回
event.clear_result() # 清除上一个 handler 的结果
except BaseException as e:
logger.warning(traceback.format_exc())
tool_call_result.append(
ToolCallMessageSegment(
role="tool",
tool_call_id=func_tool_id,
content=f"error: {str(e)}",
)
)
if tool_call_result:
# 函数调用结果
req.func_tool = None # 暂时不支持递归工具调用
assistant_msg_seg = AssistantMessageSegment(
role="assistant", tool_calls=llm_response.to_openai_tool_calls()
)
# 在多轮 Tool 调用的情况下,这里始终保持最新的 Tool 调用结果,减少上下文长度。
req.tool_calls_result = ToolCallsResult(
tool_calls_info=assistant_msg_seg,
tool_calls_result=tool_call_result,
)
yield req # 再次执行 LLM 请求
else:
if llm_response.completion_text:
event.set_result(
MessageEventResult().message(llm_response.completion_text)
)
async def _save_to_history(
self, event: AstrMessageEvent, req: ProviderRequest, llm_response: LLMResponse
):
@@ -232,6 +289,11 @@ class LLMRequestSubStage(Stage):
# 文本回复
contexts = req.contexts
contexts.append(await req.assemble_context())
# tool calls result
if req.tool_calls_result:
contexts.extend(req.tool_calls_result.to_openai_messages())
contexts.append(
{"role": "assistant", "content": llm_response.completion_text}
)
+83 -4
View File
@@ -1,11 +1,15 @@
import enum
import base64
import json
from astrbot.core.utils.io import download_image_by_url
from astrbot import logger
from dataclasses import dataclass, field
from typing import List, Dict, Type
from .func_tool_manager import FuncCall
from openai.types.chat.chat_completion import ChatCompletion
from openai.types.chat.chat_completion_message_tool_call import (
ChatCompletionMessageToolCall,
)
from astrbot.core.db.po import Conversation
from astrbot.core.message.message_event_result import MessageChain
import astrbot.core.message.components as Comp
@@ -32,6 +36,58 @@ class ProviderMetaData:
"""显示在 WebUI 配置页中的提供商名称,如空则是 type"""
@dataclass
class ToolCallMessageSegment:
"""OpenAI 格式的上下文中 role 为 tool 的消息段。参考: https://platform.openai.com/docs/guides/function-calling"""
tool_call_id: str
content: str
role: str = "tool"
def to_dict(self):
return {
"tool_call_id": self.tool_call_id,
"content": self.content,
"role": self.role,
}
@dataclass
class AssistantMessageSegment:
"""OpenAI 格式的上下文中 role 为 assistant 的消息段。参考: https://platform.openai.com/docs/guides/function-calling"""
content: str = None
tool_calls: List[ChatCompletionMessageToolCall | Dict] = None
role: str = "assistant"
def to_dict(self):
ret = {
"role": self.role,
}
if self.content:
ret["content"] = self.content
elif self.tool_calls:
ret["tool_calls"] = self.tool_calls
return ret
@dataclass
class ToolCallsResult:
"""工具调用结果"""
tool_calls_info: AssistantMessageSegment
"""函数调用的信息"""
tool_calls_result: List[ToolCallMessageSegment]
"""函数调用的结果"""
def to_openai_messages(self) -> List[Dict]:
ret = [
self.tool_calls_info.to_dict(),
*[item.to_dict() for item in self.tool_calls_result],
]
return ret
@dataclass
class ProviderRequest:
prompt: str
@@ -41,7 +97,7 @@ class ProviderRequest:
image_urls: List[str] = None
"""图片 URL 列表"""
func_tool: FuncCall = None
"""工具"""
"""可用的函数工具"""
contexts: List = None
"""上下文。格式与 openai 的上下文格式一致:
参考 https://platform.openai.com/docs/api-reference/chat/create#chat-create-messages
@@ -50,8 +106,11 @@ class ProviderRequest:
"""系统提示词"""
conversation: Conversation = None
tool_calls_result: ToolCallsResult = None
"""附加的上次请求后工具调用的结果。参考: https://platform.openai.com/docs/guides/function-calling#handling-function-calls"""
def __repr__(self):
return f"ProviderRequest(prompt={self.prompt}, session_id={self.session_id}, image_urls={self.image_urls}, func_tool={self.func_tool}, contexts={self._print_friendly_context()}, system_prompt={self.system_prompt.strip()})"
return f"ProviderRequest(prompt={self.prompt}, session_id={self.session_id}, image_urls={self.image_urls}, func_tool={self.func_tool}, contexts={self._print_friendly_context()}, system_prompt={self.system_prompt.strip()}, tool_calls_result={self.tool_calls_result})"
def __str__(self):
return self.__repr__()
@@ -137,6 +196,8 @@ class LLMResponse:
"""工具调用参数"""
tools_call_name: List[str] = field(default_factory=list)
"""工具调用名称"""
tools_call_ids: List[str] = field(default_factory=list)
"""工具调用 ID"""
raw_completion: ChatCompletion = None
_new_record: Dict[str, any] = None
@@ -148,8 +209,9 @@ class LLMResponse:
role: str,
completion_text: str = "",
result_chain: MessageChain = None,
tools_call_args: List[Dict[str, any]] = None,
tools_call_name: List[str] = None,
tools_call_args: List[Dict[str, any]] = [],
tools_call_name: List[str] = [],
tools_call_ids: List[str] = [],
raw_completion: ChatCompletion = None,
_new_record: Dict[str, any] = None,
):
@@ -168,6 +230,7 @@ class LLMResponse:
self.result_chain = result_chain
self.tools_call_args = tools_call_args
self.tools_call_name = tools_call_name
self.tools_call_ids = tools_call_ids
self.raw_completion = raw_completion
self._new_record = _new_record
@@ -188,3 +251,19 @@ class LLMResponse:
self.result_chain.chain.insert(0, Comp.Plain(value))
else:
self._completion_text = value
def to_openai_tool_calls(self) -> List[Dict]:
"""将工具调用信息转换为 OpenAI 格式"""
ret = []
for idx, tool_call_arg in enumerate(self.tools_call_args):
ret.append(
{
"id": self.tools_call_ids[idx],
"function": {
"name": self.tools_call_name[idx],
"arguments": json.dumps(tool_call_arg),
},
"type": "function",
}
)
return ret
+277 -21
View File
@@ -1,9 +1,29 @@
from __future__ import annotations
import json
import textwrap
from typing import Dict, List, Awaitable
import os
import asyncio
import mcp
import copy
from typing import Dict, List, Awaitable, Literal, Any
from dataclasses import dataclass
from typing import Optional
from contextlib import AsyncExitStack
from mcp.client.stdio import stdio_client
from astrbot import logger
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
SUPPORTED_TYPES = [
"string",
"number",
"object",
"array",
"boolean",
] # json schema 支持的数据类型
@dataclass
class FuncTool:
@@ -14,28 +34,101 @@ class FuncTool:
name: str
parameters: Dict
description: str
handler: Awaitable
handler_module_path: str = None # 必须要保留这个,handler 在初始化会被 functools.partial 包装,导致 handler 的 __module__ 为 functools
handler: Awaitable = None
"""处理函数, 当 origin 为 mcp 时,这个为空"""
handler_module_path: str = None
"""处理函数的模块路径,当 origin 为 mcp 时,这个为空
必须要保留这个字段, handler 在初始化会被 functools.partial 包装,导致 handler 的 __module__ 为 functools
"""
active: bool = True
"""是否激活"""
origin: Literal["local", "mcp"] = "local"
"""函数工具的来源, local 为本地函数工具, mcp 为 MCP 服务"""
# MCP 相关字段
mcp_server_name: str = None
"""MCP 服务名称,当 origin 为 mcp 时有效"""
mcp_client: MCPClient = None
"""MCP 客户端,当 origin 为 mcp 时有效"""
def __repr__(self):
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description}), active={self.active})"
return f"FuncTool(name={self.name}, parameters={self.parameters}, description={self.description}, active={self.active}, origin={self.origin})"
async def execute(self, **args) -> Any:
"""执行函数调用"""
if self.origin == "local":
if not self.handler:
raise Exception(f"Local function {self.name} has no handler")
return await self.handler(**args)
elif self.origin == "mcp":
if not self.mcp_client or not self.mcp_client.session:
raise Exception(f"MCP client for {self.name} is not available")
# 使用name属性而不是额外的mcp_tool_name
if ":" in self.name:
# 如果名字是格式为 mcp:server:tool_name,提取实际的工具名
actual_tool_name = self.name.split(":")[-1]
return await self.mcp_client.session.call_tool(actual_tool_name, args)
else:
return await self.mcp_client.session.call_tool(self.name, args)
else:
raise Exception(f"Unknown function origin: {self.origin}")
SUPPORTED_TYPES = [
"string",
"number",
"object",
"array",
"boolean",
] # json schema 支持的数据类型
class MCPClient:
def __init__(self):
# Initialize session and client objects
self.session: Optional[mcp.ClientSession] = None
self.exit_stack = AsyncExitStack()
self.name = None
self.active: bool = True
self.tools: List[mcp.Tool] = []
async def connect_to_server(self, mcp_server_config: dict):
"""Connect to an MCP server
Args:
mcp_server_config (dict): Configuration for the MCP server. See https://modelcontextprotocol.io/quickstart/server
"""
cfg = mcp_server_config.copy()
cfg.pop("active", None)
server_params = mcp.StdioServerParameters(
**cfg,
)
stdio_transport = await self.exit_stack.enter_async_context(
stdio_client(server_params)
)
self.stdio, self.write = stdio_transport
self.session = await self.exit_stack.enter_async_context(
mcp.ClientSession(self.stdio, self.write)
)
await self.session.initialize()
async def list_tools_and_save(self) -> mcp.ListToolsResult:
"""List all tools from the server and save them to self.tools"""
response = await self.session.list_tools()
logger.debug(f"MCP server {self.name} list tools response: {response}")
self.tools = response.tools
return response
async def cleanup(self):
"""Clean up resources"""
await self.exit_stack.aclose()
class FuncCall:
def __init__(self) -> None:
self.func_list: List[FuncTool] = []
"""内部加载的 func tools"""
self.mcp_client_dict: Dict[str, MCPClient] = {}
"""MCP 服务列表"""
self.mcp_service_queue = asyncio.Queue()
"""用于外部控制 MCP 服务的启停"""
self.mcp_client_event: Dict[str, asyncio.Event] = {}
def empty(self) -> bool:
return len(self.func_list) == 0
@@ -90,11 +183,166 @@ class FuncCall:
return f
return None
async def _init_mcp_clients(self) -> None:
"""从项目根目录读取 mcp_server.json 文件,初始化 MCP 服务列表。文件格式如下:
```
{
"mcpServers": {
"weather": {
"command": "uv",
"args": [
"--directory",
"/ABSOLUTE/PATH/TO/PARENT/FOLDER/weather",
"run",
"weather.py"
]
}
}
...
}
```
"""
current_dir = os.path.dirname(os.path.abspath(__file__))
data_dir = os.path.abspath(os.path.join(current_dir, "../../../data"))
mcp_json_file = os.path.join(data_dir, "mcp_server.json")
if not os.path.exists(mcp_json_file):
# 配置文件不存在错误处理
with open(mcp_json_file, "w", encoding="utf-8") as f:
json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4)
logger.info(f"未找到 MCP 服务配置文件,已创建默认配置文件 {mcp_json_file}")
return
mcp_server_json_obj: Dict[str, Dict] = json.load(
open(mcp_json_file, "r", encoding="utf-8")
)["mcpServers"]
for name in mcp_server_json_obj.keys():
cfg = mcp_server_json_obj[name]
if cfg.get("active", True):
event = asyncio.Event()
asyncio.create_task(
self._init_mcp_client_task_wrapper(name, cfg, event)
)
self.mcp_client_event[name] = event
async def mcp_service_selector(self):
"""为了避免在不同异步任务中控制 MCP 服务导致的报错,整个项目统一通过这个 Task 来控制
使用 self.mcp_service_queue.put_nowait() 来控制 MCP 服务的启停,数据格式如下:
{"type": "init"} 初始化所有MCP客户端
{"type": "init", "name": "mcp_server_name", "cfg": {...}} 初始化指定的MCP客户端
{"type": "terminate"} 终止所有MCP客户端
{"type": "terminate", "name": "mcp_server_name"} 终止指定的MCP客户端
"""
while True:
data = await self.mcp_service_queue.get()
if data["type"] == "init":
if "name" in data:
event = asyncio.Event()
asyncio.create_task(
self._init_mcp_client_task_wrapper(
data["name"], data["cfg"], event
)
)
self.mcp_client_event[data["name"]] = event
else:
await self._init_mcp_clients()
elif data["type"] == "terminate":
if "name" in data:
# await self._terminate_mcp_client(data["name"])
if data["name"] in self.mcp_client_event:
self.mcp_client_event[data["name"]].set()
self.mcp_client_event.pop(data["name"], None)
else:
for name in self.mcp_client_dict.keys():
# await self._terminate_mcp_client(name)
# self.mcp_client_event[name].set()
if name in self.mcp_client_event:
self.mcp_client_event[name].set()
self.mcp_client_event.pop(name, None)
async def _init_mcp_client_task_wrapper(
self, name: str, cfg: dict, event: asyncio.Event
) -> None:
"""初始化 MCP 客户端的包装函数,用于捕获异常"""
try:
await self._init_mcp_client(name, cfg)
await event.wait()
logger.info(f"收到 MCP 客户端 {name} 终止信号")
await self._terminate_mcp_client(name)
except Exception as e:
logger.error(f"初始化 MCP 客户端 {name} 失败: {e}")
async def _init_mcp_client(self, name: str, config: dict) -> None:
"""初始化单个MCP客户端"""
try:
# 先清理之前的客户端,如果存在
if name in self.mcp_client_dict:
await self._terminate_mcp_client(name)
mcp_client = MCPClient()
mcp_client.name = name
await mcp_client.connect_to_server(config)
tools_res = await mcp_client.list_tools_and_save()
tool_names = [tool.name for tool in tools_res.tools]
self.mcp_client_dict[name] = mcp_client
# 移除该MCP服务之前的工具(如有)
self.func_list = [
f
for f in self.func_list
if not (f.origin == "mcp" and f.mcp_server_name == name)
]
# 将 MCP 工具转换为 FuncTool 并添加到 func_list
for tool in mcp_client.tools:
func_tool = FuncTool(
name=tool.name,
parameters=tool.inputSchema,
description=tool.description,
origin="mcp",
mcp_server_name=name,
mcp_client=mcp_client,
)
self.func_list.append(func_tool)
logger.info(f"已连接 MCP 服务 {name}, Tools: {tool_names}")
return True
except Exception as e:
logger.error(f"初始化 MCP 客户端 {name} 失败: {e}")
# 发生错误时确保客户端被清理
if name in self.mcp_client_dict:
await self._terminate_mcp_client(name)
return False
async def _terminate_mcp_client(self, name: str) -> None:
"""关闭并清理MCP客户端"""
if name in self.mcp_client_dict:
try:
# 关闭MCP连接
await self.mcp_client_dict[name].cleanup()
del self.mcp_client_dict[name]
except Exception as e:
logger.info(f"清空 MCP 客户端资源 {name}: {e}")
# 移除关联的FuncTool
self.func_list = [
f
for f in self.func_list
if not (f.origin == "mcp" and f.mcp_server_name == name)
]
logger.info(f"已关闭 MCP 服务 {name}")
def get_func_desc_openai_style(self) -> list:
"""
获得 OpenAI API 风格的**已经激活**的工具描述
"""
_l = []
# 处理所有工具(包括本地和MCP工具)
for f in self.func_list:
if not f.active:
continue
@@ -144,7 +392,13 @@ class FuncCall:
# 检查并添加非空的properties参数
params = f.parameters if isinstance(f.parameters, dict) else {}
params = copy.deepcopy(params)
if params.get("properties", {}):
properties = params["properties"]
for key, value in properties.items():
if "default" in value:
del value["default"]
params["properties"] = properties
func_declaration["parameters"] = params
tools.append(func_declaration)
@@ -160,9 +414,9 @@ class FuncCall:
continue
_l.append(
{
"name": f["name"],
"parameters": f["parameters"],
"description": f["description"],
"name": f.name,
"parameters": f.parameters,
"description": f.description,
}
)
func_definition = json.dumps(_l, ensure_ascii=False)
@@ -212,14 +466,11 @@ class FuncCall:
func_name = tool["name"]
args = tool["args"]
# 调用函数
tool_callable = None
for func in self.func_list:
if func.name == func_name:
tool_callable = func.star_handler_metadata.handler
break
if not tool_callable:
func_tool = self.get_func(func_name)
if not func_tool:
raise Exception(f"Request function {func_name} not found.")
ret = await tool_callable(**args)
ret = await func_tool.execute(**args)
if ret:
tool_call_result.append(str(ret))
return tool_call_result, True
@@ -229,3 +480,8 @@ class FuncCall:
def __repr__(self):
return str(self.func_list)
async def terminate(self):
for name in self.mcp_client_dict.keys():
await self._terminate_mcp_client(name)
logger.debug(f"清理 MCP 客户端 {name} 资源")
+9
View File
@@ -1,4 +1,5 @@
import traceback
import asyncio
from astrbot.core.config.astrbot_config import AstrBotConfig
from .provider import Provider, STTProvider, TTSProvider, Personality
from .entites import ProviderType
@@ -127,6 +128,12 @@ class ProviderManager:
if self.tts_enabled and not self.curr_tts_provider_inst:
logger.warning("未启用任何用于 文本转语音 的提供商适配器。")
# 初始化 MCP Client 连接
asyncio.create_task(
self.llm_tools.mcp_service_selector(), name="mcp-service-handler"
)
self.llm_tools.mcp_service_queue.put_nowait({"type": "init"})
async def load_provider(self, provider_config: dict):
if not provider_config["enable"]:
return
@@ -339,3 +346,5 @@ class ProviderManager:
for provider_inst in self.provider_insts:
if hasattr(provider_inst, "terminate"):
await provider_inst.terminate()
# 清理 MCP Client 连接
await self.llm_tools.mcp_service_queue.put({"type": "terminate"})
+3 -1
View File
@@ -3,7 +3,7 @@ from typing import List
from astrbot.core.db import BaseDatabase
from typing import TypedDict
from astrbot.core.provider.func_tool_manager import FuncCall
from astrbot.core.provider.entites import LLMResponse
from astrbot.core.provider.entites import LLMResponse, ToolCallsResult
from dataclasses import dataclass
@@ -90,6 +90,7 @@ class Provider(AbstractProvider):
func_tool: FuncCall = None,
contexts: List = None,
system_prompt: str = None,
tool_calls_result: ToolCallsResult = None,
**kwargs,
) -> LLMResponse:
"""获得 LLM 的文本对话结果。会使用当前的模型进行对话。
@@ -100,6 +101,7 @@ class Provider(AbstractProvider):
image_urls: 图片 URL 列表
tools: Function-calling 工具
contexts: 上下文
tool_calls_result: 回传给 LLM 的工具调用结果。参考: https://platform.openai.com/docs/guides/function-calling
kwargs: 其他参数
Notes:
@@ -10,7 +10,7 @@ from astrbot.api.provider import Provider, Personality
from astrbot import logger
from astrbot.core.provider.func_tool_manager import FuncCall
from ..register import register_provider_adapter
from astrbot.core.provider.entites import LLMResponse
from astrbot.core.provider.entites import LLMResponse, ToolCallsResult
from .openai_source import ProviderOpenAIOfficial
@@ -79,11 +79,14 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
# tools call (function calling)
args_ls = []
func_name_ls = []
tool_use_ids = []
func_name_ls.append(content.name)
args_ls.append(content.input)
tool_use_ids.append(content.id)
llm_response.role = "tool"
llm_response.tools_call_args = args_ls
llm_response.tools_call_name = func_name_ls
llm_response.tools_call_ids = tool_use_ids
if not llm_response.completion_text and not llm_response.tools_call_args:
logger.error(f"API 返回的 completion 无法解析:{completion}")
@@ -101,6 +104,7 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
func_tool: FuncCall = None,
contexts=[],
system_prompt=None,
tool_calls_result: ToolCallsResult = None,
**kwargs,
) -> LLMResponse:
if not prompt:
@@ -113,6 +117,10 @@ class ProviderAnthropic(ProviderOpenAIOfficial):
if "_no_save" in part:
del part["_no_save"]
if tool_calls_result:
# 暂时这样写。
prompt += f"Here are the related results via using tools: {str(tool_calls_result.tool_calls_result)}"
model_config = self.provider_config.get("model_config", {})
payloads = {"messages": context_query, **model_config}
+43 -4
View File
@@ -1,5 +1,6 @@
import base64
import aiohttp
import json
import random
from astrbot.core.utils.io import download_image_by_url
from astrbot.core.db import BaseDatabase
@@ -146,11 +147,41 @@ class ProviderGoogleGenAI(Provider):
google_genai_conversation.append({"role": "user", "parts": parts})
elif message["role"] == "assistant":
if not message["content"]:
message["content"] = "<empty_content>"
google_genai_conversation.append(
{"role": "model", "parts": [{"text": message["content"]}]}
if "content" in message:
if not message["content"]:
message["content"] = "<empty_content>"
google_genai_conversation.append(
{"role": "model", "parts": [{"text": message["content"]}]}
)
elif "tool_calls" in message:
# tool calls in the last turn
parts = []
for tool_call in message["tool_calls"]:
parts.append(
{
"functionCall": {
"name": tool_call["function"]["name"],
"args": json.loads(
tool_call["function"]["arguments"]
),
}
}
)
google_genai_conversation.append({"role": "model", "parts": parts})
elif message["role"] == "tool":
parts = []
parts.append(
{
"functionResponse": {
"name": message["tool_call_id"],
"response": {
"name": message["tool_call_id"],
"content": message["content"],
},
}
}
)
google_genai_conversation.append({"role": "user", "parts": parts})
logger.debug(f"google_genai_conversation: {google_genai_conversation}")
@@ -174,6 +205,9 @@ class ProviderGoogleGenAI(Provider):
llm_response.role = "tool"
llm_response.tools_call_args.append(candidate["functionCall"]["args"])
llm_response.tools_call_name.append(candidate["functionCall"]["name"])
llm_response.tools_call_ids.append(
candidate["functionCall"]["name"]
) # 没有 tool id
llm_response.completion_text = llm_response.completion_text.strip()
return llm_response
@@ -186,6 +220,7 @@ class ProviderGoogleGenAI(Provider):
func_tool: FuncCall = None,
contexts=[],
system_prompt=None,
tool_calls_result=None,
**kwargs,
) -> LLMResponse:
new_record = await self.assemble_context(prompt, image_urls)
@@ -198,6 +233,10 @@ class ProviderGoogleGenAI(Provider):
if "_no_save" in part:
del part["_no_save"]
# tool calls result
if tool_calls_result:
context_query.extend(tool_calls_result.to_openai_messages())
model_config = self.provider_config.get("model_config", {})
model_config["model"] = self.get_model()
+11 -4
View File
@@ -120,15 +120,18 @@ class ProviderOpenAIOfficial(Provider):
# tools call (function calling)
args_ls = []
func_name_ls = []
tool_call_ids = []
for tool_call in choice.message.tool_calls:
for tool in tools.func_list:
if tool.name == tool_call.function.name:
args = json.loads(tool_call.function.arguments)
args_ls.append(args)
func_name_ls.append(tool_call.function.name)
tool_call_ids.append(tool_call.id)
llm_response.role = "tool"
llm_response.tools_call_args = args_ls
llm_response.tools_call_name = func_name_ls
llm_response.tools_call_ids = tool_call_ids
if choice.finish_reason == "content_filter":
raise Exception(
@@ -151,6 +154,7 @@ class ProviderOpenAIOfficial(Provider):
func_tool: FuncCall = None,
contexts=[],
system_prompt=None,
tool_calls_result=None,
**kwargs,
) -> LLMResponse:
new_record = await self.assemble_context(prompt, image_urls)
@@ -162,10 +166,15 @@ class ProviderOpenAIOfficial(Provider):
if "_no_save" in part:
del part["_no_save"]
# tool calls result
if tool_calls_result:
context_query.extend(tool_calls_result.to_openai_messages())
model_config = self.provider_config.get("model_config", {})
model_config["model"] = self.get_model()
payloads = {"messages": context_query, **model_config}
llm_response = None
try:
llm_response = await self._query(payloads, func_tool)
@@ -275,10 +284,8 @@ class ProviderOpenAIOfficial(Provider):
def set_key(self, key):
self.client.api_key = key
async def assemble_context(self, text: str, image_urls: List[str] = None):
"""
组装上下文。
"""
async def assemble_context(self, text: str, image_urls: List[str] = None) -> dict:
"""组装成符合 OpenAI 格式的 role 为 user 的消息段"""
if image_urls:
user_content = {"role": "user", "content": [{"type": "text", "text": text}]}
for image_url in image_urls:
+2
View File
@@ -6,6 +6,7 @@ from .stat import StatRoute
from .log import LogRoute
from .static_file import StaticFileRoute
from .chat import ChatRoute
from .tools import ToolsRoute # 导入新的ToolsRoute
__all__ = [
@@ -17,4 +18,5 @@ __all__ = [
"LogRoute",
"StaticFileRoute",
"ChatRoute",
"ToolsRoute", # 添加新的ToolsRoute
]
+7
View File
@@ -146,6 +146,7 @@ class ConfigRoute(Route):
"/config/provider/new": ("POST", self.post_new_provider),
"/config/provider/update": ("POST", self.post_update_provider),
"/config/provider/delete": ("POST", self.post_delete_provider),
"/config/llmtools": ("GET", self.get_llm_tools),
}
self.register_routes()
@@ -278,6 +279,12 @@ class ConfigRoute(Route):
return Response().error(str(e)).__dict__
return Response().ok(None, "删除成功,已经实时生效~").__dict__
async def get_llm_tools(self):
"""获取函数调用工具。包含了本地加载的以及 MCP 服务的工具"""
tool_mgr = self.core_lifecycle.provider_manager.llm_tools
tools = tool_mgr.get_func_desc_openai_style()
return Response().ok(tools).__dict__
async def _get_astrbot_config(self):
config = self.config
+252
View File
@@ -0,0 +1,252 @@
import os
import json
import traceback
from .route import Route, Response, RouteContext
from quart import request
from astrbot.core.core_lifecycle import AstrBotCoreLifecycle
from astrbot.core import logger
DEFAULT_MCP_CONFIG = {"mcpServers": {}}
class ToolsRoute(Route):
def __init__(
self, context: RouteContext, core_lifecycle: AstrBotCoreLifecycle
) -> None:
super().__init__(context)
self.core_lifecycle = core_lifecycle
self.routes = {
"/tools/mcp/servers": ("GET", self.get_mcp_servers),
"/tools/mcp/add": ("POST", self.add_mcp_server),
"/tools/mcp/update": ("POST", self.update_mcp_server),
"/tools/mcp/delete": ("POST", self.delete_mcp_server),
}
self.register_routes()
self.tool_mgr = self.core_lifecycle.provider_manager.llm_tools
@property
def mcp_config_path(self):
current_dir = os.path.dirname(os.path.abspath(__file__))
data_dir = os.path.abspath(os.path.join(current_dir, "../../../data"))
return os.path.join(data_dir, "mcp_server.json")
def load_mcp_config(self):
if not os.path.exists(self.mcp_config_path):
# 配置文件不存在,创建默认配置
os.makedirs(os.path.dirname(self.mcp_config_path), exist_ok=True)
with open(self.mcp_config_path, "w", encoding="utf-8") as f:
json.dump(DEFAULT_MCP_CONFIG, f, ensure_ascii=False, indent=4)
return DEFAULT_MCP_CONFIG
try:
with open(self.mcp_config_path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception as e:
logger.error(f"加载 MCP 配置失败: {e}")
return DEFAULT_MCP_CONFIG
def save_mcp_config(self, config):
try:
with open(self.mcp_config_path, "w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=4)
return True
except Exception as e:
logger.error(f"保存 MCP 配置失败: {e}")
return False
async def get_mcp_servers(self):
try:
config = self.load_mcp_config()
servers = []
# 获取所有服务器并添加它们的工具列表
for name, server_config in config["mcpServers"].items():
server_info = {
"name": name,
"active": server_config.get("active", True),
}
# 复制所有配置字段
for key, value in server_config.items():
if key != "active": # active 已经处理
server_info[key] = value
# 如果MCP客户端已初始化,从客户端获取工具名称
for (
name_key,
mcp_client,
) in self.tool_mgr.mcp_client_dict.items():
if name_key == name:
server_info["tools"] = [tool.name for tool in mcp_client.tools]
break
else:
server_info["tools"] = []
servers.append(server_info)
return Response().ok(servers).__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"获取 MCP 服务器列表失败: {str(e)}").__dict__
async def add_mcp_server(self):
try:
server_data = await request.json
name = server_data.get("name", "")
# 检查必填字段
if not name:
return Response().error("服务器名称不能为空").__dict__
# 移除特殊字段并检查配置是否有效
has_valid_config = False
server_config = {"active": server_data.get("active", True)}
# 复制所有配置字段
for key, value in server_data.items():
if key not in ["name", "active", "tools"]: # 排除特殊字段
server_config[key] = value
has_valid_config = True
if not has_valid_config:
return Response().error("必须提供有效的服务器配置").__dict__
config = self.load_mcp_config()
if name in config["mcpServers"]:
return Response().error(f"服务器 {name} 已存在").__dict__
config["mcpServers"][name] = server_config
if self.save_mcp_config(config):
# 动态初始化新MCP客户端
self.tool_mgr.mcp_service_queue.put_nowait(
{
"type": "init",
"name": name,
"cfg": config["mcpServers"][name],
}
)
return Response().ok(None, f"成功添加 MCP 服务器 {name}").__dict__
else:
return Response().error("保存配置失败").__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"添加 MCP 服务器失败: {str(e)}").__dict__
async def update_mcp_server(self):
try:
server_data = await request.json
name = server_data.get("name", "")
if not name:
return Response().error("服务器名称不能为空").__dict__
config = self.load_mcp_config()
if name not in config["mcpServers"]:
return Response().error(f"服务器 {name} 不存在").__dict__
# 获取活动状态
active = server_data.get(
"active", config["mcpServers"][name].get("active", True)
)
# 创建新的配置对象
server_config = {"active": active}
# 仅更新活动状态的特殊处理
only_update_active = True
# 复制所有配置字段
for key, value in server_data.items():
if key not in ["name", "active", "tools"]: # 排除特殊字段
server_config[key] = value
only_update_active = False
# 如果只更新活动状态,保留原始配置
if only_update_active:
for key, value in config["mcpServers"][name].items():
if key != "active": # 除了active之外的所有字段都保留
server_config[key] = value
config["mcpServers"][name] = server_config
if self.save_mcp_config(config):
# 处理MCP客户端状态变化
if active:
# 如果要激活服务器或者配置已更改
if name in self.tool_mgr.mcp_client_dict or not only_update_active:
await self.tool_mgr.mcp_service_queue.put(
{
"type": "terminate",
"name": name,
}
)
await self.tool_mgr.mcp_service_queue.put(
{
"type": "init",
"name": name,
"cfg": config["mcpServers"][name],
}
)
else:
# 客户端不存在,初始化
self.tool_mgr.mcp_service_queue.put_nowait(
{
"type": "init",
"name": name,
"cfg": config["mcpServers"][name],
}
)
else:
# 如果要停用服务器
if name in self.tool_mgr.mcp_client_dict:
self.tool_mgr.mcp_service_queue.put_nowait(
{
"type": "terminate",
"name": name,
}
)
return Response().ok(None, f"成功更新 MCP 服务器 {name}").__dict__
else:
return Response().error("保存配置失败").__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"更新 MCP 服务器失败: {str(e)}").__dict__
async def delete_mcp_server(self):
try:
server_data = await request.json
name = server_data.get("name", "")
if not name:
return Response().error("服务器名称不能为空").__dict__
config = self.load_mcp_config()
if name not in config["mcpServers"]:
return Response().error(f"服务器 {name} 不存在").__dict__
# 删除服务器配置
del config["mcpServers"][name]
if self.save_mcp_config(config):
# 关闭并删除MCP客户端
if name in self.tool_mgr.mcp_client_dict:
self.tool_mgr.mcp_service_queue.put_nowait(
{
"type": "terminate",
"name": name,
}
)
return Response().ok(None, f"成功删除 MCP 服务器 {name}").__dict__
else:
return Response().error("保存配置失败").__dict__
except Exception as e:
logger.error(traceback.format_exc())
return Response().error(f"删除 MCP 服务器失败: {str(e)}").__dict__
+1
View File
@@ -50,6 +50,7 @@ class AstrBotDashboard:
self.sfr = StaticFileRoute(self.context)
self.ar = AuthRoute(self.context)
self.chat_route = ChatRoute(self.context, db, core_lifecycle)
self.tools_root = ToolsRoute(self.context, core_lifecycle)
self.shutdown_event = shutdown_event
@@ -0,0 +1,134 @@
<template>
<div>
<v-row v-if="items.length === 0">
<v-col cols="12" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">{{ emptyIcon }}</v-icon>
<p class="text-grey mt-4">{{ emptyText }}</p>
</v-col>
</v-row>
<v-row v-else>
<v-col v-for="(item, index) in items" :key="index" cols="12" md="6" lg="4" xl="3">
<v-card class="item-card hover-elevation" :color="getItemEnabled(item) ? '' : 'grey-lighten-4'">
<div class="item-status-indicator" :class="{'active': getItemEnabled(item)}"></div>
<v-card-title class="d-flex justify-space-between align-center pb-1 pt-3">
<span class="text-h6 text-truncate" :title="getItemTitle(item)">{{ getItemTitle(item) }}</span>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-switch
color="primary"
hide-details
density="compact"
:model-value="getItemEnabled(item)"
v-bind="props"
@update:model-value="toggleEnabled(item)"
></v-switch>
</template>
<span>{{ getItemEnabled(item) ? '已启用' : '已禁用' }}</span>
</v-tooltip>
</v-card-title>
<v-card-text>
<slot name="item-details" :item="item"></slot>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-2">
<v-spacer></v-spacer>
<v-btn
variant="text"
size="small"
color="error"
prepend-icon="mdi-delete"
@click="$emit('delete', item)"
>
删除
</v-btn>
<v-btn
variant="text"
size="small"
color="primary"
prepend-icon="mdi-pencil"
@click="$emit('edit', item)"
>
编辑
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</div>
</template>
<script>
export default {
name: 'ItemCardGrid',
props: {
items: {
type: Array,
required: true
},
titleField: {
type: String,
default: 'id'
},
enabledField: {
type: String,
default: 'enable'
},
emptyIcon: {
type: String,
default: 'mdi-alert-circle-outline'
},
emptyText: {
type: String,
default: '暂无数据'
}
},
emits: ['toggle-enabled', 'delete', 'edit'],
methods: {
getItemTitle(item) {
return item[this.titleField];
},
getItemEnabled(item) {
return item[this.enabledField];
},
toggleEnabled(item) {
this.$emit('toggle-enabled', item);
}
}
}
</script>
<style scoped>
.item-card {
position: relative;
border-radius: 8px;
transition: all 0.3s ease;
overflow: hidden;
min-height: 220px;
margin-bottom: 16px;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.item-status-indicator {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 4px;
background-color: #e0e0e0;
}
.item-status-indicator.active {
background-color: #4CAF50;
}
.hover-elevation:hover {
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
</style>
@@ -45,6 +45,11 @@ const sidebarItem: menu[] = [
icon: 'mdi-storefront',
to: '/extension-marketplace'
},
{
title: '函数调用',
icon: 'mdi-function-variant',
to: '/tool-use'
},
{
title: '聊天',
icon: 'mdi-chat',
+5
View File
@@ -31,6 +31,11 @@ const MainRoutes = {
path: '/providers',
component: () => import('@/views/ProviderPage.vue')
},
{
name: 'ToolUsePage',
path: '/tool-use',
component: () => import('@/views/ToolUsePage.vue')
},
{
name: 'Configs',
path: '/config',
+277 -289
View File
@@ -1,316 +1,304 @@
<template>
<v-card style="height: 100%;">
<v-card-text style="padding: 32px; height: 100%;">
<div class="platform-page">
<v-container fluid class="pa-0">
<!-- 页面标题 -->
<v-row>
<v-col cols="12">
<h1 class="text-h4 font-weight-bold mb-2">
<v-icon size="x-large" color="primary" class="me-2">mdi-connection</v-icon>平台适配器管理
</h1>
<p class="text-subtitle-1 text-medium-emphasis mb-4">
管理机器人的平台适配器连接到不同的聊天平台
</p>
</v-col>
</v-row>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn class="flex-grow-1" variant="tonal" @click="new_platform_dialog = true" size="large"
rounded="lg" v-bind="props" color="primary">
<template v-slot:default>
<v-icon>mdi-plus</v-icon>
新增平台适配器
</template>
</v-btn>
</template>
<v-list @update:selected="addFromDefaultConfigTmpl($event)">
<v-list-item
v-for="(item, index) in metadata['platform_group']['metadata']['platform'].config_template"
:key="index" rounded="xl" :value="index">
<v-list-item-title>{{ index }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-row style="margin-top: 16px;">
<v-col v-for="(platform, index) in config_data['platform']" :key="index" cols="12" md="6" lg="3">
<v-card class="fade-in"
style="margin-bottom: 16px; min-height: 250px; max-height: 250px; display: flex; justify-content: space-between; flex-direction: column;">
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h4">{{ platform.id }}</span>
<v-switch color="primary" hide-details density="compact" v-model="platform['enable']"
@update:modelValue="platformStatusChange(platform)"></v-switch>
</v-card-title>
<v-card-text>
<div>
<span style="font-size:12px">适配器类型: </span>
<v-chip size="small" color="primary" text>{{ platform.type }}</v-chip>
</div>
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn color="error" text @click="deletePlatform(platform.id);">
删除
</v-btn>
<v-btn color="blue-darken-1" text
@click="configExistingPlatform(platform)">
配置
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<v-dialog v-model="showPlatformCfg">
<v-card>
<v-card-title>
<span class="text-h4">{{ newSelectedPlatformName }} 配置</span>
</v-card-title>
<v-card-text>
<v-row>
<v-col cols="12" md="6">
<AstrBotConfig :iterable="newSelectedPlatformConfig"
:metadata="metadata['platform_group']['metadata']" metadataKey="platform" />
</v-col>
<v-col cols="12" md="6">
<v-btn :loading="iframeLoading" @click="refreshIframe" variant="tonal" color="primary" style="float: right;">
<v-icon>mdi-refresh</v-icon>
刷新
</v-btn>
<iframe v-show="!iframeLoading"
:src="store.getTutorialLink(newSelectedPlatformConfig.type)"
@load="iframeLoading = false" style="width: 100%; border: none; height: 100%;">
</iframe>
</v-col>
</v-row>
<!-- 平台适配器部分 -->
<v-card class="mb-6" elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-apps</v-icon>
<span class="text-h6">平台适配器</span>
<v-chip color="info" size="small" class="ml-2">{{ config_data.platform?.length || 0 }}</v-chip>
<v-spacer></v-spacer>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" v-bind="props">
新增适配器
</v-btn>
</template>
<v-list @update:selected="addFromDefaultConfigTmpl($event)">
<v-list-item
v-for="(item, index) in metadata['platform_group']?.metadata?.platform?.config_template || {}"
:key="index"
rounded="xl"
:value="index"
>
<v-list-item-title>{{ index }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-card-title>
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue-darken-1" variant="text" @click="newPlatform" :loading="loading">
保存
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<v-btn style="margin-top: 16px" class="flex-grow-1" variant="tonal" size="large" rounded="lg" color="gray"
@click="showConsole = !showConsole">
<template v-slot:default>
<v-icon>mdi-console-line</v-icon>
{{ showConsole ? '隐藏' : '显示' }}日志
</template>
</v-btn>
<div v-if="showConsole" style="margin-top: 32px">
<ConsoleDisplayer style="background-color: #000; height: 300px"></ConsoleDisplayer>
</div>
<v-divider></v-divider>
<v-card-text class="px-4 py-3">
<item-card-grid
:items="config_data.platform || []"
title-field="id"
enabled-field="enable"
empty-icon="mdi-connection"
empty-text="暂无平台适配器点击 新增适配器 添加"
@toggle-enabled="platformStatusChange"
@delete="deletePlatform"
@edit="editPlatform"
>
<template v-slot:item-details="{ item }">
<div class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-tag</v-icon>
<span class="text-caption text-medium-emphasis">
适配器类型:
<v-chip size="x-small" color="primary" class="ml-1">{{ item.type }}</v-chip>
</span>
</div>
<div v-if="item.token" class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-key</v-icon>
<span class="text-caption text-medium-emphasis">Token: </span>
</div>
<div v-if="item.description" class="d-flex align-center">
<v-icon size="small" color="grey" class="me-2">mdi-information-outline</v-icon>
<span class="text-caption text-medium-emphasis text-truncate">{{ item.description }}</span>
</div>
</template>
</item-card-grid>
</v-card-text>
</v-card>
</v-card>
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack">
{{ save_message }}
<!-- 日志部分 -->
<v-card elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-console-line</v-icon>
<span class="text-h6">平台日志</span>
<v-spacer></v-spacer>
<v-btn variant="text" color="primary" @click="showConsole = !showConsole">
{{ showConsole ? '收起' : '展开' }}
<v-icon>{{ showConsole ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-expand-transition>
<v-card-text class="pa-0" v-if="showConsole">
<ConsoleDisplayer style="background-color: #1e1e1e; height: 300px; border-radius: 0"></ConsoleDisplayer>
</v-card-text>
</v-expand-transition>
</v-card>
</v-container>
<!-- 配置对话框 -->
<v-dialog v-model="showPlatformCfg" width="900" persistent>
<v-card>
<v-card-title class="bg-primary text-white py-3">
<v-icon color="white" class="me-2">{{ updatingMode ? 'mdi-pencil' : 'mdi-plus' }}</v-icon>
<span>{{ updatingMode ? '编辑' : '新增' }} {{ newSelectedPlatformName }} 平台适配器</span>
</v-card-title>
<v-card-text class="py-4">
<v-row>
<v-col cols="12" md="6">
<AstrBotConfig :iterable="newSelectedPlatformConfig"
:metadata="metadata['platform_group']?.metadata"
metadataKey="platform" />
</v-col>
<v-col cols="12" md="6">
<v-btn :loading="iframeLoading" @click="refreshIframe" variant="tonal" color="primary" style="float: right;">
<v-icon>mdi-refresh</v-icon>
刷新
</v-btn>
<iframe v-show="!iframeLoading"
:src="store.getTutorialLink(newSelectedPlatformConfig.type)"
@load="iframeLoading = false" style="width: 100%; border: none; height: 100%; min-height: 400px;">
</iframe>
</v-col>
</v-row>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showPlatformCfg = false" :disabled="loading">
取消
</v-btn>
<v-btn color="primary" @click="newPlatform" :loading="loading">
保存
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
location="top">
{{ save_message }}
</v-snackbar>
<WaitingForRestart ref="wfr"></WaitingForRestart>
</div>
</template>
<script>
<script>
import axios from 'axios';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
import ItemCardGrid from '@/components/shared/ItemCardGrid.vue';
import { useCommonStore } from '@/stores/common';
export default {
name: 'PlatformPage',
components: {
AstrBotConfig,
WaitingForRestart,
ConsoleDisplayer
},
data() {
return {
config_data: {},
fetched: false,
metadata: {},
showPlatformCfg: false,
name: 'PlatformPage',
components: {
AstrBotConfig,
WaitingForRestart,
ConsoleDisplayer,
ItemCardGrid
},
data() {
return {
config_data: {},
fetched: false,
metadata: {},
showPlatformCfg: false,
newSelectedPlatformName: '',
newSelectedPlatformConfig: {},
updatingMode: false,
newSelectedPlatformName: '',
newSelectedPlatformConfig: {},
updatingMode: false,
loading: false,
loading: false,
save_message_snack: false,
save_message: "",
save_message_success: "",
save_message_snack: false,
save_message: "",
save_message_success: "success",
showConsole: false,
iframeLoading: true,
store: useCommonStore()
}
},
mounted() {
this.getConfig();
},
methods: {
refreshIframe() {
this.iframeLoading = true;
const iframe = document.querySelector('iframe');
console.log(iframe.src);
iframe.src = iframe.src + '?t=' + new Date().getTime();
},
getConfig() {
// 获取配置
axios.get('/api/config/get').then((res) => {
this.config_data = res.data.data.config;
this.fetched = true
this.metadata = res.data.data.metadata;
}).catch((err) => {
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
},
addFromDefaultConfigTmpl(index) {
// 从默认配置模板中添加
console.log(index);
this.newSelectedPlatformName = index[0];
this.showPlatformCfg = true;
this.updatingMode = false;
this.newSelectedPlatformConfig = this.metadata['platform_group']['metadata']['platform'].config_template[index[0]];
},
newPlatform() {
// 新建或者更新平台
this.loading = true;
if (this.updatingMode) {
axios.post('/api/config/platform/update', {
id: this.newSelectedPlatformName,
config: this.newSelectedPlatformConfig
}).then((res) => {
this.loading = false;
this.showPlatformCfg = false;
this.getConfig();
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
}).catch((err) => {
this.loading = false;
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
this.updatingMode = false;
} else {
axios.post('/api/config/platform/new', this.newSelectedPlatformConfig).then((res) => {
this.loading = false;
this.showPlatformCfg = false;
this.getConfig();
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
}).catch((err) => {
this.loading = false;
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
}
},
deletePlatform(platform_id) {
// 删除平台
axios.post('/api/config/platform/delete', { id: platform_id }).then((res) => {
this.getConfig();
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
}).catch((err) => {
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
},
platformStatusChange(platform) {
// 平台状态改变
axios.post('/api/config/platform/update', {
id: platform.id,
config: platform
}).then((res) => {
this.getConfig();
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
}).catch((err) => {
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
},
configExistingPlatform(platform) {
// 配置现有平台
this.newSelectedPlatformName = platform.id;
this.newSelectedPlatformConfig = {};
// 比对默认配置模版,看看是否有更新
let templates = this.metadata['platform_group']['metadata']['platform'].config_template;
let defaultConfig = {};
for (let key in templates) {
if (templates[key]?.type === platform.type) {
defaultConfig = templates[key];
break;
}
}
const mergeConfigWithOrder = (target, source, reference) => {
// 首先复制所有source中的属性到target
if (source && typeof source === 'object' && !Array.isArray(source)) {
for (let key in source) {
if (source.hasOwnProperty(key)) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = Array.isArray(source[key]) ? [...source[key]] : {...source[key]};
} else {
target[key] = source[key];
}
}
}
}
// 然后根据reference的结构添加或覆盖属性
for (let key in reference) {
if (typeof reference[key] === 'object' && reference[key] !== null) {
if (!(key in target)) {
target[key] = Array.isArray(reference[key]) ? [] : {};
}
mergeConfigWithOrder(
target[key],
source && source[key] ? source[key] : {},
reference[key]
);
} else if (!(key in target)) {
// 只有当target中不存在该键时才从reference复制
target[key] = reference[key];
}
}
};
if (defaultConfig) {
mergeConfigWithOrder(this.newSelectedPlatformConfig, platform, defaultConfig);
}
this.showPlatformCfg = true;
this.updatingMode = true;
}
showConsole: false,
iframeLoading: true,
store: useCommonStore()
}
}
},
mounted() {
this.getConfig();
},
methods: {
refreshIframe() {
this.iframeLoading = true;
const iframe = document.querySelector('iframe');
iframe.src = iframe.src + '?t=' + new Date().getTime();
},
getConfig() {
axios.get('/api/config/get').then((res) => {
this.config_data = res.data.data.config;
this.fetched = true
this.metadata = res.data.data.metadata;
}).catch((err) => {
this.showError(err);
});
},
addFromDefaultConfigTmpl(index) {
this.newSelectedPlatformName = index[0];
this.showPlatformCfg = true;
this.updatingMode = false;
this.newSelectedPlatformConfig = JSON.parse(JSON.stringify(
this.metadata['platform_group']?.metadata?.platform?.config_template[index[0]] || {}
));
},
editPlatform(platform) {
this.newSelectedPlatformName = platform.id;
this.newSelectedPlatformConfig = JSON.parse(JSON.stringify(platform));
this.updatingMode = true;
this.showPlatformCfg = true;
},
newPlatform() {
this.loading = true;
if (this.updatingMode) {
axios.post('/api/config/platform/update', {
id: this.newSelectedPlatformName,
config: this.newSelectedPlatformConfig
}).then((res) => {
this.loading = false;
this.showPlatformCfg = false;
this.getConfig();
this.$refs.wfr.check();
this.showSuccess(res.data.message || "更新成功!");
}).catch((err) => {
this.loading = false;
this.showError(err.response?.data?.message || err.message);
});
this.updatingMode = false;
} else {
axios.post('/api/config/platform/new', this.newSelectedPlatformConfig).then((res) => {
this.loading = false;
this.showPlatformCfg = false;
this.getConfig();
this.showSuccess(res.data.message || "添加成功!");
}).catch((err) => {
this.loading = false;
this.showError(err.response?.data?.message || err.message);
});
}
},
deletePlatform(platform) {
if (confirm(`确定要删除平台适配器 ${platform.id} 吗?`)) {
axios.post('/api/config/platform/delete', { id: platform.id }).then((res) => {
this.getConfig();
this.$refs.wfr.check();
this.showSuccess(res.data.message || "删除成功!");
}).catch((err) => {
this.showError(err.response?.data?.message || err.message);
});
}
},
platformStatusChange(platform) {
platform.enable = !platform.enable; // 切换状态
axios.post('/api/config/platform/update', {
id: platform.id,
config: platform
}).then((res) => {
this.getConfig();
this.$refs.wfr.check();
this.showSuccess(res.data.message || "状态更新成功!");
}).catch((err) => {
platform.enable = !platform.enable; // 发生错误时回滚状态
this.showError(err.response?.data?.message || err.message);
});
},
showSuccess(message) {
this.save_message = message;
this.save_message_success = "success";
this.save_message_snack = true;
},
showError(message) {
this.save_message = message;
this.save_message_success = "error";
this.save_message_snack = true;
}
}
}
</script>
<style>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.fade-in {
animation: fadeIn 0.2s ease-in-out;
<style scoped>
.platform-page {
padding: 20px;
}
</style>
+302 -265
View File
@@ -1,291 +1,328 @@
<template>
<v-card style="height: 100%;">
<v-card-text style="padding: 32px; height: 100%;">
<div class="provider-page">
<v-container fluid class="pa-0">
<!-- 页面标题 -->
<v-row>
<v-col cols="12">
<h1 class="text-h4 font-weight-bold mb-2">
<v-icon size="x-large" color="primary" class="me-2">mdi-creation</v-icon>服务提供商管理
</h1>
<p class="text-subtitle-1 text-medium-emphasis mb-4">
管理AI服务提供商连接到不同的大语言模型
</p>
</v-col>
</v-row>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn class="flex-grow-1" variant="tonal" @click="new_provider_dialog = true" size="large"
rounded="lg" v-bind="props" color="primary">
<template v-slot:default>
<v-icon>mdi-plus</v-icon>
新增服务提供商
</template>
</v-btn>
</template>
<v-list @update:selected="addFromDefaultConfigTmpl($event)">
<v-list-item
v-for="(item, index) in metadata['provider_group']['metadata']['provider'].config_template"
:key="index" rounded="xl" :value="index">
<v-list-item-title>{{ index }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
<v-row style="margin-top: 16px;">
<v-col v-for="(provider, index) in config_data['provider']" :key="index" cols="12" md="6" lg="3">
<v-card class="fade-in" style="margin-bottom: 16px; min-height: 250px; max-height: 250px; display: flex; justify-content: space-between; flex-direction: column;">
<v-card-title class="d-flex justify-space-between align-center">
<span class="text-h4">{{ provider.id }}</span>
<v-switch color="primary" hide-details density="compact" v-model="provider['enable']"
@update:modelValue="providerStatusChange(provider)"></v-switch>
</v-card-title>
<v-card-text>
<div>
<span style="font-size:12px">适配器类型: </span> <v-chip size="small" color="primary" text>{{ provider.type }}</v-chip>
</div>
<div v-if="provider?.api_base" style="margin-top: 8px;">
<span style="font-size:12px">API Base: </span> <v-chip size="small" color="primary" text>{{ provider?.api_base }}</v-chip>
</div>
</v-card-text>
<v-card-actions class="d-flex justify-end">
<v-btn color="error" text @click="deleteprovider(provider.id);">
删除
</v-btn>
<v-btn color="blue-darken-1" text
@click="configExistingProvider(provider)">
配置
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
<v-dialog v-model="showproviderCfg" width="900">
<v-card>
<v-card-title>
<span class="text-h4">{{ newSelectedproviderName }} 配置</span>
</v-card-title>
<v-card-text>
<AstrBotConfig :iterable="newSelectedproviderConfig"
:metadata="metadata['provider_group']['metadata']" metadataKey="provider" />
</v-card-text>
<v-card-actions>
<v-spacer></v-spacer>
<v-btn color="blue-darken-1" variant="text" @click="newprovider" :loading="loading">
保存
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 服务提供商部分 -->
<v-card class="mb-6" elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-api</v-icon>
<span class="text-h6">服务提供商</span>
<v-chip color="info" size="small" class="ml-2">{{ config_data.provider?.length || 0 }}</v-chip>
<v-spacer></v-spacer>
<v-menu>
<template v-slot:activator="{ props }">
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" v-bind="props">
新增服务提供商
</v-btn>
</template>
<v-list @update:selected="addFromDefaultConfigTmpl($event)">
<v-list-item
v-for="(item, index) in metadata['provider_group']?.metadata?.provider?.config_template || {}"
:key="index"
rounded="xl"
:value="index"
>
<v-list-item-title>{{ index }}</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</v-card-title>
<v-btn style="margin-top: 16px" class="flex-grow-1" variant="tonal" size="large" rounded="lg" color="gray" @click="showConsole = !showConsole">
<template v-slot:default>
<v-icon>mdi-console-line</v-icon>
{{ showConsole ? '隐藏' : '显示' }}日志
</template>
</v-btn>
<div v-if="showConsole" style="margin-top: 32px">
<ConsoleDisplayer style="background-color: #000; height: 300px"></ConsoleDisplayer>
</div>
<v-divider></v-divider>
<v-card-text class="px-4 py-3">
<item-card-grid
:items="config_data.provider || []"
title-field="id"
enabled-field="enable"
empty-icon="mdi-api-off"
empty-text="暂无服务提供商点击 新增服务提供商 添加"
@toggle-enabled="providerStatusChange"
@delete="deleteProvider"
@edit="configExistingProvider"
>
<template v-slot:item-details="{ item }">
<div class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-tag</v-icon>
<span class="text-caption text-medium-emphasis">
提供商类型:
<v-chip size="x-small" color="primary" class="ml-1">{{ item.type }}</v-chip>
</span>
</div>
<div v-if="item.api_base" class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-web</v-icon>
<span class="text-caption text-medium-emphasis text-truncate" :title="item.api_base">
API Base: {{ item.api_base }}
</span>
</div>
<div v-if="item.api_key" class="d-flex align-center">
<v-icon size="small" color="grey" class="me-2">mdi-key</v-icon>
<span class="text-caption text-medium-emphasis">API Key: </span>
</div>
</template>
</item-card-grid>
</v-card-text>
</v-card>
</v-card>
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack">
{{ save_message }}
<!-- 日志部分 -->
<v-card elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-console-line</v-icon>
<span class="text-h6">服务日志</span>
<v-spacer></v-spacer>
<v-btn variant="text" color="primary" @click="showConsole = !showConsole">
{{ showConsole ? '收起' : '展开' }}
<v-icon>{{ showConsole ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-expand-transition>
<v-card-text class="pa-0" v-if="showConsole">
<ConsoleDisplayer style="background-color: #1e1e1e; height: 300px; border-radius: 0"></ConsoleDisplayer>
</v-card-text>
</v-expand-transition>
</v-card>
</v-container>
<!-- 配置对话框 -->
<v-dialog v-model="showProviderCfg" width="900" persistent>
<v-card>
<v-card-title class="bg-primary text-white py-3">
<v-icon color="white" class="me-2">{{ updatingMode ? 'mdi-pencil' : 'mdi-plus' }}</v-icon>
<span>{{ updatingMode ? '编辑' : '新增' }} {{ newSelectedProviderName }} 服务提供商</span>
</v-card-title>
<v-card-text class="py-4">
<AstrBotConfig
:iterable="newSelectedProviderConfig"
:metadata="metadata['provider_group']?.metadata"
metadataKey="provider"
/>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="showProviderCfg = false" :disabled="loading">
取消
</v-btn>
<v-btn color="primary" @click="newProvider" :loading="loading">
保存
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
location="top">
{{ save_message }}
</v-snackbar>
<WaitingForRestart ref="wfr"></WaitingForRestart>
</div>
</template>
<script>
<script>
import axios from 'axios';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue';
import ConsoleDisplayer from '@/components/shared/ConsoleDisplayer.vue';
import ItemCardGrid from '@/components/shared/ItemCardGrid.vue';
export default {
name: 'ProviderPage',
components: {
AstrBotConfig,
WaitingForRestart,
ConsoleDisplayer
},
data() {
return {
config_data: {},
fetched: false,
metadata: {},
showproviderCfg: false,
name: 'ProviderPage',
components: {
AstrBotConfig,
WaitingForRestart,
ConsoleDisplayer,
ItemCardGrid
},
data() {
return {
config_data: {},
fetched: false,
metadata: {},
showProviderCfg: false,
newSelectedproviderName: '',
newSelectedproviderConfig: {},
updatingMode: false,
newSelectedProviderName: '',
newSelectedProviderConfig: {},
updatingMode: false,
loading: false,
loading: false,
save_message_snack: false,
save_message: "",
save_message_success: "",
showConsole: false,
}
},
mounted() {
this.getConfig();
},
methods: {
getConfig() {
// 获取配置
axios.get('/api/config/get').then((res) => {
this.config_data = res.data.data.config;
this.fetched = true
this.metadata = res.data.data.metadata;
}).catch((err) => {
save_message = err;
save_message_snack = true;
save_message_success = "error";
});
},
addFromDefaultConfigTmpl(index) {
// 从默认配置模板中添加
console.log(index);
this.newSelectedproviderName = index[0];
this.showproviderCfg = true;
this.updatingMode = false;
this.newSelectedproviderConfig = this.metadata['provider_group']['metadata']['provider'].config_template[index[0]];
},
newprovider() {
// 新建或者更新平台
this.loading = true;
if (this.updatingMode) {
axios.post('/api/config/provider/update', {
id: this.newSelectedproviderName,
config: this.newSelectedproviderConfig
}).then((res) => {
this.loading = false;
this.showproviderCfg = false;
this.getConfig();
// this.$refs.wfr.check();
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
}).catch((err) => {
this.loading = false;
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
this.updatingMode = false;
} else {
axios.post('/api/config/provider/new', this.newSelectedproviderConfig).then((res) => {
this.loading = false;
this.showproviderCfg = false;
this.getConfig();
}).catch((err) => {
this.loading = false;
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
}
},
deleteprovider(provider_id) {
// 删除平台
axios.post('/api/config/provider/delete', { id: provider_id }).then((res) => {
this.getConfig();
// this.$refs.wfr.check();
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
}).catch((err) => {
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
},
providerStatusChange(provider) {
// 平台状态改变
axios.post('/api/config/provider/update', {
id: provider.id,
config: provider
}).then((res) => {
this.getConfig();
// this.$refs.wfr.check();
this.save_message = res.data.message;
this.save_message_snack = true;
this.save_message_success = "success";
}).catch((err) => {
this.save_message = err;
this.save_message_snack = true;
this.save_message_success = "error";
});
},
configExistingProvider(provider) {
// 配置现有平台
this.newSelectedproviderName = provider.id;
this.newSelectedproviderConfig = {};
// 比对默认配置模版,看看是否有更新
let templates = this.metadata['provider_group']['metadata']['provider'].config_template;
let defaultConfig = {};
for (let key in templates) {
if (templates[key]?.type === provider.type) {
defaultConfig = templates[key];
break;
}
}
const mergeConfigWithOrder = (target, source, reference) => {
// 首先复制所有source中的属性到target
if (source && typeof source === 'object' && !Array.isArray(source)) {
for (let key in source) {
if (source.hasOwnProperty(key)) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = Array.isArray(source[key]) ? [...source[key]] : {...source[key]};
} else {
target[key] = source[key];
}
}
}
}
// 然后根据reference的结构添加或覆盖属性
for (let key in reference) {
if (typeof reference[key] === 'object' && reference[key] !== null) {
if (!(key in target)) {
target[key] = Array.isArray(reference[key]) ? [] : {};
}
mergeConfigWithOrder(
target[key],
source && source[key] ? source[key] : {},
reference[key]
);
} else if (!(key in target)) {
// 只有当target中不存在该键时才从reference复制
target[key] = reference[key];
}
}
};
if (defaultConfig) {
mergeConfigWithOrder(this.newSelectedproviderConfig, provider, defaultConfig);
}
this.showproviderCfg = true;
this.updatingMode = true;
}
save_message_snack: false,
save_message: "",
save_message_success: "success",
showConsole: false,
}
}
},
mounted() {
this.getConfig();
},
methods: {
getConfig() {
axios.get('/api/config/get').then((res) => {
this.config_data = res.data.data.config;
this.fetched = true
this.metadata = res.data.data.metadata;
}).catch((err) => {
this.showError(err.response?.data?.message || err.message);
});
},
addFromDefaultConfigTmpl(index) {
this.newSelectedProviderName = index[0];
this.showProviderCfg = true;
this.updatingMode = false;
this.newSelectedProviderConfig = JSON.parse(JSON.stringify(
this.metadata['provider_group']?.metadata?.provider?.config_template[index[0]] || {}
));
},
configExistingProvider(provider) {
this.newSelectedProviderName = provider.id;
this.newSelectedProviderConfig = {};
// 比对默认配置模版,看看是否有更新
let templates = this.metadata['provider_group']?.metadata?.provider?.config_template || {};
let defaultConfig = {};
for (let key in templates) {
if (templates[key]?.type === provider.type) {
defaultConfig = templates[key];
break;
}
}
const mergeConfigWithOrder = (target, source, reference) => {
// 首先复制所有source中的属性到target
if (source && typeof source === 'object' && !Array.isArray(source)) {
for (let key in source) {
if (source.hasOwnProperty(key)) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = Array.isArray(source[key]) ? [...source[key]] : {...source[key]};
} else {
target[key] = source[key];
}
}
}
}
// 然后根据reference的结构添加或覆盖属性
for (let key in reference) {
if (typeof reference[key] === 'object' && reference[key] !== null) {
if (!(key in target)) {
target[key] = Array.isArray(reference[key]) ? [] : {};
}
mergeConfigWithOrder(
target[key],
source && source[key] ? source[key] : {},
reference[key]
);
} else if (!(key in target)) {
// 只有当target中不存在该键时才从reference复制
target[key] = reference[key];
}
}
};
if (defaultConfig) {
mergeConfigWithOrder(this.newSelectedProviderConfig, provider, defaultConfig);
}
this.showProviderCfg = true;
this.updatingMode = true;
},
newProvider() {
this.loading = true;
if (this.updatingMode) {
axios.post('/api/config/provider/update', {
id: this.newSelectedProviderName,
config: this.newSelectedProviderConfig
}).then((res) => {
this.loading = false;
this.showProviderCfg = false;
this.getConfig();
this.showSuccess(res.data.message || "更新成功!");
}).catch((err) => {
this.loading = false;
this.showError(err.response?.data?.message || err.message);
});
this.updatingMode = false;
} else {
axios.post('/api/config/provider/new', this.newSelectedProviderConfig).then((res) => {
this.loading = false;
this.showProviderCfg = false;
this.getConfig();
this.showSuccess(res.data.message || "添加成功!");
}).catch((err) => {
this.loading = false;
this.showError(err.response?.data?.message || err.message);
});
}
},
deleteProvider(provider) {
if (confirm(`确定要删除服务提供商 ${provider.id} 吗?`)) {
axios.post('/api/config/provider/delete', { id: provider.id }).then((res) => {
this.getConfig();
this.showSuccess(res.data.message || "删除成功!");
}).catch((err) => {
this.showError(err.response?.data?.message || err.message);
});
}
},
providerStatusChange(provider) {
provider.enable = !provider.enable; // 切换状态
axios.post('/api/config/provider/update', {
id: provider.id,
config: provider
}).then((res) => {
this.getConfig();
this.showSuccess(res.data.message || "状态更新成功!");
}).catch((err) => {
provider.enable = !provider.enable; // 发生错误时回滚状态
this.showError(err.response?.data?.message || err.message);
});
},
showSuccess(message) {
this.save_message = message;
this.save_message_success = "success";
this.save_message_snack = true;
},
showError(message) {
this.save_message = message;
this.save_message_success = "error";
this.save_message_snack = true;
}
}
}
</script>
<style>
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.fade-in {
animation: fadeIn 0.2s ease-in-out;
<style scoped>
.provider-page {
padding: 20px;
}
</style>
+648
View File
@@ -0,0 +1,648 @@
<template>
<div class="tools-page">
<v-container fluid class="pa-0">
<!-- 页面标题 -->
<v-row>
<v-col cols="12">
<h1 class="text-h4 font-weight-bold mb-2">
<v-icon size="x-large" color="primary" class="me-2">mdi-function-variant</v-icon>函数工具管理
</h1>
<p class="text-subtitle-1 text-medium-emphasis mb-4 d-flex align-center">
管理 MCP 服务器和查看可用的函数工具
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-icon
v-bind="props"
size="small"
color="primary"
class="ms-1 cursor-pointer"
@click="openurl('https://astrbot.app/use/function-calling.html')"
>
mdi-information
</v-icon>
</template>
<span>函数调用和 MCP 是什么</span>
</v-tooltip>
</p>
</v-col>
</v-row>
<!-- MCP 服务器部分 -->
<v-card class="mb-6" elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-server</v-icon>
<span class="text-h6">MCP 服务器</span>
<v-spacer></v-spacer>
<v-btn color="primary" prepend-icon="mdi-plus" variant="tonal" @click="showMcpServerDialog = true">
新增服务器
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-card-text class="px-4 py-3">
<v-row v-if="mcpServers.length === 0">
<v-col cols="12" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-server-off</v-icon>
<p class="text-grey mt-4">暂无 MCP 服务器点击"新增服务器"添加</p>
</v-col>
</v-row>
<v-row v-else>
<v-col v-for="(server, index) in mcpServers" :key="index" cols="12" md="6" lg="4" xl="3">
<v-card class="server-card hover-elevation" :color="server.active ? '' : 'grey-lighten-4'">
<div class="server-status-indicator" :class="{'active': server.active}"></div>
<v-card-title class="d-flex justify-space-between align-center pb-1 pt-3">
<span class="text-h6 text-truncate" :title="server.name">{{ server.name }}</span>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-switch color="primary" hide-details density="compact" v-model="server.active"
v-bind="props" @update:modelValue="updateServerStatus(server)"></v-switch>
</template>
<span>{{ server.active ? '已启用' : '已禁用' }}</span>
</v-tooltip>
</v-card-title>
<v-card-text>
<div class="d-flex align-center mb-2">
<v-icon size="small" color="grey" class="me-2">mdi-file-code</v-icon>
<span class="text-caption text-medium-emphasis text-truncate" :title="getServerConfigSummary(server)">
{{ getServerConfigSummary(server) }}
</span>
</div>
<div v-if="server.tools && server.tools.length > 0">
<div class="d-flex align-center mb-1">
<v-icon size="small" color="grey" class="me-2">mdi-tools</v-icon>
<span class="text-caption text-medium-emphasis">可用工具 ({{ server.tools.length }})</span>
</div>
<v-chip-group class="tool-chips">
<v-chip v-for="(tool, idx) in server.tools" :key="idx" size="x-small"
density="compact" color="info" class="text-caption">
{{ tool }}
</v-chip>
</v-chip-group>
</div>
<div v-else class="text-caption text-medium-emphasis mt-2">
<v-icon size="small" color="warning" class="me-1">mdi-alert-circle</v-icon>
无可用工具
</div>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-2">
<v-spacer></v-spacer>
<v-btn variant="text" size="small" color="error" prepend-icon="mdi-delete"
@click="deleteServer(server.name)">
删除
</v-btn>
<v-btn variant="text" size="small" color="primary" prepend-icon="mdi-pencil"
@click="editServer(server)">
编辑
</v-btn>
</v-card-actions>
</v-card>
</v-col>
</v-row>
</v-card-text>
</v-card>
<!-- 函数工具部分 -->
<v-card elevation="2">
<v-card-title class="d-flex align-center py-3 px-4">
<v-icon color="primary" class="me-2">mdi-function</v-icon>
<span class="text-h6">函数工具</span>
<v-chip color="info" size="small" class="ml-2">{{ tools.length }}</v-chip>
<v-spacer></v-spacer>
<v-btn variant="text" color="primary" @click="showTools = !showTools">
{{ showTools ? '收起' : '展开' }}
<v-icon>{{ showTools ? 'mdi-chevron-up' : 'mdi-chevron-down' }}</v-icon>
</v-btn>
</v-card-title>
<v-divider></v-divider>
<v-expand-transition>
<v-card-text class="pa-3" v-if="showTools">
<div v-if="tools.length === 0" class="text-center pa-8">
<v-icon size="64" color="grey-lighten-1">mdi-api-off</v-icon>
<p class="text-grey mt-4">没有可用的函数工具</p>
</div>
<div v-else>
<v-text-field
v-model="toolSearch"
prepend-inner-icon="mdi-magnify"
label="搜索函数工具"
variant="outlined"
density="compact"
class="mb-4"
hide-details
clearable
></v-text-field>
<v-expansion-panels v-model="openedPanel" multiple>
<v-expansion-panel
v-for="(tool, index) in filteredTools"
:key="index"
:value="index"
class="mb-2 tool-panel"
rounded="lg"
>
<v-expansion-panel-title>
<v-row no-gutters align="center">
<v-col cols="3">
<div class="d-flex align-center">
<v-icon color="primary" class="me-2" size="small">
{{ tool.function.name.includes(':') ? 'mdi-server-network' : 'mdi-function-variant' }}
</v-icon>
<span class="text-body-1 text-high-emphasis font-weight-medium text-truncate"
:title="tool.function.name">
{{ formatToolName(tool.function.name) }}
</span>
</div>
</v-col>
<v-col cols="9" class="text-grey">
{{ tool.function.description }}
</v-col>
</v-row>
</v-expansion-panel-title>
<v-expansion-panel-text>
<v-card flat>
<v-card-text>
<p class="text-body-1 font-weight-medium mb-3">
<v-icon color="primary" size="small" class="me-1">mdi-information</v-icon>
功能描述
</p>
<p class="text-body-2 ml-6 mb-4">{{ tool.function.description }}</p>
<template v-if="tool.function.parameters && tool.function.parameters.properties">
<p class="text-body-1 font-weight-medium mb-3">
<v-icon color="primary" size="small" class="me-1">mdi-code-json</v-icon>
参数列表
</p>
<v-table density="compact" class="params-table mt-1">
<thead>
<tr>
<th>参数名</th>
<th>类型</th>
<th>描述</th>
</tr>
</thead>
<tbody>
<tr v-for="(param, paramName) in tool.function.parameters.properties" :key="paramName">
<td class="font-weight-medium">{{ paramName }}</td>
<td>
<v-chip size="x-small" color="primary" text class="text-caption">
{{ param.type }}
</v-chip>
</td>
<td>{{ param.description }}</td>
</tr>
</tbody>
</v-table>
</template>
<div v-else class="text-center pa-4 text-medium-emphasis">
<v-icon size="large" color="grey-lighten-1">mdi-code-brackets</v-icon>
<p>此工具没有参数</p>
</div>
</v-card-text>
</v-card>
</v-expansion-panel-text>
</v-expansion-panel>
</v-expansion-panels>
</div>
</v-card-text>
</v-expand-transition>
</v-card>
</v-container>
<!-- 添加/编辑 MCP 服务器对话框 -->
<v-dialog v-model="showMcpServerDialog" max-width="750px" persistent>
<v-card>
<v-card-title class="bg-primary text-white py-3">
<v-icon color="white" class="me-2">{{ isEditMode ? 'mdi-pencil' : 'mdi-plus' }}</v-icon>
<span>{{ isEditMode ? '编辑' : '新增' }} MCP 服务器</span>
</v-card-title>
<v-card-text class="py-4">
<v-form @submit.prevent="saveServer" ref="form">
<v-text-field
v-model="currentServer.name"
label="服务器名称"
variant="outlined"
:rules="[v => !!v || '名称是必填项']"
required
class="mb-3"
></v-text-field>
<v-switch
v-model="currentServer.active"
label="启用服务器"
color="primary"
hide-details
class="mb-3"
></v-switch>
<div class="mb-2 d-flex align-center">
<span class="text-subtitle-1">服务器配置</span>
<v-tooltip location="top">
<template v-slot:activator="{ props }">
<v-icon v-bind="props" class="ms-2" size="small" color="primary">mdi-information</v-icon>
</template>
<div>
<p class="mb-1">MCP 服务器(stdio)配置支持以下字段:</p>
<p class="mb-1"><code>command</code>: 命令名称 (例如 python uv)</p>
<p class="mb-1"><code>args</code>: 命令参数数组 (例如 ["run", "server.py"])</p>
<p class="mb-1"><code>env</code>: 环境变量对象 (例如 {"api_key": "abc"})</p>
<p class="mb-1"><code>cwd</code>: 工作目录路径 (例如 /path/to/server)</p>
<p class="mb-1"><code>encoding</code>: 输出编码 (默认 utf-8)</p>
<p class="mb-1"><code>encoding_error_handler</code>: The text encoding error handler. Defaults to strict.</p>
<p class="mb-1">其他字段请参考 MCP 文档</p>
<p class="mb-1"> 如果您使用 Docker 部署 AstrBot, 请务必将 MCP 服务器装在 AstrBot 挂载好的 data 目录下</p>
</div>
</v-tooltip>
<v-spacer></v-spacer>
<v-btn
size="small"
color="info"
variant="text"
@click="setConfigTemplate"
class="me-1"
>
使用模板
</v-btn>
</div>
<div class="monaco-container">
<VueMonacoEditor
v-model:value="serverConfigJson"
theme="vs-dark"
language="json"
:options="{
minimap: {
enabled: false
},
scrollBeyondLastLine: false,
automaticLayout: true,
lineNumbers: 'on',
roundedSelection: true,
tabSize: 2
}"
@change="validateJson"
/>
</div>
<div v-if="jsonError" class="mt-2 text-error">
<v-icon color="error" size="small" class="me-1">mdi-alert-circle</v-icon>
<span>{{ jsonError }}</span>
</div>
</v-form>
</v-card-text>
<v-divider></v-divider>
<v-card-actions class="pa-4">
<v-spacer></v-spacer>
<v-btn variant="text" @click="closeServerDialog" :disabled="loading">
取消
</v-btn>
<v-btn
color="primary"
@click="saveServer"
:loading="loading"
:disabled="!isServerFormValid"
>
保存
</v-btn>
</v-card-actions>
</v-card>
</v-dialog>
<!-- 消息提示 -->
<v-snackbar :timeout="3000" elevation="24" :color="save_message_success" v-model="save_message_snack"
location="top">
{{ save_message }}
</v-snackbar>
</div>
</template>
<script>
import axios from 'axios';
import AstrBotConfig from '@/components/shared/AstrBotConfig.vue';
import { VueMonacoEditor } from '@guolao/vue-monaco-editor';
export default {
name: 'ToolUsePage',
components: {
AstrBotConfig,
VueMonacoEditor
},
data() {
return {
mcpServers: [],
tools: [],
showMcpServerDialog: false,
showTools: true,
loading: false,
isEditMode: false,
serverConfigJson: '',
jsonError: null,
currentServer: {
name: '',
active: true,
tools: []
},
save_message_snack: false,
save_message: "",
save_message_success: "success",
toolSearch: '',
openedPanel: [], // 存储打开的面板索引
}
},
computed: {
filteredTools() {
if (!this.toolSearch) return this.tools;
const searchTerm = this.toolSearch.toLowerCase();
return this.tools.filter(tool =>
tool.function.name.toLowerCase().includes(searchTerm) ||
tool.function.description.toLowerCase().includes(searchTerm)
);
},
isServerFormValid() {
return !!this.currentServer.name && !this.jsonError;
},
// 显示服务器配置的文本摘要
getServerConfigSummary() {
return (server) => {
if (server.command) {
return `${server.command} ${(server.args || []).join(' ')}`;
}
// 如果没有command字段,尝试显示其他有意义的配置信息
const configKeys = Object.keys(server).filter(key =>
!['name', 'active', 'tools'].includes(key)
);
if (configKeys.length > 0) {
return `配置: ${configKeys.join(', ')}`;
}
return '未设置配置';
}
}
},
mounted() {
this.getServers();
this.getTools();
},
methods: {
openurl(url) {
window.open(url, '_blank');
},
formatToolName(name) {
if (name.includes(':')) {
// MCP 工具通常命名为 mcp:server:tool
const parts = name.split(':');
return parts[parts.length - 1]; // 返回最后一部分
}
return name;
},
getServers() {
axios.get('/api/tools/mcp/servers')
.then(response => {
this.mcpServers = response.data.data || [];
})
.catch(error => {
this.showError("获取 MCP 服务器列表失败: " + error.message);
});
},
getTools() {
axios.get('/api/config/llmtools')
.then(response => {
this.tools = response.data.data || [];
})
.catch(error => {
this.showError("获取函数工具列表失败: " + error.message);
});
},
validateJson() {
try {
if (!this.serverConfigJson.trim()) {
this.jsonError = '配置不能为空';
return false;
}
JSON.parse(this.serverConfigJson);
this.jsonError = null;
return true;
} catch (e) {
this.jsonError = `JSON 格式错误: ${e.message}`;
return false;
}
},
setConfigTemplate() {
// 设置一个基本的配置模板
const template = {
command: "python",
args: ["-m", "your_module"],
// 可以添加其他 MCP 支持的配置项
};
this.serverConfigJson = JSON.stringify(template, null, 2);
},
saveServer() {
if (!this.validateJson()) {
return;
}
this.loading = true;
// 解析JSON配置并与基本信息合并
try {
const configObj = JSON.parse(this.serverConfigJson);
// 创建要发送的完整配置对象
const serverData = {
name: this.currentServer.name,
active: this.currentServer.active,
...configObj
};
const endpoint = this.isEditMode ? '/api/tools/mcp/update' : '/api/tools/mcp/add';
axios.post(endpoint, serverData)
.then(response => {
this.loading = false;
this.showMcpServerDialog = false;
this.getServers();
this.getTools();
this.showSuccess(response.data.message || "保存成功!");
this.resetForm();
})
.catch(error => {
this.loading = false;
this.showError("保存失败: " + (error.response?.data?.message || error.message));
});
} catch (e) {
this.loading = false;
this.showError(`JSON 解析错误: ${e.message}`);
}
},
deleteServer(serverName) {
if (confirm(`确定要删除服务器 ${serverName} 吗?`)) {
axios.post('/api/tools/mcp/delete', { name: serverName })
.then(response => {
this.getServers();
this.getTools();
this.showSuccess(response.data.message || "删除成功!");
})
.catch(error => {
this.showError("删除失败: " + (error.response?.data?.message || error.message));
});
}
},
editServer(server) {
// 创建一个不包含基本字段的配置对象副本
const configCopy = { ...server };
// 移除基本字段,只保留配置相关字段
delete configCopy.name;
delete configCopy.active;
delete configCopy.tools;
// 设置当前服务器的基本信息
this.currentServer = {
name: server.name,
active: server.active,
tools: server.tools || []
};
// 将剩余配置转换为JSON字符串
this.serverConfigJson = JSON.stringify(configCopy, null, 2);
this.isEditMode = true;
this.showMcpServerDialog = true;
},
updateServerStatus(server) {
axios.post('/api/tools/mcp/update', server)
.then(response => {
this.getServers();
this.showSuccess(response.data.message || "更新成功!");
})
.catch(error => {
this.showError("更新失败: " + (error.response?.data?.message || error.message));
// 回滚状态
server.active = !server.active;
});
},
closeServerDialog() {
this.showMcpServerDialog = false;
this.resetForm();
},
resetForm() {
this.currentServer = {
name: '',
active: true,
tools: []
};
this.serverConfigJson = '';
this.jsonError = null;
this.isEditMode = false;
},
showSuccess(message) {
this.save_message = message;
this.save_message_success = "success";
this.save_message_snack = true;
},
showError(message) {
this.save_message = message;
this.save_message_success = "error";
this.save_message_snack = true;
}
}
}
</script>
<style scoped>
.tools-page {
padding: 20px;
}
.server-card {
position: relative;
border-radius: 8px;
transition: all 0.3s ease;
overflow: hidden;
}
.server-status-indicator {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 4px;
background-color: #e0e0e0;
}
.server-status-indicator.active {
background-color: #4CAF50;
}
.hover-elevation:hover {
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.1);
transform: translateY(-2px);
}
.tool-chips {
max-height: 60px;
overflow-y: auto;
}
.tool-panel {
border: 1px solid rgba(0, 0, 0, 0.05);
transition: all 0.2s ease;
}
.tool-panel:hover {
border-color: rgba(0, 0, 0, 0.1);
}
.params-table {
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 8px;
}
.params-table th {
background-color: rgba(0, 0, 0, 0.02);
}
.monaco-container {
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 4px;
height: 300px;
overflow: hidden;
}
</style>
+2 -1
View File
@@ -24,4 +24,5 @@ cryptography
dashscope
python-telegram-bot
wechatpy
dingtalk-stream
dingtalk-stream
mcp