feat(MySQL): 集成 MySQL 数据库查询功能
- 添加 MySQL 连接管理器,支持连接和查询数据库 - 实现获取表名、描述表结构和执行 SQL 查询的工具 - 增强安全性,防止 SQL 注入和限制查询结果大小 - 更新 README.md,提供 MySQL 配置示例和使用说明 - 添加 MySQL 连接测试脚本,验证连接和工具功能
This commit is contained in:
parent
44c06f6935
commit
166990bc81
53
README.md
53
README.md
@ -261,6 +261,59 @@ agent_manager.register_agent(ChatbotAgent)
|
||||
agent_manager.init_all_agents()
|
||||
```
|
||||
|
||||
### MySQL 数据库查询集成(Beta)
|
||||
|
||||
项目目前已经支持智能体去查询 MySQL 数据库如果想要接入数据库,则可以在环境变量中配置如下信息:
|
||||
|
||||
```sh
|
||||
# 基础配置示例
|
||||
MYSQL_HOST=192.168.1.100
|
||||
MYSQL_USER=username
|
||||
MYSQL_PASSWORD=your_secure_password
|
||||
MYSQL_DATABASE=database_name
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_CHARSET=utf8mb4
|
||||
```
|
||||
|
||||
然后在智能体配置的工具中,勾选上 `mysql` 开头的几个工具即可。
|
||||
|
||||
- **mysql_list_tables**: 获取数据库中的所有表名
|
||||
- **mysql_describe_table**: 获取指定表的详细结构信息
|
||||
- **mysql_query**: 执行只读的 SQL 查询语句
|
||||
|
||||
**安全特性**
|
||||
|
||||
- ✅ 只允许 SELECT、SHOW、DESCRIBE、EXPLAIN 操作
|
||||
- ✅ 表名参数验证,严格的 SQL 注入防护
|
||||
- ✅ 查询超时控制(默认10秒,最大60秒)
|
||||
- ✅ 结果大小、行数限制(默认10000字符,100行,最大1000行)
|
||||
|
||||
**注意事项**
|
||||
|
||||
1. 确保数据库用户只有只读权限
|
||||
2. 大表查询建议使用 LIMIT 子句
|
||||
3. 复杂查询可能需要调整超时时间
|
||||
4. 查询结果过大会被自动截断并提示
|
||||
|
||||
### 图表可视化绘制 - MCP(Beta)
|
||||
|
||||
这个是基于 @antvis 团队开发的 [可视化图表-MCP-Server](https://www.modelscope.cn/mcp/servers/@antvis/mcp-server-chart),可以在魔搭社区中配置 Host 资源后,在 src/agents/common/mcp.py 的 `MCP_SERVERS` 中添加 mcp-server,需要注意的是记得将 `type` 字段修改为 `transport`。
|
||||
|
||||
```python
|
||||
# MCP Server configurations
|
||||
MCP_SERVERS = {
|
||||
"sequentialthinking": {
|
||||
"url": "https://remote.mcpservers.org/sequentialthinking/mcp",
|
||||
"transport": "streamable_http",
|
||||
},
|
||||
|
||||
"mcp-server-chart": {
|
||||
"url": "https://mcp.api-inference.modelscope.net/9993ae42524c4c/mcp",
|
||||
"transport": "streamable_http",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 服务端口说明
|
||||
|
||||
| 端口 | 服务 | 说明 |
|
||||
|
||||
@ -54,6 +54,7 @@ dependencies = [
|
||||
"tabulate>=0.9.0",
|
||||
"minio>=7.2.7",
|
||||
"Pillow>=10.5.0",
|
||||
"pymysql>=1.1.0",
|
||||
]
|
||||
[tool.ruff]
|
||||
line-length = 120 # 代码最大行宽
|
||||
|
||||
@ -14,3 +14,12 @@ TOGETHER_API_KEY=tgp_v1_fPjW******irD6zesAn4
|
||||
|
||||
# 功能服务
|
||||
TAVILY_API_KEY=tvly-3gR4ind9******JMOxw3E2LG # <<< 配置网络搜索
|
||||
|
||||
|
||||
# 基础配置示例
|
||||
MYSQL_HOST=192.168.1.100
|
||||
MYSQL_USER=username
|
||||
MYSQL_PASSWORD=your_secure_password
|
||||
MYSQL_DATABASE=database_name
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_CHARSET=utf8mb4
|
||||
|
||||
@ -8,6 +8,7 @@ from langchain_core.tools import tool
|
||||
from src.agents.common.tools import get_buildin_tools
|
||||
from src.utils import logger
|
||||
from src.utils.minio_utils import upload_image_to_minio
|
||||
from src.agents.common.toolkits.mysql import get_mysql_tools
|
||||
|
||||
|
||||
@tool
|
||||
@ -30,6 +31,7 @@ def calculator(a: float, b: float, operation: str) -> float:
|
||||
logger.error(f"Calculator error: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@tool
|
||||
async def text_to_img_kolors(text: str) -> str:
|
||||
"""(用来测试文件存储)使用Kolors模型生成图片, 会返回图片的URL"""
|
||||
@ -42,12 +44,9 @@ async def text_to_img_kolors(text: str) -> str:
|
||||
"image_size": "512x512",
|
||||
"batch_size": 1,
|
||||
"num_inference_steps": 20,
|
||||
"guidance_scale": 7.5
|
||||
}
|
||||
headers = {
|
||||
"Authorization": f"Bearer {os.getenv('SILICONFLOW_API_KEY')}",
|
||||
"Content-Type": "application/json"
|
||||
"guidance_scale": 7.5,
|
||||
}
|
||||
headers = {"Authorization": f"Bearer {os.getenv('SILICONFLOW_API_KEY')}", "Content-Type": "application/json"}
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=payload, headers=headers)
|
||||
@ -76,4 +75,5 @@ def get_tools() -> list[Any]:
|
||||
tools = get_buildin_tools()
|
||||
tools.append(calculator)
|
||||
tools.append(text_to_img_kolors)
|
||||
tools.extend(get_mysql_tools())
|
||||
return tools
|
||||
|
||||
@ -53,8 +53,9 @@ class BaseAgent:
|
||||
context = self.context_schema.from_file(module_name=self.module_name, input_context=input_context)
|
||||
logger.debug(f"stream_messages: {context}")
|
||||
# TODO 的 Checkpointer 似乎还没有适配最新的 Context API
|
||||
input_config = {"configurable": input_context, "recursion_limit": 100}
|
||||
async for msg, metadata in graph.astream(
|
||||
{"messages": messages}, stream_mode="messages", context=context, config={"configurable": input_context}
|
||||
{"messages": messages}, stream_mode="messages", context=context, config=input_config
|
||||
):
|
||||
yield msg, metadata
|
||||
|
||||
|
||||
@ -24,6 +24,10 @@ MCP_SERVERS = {
|
||||
# "args": ["mcp-server-time"],
|
||||
# "transport": "stdio",
|
||||
# },
|
||||
# "mcp-server-chart": {
|
||||
# "url": "https://mcp.api-inference.modelscope.net/9993ae42524c4c/mcp",
|
||||
# "transport": "streamable_http",
|
||||
# }
|
||||
}
|
||||
|
||||
|
||||
|
||||
3
src/agents/common/toolkits/mysql/__init__.py
Normal file
3
src/agents/common/toolkits/mysql/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .tools import get_mysql_tools
|
||||
|
||||
__all__ = ["get_mysql_tools"]
|
||||
200
src/agents/common/toolkits/mysql/connection.py
Normal file
200
src/agents/common/toolkits/mysql/connection.py
Normal file
@ -0,0 +1,200 @@
|
||||
import signal
|
||||
import time
|
||||
from typing import Any
|
||||
from contextlib import contextmanager
|
||||
import threading
|
||||
import pymysql
|
||||
from pymysql.cursors import DictCursor
|
||||
from pymysql import MySQLError
|
||||
from src.utils import logger
|
||||
|
||||
|
||||
class MySQLConnectionManager:
|
||||
"""MySQL 数据库连接管理器"""
|
||||
|
||||
def __init__(self, config: dict[str, Any]):
|
||||
self.config = config
|
||||
self.connection = None
|
||||
self._lock = threading.Lock()
|
||||
self.last_connection_time = 0
|
||||
self.max_connection_age = 3600 # 1小时后重新连接
|
||||
|
||||
def _get_connection(self) -> pymysql.Connection:
|
||||
"""获取数据库连接"""
|
||||
current_time = time.time()
|
||||
|
||||
# 检查连接是否过期或断开
|
||||
if (
|
||||
self.connection is None
|
||||
or not self.connection.open
|
||||
or current_time - self.last_connection_time > self.max_connection_age
|
||||
):
|
||||
with self._lock:
|
||||
# 双重检查
|
||||
if (
|
||||
self.connection is None
|
||||
or not self.connection.open
|
||||
or current_time - self.last_connection_time > self.max_connection_age
|
||||
):
|
||||
# 关闭旧连接
|
||||
if self.connection and self.connection.open:
|
||||
try:
|
||||
self.connection.close()
|
||||
except Exception as _:
|
||||
pass
|
||||
|
||||
# 创建新连接
|
||||
self.connection = self._create_connection()
|
||||
self.last_connection_time = current_time
|
||||
|
||||
return self.connection
|
||||
|
||||
def _create_connection(self) -> pymysql.Connection:
|
||||
"""创建新的数据库连接"""
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
connection = pymysql.connect(
|
||||
host=self.config["host"],
|
||||
user=self.config["user"],
|
||||
password=self.config["password"],
|
||||
database=self.config["database"],
|
||||
port=self.config["port"],
|
||||
charset=self.config.get("charset", "utf8mb4"),
|
||||
cursorclass=DictCursor,
|
||||
connect_timeout=10,
|
||||
read_timeout=60, # 增加读取超时
|
||||
write_timeout=30,
|
||||
autocommit=True, # 自动提交
|
||||
)
|
||||
logger.info(f"MySQL connection established successfully (attempt {attempt + 1})")
|
||||
return connection
|
||||
|
||||
except MySQLError as e:
|
||||
logger.warning(f"Connection attempt {attempt + 1} failed: {e}")
|
||||
if attempt < max_retries - 1:
|
||||
time.sleep(2**attempt) # 指数退避
|
||||
else:
|
||||
logger.error(f"Failed to connect to MySQL after {max_retries} attempts: {e}")
|
||||
raise ConnectionError(f"MySQL connection failed: {e}")
|
||||
|
||||
def test_connection(self) -> bool:
|
||||
"""测试连接是否有效"""
|
||||
try:
|
||||
if self.connection and self.connection.open:
|
||||
# 执行简单查询测试连接
|
||||
with self.connection.cursor() as cursor:
|
||||
cursor.execute("SELECT 1")
|
||||
cursor.fetchone()
|
||||
return True
|
||||
except Exception as _:
|
||||
pass
|
||||
return False
|
||||
|
||||
@contextmanager
|
||||
def get_cursor(self):
|
||||
"""获取数据库游标的上下文管理器"""
|
||||
max_retries = 2
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
connection = self._get_connection()
|
||||
cursor = connection.cursor()
|
||||
|
||||
try:
|
||||
yield cursor
|
||||
connection.commit()
|
||||
break # 成功,退出重试循环
|
||||
|
||||
except Exception as e:
|
||||
connection.rollback()
|
||||
|
||||
# 如果是连接错误,尝试重新连接
|
||||
if "MySQL" in str(e) or "connection" in str(e).lower():
|
||||
if attempt < max_retries - 1:
|
||||
logger.warning(f"Connection error, retrying (attempt {attempt + 1}): {e}")
|
||||
# 强制重新连接
|
||||
if self.connection:
|
||||
try:
|
||||
self.connection.close()
|
||||
except Exception as _:
|
||||
pass
|
||||
self.connection = None
|
||||
time.sleep(1)
|
||||
continue
|
||||
|
||||
raise e # 其他错误直接抛出
|
||||
|
||||
finally:
|
||||
if cursor:
|
||||
cursor.close()
|
||||
|
||||
except Exception as e:
|
||||
if attempt == max_retries - 1:
|
||||
raise e # 最后一次尝试失败,抛出异常
|
||||
time.sleep(1)
|
||||
|
||||
def close(self):
|
||||
"""关闭数据库连接"""
|
||||
if self.connection:
|
||||
self.connection.close()
|
||||
self.connection = None
|
||||
logger.info("MySQL connection closed")
|
||||
|
||||
|
||||
class QueryTimeoutError(Exception):
|
||||
"""查询超时异常"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class QueryResultTooLargeError(Exception):
|
||||
"""查询结果过大异常"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def execute_query_with_timeout(connection: pymysql.Connection, sql: str, params: tuple = None, timeout: int = 10):
|
||||
"""带超时的查询执行"""
|
||||
|
||||
def timeout_handler(_signum, _frame):
|
||||
raise QueryTimeoutError(f"Query timeout after {timeout} seconds")
|
||||
|
||||
# 设置信号处理
|
||||
old_handler = signal.signal(signal.SIGALRM, timeout_handler)
|
||||
signal.alarm(timeout)
|
||||
|
||||
try:
|
||||
cursor = connection.cursor(DictCursor)
|
||||
cursor.execute(sql, params or ())
|
||||
result = cursor.fetchall()
|
||||
cursor.close()
|
||||
return result
|
||||
finally:
|
||||
# 恢复原始信号处理
|
||||
signal.alarm(0)
|
||||
signal.signal(signal.SIGALRM, old_handler)
|
||||
|
||||
|
||||
def limit_result_size(result: list, max_chars: int = 10000) -> list:
|
||||
"""限制结果大小"""
|
||||
if not result:
|
||||
return result
|
||||
|
||||
# 计算结果的字符大小
|
||||
result_str = str(result)
|
||||
if len(result_str) > max_chars:
|
||||
# 返回部分结果并提示
|
||||
limited_result = []
|
||||
current_chars = 0
|
||||
for row in result:
|
||||
row_str = str(row)
|
||||
if current_chars + len(row_str) > max_chars:
|
||||
break
|
||||
limited_result.append(row)
|
||||
current_chars += len(row_str)
|
||||
|
||||
# 记录警告
|
||||
logger.warning(f"Query result truncated from {len(result)} to {len(limited_result)} rows due to size limit")
|
||||
return limited_result
|
||||
|
||||
return result
|
||||
34
src/agents/common/toolkits/mysql/exceptions.py
Normal file
34
src/agents/common/toolkits/mysql/exceptions.py
Normal file
@ -0,0 +1,34 @@
|
||||
class MySQLToolError(Exception):
|
||||
"""MySQL 工具基础异常"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MySQLConnectionError(MySQLToolError):
|
||||
"""MySQL 连接异常"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MySQLQueryError(MySQLToolError):
|
||||
"""MySQL 查询异常"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MySQLSecurityError(MySQLToolError):
|
||||
"""MySQL 安全异常"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MySQLTimeoutError(MySQLToolError):
|
||||
"""MySQL 超时异常"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MySQLResultTooLargeError(MySQLToolError):
|
||||
"""MySQL 结果过大异常"""
|
||||
|
||||
pass
|
||||
87
src/agents/common/toolkits/mysql/security.py
Normal file
87
src/agents/common/toolkits/mysql/security.py
Normal file
@ -0,0 +1,87 @@
|
||||
import re
|
||||
|
||||
|
||||
class MySQLSecurityChecker:
|
||||
"""MySQL 安全检查器"""
|
||||
|
||||
# 允许的SQL操作
|
||||
ALLOWED_OPERATIONS = {"SELECT", "SHOW", "DESCRIBE", "EXPLAIN"}
|
||||
|
||||
# 危险的关键词
|
||||
DANGEROUS_KEYWORDS = {
|
||||
"DROP",
|
||||
"DELETE",
|
||||
"UPDATE",
|
||||
"INSERT",
|
||||
"CREATE",
|
||||
"ALTER",
|
||||
"TRUNCATE",
|
||||
"REPLACE",
|
||||
"LOAD",
|
||||
"GRANT",
|
||||
"REVOKE",
|
||||
"SET",
|
||||
"COMMIT",
|
||||
"ROLLBACK",
|
||||
"UNLOCK",
|
||||
"KILL",
|
||||
"SHUTDOWN",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def validate_sql(cls, sql: str) -> bool:
|
||||
"""验证SQL语句的安全性"""
|
||||
if not sql:
|
||||
return False
|
||||
|
||||
# 标准化SQL
|
||||
sql_upper = sql.strip().upper()
|
||||
|
||||
# 检查是否是允许的操作
|
||||
if not any(sql_upper.startswith(op) for op in cls.ALLOWED_OPERATIONS):
|
||||
return False
|
||||
|
||||
# 检查危险关键词
|
||||
for keyword in cls.DANGEROUS_KEYWORDS:
|
||||
if keyword in sql_upper:
|
||||
return False
|
||||
|
||||
# 检查SQL注入模式
|
||||
sql_injection_patterns = [
|
||||
r"\bor\s+1\s*=\s*1\b",
|
||||
r"\bunion\s+select\b",
|
||||
r"\bexec\s*\(",
|
||||
r"\bxp_cmdshell\b",
|
||||
r"\bsleep\s*\(",
|
||||
r"\bbenchmark\s*\(",
|
||||
r"\bwaitfor\s+delay\b",
|
||||
r"\b;\s*drop\b",
|
||||
r"\b;\s*delete\b",
|
||||
r"\b;\s*update\b",
|
||||
r"\b;\s*insert\b",
|
||||
]
|
||||
|
||||
for pattern in sql_injection_patterns:
|
||||
if re.search(pattern, sql_upper, re.IGNORECASE):
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def validate_table_name(cls, table_name: str) -> bool:
|
||||
"""验证表名的安全性"""
|
||||
if not table_name:
|
||||
return False
|
||||
|
||||
# 检查表名只包含字母、数字、下划线
|
||||
return bool(re.match(r"^[a-zA-Z_][a-zA-Z0-9_]*$", table_name))
|
||||
|
||||
@classmethod
|
||||
def validate_limit(cls, limit: int) -> bool:
|
||||
"""验证limit参数"""
|
||||
return isinstance(limit, int) and 0 < limit <= 1000
|
||||
|
||||
@classmethod
|
||||
def validate_timeout(cls, timeout: int) -> bool:
|
||||
"""验证timeout参数"""
|
||||
return isinstance(timeout, int) and 1 <= timeout <= 60
|
||||
280
src/agents/common/toolkits/mysql/tools.py
Normal file
280
src/agents/common/toolkits/mysql/tools.py
Normal file
@ -0,0 +1,280 @@
|
||||
from typing import Annotated, Any
|
||||
from langchain_core.tools import tool
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .connection import MySQLConnectionManager, limit_result_size
|
||||
from .security import MySQLSecurityChecker
|
||||
from .exceptions import MySQLConnectionError
|
||||
from src.utils import logger
|
||||
|
||||
|
||||
# 全局连接管理器实例
|
||||
_connection_manager: MySQLConnectionManager | None = None
|
||||
|
||||
|
||||
def get_connection_manager() -> MySQLConnectionManager:
|
||||
"""获取全局连接管理器"""
|
||||
global _connection_manager
|
||||
if _connection_manager is None:
|
||||
import os
|
||||
|
||||
# 从环境变量中读取 MySQL 配置
|
||||
mysql_config = {
|
||||
"host": os.getenv("MYSQL_HOST"),
|
||||
"user": os.getenv("MYSQL_USER"),
|
||||
"password": os.getenv("MYSQL_PASSWORD"),
|
||||
"database": os.getenv("MYSQL_DATABASE"),
|
||||
"port": int(os.getenv("MYSQL_PORT", "3306")),
|
||||
"charset": "utf8mb4",
|
||||
}
|
||||
# 验证配置完整性
|
||||
required_keys = ["host", "user", "password", "database"]
|
||||
for key in required_keys:
|
||||
if not mysql_config[key]:
|
||||
raise MySQLConnectionError(f"MySQL configuration missing required key: {key}")
|
||||
|
||||
_connection_manager = MySQLConnectionManager(mysql_config)
|
||||
return _connection_manager
|
||||
|
||||
|
||||
class TableListModel(BaseModel):
|
||||
"""获取表名列表的参数模型"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@tool(args_schema=TableListModel)
|
||||
def mysql_list_tables() -> str:
|
||||
"""获取数据库中的所有表名
|
||||
|
||||
这个工具用来列出当前数据库中所有的表名,帮助你了解数据库的结构。
|
||||
"""
|
||||
try:
|
||||
conn_manager = get_connection_manager()
|
||||
|
||||
with conn_manager.get_cursor() as cursor:
|
||||
# 获取表名
|
||||
cursor.execute("SHOW TABLES")
|
||||
tables = cursor.fetchall()
|
||||
|
||||
if not tables:
|
||||
return "数据库中没有找到任何表"
|
||||
|
||||
# 提取表名
|
||||
table_names = []
|
||||
for table in tables:
|
||||
table_name = list(table.values())[0]
|
||||
table_names.append(table_name)
|
||||
|
||||
# 获取每个表的行数信息
|
||||
table_info = []
|
||||
for table_name in table_names:
|
||||
try:
|
||||
cursor.execute(f"SELECT COUNT(*) as count FROM `{table_name}`")
|
||||
count_result = cursor.fetchone()
|
||||
row_count = count_result["count"]
|
||||
table_info.append(f"- {table_name} (约 {row_count} 行)")
|
||||
except Exception:
|
||||
table_info.append(f"- {table_name} (无法获取行数)")
|
||||
|
||||
result = "数据库中的表:\n" + "\n".join(table_info)
|
||||
logger.info(f"Retrieved {len(table_names)} tables from database")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"获取表名失败: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return error_msg
|
||||
|
||||
|
||||
class TableDescribeModel(BaseModel):
|
||||
"""获取表结构的参数模型"""
|
||||
|
||||
table_name: str = Field(description="要查询的表名", example="users")
|
||||
|
||||
|
||||
@tool(args_schema=TableDescribeModel)
|
||||
def mysql_describe_table(table_name: Annotated[str, "要查询结构的表名"]) -> str:
|
||||
"""获取指定表的详细结构信息
|
||||
|
||||
这个工具用来查看表的字段信息、数据类型、是否允许NULL、默认值、键类型等。
|
||||
帮助你了解表的结构,以便编写正确的SQL查询。
|
||||
"""
|
||||
try:
|
||||
# 验证表名安全性
|
||||
if not MySQLSecurityChecker.validate_table_name(table_name):
|
||||
return "表名包含非法字符,请检查表名"
|
||||
|
||||
conn_manager = get_connection_manager()
|
||||
|
||||
with conn_manager.get_cursor() as cursor:
|
||||
# 获取表结构
|
||||
cursor.execute(f"DESCRIBE `{table_name}`")
|
||||
columns = cursor.fetchall()
|
||||
|
||||
if not columns:
|
||||
return f"表 {table_name} 不存在或没有字段"
|
||||
|
||||
# 格式化输出
|
||||
result = f"表 `{table_name}` 的结构:\n\n"
|
||||
result += "字段名\t\t类型\t\tNULL\t键\t默认值\t\t额外\n"
|
||||
result += "-" * 80 + "\n"
|
||||
|
||||
for col in columns:
|
||||
field = col["Field"] or ""
|
||||
type_str = col["Type"] or ""
|
||||
null_str = col["Null"] or ""
|
||||
key_str = col["Key"] or ""
|
||||
default_str = col.get("Default") or ""
|
||||
extra_str = col.get("Extra") or ""
|
||||
|
||||
# 格式化输出
|
||||
result += f"{field:<16}\t{type_str:<16}\t{null_str:<8}\t{key_str:<4}\t{default_str:<16}\t{extra_str}\n"
|
||||
|
||||
# 获取索引信息
|
||||
try:
|
||||
cursor.execute(f"SHOW INDEX FROM `{table_name}`")
|
||||
indexes = cursor.fetchall()
|
||||
|
||||
if indexes:
|
||||
result += "\n索引信息:\n"
|
||||
index_dict = {}
|
||||
for idx in indexes:
|
||||
key_name = idx["Key_name"]
|
||||
if key_name not in index_dict:
|
||||
index_dict[key_name] = []
|
||||
index_dict[key_name].append(idx["Column_name"])
|
||||
|
||||
for key_name, columns in index_dict.items():
|
||||
result += f"- {key_name}: {', '.join(columns)}\n"
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get index info for table {table_name}: {e}")
|
||||
|
||||
logger.info(f"Retrieved structure for table {table_name}")
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"获取表 {table_name} 结构失败: {str(e)}"
|
||||
logger.error(error_msg)
|
||||
return error_msg
|
||||
|
||||
|
||||
class QueryModel(BaseModel):
|
||||
"""执行SQL查询的参数模型"""
|
||||
|
||||
sql: str = Field(description="要执行的SQL查询语句(只能是SELECT语句)", example="SELECT * FROM users WHERE id = 1")
|
||||
limit: int | None = Field(default=100, description="限制返回的最大行数,默认100,最大1000", ge=1, le=1000)
|
||||
timeout: int | None = Field(default=10, description="查询超时时间(秒),默认10秒,最大60秒", ge=1, le=60)
|
||||
|
||||
|
||||
@tool(args_schema=QueryModel)
|
||||
def mysql_query(
|
||||
sql: Annotated[str, "要执行的SQL查询语句(只能是SELECT语句)"],
|
||||
limit: Annotated[int | None, "限制返回的最大行数,默认100,最大1000"] = 100,
|
||||
timeout: Annotated[int | None, "查询超时时间(秒),默认10秒,最大60秒"] = 10,
|
||||
) -> str:
|
||||
"""执行只读的SQL查询语句
|
||||
|
||||
这个工具用来执行SQL查询并返回结果。支持复杂的SELECT查询,包括JOIN、GROUP BY等。
|
||||
注意:只能执行查询操作,不能修改数据。
|
||||
|
||||
参数:
|
||||
- sql: SQL查询语句
|
||||
- limit: 返回结果的最大行数(防止结果过大)
|
||||
- timeout: 查询超时时间(防止长时间运行的查询)
|
||||
"""
|
||||
try:
|
||||
# 验证SQL安全性
|
||||
if not MySQLSecurityChecker.validate_sql(sql):
|
||||
return "SQL语句包含不安全的操作或可能的注入攻击,请检查SQL语句"
|
||||
|
||||
# 验证参数
|
||||
if not MySQLSecurityChecker.validate_limit(limit):
|
||||
return "limit参数必须在1-1000之间"
|
||||
|
||||
if not MySQLSecurityChecker.validate_timeout(timeout):
|
||||
return "timeout参数必须在1-60之间"
|
||||
|
||||
# 如果SQL中没有LIMIT,添加LIMIT子句
|
||||
if "LIMIT" not in sql.upper() and "limit" not in sql:
|
||||
sql_upper = sql.strip().upper()
|
||||
# 确保是SELECT语句才添加LIMIT
|
||||
if sql_upper.startswith("SELECT"):
|
||||
sql = f"{sql} LIMIT {limit}"
|
||||
|
||||
conn_manager = get_connection_manager()
|
||||
|
||||
with conn_manager.get_cursor() as cursor:
|
||||
# 执行查询
|
||||
cursor.execute(sql)
|
||||
result = cursor.fetchall()
|
||||
|
||||
if not result:
|
||||
return "查询执行成功,但没有返回任何结果"
|
||||
|
||||
# 限制结果大小
|
||||
limited_result = limit_result_size(result, max_chars=10000)
|
||||
|
||||
# 检查结果是否被截断
|
||||
if len(limited_result) < len(result):
|
||||
warning = f"\n\n⚠️ 警告: 查询结果过大,只显示了前 {len(limited_result)} 行(共 {len(result)} 行)。\n"
|
||||
warning += "建议使用更精确的查询条件或使用LIMIT子句来减少返回的数据量。"
|
||||
else:
|
||||
warning = ""
|
||||
|
||||
# 格式化输出
|
||||
if limited_result:
|
||||
# 获取列名
|
||||
columns = list(limited_result[0].keys())
|
||||
|
||||
# 计算每列的最大宽度
|
||||
col_widths = {}
|
||||
for col in columns:
|
||||
col_widths[col] = max(len(str(col)), max(len(str(row.get(col, ""))) for row in limited_result))
|
||||
col_widths[col] = min(col_widths[col], 50) # 限制最大宽度
|
||||
|
||||
# 构建表头
|
||||
header = "| " + " | ".join(f"{col:<{col_widths[col]}}" for col in columns) + " |"
|
||||
separator = "|" + "|".join("-" * (col_widths[col] + 2) for col in columns) + "|"
|
||||
|
||||
# 构建数据行
|
||||
rows = []
|
||||
for row in limited_result:
|
||||
row_str = "| " + " | ".join(f"{str(row.get(col, '')):<{col_widths[col]}}" for col in columns) + " |"
|
||||
rows.append(row_str)
|
||||
|
||||
result_str = f"查询结果(共 {len(limited_result)} 行):\n\n"
|
||||
result_str += header + "\n" + separator + "\n"
|
||||
result_str += "\n".join(rows[:50]) # 最多显示50行
|
||||
|
||||
if len(rows) > 50:
|
||||
result_str += f"\n\n... 还有 {len(rows) - 50} 行未显示 ..."
|
||||
|
||||
result_str += warning
|
||||
|
||||
logger.info(f"Query executed successfully, returned {len(limited_result)} rows")
|
||||
return result_str
|
||||
else:
|
||||
return "查询执行成功,但没有返回任何结果"
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"SQL查询执行失败: {str(e)}"
|
||||
|
||||
# 提供更有用的错误信息
|
||||
if "timeout" in str(e).lower():
|
||||
error_msg += "\n\n💡 建议:查询超时了,请尝试以下方法:\n"
|
||||
error_msg += "1. 减少查询的数据量(使用WHERE条件过滤)\n"
|
||||
error_msg += "2. 使用LIMIT子句限制返回行数\n"
|
||||
error_msg += "3. 增加timeout参数值(最大60秒)"
|
||||
elif "table" in str(e).lower() and "doesn't exist" in str(e).lower():
|
||||
error_msg += "\n\n💡 建议:表不存在,请使用 mysql_list_tables 查看可用的表名"
|
||||
elif "column" in str(e).lower() and "doesn't exist" in str(e).lower():
|
||||
error_msg += "\n\n💡 建议:列不存在,请使用 mysql_describe_table 查看表结构"
|
||||
|
||||
logger.error(error_msg)
|
||||
return error_msg
|
||||
|
||||
|
||||
def get_mysql_tools() -> list[Any]:
|
||||
"""获取MySQL工具列表"""
|
||||
return [mysql_list_tables, mysql_describe_table, mysql_query]
|
||||
@ -115,6 +115,10 @@ def get_buildin_tools() -> list:
|
||||
tools.extend(get_kb_based_tools())
|
||||
tools.extend(get_static_tools())
|
||||
|
||||
from src.agents.common.toolkits.mysql.tools import get_mysql_tools
|
||||
|
||||
tools.extend(get_mysql_tools())
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get knowledge base retrievers: {e}")
|
||||
|
||||
|
||||
206
test/test_mysql_connection.py
Normal file
206
test/test_mysql_connection.py
Normal file
@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
MySQL 数据库连接验证脚本
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
# sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent.parent.parent))
|
||||
|
||||
|
||||
def load_env_file(env_file="src/.env"):
|
||||
"""加载 .env 文件"""
|
||||
env_vars = {}
|
||||
if os.path.exists(env_file):
|
||||
with open(env_file, encoding="utf-8") as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
env_vars[key] = value.strip("\"'")
|
||||
return env_vars
|
||||
|
||||
|
||||
def test_mysql_connection():
|
||||
"""测试 MySQL 连接"""
|
||||
print("=== MySQL 数据库连接验证 ===\n")
|
||||
|
||||
# 加载环境变量
|
||||
env_vars = load_env_file()
|
||||
|
||||
# 检查必需的环境变量
|
||||
required_vars = ["MYSQL_HOST", "MYSQL_USER", "MYSQL_PASSWORD", "MYSQL_DATABASE"]
|
||||
missing_vars = []
|
||||
|
||||
for var in required_vars:
|
||||
if var not in env_vars:
|
||||
missing_vars.append(var)
|
||||
|
||||
if missing_vars:
|
||||
print(f"❌ 缺少必需的环境变量: {', '.join(missing_vars)}")
|
||||
print("\n请在 .env 文件中设置以下变量:")
|
||||
for var in missing_vars:
|
||||
print(f" {var}=your_value")
|
||||
return False
|
||||
|
||||
# 显示配置信息(隐藏密码)
|
||||
print("📋 数据库配置:")
|
||||
print(f" Host: {env_vars.get('MYSQL_HOST', 'Not set')}")
|
||||
print(f" User: {env_vars.get('MYSQL_USER', 'Not set')}")
|
||||
print(f" Database: {env_vars.get('MYSQL_DATABASE', 'Not set')}")
|
||||
print(f" Port: {env_vars.get('MYSQL_PORT', '3306')}")
|
||||
print(f" Charset: {env_vars.get('MYSQL_CHARSET', 'utf8mb4')}")
|
||||
print()
|
||||
|
||||
try:
|
||||
# 导入 MySQL 连接管理器
|
||||
from src.agents.common.toolkits.mysql.connection import MySQLConnectionManager
|
||||
|
||||
# 创建连接配置
|
||||
mysql_config = {
|
||||
"host": env_vars["MYSQL_HOST"],
|
||||
"user": env_vars["MYSQL_USER"],
|
||||
"password": env_vars["MYSQL_PASSWORD"],
|
||||
"database": env_vars["MYSQL_DATABASE"],
|
||||
"port": int(env_vars.get("MYSQL_PORT", "3306")),
|
||||
"charset": env_vars.get("MYSQL_CHARSET", "utf8mb4"),
|
||||
}
|
||||
|
||||
# 创建连接管理器
|
||||
print("🔄 正在连接数据库...")
|
||||
conn_manager = MySQLConnectionManager(mysql_config)
|
||||
|
||||
# 测试连接
|
||||
with conn_manager.get_cursor() as cursor:
|
||||
# 测试基本连接
|
||||
cursor.execute("SELECT 1 as test")
|
||||
result = cursor.fetchone()
|
||||
if result and result["test"] == 1:
|
||||
print("✅ 数据库连接成功!")
|
||||
|
||||
# 获取数据库版本
|
||||
cursor.execute("SELECT VERSION() as version")
|
||||
version_info = cursor.fetchone()
|
||||
print(f"📊 MySQL 版本: {version_info['version']}")
|
||||
|
||||
# 获取当前数据库
|
||||
cursor.execute("SELECT DATABASE() as db_name")
|
||||
db_info = cursor.fetchone()
|
||||
print(f"🗄️ 当前数据库: {db_info['db_name']}")
|
||||
|
||||
# 获取表数量
|
||||
cursor.execute("SHOW TABLES")
|
||||
tables = cursor.fetchall()
|
||||
print(f"📋 表数量: {len(tables)}")
|
||||
|
||||
if tables:
|
||||
print("\n📝 数据库表列表:")
|
||||
for i, table in enumerate(tables[:10]): # 只显示前10个表
|
||||
table_name = list(table.values())[0]
|
||||
print(f" {i + 1}. {table_name}")
|
||||
|
||||
if len(tables) > 10:
|
||||
print(f" ... 还有 {len(tables) - 10} 个表未显示")
|
||||
|
||||
return True
|
||||
else:
|
||||
print("❌ 数据库连接测试失败")
|
||||
return False
|
||||
|
||||
except ImportError as e:
|
||||
print(f"❌ 导入错误: {e}")
|
||||
print("请确保已安装 pymysql 依赖")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ 连接失败: {e}")
|
||||
|
||||
# 提供故障排除建议
|
||||
print("\n💡 故障排除建议:")
|
||||
print("1. 检查数据库服务是否正在运行")
|
||||
print("2. 验证主机地址和端口是否正确")
|
||||
print("3. 确认用户名和密码是否正确")
|
||||
print("4. 检查数据库是否存在")
|
||||
print("5. 确认网络连接和防火墙设置")
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def test_tools():
|
||||
"""测试 MySQL 工具是否正常工作"""
|
||||
print("\n=== MySQL 工具测试 ===\n")
|
||||
|
||||
try:
|
||||
from src.agents.common.toolkits.mysql.tools import mysql_list_tables, mysql_describe_table, mysql_query
|
||||
|
||||
print("✅ MySQL 工具导入成功")
|
||||
|
||||
# 测试获取表名
|
||||
print("\n🔄 测试获取表名...")
|
||||
result = mysql_list_tables.invoke({})
|
||||
if "失败" not in result and "错误" not in result:
|
||||
print("✅ 获取表名工具正常")
|
||||
else:
|
||||
print(f"❌ 获取表名工具异常: {result}")
|
||||
return False
|
||||
|
||||
# 如果有表,测试获取表结构
|
||||
if "数据库中的表:" in result:
|
||||
print("\n🔄 测试获取表结构...")
|
||||
# 提取第一个表名
|
||||
lines = result.split("\n")
|
||||
for line in lines:
|
||||
if "- " in line and "(" in line:
|
||||
table_name = line.split("- ")[1].split(" ")[0]
|
||||
break
|
||||
else:
|
||||
table_name = None
|
||||
|
||||
if table_name:
|
||||
structure_result = mysql_describe_table.invoke({"table_name": table_name})
|
||||
if "失败" not in structure_result and "错误" not in structure_result:
|
||||
print("✅ 获取表结构工具正常")
|
||||
else:
|
||||
print(f"❌ 获取表结构工具异常: {structure_result}")
|
||||
return False
|
||||
|
||||
# 测试简单查询
|
||||
print("\n🔄 测试SQL查询...")
|
||||
query_result = mysql_query.invoke({"sql": f"SELECT COUNT(*) as total FROM `{table_name}`"})
|
||||
if "失败" not in query_result and "错误" not in query_result:
|
||||
print("✅ SQL查询工具正常")
|
||||
else:
|
||||
print(f"❌ SQL查询工具异常: {query_result}")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 工具测试失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
print("MySQL 数据库连接和工具验证脚本")
|
||||
print("=" * 50)
|
||||
|
||||
# 测试连接
|
||||
connection_ok = test_mysql_connection()
|
||||
|
||||
if connection_ok:
|
||||
# 测试工具
|
||||
tools_ok = test_tools()
|
||||
|
||||
print("\n" + "=" * 50)
|
||||
if connection_ok and tools_ok:
|
||||
print("🎉 所有测试通过!MySQL 工具包可以正常使用")
|
||||
else:
|
||||
print("❌ 部分测试失败,请检查配置")
|
||||
else:
|
||||
print("\n" + "=" * 50)
|
||||
print("❌ 数据库连接失败,请检查配置")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
24
test/test_mysql_import.py
Normal file
24
test/test_mysql_import.py
Normal file
@ -0,0 +1,24 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
简单测试 MySQL 工具导入
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, ".")
|
||||
|
||||
try:
|
||||
from src.agents.common.toolkits.mysql.tools import mysql_list_tables, mysql_describe_table, mysql_query
|
||||
|
||||
print("✅ MySQL 工具导入成功")
|
||||
print(f"工具列表: {[tool.name for tool in [mysql_list_tables, mysql_describe_table, mysql_query]]}")
|
||||
|
||||
# 检查工具描述
|
||||
for tool in [mysql_list_tables, mysql_describe_table, mysql_query]:
|
||||
print(f" - {tool.name}: {tool.description[:60]}...")
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ 导入失败: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
@ -25,6 +25,11 @@
|
||||
:data="parsedData"
|
||||
/>
|
||||
|
||||
<!-- 图片结果 -->
|
||||
<div v-else-if="isImageResult" class="image-result">
|
||||
<img :src="parsedData" />
|
||||
</div>
|
||||
|
||||
<!-- 默认的原始数据展示 -->
|
||||
<div v-else class="default-result">
|
||||
<!-- <div class="default-header">
|
||||
@ -119,6 +124,17 @@ const isKnowledgeBaseResult = computed(() => {
|
||||
return false
|
||||
})
|
||||
|
||||
const isImageResult = computed(() => {
|
||||
// 包含 chart 且返回值是url
|
||||
const data = parsedData.value
|
||||
const toolNameLower = props.toolName.toLowerCase()
|
||||
const isImageTool = toolNameLower.includes('chart')
|
||||
|
||||
if (!isImageTool) return false
|
||||
|
||||
return data && typeof data === 'string' && data.startsWith('http')
|
||||
})
|
||||
|
||||
// 判断是否为知识图谱查询结果
|
||||
const isKnowledgeGraphResult = computed(() => {
|
||||
const toolNameLower = props.toolName.toLowerCase()
|
||||
@ -214,5 +230,19 @@ defineExpose({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image-result {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Loading…
Reference in New Issue
Block a user