Files
aiagent/backend/app/services/builtin_tools.py
renjianbo d0b55f2b16 feat: expose graph orchestration mode, fix pipeline multi-agent, add Feishu tools (Phase 3)
增强编排 + 飞书深度集成:
- Graph 模式:暴露 orchestrator._graph() 到 run() 方法,workflow_integration 支持 graph nodes/edges
- Pipeline 修复:多 Agent 按步骤轮转分配,不再只用 agents[0]
- 4个飞书操作工具: feishu_create_doc / feishu_create_calendar_event / feishu_search_contacts / feishu_send_approval
- 飞书 @mention→Goal:feishu/ orange WS handler 支持 "目标: xxx" 触发自动创建 Goal

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 20:08:26 +08:00

5773 lines
224 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
内置工具实现
"""
from typing import Dict, Any, Optional, List, Tuple
from pathlib import Path
import httpx
import json
import os
import logging
import re
import math
from datetime import datetime, timedelta
import platform
import sys
import asyncio
import subprocess
import shlex
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from app.core.config import settings
logger = logging.getLogger(__name__)
def _local_file_workspace_root() -> Path:
"""file_read/file_write 允许操作的根目录(解析后路径)。"""
raw = (getattr(settings, "LOCAL_FILE_TOOLS_ROOT", None) or "").strip()
if raw:
return Path(raw).expanduser().resolve()
# backend/app/services/builtin_tools.py -> 上级 x4 = 仓库根(与历史上 join(services_dir, '../../..') 一致)
return Path(__file__).resolve().parent.parent.parent.parent
def _sanitize_tool_path_string(file_path: str) -> str:
"""
去掉 LLM/DSML/Markdown 泄漏到路径末尾的非法字符(如 123.md<|、引号),避免 WinError 123。
"""
if not isinstance(file_path, str):
return str(file_path)
s = file_path.replace("\ufeff", "").strip()
s = s.strip('"').strip("'")
# 行尾非法Windows 文件名不能含 <>:"|?*;模型常把半角/全角尖括号粘在扩展名后
tail_bad = '<>"|?*'
while s and s[-1] in tail_bad:
s = s[:-1].rstrip()
# 若仍含未配对尖括号片段在行尾(如 xxx.md<
s = re.sub(r'[<][|]?\s*$', "", s).rstrip()
return s
def _resolve_path_under_workspace(file_path: str) -> Tuple[Optional[Path], Optional[str]]:
"""将用户给定路径解析为绝对路径,且必须位于工作区内。"""
file_path = _sanitize_tool_path_string(file_path)
root = _local_file_workspace_root()
try:
p = Path(file_path).expanduser()
if not p.is_absolute():
p = (root / p).resolve()
else:
p = p.resolve()
except (OSError, ValueError) as e:
return None, str(e)
try:
p.relative_to(root)
except ValueError:
return None, f"不允许访问工作区外路径,允许根目录: {root}"
return p, None
_HTTP_RESPONSE_HEADER_ALLOWLIST = frozenset(
{
"content-type",
"content-length",
"content-encoding",
"date",
"last-modified",
"location",
"server",
"cache-control",
}
)
def _compact_http_response_headers(response: httpx.Response) -> Dict[str, str]:
"""减少 Set-Cookie 等大字段进入 LLM 上下文。"""
out: Dict[str, str] = {}
for key, value in response.headers.multi_items():
lk = key.lower()
if lk in ("set-cookie", "cookie"):
continue
if lk in _HTTP_RESPONSE_HEADER_ALLOWLIST:
out[key] = value if len(value) <= 2048 else value[:2048] + "..."
if not out:
n = 0
for key, value in response.headers.multi_items():
lk = key.lower()
if lk in ("set-cookie", "cookie"):
continue
if n >= 12:
break
out[key] = value if len(value) <= 512 else value[:512] + "..."
n += 1
return out
def _truncate_http_body_for_tool(body: Any, max_chars: int) -> Tuple[Any, bool, Optional[str]]:
"""
将 HTTP 正文限制在 max_chars 字符以内,避免门户首页等大 HTML 撑爆模型 context。
返回 (可能被截断后的 body, 是否截断, 说明文案)
"""
if max_chars <= 0:
max_chars = 32_000
note: Optional[str] = None
if isinstance(body, str):
if len(body) <= max_chars:
return body, False, None
note = (
f"正文已截断:原始约 {len(body)} 字符,仅保留前 {max_chars} 字符。"
"门户/频道首页通常过大,摘要请优先使用具体文章页 URL。"
)
return body[:max_chars], True, note
try:
serialized = json.dumps(body, ensure_ascii=False)
except (TypeError, ValueError):
serialized = str(body)
if len(serialized) <= max_chars:
return body, False, None
note = f"JSON 响应已截断:序列化长度约 {len(serialized)} 字符,仅保留前 {max_chars} 字符。"
return serialized[:max_chars], True, note
async def http_request_tool(
url: str,
method: str = "GET",
headers: Optional[Dict[str, str]] = None,
body: Any = None,
max_body_chars: Optional[int] = None,
) -> str:
"""
HTTP请求工具
Args:
url: 请求URL
method: HTTP方法 (GET, POST, PUT, DELETE)
headers: 请求头
body: 请求体POST/PUT时使用
max_body_chars: 响应正文写入工具结果的最大字符数;默认读取配置 HTTP_REQUEST_MAX_BODY_CHARS
Returns:
JSON格式的响应结果
"""
try:
limit = max_body_chars
if limit is None:
limit = int(getattr(settings, "HTTP_REQUEST_MAX_BODY_CHARS", 32_000) or 32_000)
limit = max(4096, min(limit, 200_000))
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
method_upper = method.upper()
if method_upper == "GET":
response = await client.get(url, headers=headers)
elif method_upper == "POST":
response = await client.post(url, json=body, headers=headers)
elif method_upper == "PUT":
response = await client.put(url, json=body, headers=headers)
elif method_upper == "DELETE":
response = await client.delete(url, headers=headers)
else:
raise ValueError(f"不支持的HTTP方法: {method}")
# 尝试解析JSON响应
try:
response_body = response.json()
except Exception:
response_body = response.text
truncated_body, truncated, trunc_note = _truncate_http_body_for_tool(response_body, limit)
result: Dict[str, Any] = {
"status_code": response.status_code,
"headers": _compact_http_response_headers(response),
"body": truncated_body,
}
if truncated:
result["body_truncated"] = True
if trunc_note:
result["truncation_note"] = trunc_note
return json.dumps(result, ensure_ascii=False)
except Exception as e:
logger.error(f"HTTP请求工具执行失败: {str(e)}")
return json.dumps({"error": str(e)}, ensure_ascii=False)
_FILE_READ_TEXT_SUFFIXES = frozenset(
{
".txt",
".md",
".markdown",
".csv",
".tsv",
".json",
".jsonl",
".xml",
".html",
".htm",
".css",
".js",
".mjs",
".cjs",
".ts",
".tsx",
".jsx",
".vue",
".py",
".java",
".go",
".rs",
".sql",
".sh",
".bat",
".ps1",
".yaml",
".yml",
".toml",
".ini",
".cfg",
".log",
".rst",
".tex",
".gitignore",
".env",
".properties",
}
)
_FILE_READ_IMAGE_SUFFIXES = frozenset(
{".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tif", ".tiff"}
)
def _truncate_utf8_text(s: str, max_bytes: int) -> str:
raw = s.encode("utf-8")
if len(raw) <= max_bytes:
return s
cut = raw[:max_bytes].decode("utf-8", errors="ignore")
return cut + "\n\n...[内容已按字节上限截断]"
def _read_pdf_text_sync(path: Path, max_bytes: int) -> str:
from pypdf import PdfReader
reader = PdfReader(str(path))
parts: List[str] = []
for page in reader.pages:
t = page.extract_text() or ""
if t.strip():
parts.append(t)
body = "\n\n".join(parts) if parts else ""
if not body.strip():
return "[PDF 未解析出文本,可能是扫描件或图片型 PDF可将页导出为图片后上传或安装 OCR 流程]"
return _truncate_utf8_text(body, max_bytes)
def _read_docx_text_sync(path: Path, max_bytes: int) -> str:
import docx
doc = docx.Document(str(path))
lines = [p.text for p in doc.paragraphs if p.text and p.text.strip()]
body = "\n".join(lines)
return _truncate_utf8_text(body or "[docx 中未找到段落文本]", max_bytes)
def _read_xlsx_text_sync(path: Path, max_bytes: int) -> str:
from openpyxl import load_workbook
wb = load_workbook(filename=str(path), read_only=True, data_only=True)
try:
lines: List[str] = []
for ws in wb.worksheets:
lines.append(f"## {ws.title}")
for row in ws.iter_rows(values_only=True):
cells = ["" if c is None else str(c) for c in row]
if any(x.strip() for x in cells):
lines.append("\t".join(cells))
body = "\n".join(lines)
finally:
wb.close()
return _truncate_utf8_text(body or "[xlsx 中未读到单元格文本]", max_bytes)
def _tessdata_dir_for_ocr() -> Optional[Path]:
"""返回 tessdata 目录(内含 .traineddata传给 Tesseract --tessdata-dir。"""
raw = (getattr(settings, "TESSERACT_TESSDATA_DIR", None) or "").strip()
if raw:
p = Path(raw).expanduser().resolve()
return p if p.is_dir() else None
root = _local_file_workspace_root()
loc = root / "tessdata"
if loc.is_dir() and any(loc.glob("*.traineddata")):
return loc.resolve()
return None
def _read_image_ocr_sync(path: Path, max_bytes: int) -> str:
from PIL import Image
import pytesseract
cmd = (getattr(settings, "TESSERACT_CMD", None) or "").strip()
if cmd:
pytesseract.pytesseract.tesseract_cmd = cmd
tess_cfg = ""
td = _tessdata_dir_for_ocr()
if td is not None:
tess_cfg = f"--tessdata-dir {shlex.quote(str(td))}"
img = Image.open(path)
if img.mode not in ("RGB", "L"):
img = img.convert("RGB")
text = ""
last_err: Optional[Exception] = None
for lang in ("chi_sim+eng", "eng"):
try:
chunk = pytesseract.image_to_string(img, lang=lang, config=tess_cfg) or ""
if chunk.strip():
text = chunk
break
except Exception as e:
last_err = e
continue
if not text.strip() and last_err:
raise last_err
if not text.strip():
return "[图片中未识别到文字;可尝试更清晰、正向拍摄的作业照片]"
return _truncate_utf8_text(text, max_bytes)
async def file_read_tool(file_path: str) -> str:
"""
文件读取工具UTF-8 文本、PDF 文本、docx、xlsx 单元格文本、常见图片 OCR需本机 Tesseract
Args:
file_path: 文件路径(相对工作区根或工作区内绝对路径)
Returns:
JSONfile_path, content, size或 error
"""
try:
max_bytes = int(getattr(settings, "LOCAL_FILE_READ_MAX_BYTES", 2_097_152) or 2_097_152)
path, err = _resolve_path_under_workspace(file_path)
if err:
return json.dumps({"error": err}, ensure_ascii=False)
if not path.is_file():
return json.dumps({"error": f"文件不存在或不是普通文件: {path}"}, ensure_ascii=False)
fsize = path.stat().st_size
if fsize > max_bytes:
return json.dumps(
{"error": f"文件过大({fsize} 字节),上限 {max_bytes}"},
ensure_ascii=False,
)
suffix = path.suffix.lower()
if suffix == ".doc":
return json.dumps(
{
"error": "暂不支持旧版 .doc请在 Word 中另存为 .docx 后上传",
"file_path": str(path),
},
ensure_ascii=False,
)
content: str
extract_mode = "text"
if suffix in _FILE_READ_IMAGE_SUFFIXES:
extract_mode = "image_ocr"
try:
content = await asyncio.to_thread(_read_image_ocr_sync, path, max_bytes)
except ImportError as e:
return json.dumps(
{
"error": f"图片识别依赖未安装: {e}。请在后端环境执行 pip install Pillow pytesseract",
"file_path": str(path),
},
ensure_ascii=False,
)
except Exception as e:
err_low = str(e).lower()
if "tesseract" in err_low or "tesseractnotfound" in type(e).__name__.lower():
return json.dumps(
{
"error": (
"未找到 Tesseract OCR。请安装 TesseractWindows 可装官方安装包),"
"并在 .env 中配置 TESSERACT_CMD 指向 tesseract.exe或将其加入 PATH"
"中文识别需额外下载 chi_sim 语言包。"
),
"file_path": str(path),
},
ensure_ascii=False,
)
logger.warning("图片 OCR 失败: %s", e)
return json.dumps({"error": f"图片识别失败: {e}", "file_path": str(path)}, ensure_ascii=False)
elif suffix == ".pdf":
extract_mode = "pdf"
try:
content = await asyncio.to_thread(_read_pdf_text_sync, path, max_bytes)
except ImportError as e:
return json.dumps(
{"error": f"PDF 解析依赖未安装: {e}。请 pip install pypdf", "file_path": str(path)},
ensure_ascii=False,
)
except Exception as e:
logger.warning("PDF 解析失败: %s", e)
return json.dumps({"error": f"PDF 解析失败: {e}", "file_path": str(path)}, ensure_ascii=False)
elif suffix == ".docx":
extract_mode = "docx"
try:
content = await asyncio.to_thread(_read_docx_text_sync, path, max_bytes)
except ImportError as e:
return json.dumps(
{"error": f"Word 解析依赖未安装: {e}。请 pip install python-docx", "file_path": str(path)},
ensure_ascii=False,
)
except Exception as e:
logger.warning("docx 解析失败: %s", e)
return json.dumps({"error": f"docx 解析失败: {e}", "file_path": str(path)}, ensure_ascii=False)
elif suffix == ".xlsx":
extract_mode = "xlsx"
try:
content = await asyncio.to_thread(_read_xlsx_text_sync, path, max_bytes)
except ImportError as e:
return json.dumps(
{"error": f"Excel 解析依赖未安装: {e}。请 pip install openpyxl", "file_path": str(path)},
ensure_ascii=False,
)
except Exception as e:
logger.warning("xlsx 解析失败: %s", e)
return json.dumps({"error": f"xlsx 解析失败: {e}", "file_path": str(path)}, ensure_ascii=False)
elif suffix in _FILE_READ_TEXT_SUFFIXES or suffix == "":
extract_mode = "text"
content = path.read_text(encoding="utf-8", errors="replace")
content = _truncate_utf8_text(content, max_bytes)
else:
# 未知扩展名:仍尝试按 UTF-8 文本读(兼容无后缀文本)
extract_mode = "text"
content = path.read_text(encoding="utf-8", errors="replace")
content = _truncate_utf8_text(content, max_bytes)
out = {
"file_path": str(path),
"content": content,
"size": len(content.encode("utf-8")),
"extract_mode": extract_mode,
}
logger.info("file_read %s mode=%s content_len=%s", path, extract_mode, out["size"])
return json.dumps(out, ensure_ascii=False)
except FileNotFoundError:
return json.dumps({"error": f"文件不存在: {file_path}"}, ensure_ascii=False)
except Exception as e:
logger.error(f"文件读取工具执行失败: {str(e)}")
return json.dumps({"error": str(e)}, ensure_ascii=False)
async def file_write_tool(file_path: str, content: str, mode: str = "w") -> str:
"""
文件写入工具
Args:
file_path: 文件路径
content: 要写入的内容
mode: 写入模式w=覆盖a=追加)
Returns:
写入结果
"""
try:
max_bytes = int(getattr(settings, "LOCAL_FILE_WRITE_MAX_BYTES", 2_097_152) or 2_097_152)
raw = content if isinstance(content, str) else str(content)
enc_len = len(raw.encode("utf-8"))
if enc_len > max_bytes:
return json.dumps(
{"error": f"写入内容过大({enc_len} 字节 UTF-8上限 {max_bytes}"},
ensure_ascii=False,
)
path, err = _resolve_path_under_workspace(file_path)
if err:
return json.dumps({"error": err}, ensure_ascii=False)
path.parent.mkdir(parents=True, exist_ok=True)
write_mode = "a" if mode == "a" else "w"
if write_mode == "a":
with path.open("a", encoding="utf-8") as f:
f.write(raw)
else:
path.write_text(raw, encoding="utf-8")
return json.dumps(
{
"success": True,
"file_path": str(path),
"mode": write_mode,
"content_length": enc_len,
},
ensure_ascii=False,
)
except Exception as e:
logger.error(f"文件写入工具执行失败: {str(e)}")
return json.dumps({"error": str(e)}, ensure_ascii=False)
async def text_analyze_tool(text: str, operation: str = "count") -> str:
"""
文本分析工具
Args:
text: 要分析的文本
operation: 操作类型count=统计, keywords=提取关键词, summary=摘要)
Returns:
分析结果
"""
try:
if operation == "count":
# 统计字数、字符数、行数等
char_count = len(text)
char_count_no_spaces = len(text.replace(" ", ""))
word_count = len(text.split())
line_count = len(text.splitlines())
paragraph_count = len([p for p in text.split("\n\n") if p.strip()])
return json.dumps({
"char_count": char_count,
"char_count_no_spaces": char_count_no_spaces,
"word_count": word_count,
"line_count": line_count,
"paragraph_count": paragraph_count
}, ensure_ascii=False)
elif operation == "keywords":
# 简单的关键词提取(基于词频)
words = re.findall(r'\b\w+\b', text.lower())
word_freq = {}
for word in words:
if len(word) > 2: # 忽略太短的词
word_freq[word] = word_freq.get(word, 0) + 1
# 取频率最高的10个词
top_keywords = sorted(word_freq.items(), key=lambda x: x[1], reverse=True)[:10]
return json.dumps({
"keywords": [{"word": k, "frequency": v} for k, v in top_keywords]
}, ensure_ascii=False)
elif operation == "summary":
# 简单的摘要取前3句
sentences = re.split(r'[.!?。!?]\s+', text)
summary = ". ".join(sentences[:3]) + "."
return json.dumps({
"summary": summary,
"original_length": len(text),
"summary_length": len(summary)
}, ensure_ascii=False)
else:
return json.dumps({
"error": f"不支持的操作类型: {operation}"
}, ensure_ascii=False)
except Exception as e:
logger.error(f"文本分析工具执行失败: {str(e)}")
return json.dumps({
"error": str(e)
}, ensure_ascii=False)
async def datetime_tool(operation: str = "now", format: str = "%Y-%m-%d %H:%M:%S",
timezone: Optional[str] = None) -> str:
"""
日期时间工具
Args:
operation: 操作类型now=当前时间, format=格式化, parse=解析)
format: 时间格式
timezone: 时区(暂未实现)
Returns:
日期时间结果
"""
try:
if operation == "now":
now = datetime.now()
return json.dumps({
"datetime": now.strftime(format),
"timestamp": now.timestamp(),
"iso_format": now.isoformat()
}, ensure_ascii=False)
elif operation == "format":
now = datetime.now()
return json.dumps({
"formatted": now.strftime(format)
}, ensure_ascii=False)
else:
return json.dumps({
"error": f"不支持的操作类型: {operation}"
}, ensure_ascii=False)
except Exception as e:
logger.error(f"日期时间工具执行失败: {str(e)}")
return json.dumps({
"error": str(e)
}, ensure_ascii=False)
async def math_calculate_tool(expression: str) -> str:
"""
数学计算工具(安全限制:只允许基本数学运算)
Args:
expression: 数学表达式(如 "2+2", "sqrt(16)", "sin(0)"
Returns:
计算结果
"""
try:
# 安全检查:只允许数字、基本运算符和安全的数学函数
allowed_chars = set("0123456789+-*/.() ")
allowed_functions = ['sqrt', 'sin', 'cos', 'tan', 'log', 'exp', 'abs', 'pow']
# 检查表达式是否安全
if not all(c in allowed_chars or any(f in expression for f in allowed_functions) for c in expression):
return json.dumps({
"error": "表达式包含不安全的字符"
}, ensure_ascii=False)
# 构建安全的数学环境
safe_dict = {
"__builtins__": {},
"abs": abs,
"sqrt": math.sqrt,
"sin": math.sin,
"cos": math.cos,
"tan": math.tan,
"log": math.log,
"exp": math.exp,
"pow": pow,
"pi": math.pi,
"e": math.e
}
result = eval(expression, safe_dict)
return json.dumps({
"expression": expression,
"result": result,
"result_type": type(result).__name__
}, ensure_ascii=False)
except Exception as e:
logger.error(f"数学计算工具执行失败: {str(e)}")
return json.dumps({
"error": str(e)
}, ensure_ascii=False)
async def system_info_tool() -> str:
"""
系统信息工具
Returns:
系统信息
"""
try:
info = {
"platform": platform.system(),
"platform_version": platform.version(),
"architecture": platform.machine(),
"processor": platform.processor(),
"python_version": sys.version,
"python_version_info": {
"major": sys.version_info.major,
"minor": sys.version_info.minor,
"micro": sys.version_info.micro,
},
# file_read/file_write 使用的允许根目录(与 LOCAL_FILE_TOOLS_ROOT 一致)
"local_file_workspace_root": str(_local_file_workspace_root()),
}
return json.dumps(info, ensure_ascii=False)
except Exception as e:
logger.error(f"系统信息工具执行失败: {str(e)}")
return json.dumps({
"error": str(e)
}, ensure_ascii=False)
async def json_process_tool(json_string: str, operation: str = "parse") -> str:
"""
JSON处理工具
Args:
json_string: JSON字符串
operation: 操作类型parse=解析, stringify=序列化, validate=验证)
Returns:
处理结果
"""
try:
if operation == "parse":
data = json.loads(json_string)
return json.dumps({
"parsed": data,
"type": type(data).__name__
}, ensure_ascii=False)
elif operation == "stringify":
# 如果输入已经是字符串,尝试解析后再序列化
try:
data = json.loads(json_string)
except:
data = json_string
return json.dumps({
"stringified": json.dumps(data, ensure_ascii=False, indent=2)
}, ensure_ascii=False)
elif operation == "validate":
try:
json.loads(json_string)
return json.dumps({
"valid": True
}, ensure_ascii=False)
except json.JSONDecodeError as e:
return json.dumps({
"valid": False,
"error": str(e)
}, ensure_ascii=False)
else:
return json.dumps({
"error": f"不支持的操作类型: {operation}"
}, ensure_ascii=False)
except Exception as e:
logger.error(f"JSON处理工具执行失败: {str(e)}")
return json.dumps({
"error": str(e)
}, ensure_ascii=False)
def _validate_sql_query(sql: str) -> Tuple[bool, Optional[str]]:
"""
验证SQL查询的安全性
Args:
sql: SQL查询语句
Returns:
(是否安全, 错误信息)
"""
# 去除注释和多余空白
sql_clean = re.sub(r'--.*?\n', '', sql)
sql_clean = re.sub(r'/\*.*?\*/', '', sql_clean, flags=re.DOTALL)
sql_clean = ' '.join(sql_clean.split())
# 转换为小写以便检查
sql_lower = sql_clean.lower().strip()
# 只允许SELECT查询
if not sql_lower.startswith('select'):
return False, "只允许SELECT查询不允许INSERT、UPDATE、DELETE、DROP等操作"
# 检查危险关键字
dangerous_keywords = [
'insert', 'update', 'delete', 'drop', 'truncate', 'alter',
'create', 'grant', 'revoke', 'exec', 'execute', 'call',
'commit', 'rollback', 'savepoint'
]
for keyword in dangerous_keywords:
# 使用单词边界匹配,避免误判(如"select"中包含"select"
pattern = r'\b' + keyword + r'\b'
if re.search(pattern, sql_lower):
return False, f"检测到危险关键字: {keyword.upper()},查询被拒绝"
# 检查是否有多个SQL语句防止SQL注入
if ';' in sql_clean and sql_clean.count(';') > 1:
return False, "不允许执行多个SQL语句"
return True, None
async def _execute_default_db_query(sql: str, timeout: int = 30) -> List[Dict[str, Any]]:
"""
执行默认数据库查询
Args:
sql: SQL查询语句
timeout: 查询超时时间(秒)
Returns:
查询结果列表
"""
from app.core.database import engine
try:
# 使用asyncio实现超时控制
loop = asyncio.get_event_loop()
def _execute():
with engine.connect() as connection:
result = connection.execute(text(sql))
# 获取列名
columns = result.keys()
# 获取所有行
rows = result.fetchall()
# 转换为字典列表
return [dict(zip(columns, row)) for row in rows]
# 执行查询,带超时控制
result = await asyncio.wait_for(
asyncio.to_thread(_execute),
timeout=timeout
)
return result
except asyncio.TimeoutError:
raise Exception(f"查询超时(超过{timeout}秒)")
except SQLAlchemyError as e:
raise Exception(f"数据库查询失败: {str(e)}")
except Exception as e:
raise Exception(f"执行查询时发生错误: {str(e)}")
async def _execute_data_source_query(data_source_id: str, sql: str, timeout: int = 30) -> List[Dict[str, Any]]:
"""
通过数据源ID执行查询
Args:
data_source_id: 数据源ID
sql: SQL查询语句
timeout: 查询超时时间(秒)
Returns:
查询结果列表
"""
from app.core.database import SessionLocal
from app.models.data_source import DataSource
from app.services.data_source_connector import create_connector
db = SessionLocal()
try:
# 获取数据源配置
data_source = db.query(DataSource).filter(DataSource.id == data_source_id).first()
if not data_source:
raise Exception(f"数据源不存在: {data_source_id}")
if data_source.status != 'active':
raise Exception(f"数据源状态异常: {data_source.status}")
# 创建连接器
connector = create_connector(data_source.type, data_source.config)
# 执行查询(带超时控制)
query_params = {'sql': sql}
# 使用asyncio实现超时控制
def _execute():
return connector.query(query_params)
result = await asyncio.wait_for(
asyncio.to_thread(_execute),
timeout=timeout
)
# 确保结果是列表格式
if not isinstance(result, list):
result = [result] if result else []
return result
except asyncio.TimeoutError:
raise Exception(f"查询超时(超过{timeout}秒)")
except Exception as e:
raise Exception(f"数据源查询失败: {str(e)}")
finally:
db.close()
async def database_query_tool(
query: str,
database: str = "default",
data_source_id: Optional[str] = None,
timeout: int = 30
) -> str:
"""
数据库查询工具
Args:
query: SQL查询语句只允许SELECT
database: 数据库名称(已废弃,保留用于兼容)
data_source_id: 数据源ID可选如果提供则使用指定数据源
timeout: 查询超时时间默认30秒
Returns:
JSON格式的查询结果
"""
try:
# 验证SQL安全性
is_safe, error_msg = _validate_sql_query(query)
if not is_safe:
return json.dumps({
"error": error_msg,
"query": query
}, ensure_ascii=False)
# 验证超时时间
if timeout <= 0 or timeout > 300:
return json.dumps({
"error": "超时时间必须在1-300秒之间",
"timeout": timeout
}, ensure_ascii=False)
# 执行查询
if data_source_id:
# 使用指定的数据源
result = await _execute_data_source_query(data_source_id, query, timeout)
else:
# 使用默认数据库
result = await _execute_default_db_query(query, timeout)
# 格式化结果
return json.dumps({
"success": True,
"row_count": len(result),
"data": result,
"query": query
}, ensure_ascii=False, default=str)
except Exception as e:
logger.error(f"数据库查询工具执行失败: {str(e)}")
return json.dumps({
"error": str(e),
"query": query
}, ensure_ascii=False)
async def adb_log_tool(
command: str = "logcat",
filter_tag: Optional[str] = None,
level: Optional[str] = None,
max_lines: int = 100,
timeout: int = 10
) -> str:
"""
ADB日志工具 - 获取Android设备日志
Args:
command: ADB命令类型logcat=获取日志, devices=列出设备, shell=执行shell命令
filter_tag: 日志标签过滤(如 "ActivityManager", "SystemServer"
level: 日志级别过滤V/D/I/W/E/F/S"E" 只显示错误)
max_lines: 最大返回行数默认100行避免输出过长
timeout: 命令执行超时时间默认10秒
Returns:
JSON格式的执行结果
"""
try:
# 验证超时时间
if timeout <= 0 or timeout > 60:
return json.dumps({
"error": "超时时间必须在1-60秒之间",
"timeout": timeout
}, ensure_ascii=False)
# 验证最大行数
if max_lines <= 0 or max_lines > 10000:
return json.dumps({
"error": "最大行数必须在1-10000之间",
"max_lines": max_lines
}, ensure_ascii=False)
# 构建adb命令
if command == "logcat":
# 获取日志
adb_cmd = ["adb", "logcat", "-d"] # -d 表示获取已缓存的日志
# 添加日志级别过滤
if level:
level = level.upper()
if level in ["V", "D", "I", "W", "E", "F", "S"]:
adb_cmd.extend([f"*:{level}"])
else:
return json.dumps({
"error": f"无效的日志级别: {level},支持: V/D/I/W/E/F/S"
}, ensure_ascii=False)
# 添加标签过滤
if filter_tag:
adb_cmd.append(filter_tag)
# 限制输出行数
adb_cmd.extend(["-t", str(max_lines)])
elif command == "devices":
# 列出连接的设备
adb_cmd = ["adb", "devices", "-l"]
elif command == "shell":
# 执行shell命令受限只允许安全命令
if filter_tag:
# filter_tag在这里作为shell命令
# 只允许安全的命令
safe_commands = ["getprop", "dumpsys", "pm", "am", "settings"]
cmd_parts = filter_tag.split()
if not cmd_parts or cmd_parts[0] not in safe_commands:
return json.dumps({
"error": f"不允许执行命令: {filter_tag},只允许: {', '.join(safe_commands)}"
}, ensure_ascii=False)
adb_cmd = ["adb", "shell"] + cmd_parts
else:
return json.dumps({
"error": "shell命令需要提供filter_tag参数作为命令"
}, ensure_ascii=False)
else:
return json.dumps({
"error": f"不支持的ADB命令: {command},支持: logcat/devices/shell"
}, ensure_ascii=False)
# 执行adb命令
def _execute_adb():
try:
result = subprocess.run(
adb_cmd,
capture_output=True,
text=True,
timeout=timeout,
encoding='utf-8',
errors='replace' # 处理编码错误
)
return result
except subprocess.TimeoutExpired:
raise Exception(f"ADB命令执行超时超过{timeout}秒)")
except FileNotFoundError:
raise Exception("未找到adb命令请确保已安装Android SDK Platform Tools并配置PATH")
except Exception as e:
raise Exception(f"执行ADB命令失败: {str(e)}")
# 异步执行命令
result = await asyncio.wait_for(
asyncio.to_thread(_execute_adb),
timeout=timeout + 2 # 额外2秒缓冲
)
# 处理结果
output_lines = result.stdout.split('\n') if result.stdout else []
error_lines = result.stderr.split('\n') if result.stderr else []
# 如果输出太长,截断
if len(output_lines) > max_lines:
output_lines = output_lines[:max_lines]
output_lines.append(f"... (已截断,共 {len(result.stdout.split(chr(10)))} 行)")
return json.dumps({
"success": result.returncode == 0,
"command": " ".join(adb_cmd),
"return_code": result.returncode,
"output": "\n".join(output_lines),
"output_lines": len(output_lines),
"error": "\n".join(error_lines) if error_lines and any(error_lines) else None,
"timestamp": datetime.now().isoformat()
}, ensure_ascii=False)
except asyncio.TimeoutError:
return json.dumps({
"error": f"ADB命令执行超时超过{timeout}秒)",
"command": " ".join(adb_cmd) if 'adb_cmd' in locals() else command
}, ensure_ascii=False)
except Exception as e:
logger.error(f"ADB日志工具执行失败: {str(e)}")
return json.dumps({
"error": str(e),
"command": " ".join(adb_cmd) if 'adb_cmd' in locals() else command
}, ensure_ascii=False)
# 工具定义OpenAI Function格式
HTTP_REQUEST_SCHEMA = {
"type": "function",
"function": {
"name": "http_request",
"description": "发送HTTP请求支持GET、POST、PUT、DELETE方法。可以用于调用API、获取网页内容等。",
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "请求的URL地址"
},
"method": {
"type": "string",
"enum": ["GET", "POST", "PUT", "DELETE"],
"description": "HTTP请求方法",
"default": "GET"
},
"headers": {
"type": "object",
"description": "HTTP请求头可选",
"additionalProperties": {
"type": "string"
}
},
"body": {
"type": "object",
"description": "请求体POST/PUT时使用可选"
},
"max_body_chars": {
"type": "integer",
"description": (
"响应正文写入结果的最大字符数(可选)。"
"门户首页等大 HTML 默认会按平台配置截断;摘要单篇文章可适当调大(如 80000仍可能受模型总上下文限制。"
),
},
},
"required": ["url", "method"]
}
}
}
FILE_READ_SCHEMA = {
"type": "function",
"function": {
"name": "file_read",
"description": (
"读取工作区内文件并尽量提取可读文本UTF-8 文本、.pdf 文字层、.docx 段落、.xlsx 单元格、"
"常见图片(.png/.jpg 等)用 OCR 提取文字(需服务器安装 Tesseract可选配置 TESSERACT_CMD"
"用户上传附件后消息中会给出相对路径,请用本工具读取该路径。路径须落在 LOCAL_FILE_TOOLS_ROOT 下。"
),
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "要读取的文件路径(相对于项目根目录或绝对路径)"
}
},
"required": ["file_path"]
}
}
}
FILE_WRITE_SCHEMA = {
"type": "function",
"function": {
"name": "file_write",
"description": "写入本地文本文件UTF-8w 覆盖 / a 追加)。路径须落在工作区内;父目录不存在会自动创建。勿写入密码、密钥等大型敏感内容。",
"parameters": {
"type": "object",
"properties": {
"file_path": {
"type": "string",
"description": "要写入的文件路径(相对于项目根目录或绝对路径)"
},
"content": {
"type": "string",
"description": "要写入的内容"
},
"mode": {
"type": "string",
"enum": ["w", "a"],
"description": "写入模式w=覆盖写入a=追加写入",
"default": "w"
}
},
"required": ["file_path", "content"]
}
}
}
TEXT_ANALYZE_SCHEMA = {
"type": "function",
"function": {
"name": "text_analyze",
"description": "分析文本内容,支持统计字数、提取关键词、生成摘要等功能。",
"parameters": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "要分析的文本内容"
},
"operation": {
"type": "string",
"enum": ["count", "keywords", "summary"],
"description": "操作类型count=统计字数等信息keywords=提取关键词summary=生成摘要",
"default": "count"
}
},
"required": ["text"]
}
}
}
DATETIME_SCHEMA = {
"type": "function",
"function": {
"name": "datetime",
"description": "获取和处理日期时间信息,支持获取当前时间、格式化时间等。",
"parameters": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["now", "format"],
"description": "操作类型now=获取当前时间format=格式化时间",
"default": "now"
},
"format": {
"type": "string",
"description": "时间格式字符串(如:%Y-%m-%d %H:%M:%S",
"default": "%Y-%m-%d %H:%M:%S"
}
}
}
}
}
MATH_CALCULATE_SCHEMA = {
"type": "function",
"function": {
"name": "math_calculate",
"description": "执行数学计算支持基本运算和常用数学函数如sqrt, sin, cos, log等",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "数学表达式2+2, sqrt(16), sin(0), pow(2,3)"
}
},
"required": ["expression"]
}
}
}
SYSTEM_INFO_SCHEMA = {
"type": "function",
"function": {
"name": "system_info",
"description": "获取系统信息(含 local_file_workspace_rootfile_read/file_write 允许访问的根目录)。用户问「工作区路径」时应调用本工具并如实转述该字段。",
"parameters": {
"type": "object",
"properties": {}
}
}
}
JSON_PROCESS_SCHEMA = {
"type": "function",
"function": {
"name": "json_process",
"description": "处理JSON数据支持解析、序列化、验证等功能。",
"parameters": {
"type": "object",
"properties": {
"json_string": {
"type": "string",
"description": "JSON字符串"
},
"operation": {
"type": "string",
"enum": ["parse", "stringify", "validate"],
"description": "操作类型parse=解析JSONstringify=序列化为JSONvalidate=验证JSON格式",
"default": "parse"
}
},
"required": ["json_string"]
}
}
}
ADB_LOG_SCHEMA = {
"type": "function",
"function": {
"name": "adb_log",
"description": "执行ADB命令获取Android设备日志。支持logcat获取日志、devices列出设备、shell执行shell命令。可以过滤日志标签和级别。",
"parameters": {
"type": "object",
"properties": {
"command": {
"type": "string",
"enum": ["logcat", "devices", "shell"],
"description": "ADB命令类型logcat=获取日志devices=列出连接的设备shell=执行shell命令",
"default": "logcat"
},
"filter_tag": {
"type": "string",
"description": "日志标签过滤(如 'ActivityManager', 'SystemServer'或shell命令当command=shell时"
},
"level": {
"type": "string",
"enum": ["V", "D", "I", "W", "E", "F", "S"],
"description": "日志级别过滤V=Verbose, D=Debug, I=Info, W=Warning, E=Error, F=Fatal, S=Silent"
},
"max_lines": {
"type": "integer",
"description": "最大返回行数默认100行避免输出过长",
"default": 100
},
"timeout": {
"type": "integer",
"description": "命令执行超时时间默认10秒最大60秒",
"default": 10
}
}
}
}
}
DATABASE_QUERY_SCHEMA = {
"type": "function",
"function": {
"name": "database_query",
"description": "执行数据库查询只允许SELECT查询支持默认数据库和指定数据源。可以查询工作流、Agent、执行记录等系统数据或通过数据源ID查询外部数据库。",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "SQL查询语句只允许SELECT查询不允许INSERT、UPDATE、DELETE等操作"
},
"data_source_id": {
"type": "string",
"description": "数据源ID可选如果提供则使用指定的数据源否则使用默认数据库"
},
"timeout": {
"type": "integer",
"description": "查询超时时间默认30秒最大300秒",
"default": 30,
"minimum": 1,
"maximum": 300
}
},
"required": ["query"]
}
}
}
# ─── 加密/哈希/编码工具 ──────────────────────────────────
async def crypto_util_tool(
operation: str = "uuid",
data: Optional[str] = None,
algorithm: str = "sha256",
) -> str:
"""
加密/哈希/编码工具。
Args:
operation: 操作类型
- uuid: 生成 UUID
- base64_encode: Base64 编码
- base64_decode: Base64 解码
- hash: 计算哈希值
data: 输入数据base64/hash 操作需要)
algorithm: 哈希算法hash 操作时使用,支持 md5/sha1/sha256/sha512
Returns:
操作结果
"""
import hashlib
import base64
import uuid as _uuid
try:
if operation == "uuid":
u = str(_uuid.uuid4())
return json.dumps({"uuid": u, "type": "uuid4"}, ensure_ascii=False)
elif operation == "base64_encode":
if not data:
return json.dumps({"error": "data 参数不能为空"}, ensure_ascii=False)
encoded = base64.b64encode(data.encode("utf-8")).decode("utf-8")
return json.dumps({"encoded": encoded}, ensure_ascii=False)
elif operation == "base64_decode":
if not data:
return json.dumps({"error": "data 参数不能为空"}, ensure_ascii=False)
try:
decoded = base64.b64decode(data.encode("utf-8")).decode("utf-8")
return json.dumps({"decoded": decoded}, ensure_ascii=False)
except Exception as e:
return json.dumps({"error": f"Base64 解码失败: {e}"}, ensure_ascii=False)
elif operation == "hash":
if not data:
return json.dumps({"error": "data 参数不能为空"}, ensure_ascii=False)
valid_algos = {"md5": hashlib.md5, "sha1": hashlib.sha1,
"sha256": hashlib.sha256, "sha512": hashlib.sha512}
if algorithm not in valid_algos:
return json.dumps(
{"error": f"不支持的算法: {algorithm},支持: {list(valid_algos.keys())}"},
ensure_ascii=False,
)
h = valid_algos[algorithm](data.encode("utf-8")).hexdigest()
return json.dumps({"algorithm": algorithm, "hash": h}, ensure_ascii=False)
else:
return json.dumps(
{"error": f"不支持的操作: {operation},支持: uuid/base64_encode/base64_decode/hash"},
ensure_ascii=False,
)
except Exception as e:
logger.error(f"crypto_util 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": f"操作失败: {e}"}, ensure_ascii=False)
# ─── 随机生成工具 ─────────────────────────────────────────
async def random_generate_tool(
generate_type: str = "password",
length: int = 16,
count: int = 1,
min_val: float = 0,
max_val: float = 100,
) -> str:
"""
随机数据生成工具。
Args:
generate_type: 生成类型
- password: 安全密码(含大小写字母+数字+符号)
- string: 随机字符串(字母+数字)
- number: 随机整数
- float: 随机浮点数
length: 密码/字符串长度默认16
count: 生成数量默认1
min_val: 随机数最小值number/float 时使用)
max_val: 随机数最大值number/float 时使用)
Returns:
生成的随机数据
"""
import random
import string as _string
import secrets
try:
count = max(1, min(count, 50)) # 限制最多 50 个
if generate_type == "password":
length = max(8, min(length, 128))
chars = _string.ascii_letters + _string.digits + "!@#$%^&*()_+-=[]{}|;:,.<>?"
result = []
for _ in range(count):
pwd = "".join(secrets.choice(chars) for _ in range(length))
result.append(pwd)
return json.dumps({
"passwords": result,
"length": length,
"strength": "high" if length >= 16 else "medium" if length >= 12 else "basic",
}, ensure_ascii=False)
elif generate_type == "string":
length = max(1, min(length, 256))
chars = _string.ascii_letters + _string.digits
result = ["".join(secrets.choice(chars) for _ in range(length)) for _ in range(count)]
return json.dumps({"strings": result, "length": length}, ensure_ascii=False)
elif generate_type == "number":
min_val, max_val = int(min_val), int(max_val)
if min_val > max_val:
min_val, max_val = max_val, min_val
result = [secrets.randbelow(max_val - min_val + 1) + min_val for _ in range(count)]
return json.dumps({"numbers": result, "range": [min_val, max_val]}, ensure_ascii=False)
elif generate_type == "float":
if min_val > max_val:
min_val, max_val = max_val, min_val
result = [round(random.uniform(min_val, max_val), 6) for _ in range(count)]
return json.dumps({"floats": result, "range": [min_val, max_val]}, ensure_ascii=False)
else:
return json.dumps(
{"error": f"不支持的类型: {generate_type},支持: password/string/number/float"},
ensure_ascii=False,
)
except Exception as e:
logger.error(f"random_generate 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": f"生成失败: {e}"}, ensure_ascii=False)
# ─── 邮件发送工具 ─────────────────────────────────────────
async def send_email_tool(
to: str,
subject: str,
body: str,
smtp_host: str = "",
smtp_port: int = 587,
smtp_user: str = "",
smtp_password: str = "",
from_name: str = "",
body_type: str = "plain",
) -> str:
"""
发送邮件工具。
Args:
to: 收件人邮箱地址(多个用逗号分隔)
subject: 邮件主题
body: 邮件正文
smtp_host: SMTP 服务器地址(如 smtp.qq.com留空从环境变量读取
smtp_port: SMTP 端口(默认 587
smtp_user: SMTP 用户名/发件人邮箱,留空从环境变量读取
smtp_password: SMTP 密码/授权码,留空从环境变量读取
from_name: 发件人显示名称(可选)
body_type: 正文类型 plain=纯文本, html=HTML
Returns:
发送结果
"""
try:
# 从环境变量读取 SMTP 配置(如果未传参)
import os
host = smtp_host or os.getenv("SMTP_HOST", "")
user = smtp_user or os.getenv("SMTP_USER", "")
password = smtp_password or os.getenv("SMTP_PASSWORD", "")
if not host or not user or not password:
return json.dumps({
"error": "SMTP 配置不完整。请在 .env 中设置 SMTP_HOST/SMTP_USER/SMTP_PASSWORD或在调用时传入 smtp_host/smtp_user/smtp_password 参数",
}, ensure_ascii=False)
import aiosmtplib
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
recipients = [r.strip() for r in to.split(",") if r.strip()]
if not recipients:
return json.dumps({"error": "收件人地址不能为空"}, ensure_ascii=False)
msg = MIMEMultipart()
msg["From"] = f"{from_name} <{user}>" if from_name else user
msg["To"] = ", ".join(recipients)
msg["Subject"] = subject
msg.attach(MIMEText(body, body_type if body_type == "html" else "plain", "utf-8"))
await aiosmtplib.send(
msg,
hostname=host,
port=smtp_port,
username=user,
password=password,
start_tls=True,
)
return json.dumps({
"success": True,
"message": f"邮件已发送到 {len(recipients)} 个收件人",
"recipients": recipients,
}, ensure_ascii=False)
except Exception as e:
logger.error(f"send_email 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": f"邮件发送失败: {e}"}, ensure_ascii=False)
# ─── URL 解析工具 ─────────────────────────────────────────
async def url_parse_tool(
url: str,
operation: str = "parse",
key: Optional[str] = None,
params: Optional[str] = None,
) -> str:
"""
URL 解析和构建工具。
Args:
url: 要处理的 URL
operation: 操作类型
- parse: 解析 URL 为各部分(协议/主机/端口/路径/参数/片段)
- get_param: 获取指定查询参数的值(需传 key
- all_params: 获取所有查询参数
- build: 构建 URLurl=基础URL, params=要追加的参数 JSON
key: 查询参数名operation=get_param 时使用)
params: 参数 JSON 字符串operation=build 时使用,如 {"page": "1", "size": "10"}
Returns:
处理结果
"""
from urllib.parse import urlparse, parse_qs, urlencode, urlunparse
try:
if operation == "parse":
parsed = urlparse(url)
return json.dumps({
"scheme": parsed.scheme,
"hostname": parsed.hostname,
"port": parsed.port,
"path": parsed.path,
"params": parsed.params,
"query": parsed.query,
"fragment": parsed.fragment,
}, ensure_ascii=False)
elif operation == "get_param":
if not key:
return json.dumps({"error": "需要提供 key 参数"}, ensure_ascii=False)
parsed = urlparse(url)
query_params = parse_qs(parsed.query)
val = query_params.get(key, [None])[0]
return json.dumps({"key": key, "value": val}, ensure_ascii=False)
elif operation == "all_params":
parsed = urlparse(url)
query_params = parse_qs(parsed.query)
# parse_qs 返回 {key: [value1, value2]},简化为单值
flat = {k: v[0] if len(v) == 1 else v for k, v in query_params.items()}
return json.dumps({"params": flat}, ensure_ascii=False)
elif operation == "build":
parsed = urlparse(url)
existing = parse_qs(parsed.query, keep_blank_values=True)
if params:
try:
extra = json.loads(params)
if isinstance(extra, dict):
existing.update(extra)
except json.JSONDecodeError as e:
return json.dumps({"error": f"params JSON 解析失败: {e}"}, ensure_ascii=False)
new_query = urlencode(existing, doseq=True)
new_parsed = parsed._replace(query=new_query)
new_url = urlunparse(new_parsed)
return json.dumps({"url": new_url}, ensure_ascii=False)
else:
return json.dumps(
{"error": f"不支持的操作: {operation},支持: parse/get_param/all_params/build"},
ensure_ascii=False,
)
except Exception as e:
logger.error(f"url_parse 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": f"URL 处理失败: {e}"}, ensure_ascii=False)
# ─── 正则表达式工具 ─────────────────────────────────────
async def regex_test_tool(
pattern: str,
test_string: str,
operation: str = "match",
flags: str = "",
replacement: str = "",
) -> str:
"""
正则表达式测试工具。
Args:
pattern: 正则表达式模式
test_string: 要测试的字符串
operation: 操作类型
- match: 查找第一个匹配
- findall: 查找所有匹配
- replace: 替换匹配内容(需传 replacement
- split: 按正则分割字符串
- validate: 验证完整字符串是否匹配
flags: 标志可选组合i=忽略大小写, m=多行, s=点匹配换行)
replacement: 替换文本operation=replace 时使用)
Returns:
处理结果
"""
import re as _re
try:
# 解析 flags
flag_val = 0
for c in flags.lower():
if c == "i":
flag_val |= _re.IGNORECASE
elif c == "m":
flag_val |= _re.MULTILINE
elif c == "s":
flag_val |= _re.DOTALL
if operation == "match":
m = _re.search(pattern, test_string, flag_val)
if m:
info = {"match": m.group(0), "start": m.start(), "end": m.end()}
if m.groups():
info["groups"] = list(m.groups())
if m.groupdict():
info["named_groups"] = {k: v for k, v in m.groupdict().items()}
return json.dumps(info, ensure_ascii=False)
return json.dumps({"match": None, "message": "无匹配"}, ensure_ascii=False)
elif operation == "findall":
matches = _re.findall(pattern, test_string, flag_val)
return json.dumps({"count": len(matches), "matches": matches[:100]}, ensure_ascii=False)
elif operation == "replace":
result = _re.sub(pattern, replacement, test_string, flags=flag_val)
return json.dumps({"result": result}, ensure_ascii=False)
elif operation == "split":
parts = _re.split(pattern, test_string, flags=flag_val)
return json.dumps({"parts": parts, "count": len(parts)}, ensure_ascii=False)
elif operation == "validate":
is_match = bool(_re.fullmatch(pattern, test_string, flag_val))
return json.dumps({"valid": is_match, "pattern": pattern}, ensure_ascii=False)
else:
return json.dumps(
{"error": f"不支持的操作: {operation},支持: match/findall/replace/split/validate"},
ensure_ascii=False,
)
except _re.error as e:
return json.dumps({"error": f"正则表达式语法错误: {e}"}, ensure_ascii=False)
except Exception as e:
logger.error(f"regex_test 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": f"操作失败: {e}"}, ensure_ascii=False)
# ─── 定时任务工具 ─────────────────────────────────────────
async def schedule_create_tool(
agent_id: str,
name: str,
cron_expression: str,
input_message: str,
webhook_url: Optional[str] = None,
) -> str:
"""
为 Agent 创建定时任务。
Args:
agent_id: Agent ID
name: 任务名称,如"每日早报"
cron_expression: 标准 5 位 cron 表达式,如 0 9 * * * 表示每天9点
input_message: 定时执行时发送给 Agent 的消息
webhook_url: 可选,飞书机器人 Webhook URL执行完成后推送通知
Returns:
执行结果
"""
try:
from app.core.database import SessionLocal
from app.models.agent import Agent
from app.models.agent_schedule import AgentSchedule
from app.services.agent_schedule_service import compute_next_run
db = SessionLocal()
try:
agent = db.query(Agent).filter(Agent.id == agent_id).first()
if not agent:
return json.dumps({"error": f"Agent 不存在: {agent_id}"}, ensure_ascii=False)
# 尝试计算下次执行时间(使用北京时间,与 schedule 表 timezone 默认值一致)
try:
next_run = compute_next_run(cron_expression, tz="Asia/Shanghai")
except (ValueError, KeyError) as e:
return json.dumps({"error": f"cron 表达式无效: {e}(标准 5 位格式,如 0 9 * * *"}, ensure_ascii=False)
# 获取 user_id优先代理的 owner否则 fallback 到第一个用户
user_id = agent.user_id
if not user_id:
from app.models.user import User
first_user = db.query(User).first()
if not first_user:
return json.dumps({"error": "数据库无用户,无法创建定时任务"}, ensure_ascii=False)
user_id = first_user.id
schedule = AgentSchedule(
agent_id=agent_id,
name=name,
cron_expression=cron_expression,
input_message=input_message,
webhook_url=webhook_url,
enabled=True,
next_run_at=next_run,
user_id=user_id,
)
db.add(schedule)
db.commit()
db.refresh(schedule)
return json.dumps({
"success": True,
"schedule_id": schedule.id,
"name": schedule.name,
"cron": schedule.cron_expression,
"next_run_at": next_run.isoformat(),
"message": f"定时任务「{name}」已创建,将于 {next_run.isoformat()} 首次执行",
}, ensure_ascii=False)
finally:
db.close()
except Exception as e:
logger.error(f"schedule_create 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": f"创建定时任务失败: {e}"}, ensure_ascii=False)
async def schedule_list_tool(agent_id: str) -> str:
"""
列出 Agent 的所有定时任务。
Args:
agent_id: Agent ID
Returns:
定时任务列表
"""
try:
from app.core.database import SessionLocal
from app.models.agent_schedule import AgentSchedule
db = SessionLocal()
try:
schedules = (
db.query(AgentSchedule)
.filter(
AgentSchedule.agent_id == agent_id,
)
.order_by(AgentSchedule.created_at.desc())
.all()
)
items = []
for s in schedules:
items.append({
"id": s.id,
"name": s.name,
"cron": s.cron_expression,
"enabled": s.enabled,
"next_run": s.next_run_at.isoformat() if s.next_run_at else None,
"last_run": s.last_run_at.isoformat() if s.last_run_at else None,
"last_status": s.last_run_status,
})
return json.dumps({
"count": len(items),
"schedules": items,
}, ensure_ascii=False)
finally:
db.close()
except Exception as e:
logger.error(f"schedule_list 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": f"查询定时任务失败: {e}"}, ensure_ascii=False)
async def schedule_delete_tool(
schedule_id: str = "",
agent_id: str = "",
) -> str:
"""
删除指定的定时任务。
支持三种删除方式:
1. 按 schedule_id 精确删除
2. 按 agent_id 删除该 Agent 下所有定时任务(批量清理)
Args:
schedule_id: 定时任务 ID精确删除时使用
agent_id: Agent ID批量删除该 Agent 的所有任务时使用,或用于校验 schedule_id 所属权)
Returns:
删除结果
"""
try:
from app.core.database import SessionLocal
from app.models.agent_schedule import AgentSchedule
if not schedule_id and not agent_id:
return json.dumps({
"error": "missing_params",
"message": "请提供 schedule_id删除单个或 agent_id批量删除该 Agent 的所有定时任务)",
}, ensure_ascii=False)
db = SessionLocal()
try:
if schedule_id:
# 按 ID 精确删除
schedule = db.query(AgentSchedule).filter(AgentSchedule.id == schedule_id).first()
if not schedule:
db.close()
return json.dumps({"error": f"定时任务不存在: {schedule_id}"}, ensure_ascii=False)
# 所有权校验:如果提供了 agent_id确保该 schedule 属于此 Agent
if agent_id and schedule.agent_id != agent_id:
db.close()
return json.dumps({
"error": "permission_denied",
"message": f"定时任务「{schedule.name}」不属于 Agent {agent_id}",
}, ensure_ascii=False)
name = schedule.name
# 先将引用此 schedule 的 execution 记录的 schedule_id 置空,避免外键约束
from app.models.execution import Execution
db.query(Execution).filter(Execution.schedule_id == schedule_id).update(
{Execution.schedule_id: None}, synchronize_session=False
)
db.delete(schedule)
db.commit()
db.close()
return json.dumps({
"success": True,
"message": f"定时任务「{name}」已删除",
}, ensure_ascii=False)
elif agent_id:
# 批量删除该 Agent 的所有定时任务
schedules = db.query(AgentSchedule).filter(AgentSchedule.agent_id == agent_id).all()
if not schedules:
db.close()
return json.dumps({
"success": True,
"message": f"Agent {agent_id} 没有定时任务需要删除",
"deleted_count": 0,
}, ensure_ascii=False)
names = [s.name for s in schedules]
count = len(schedules)
# 先解除所有 execution 记录的 schedule_id 外键引用
from app.models.execution import Execution
sids = [s.id for s in schedules]
db.query(Execution).filter(Execution.schedule_id.in_(sids)).update(
{Execution.schedule_id: None}, synchronize_session=False
)
for s in schedules:
db.delete(s)
db.commit()
db.close()
return json.dumps({
"success": True,
"message": f"已删除 {count} 个定时任务:{', '.join(names)}",
"deleted_count": count,
}, ensure_ascii=False)
finally:
try:
db.close()
except Exception:
pass
except Exception as e:
logger.error(f"schedule_delete 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": f"删除定时任务失败: {e}"}, ensure_ascii=False)
CRYPTO_UTIL_SCHEMA = {
"type": "function",
"function": {
"name": "crypto_util",
"description": "加密/哈希/编码工具:生成 UUID、Base64 编解码、计算 MD5/SHA256 等哈希值。",
"parameters": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["uuid", "base64_encode", "base64_decode", "hash"],
"description": "操作uuid=生成唯一ID, base64_encode=编码, base64_decode=解码, hash=计算哈希",
"default": "uuid",
},
"data": {
"type": "string",
"description": "输入数据base64编解码或哈希时必填",
},
"algorithm": {
"type": "string",
"enum": ["md5", "sha1", "sha256", "sha512"],
"description": "哈希算法hash 操作时使用,默认 sha256",
"default": "sha256",
},
},
},
},
}
RANDOM_GENERATE_SCHEMA = {
"type": "function",
"function": {
"name": "random_generate",
"description": "安全随机数据生成:密码、随机字符串、随机整数、随机浮点数。使用 secrets 模块保证安全性。",
"parameters": {
"type": "object",
"properties": {
"generate_type": {
"type": "string",
"enum": ["password", "string", "number", "float"],
"description": "类型password=安全密码, string=随机字符串, number=随机整数, float=随机浮点数",
"default": "password",
},
"length": {
"type": "integer",
"description": "密码/字符串长度password 默认16最少8string 默认16",
"default": 16,
"minimum": 1,
"maximum": 256,
},
"count": {
"type": "integer",
"description": "生成数量默认1最多50",
"default": 1,
"minimum": 1,
"maximum": 50,
},
"min_val": {
"type": "number",
"description": "最小值number/float 类型使用)",
"default": 0,
},
"max_val": {
"type": "number",
"description": "最大值number/float 类型使用)",
"default": 100,
},
},
},
},
}
SEND_EMAIL_SCHEMA = {
"type": "function",
"function": {
"name": "send_email",
"description": "发送邮件。SMTP 配置可从环境变量读取SMTP_HOST/SMTP_USER/SMTP_PASSWORD也可在调用时直接传入。",
"parameters": {
"type": "object",
"properties": {
"to": {
"type": "string",
"description": "收件人邮箱地址,多个用逗号分隔,如 a@x.com,b@x.com",
},
"subject": {
"type": "string",
"description": "邮件主题",
},
"body": {
"type": "string",
"description": "邮件正文内容",
},
"smtp_host": {
"type": "string",
"description": "SMTP 服务器地址(如 smtp.qq.com留空从环境变量 SMTP_HOST 读取",
},
"smtp_port": {
"type": "integer",
"description": "SMTP 端口,默认 587",
"default": 587,
},
"smtp_user": {
"type": "string",
"description": "发件人邮箱 / SMTP 用户名,留空从环境变量 SMTP_USER 读取",
},
"smtp_password": {
"type": "string",
"description": "SMTP 授权码/密码,留空从环境变量 SMTP_PASSWORD 读取",
},
"from_name": {
"type": "string",
"description": "发件人显示名称(可选)",
},
"body_type": {
"type": "string",
"enum": ["plain", "html"],
"description": "正文类型,默认 plain",
"default": "plain",
},
},
"required": ["to", "subject", "body"],
},
},
}
URL_PARSE_SCHEMA = {
"type": "function",
"function": {
"name": "url_parse",
"description": "URL 解析与构建工具:解析 URL 各部分、提取查询参数、构建带参数的 URL。",
"parameters": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "要处理的 URL",
},
"operation": {
"type": "string",
"enum": ["parse", "get_param", "all_params", "build"],
"description": "操作parse=解析结构, get_param=取指定参数, all_params=所有参数, build=构建URL追加参数",
},
"key": {
"type": "string",
"description": "查询参数名get_param 操作时使用)",
},
"params": {
"type": "string",
"description": "参数 JSONbuild 操作时使用),如 {\"page\":\"1\"}",
},
},
"required": ["url"],
},
},
}
REGEX_TEST_SCHEMA = {
"type": "function",
"function": {
"name": "regex_test",
"description": "正则表达式测试工具:匹配查找、查找全部、替换、分割、验证等操作。",
"parameters": {
"type": "object",
"properties": {
"pattern": {
"type": "string",
"description": "正则表达式模式,如 \\d{4}-\\d{2}-\\d{2}",
},
"test_string": {
"type": "string",
"description": "要测试的目标字符串",
},
"operation": {
"type": "string",
"enum": ["match", "findall", "replace", "split", "validate"],
"description": "操作match=首个匹配, findall=全部匹配, replace=替换, split=分割, validate=验证完整匹配",
"default": "match",
},
"flags": {
"type": "string",
"description": "标志组合i=忽略大小写, m=多行, s=点匹配换行(如 is 表示忽略大小写+点匹配换行)",
},
"replacement": {
"type": "string",
"description": "替换文本replace 操作时使用)",
},
},
"required": ["pattern", "test_string"],
},
},
}
SCHEDULE_CREATE_SCHEMA = {
"type": "function",
"function": {
"name": "schedule_create",
"description": "为 Agent 创建定时任务,按 cron 表达式周期自动执行。可用于设置每日推送、定时检查等。",
"parameters": {
"type": "object",
"properties": {
"agent_id": {
"type": "string",
"description": "Agent ID即当前对话所属的 Agent",
},
"name": {
"type": "string",
"description": "任务名称,如「每日早报推送」「每小时检查」",
},
"cron_expression": {
"type": "string",
"description": "标准 5 位 cron 表达式,空格分隔。常见:每分钟=* * * * *,每小时=0 * * * *每天9点=0 9 * * *每天18点=0 18 * * *每周一9点=0 9 * * 1每月1号=0 9 1 * *",
},
"input_message": {
"type": "string",
"description": "每次定时触发时发送给 Agent 的消息内容,如「帮我生成今日工作汇总并推送」",
},
"webhook_url": {
"type": "string",
"description": "可选,飞书机器人 Webhook URL执行完成后推送到飞书群",
},
},
"required": ["agent_id", "name", "cron_expression", "input_message"],
},
},
}
SCHEDULE_LIST_SCHEMA = {
"type": "function",
"function": {
"name": "schedule_list",
"description": "列出某个 Agent 的所有定时任务。",
"parameters": {
"type": "object",
"properties": {
"agent_id": {
"type": "string",
"description": "Agent ID要查询的 Agent",
},
},
"required": ["agent_id"],
},
},
}
SCHEDULE_DELETE_SCHEMA = {
"type": "function",
"function": {
"name": "schedule_delete",
"description": (
"删除定时任务。支持两种方式:"
"1) 提供 schedule_id 精确删除单个任务;"
"2) 提供 agent_id 批量删除该 Agent 的所有任务。"
"建议同时提供 agent_id 以校验所有权。"
),
"parameters": {
"type": "object",
"properties": {
"schedule_id": {
"type": "string",
"description": "定时任务 ID精确删除单个任务时使用可从 schedule_list 查询获得)",
},
"agent_id": {
"type": "string",
"description": "Agent ID用于校验 schedule_id 的所属权,或批量删除该 Agent 的所有任务)",
},
},
"required": [],
},
},
}
# ── agent_call ─────────────────────────────────────────────────
async def agent_call_tool(
agent_name: str,
query: str,
max_iterations: int = 10,
) -> str:
"""
调用另一个 Agent 处理任务并返回结果。
在数据库中按名称模糊匹配 Agent用其 workflow_config 中 agent/llm 节点的
配置执行一次 ReAct 推理,然后将结果返回给调用方(如全能助手)。
Args:
agent_name: 目标 Agent 名称(支持模糊匹配,匹配到多个时取最接近的一个)
query: 发给目标 Agent 的用户消息
max_iterations: 最大推理步数(默认 10
"""
import asyncio as _asyncio
try:
from app.core.database import SessionLocal
from app.models.agent import Agent
from app.agent_runtime.core import AgentRuntime
from app.agent_runtime.schemas import (
AgentConfig,
AgentLLMConfig,
AgentToolConfig,
)
# 1. 查 DB模糊匹配 Agent
db = SessionLocal()
try:
candidates = (
db.query(Agent)
.filter(Agent.name.like(f"%{agent_name}%"))
.limit(5)
.all()
)
if not candidates:
return json.dumps(
{
"error": "agent_not_found",
"message": (
f"未找到匹配「{agent_name}」的 Agent。"
"请确认 Agent 名称是否正确,或在 Agent 管理中先创建目标 Agent。"
),
},
ensure_ascii=False,
)
# 精确匹配优先,否则取第一个
target = next(
(a for a in candidates if a.name == agent_name),
candidates[0],
)
finally:
db.close()
# 2. 从 workflow_config 提取 agent/llm 节点配置
wf = target.workflow_config or {}
nodes = wf.get("nodes", [])
agent_node = next(
(n for n in nodes if n.get("type") in ("agent", "llm")),
None,
)
if not agent_node:
return json.dumps(
{
"error": "no_agent_node",
"message": f"Agent「{target.name}」的工作流中未找到 Agent/LLM 节点,无法执行",
},
ensure_ascii=False,
)
nd = agent_node.get("data", {}) or {}
system_prompt = nd.get("system_prompt") or nd.get("prompt") or (
"你是一个有用的AI助手。"
)
model = nd.get("model", "deepseek-v4-flash")
provider = nd.get("provider", "deepseek")
temperature = float(nd.get("temperature", 0.7))
node_max_iter = int(nd.get("max_iterations") or 0)
if node_max_iter > 0:
max_iterations = min(max_iterations, node_max_iter)
# 3. 构建配置并执行
config = AgentConfig(
name=target.name or "sub_agent",
system_prompt=system_prompt,
llm=AgentLLMConfig(
provider=provider,
model=model,
temperature=temperature,
max_iterations=max_iterations,
),
tools=AgentToolConfig(
include_tools=nd.get("tools") or [],
exclude_tools=nd.get("exclude_tools") or [],
),
memory={
"enabled": nd.get("memory", True),
"persist_to_db": nd.get("memory", True),
},
)
runtime = AgentRuntime(config=config)
result = await runtime.run(query)
if result.success:
out = {
"agent": target.name,
"status": "success",
"iterations": result.iterations_used,
"tool_calls": result.tool_calls_made,
"reply": result.content,
}
else:
out = {
"agent": target.name,
"status": "error",
"error": result.error,
"reply": result.content or f"Agent 执行失败: {result.error}",
}
return json.dumps(out, ensure_ascii=False)
except Exception as e:
logger.error(f"agent_call 工具执行失败: {e}", exc_info=True)
return json.dumps(
{
"error": "execution_failed",
"message": f"调用 Agent 时出错: {e}",
},
ensure_ascii=False,
)
AGENT_CALL_SCHEMA = {
"type": "function",
"function": {
"name": "agent_call",
"description": (
"调用另一个已注册的 Agent 来处理任务并返回结果。适合用来将子任务委托给"
"具备特定专长的 Agent如家庭医生助手、代码助手等。会话不会被接管"
"结果会以文本形式返回给调用方用于整合回复。"
),
"parameters": {
"type": "object",
"properties": {
"agent_name": {
"type": "string",
"description": "目标 Agent 名称,支持模糊匹配(如「家庭医生」「代码助手」)",
},
"query": {
"type": "string",
"description": "发给目标 Agent 的查询内容,可以包含上下文信息",
},
"max_iterations": {
"type": "integer",
"description": "最大推理步数(默认 10控制 Agent 的思考深度",
"default": 10,
},
},
"required": ["agent_name", "query"],
},
},
}
# ── code_execute ─────────────────────────────────────────────
CODE_EXECUTE_TIMEOUT = 30
CODE_EXECUTE_MAX_OUTPUT = 8000
_CODE_SAFE_BUILTINS = {
"abs": abs, "all": all, "any": any, "ascii": ascii,
"bin": bin, "bool": bool, "bytearray": bytearray, "bytes": bytes,
"chr": chr, "complex": complex, "dict": dict, "divmod": divmod,
"enumerate": enumerate, "filter": filter, "float": float, "format": format,
"frozenset": frozenset, "getattr": getattr, "hasattr": hasattr,
"hash": hash, "hex": hex, "id": id, "int": int, "isinstance": isinstance,
"issubclass": issubclass, "iter": iter, "len": len, "list": list,
"map": map, "max": max, "min": min, "next": next, "object": object,
"oct": oct, "ord": ord, "pow": pow, "print": print, "range": range,
"repr": repr, "reversed": reversed, "round": round, "set": set,
"slice": slice, "sorted": sorted, "str": str, "sum": sum, "tuple": tuple,
"type": type, "zip": zip,
}
async def code_execute_tool(
code: str,
language: str = "python",
timeout: int = CODE_EXECUTE_TIMEOUT,
) -> str:
"""沙箱执行 Python/JS 代码并返回 stdout/stderr。"""
import asyncio as _asyncio
import tempfile
lang = language.lower().strip()
if lang not in ("python", "py", "javascript", "js"):
return json.dumps({"error": f"不支持的语言: {language},仅支持 python / javascript"}, ensure_ascii=False)
if lang in ("javascript", "js"):
# JS 用 node -e 执行
try:
proc = await _asyncio.create_subprocess_exec(
"node", "-e", code,
stdout=_asyncio.subprocess.PIPE,
stderr=_asyncio.subprocess.PIPE,
)
stdout, stderr = await _asyncio.wait_for(
proc.communicate(), timeout=timeout,
)
return json.dumps({
"stdout": stdout.decode("utf-8", errors="replace")[:CODE_EXECUTE_MAX_OUTPUT],
"stderr": stderr.decode("utf-8", errors="replace")[:CODE_EXECUTE_MAX_OUTPUT],
"returncode": proc.returncode,
}, ensure_ascii=False)
except FileNotFoundError:
return json.dumps({"error": "Node.js 未安装,无法执行 JavaScript"}, ensure_ascii=False)
except _asyncio.TimeoutError:
return json.dumps({"error": f"执行超时 ({timeout}s)"}, ensure_ascii=False)
# Python: 写入临时文件再 exec保证安全
with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False, encoding="utf-8") as f:
f.write(code)
tmp_path = f.name
try:
proc = await _asyncio.create_subprocess_exec(
sys.executable, tmp_path,
stdout=_asyncio.subprocess.PIPE,
stderr=_asyncio.subprocess.PIPE,
)
stdout, stderr = await _asyncio.wait_for(
proc.communicate(), timeout=timeout,
)
return json.dumps({
"stdout": stdout.decode("utf-8", errors="replace")[:CODE_EXECUTE_MAX_OUTPUT],
"stderr": stderr.decode("utf-8", errors="replace")[:CODE_EXECUTE_MAX_OUTPUT],
"returncode": proc.returncode,
}, ensure_ascii=False)
except _asyncio.TimeoutError:
return json.dumps({"error": f"执行超时 ({timeout}s)"}, ensure_ascii=False)
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
CODE_EXECUTE_SCHEMA = {
"type": "function",
"function": {
"name": "code_execute",
"description": "在沙箱中执行 Python 或 JavaScript 代码,返回 stdout/stderr。适合编写脚本验证逻辑、数据处理、自动化任务。超时默认 30s。",
"parameters": {
"type": "object",
"properties": {
"code": {"type": "string", "description": "要执行的代码"},
"language": {"type": "string", "enum": ["python", "javascript"], "description": "语言(默认 python", "default": "python"},
"timeout": {"type": "integer", "description": "超时秒数(默认 30", "default": 30},
},
"required": ["code"],
},
},
}
# ── git_operation ────────────────────────────────────────────
_GIT_ALLOWED_COMMANDS = {
"log": ["git", "log", "--oneline", "--max-count=50"],
"diff": ["git", "diff"],
"diff_staged": ["git", "diff", "--staged"],
"status": ["git", "status", "--short"],
"branch": ["git", "branch", "-a"],
"blame": ["git", "blame", "--date=short"],
"show": ["git", "show", "--stat"],
"tag": ["git", "tag", "-l"],
"remote": ["git", "remote", "-v"],
"rev_parse": ["git", "rev-parse", "--abbrev-ref", "HEAD"],
}
async def git_operation_tool(operation: str, file_path: str = "", revision: str = "HEAD") -> str:
"""执行 Git 只读操作log/diff/status/blame/show/branch/tag/remote"""
import asyncio as _asyncio
op = operation.lower().strip()
if op not in _GIT_ALLOWED_COMMANDS:
return json.dumps({
"error": f"不支持的 git 操作: {operation}",
"allowed": sorted(_GIT_ALLOWED_COMMANDS.keys()),
}, ensure_ascii=False)
base_cmd = list(_GIT_ALLOWED_COMMANDS[op])
# 需要文件路径的操作
if op in ("diff", "blame", "log") and file_path:
if op == "blame":
base_cmd.append(file_path)
elif op == "diff":
base_cmd.extend(["--", file_path])
elif op == "log":
base_cmd.extend(["--", file_path])
if op in ("show",) and revision:
base_cmd[-1] = revision
try:
proc = await _asyncio.create_subprocess_exec(
*base_cmd,
stdout=_asyncio.subprocess.PIPE,
stderr=_asyncio.subprocess.PIPE,
)
stdout, stderr = await _asyncio.wait_for(proc.communicate(), timeout=15)
return json.dumps({
"stdout": stdout.decode("utf-8", errors="replace")[:8000],
"stderr": stderr.decode("utf-8", errors="replace")[:2000],
"returncode": proc.returncode,
}, ensure_ascii=False)
except FileNotFoundError:
return json.dumps({"error": "Git 未安装或不在 PATH 中"}, ensure_ascii=False)
except _asyncio.TimeoutError:
return json.dumps({"error": "Git 操作超时"}, ensure_ascii=False)
GIT_OPERATION_SCHEMA = {
"type": "function",
"function": {
"name": "git_operation",
"description": "执行 Git 只读操作log提交历史、diff差异、status状态、blame谁改了哪行、branch分支列表、show提交详情、tag标签、remote远程仓库。所有操作均为只读不会修改仓库。",
"parameters": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": sorted(_GIT_ALLOWED_COMMANDS.keys()),
"description": "Git 操作名称",
},
"file_path": {"type": "string", "description": "文件路径diff/blame/log 时可选)"},
"revision": {"type": "string", "description": "提交哈希或分支名show 时使用)", "default": "HEAD"},
},
"required": ["operation"],
},
},
}
# ── web_search ───────────────────────────────────────────────
async def _search_via_bing(query: str, max_results: int) -> str:
"""通过 Bing Web Search API 搜索(需 BING_SEARCH_API_KEY"""
from app.core.config import settings
api_key = settings.BING_SEARCH_API_KEY
if not api_key:
return json.dumps({"error": "Bing Search API Key 未配置"}, ensure_ascii=False)
proxy = settings.SEARCH_PROXY or None
client_kwargs = {"timeout": 15.0}
if proxy:
client_kwargs["proxy"] = proxy
async with httpx.AsyncClient(**client_kwargs) as client:
resp = await client.get(
"https://api.bing.microsoft.com/v7.0/search",
headers={"Ocp-Apim-Subscription-Key": api_key},
params={"q": query, "count": min(max_results, 50), "mkt": "zh-CN", "textFormat": "Raw"},
)
if resp.status_code != 200:
return json.dumps({"error": f"Bing 搜索失败 HTTP {resp.status_code}: {resp.text[:200]}"}, ensure_ascii=False)
data = resp.json()
web_pages = (data.get("webPages") or {}).get("value", [])
if not web_pages:
return json.dumps({"message": "Bing 未找到搜索结果,请尝试更具体的关键词", "results": []}, ensure_ascii=False)
results = []
for i, page in enumerate(web_pages[:max_results]):
results.append({
"index": i + 1,
"title": page.get("name", ""),
"url": page.get("url", ""),
"snippet": (page.get("snippet", "") or "")[:300],
})
return json.dumps({"results": results, "count": len(results), "source": "bing"}, ensure_ascii=False)
async def _search_via_duckduckgo(query: str, max_results: int) -> str:
"""通过 DuckDuckGo HTML 搜索(无需 API Key可配代理"""
import re as _re
import urllib.parse
from app.core.config import settings
safe_q = urllib.parse.quote(query, safe="")
url = f"https://html.duckduckgo.com/html/?q={safe_q}"
proxy = settings.SEARCH_PROXY or None
client_kwargs = {"timeout": 15.0, "follow_redirects": True}
if proxy:
client_kwargs["proxy"] = proxy
async with httpx.AsyncClient(**client_kwargs) as client:
resp = await client.get(url, headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
})
if resp.status_code != 200:
return json.dumps({"error": f"DuckDuckGo 搜索失败 HTTP {resp.status_code}"}, ensure_ascii=False)
html = resp.text
results = []
blocks = _re.findall(
r'<a[^>]*class="result__a"[^>]*href="([^"]*)"[^>]*>(.*?)</a>.*?<a[^>]*class="result__snippet"[^>]*>(.*?)</a>',
html, _re.DOTALL | _re.IGNORECASE,
)
if not blocks:
blocks = _re.findall(
r'<a[^>]*rel="nofollow"[^>]*class="result__url"[^>]*href="([^"]*)"[^>]*>.*?<a[^>]*class="result__a"[^>]*>(.*?)</a>.*?<a[^>]*class="result__snippet"[^>]*>(.*?)</a>',
html, _re.DOTALL | _re.IGNORECASE,
)
for i, (link, title, snippet) in enumerate(blocks[:max_results]):
title_clean = _re.sub(r"<.*?>", "", title).strip()
snippet_clean = _re.sub(r"<.*?>", "", snippet).strip()
results.append({
"index": i + 1,
"title": title_clean,
"url": link,
"snippet": snippet_clean[:300],
})
if not results:
return json.dumps({"message": "DuckDuckGo 未找到搜索结果,请尝试更具体的关键词", "results": []}, ensure_ascii=False)
return json.dumps({"results": results, "count": len(results), "source": "duckduckgo"}, ensure_ascii=False)
async def web_search_tool(query: str, max_results: int = 8) -> str:
"""搜索网页Bing 优先DuckDuckGo 降级,支持代理)。"""
from app.core.config import settings
# 1. 优先使用 Bing Search API国内可用
if settings.BING_SEARCH_API_KEY:
try:
result = await _search_via_bing(query, max_results)
parsed = json.loads(result)
if "results" in parsed and parsed["results"]:
return result
# Bing 返回空结果时继续降级
logger.info("Bing 搜索无结果,降级到 DuckDuckGo")
except Exception as e:
logger.warning("Bing 搜索异常,降级到 DuckDuckGo: %s", e)
# 2. 降级到 DuckDuckGo
try:
return await _search_via_duckduckgo(query, max_results)
except Exception as e:
logger.error(f"web_search 全部后端失败: {e}")
return json.dumps({"error": f"搜索失败: 所有搜索后端均不可用Bing/DuckDuckGo请检查网络或配置 SEARCH_PROXY"}, ensure_ascii=False)
WEB_SEARCH_SCHEMA = {
"type": "function",
"function": {
"name": "web_search",
"description": "搜索互联网获取网页信息Bing 优先DuckDuckGo 备用),返回标题、摘要和 URL 列表。适合查找最新文档、技术方案、新闻资讯。",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "搜索关键词"},
"max_results": {"type": "integer", "description": "最大结果数(默认 8", "default": 8},
},
"required": ["query"],
},
},
}
# ── pdf_generate ─────────────────────────────────────────────
async def pdf_generate_tool(markdown: str, output_path: str = "", title: str = "") -> str:
"""将 Markdown 内容生成 PDF 文件。优先用 weasyprint否则生成 HTML。"""
import tempfile
import asyncio as _asyncio
full_path = output_path or os.path.join(
_local_file_workspace_root(), f"report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
)
if not os.path.isabs(full_path):
full_path = os.path.join(_local_file_workspace_root(), full_path)
# 简单的 Markdown → HTML 转换(不使用外部库)
html_title = title or "报告"
html_body = _simple_md_to_html(markdown)
html_content = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head><meta charset="utf-8"><title>{html_title}</title>
<style>
body {{ font-family: 'Microsoft YaHei', 'PingFang SC', sans-serif; max-width: 800px; margin: 40px auto; padding: 20px; line-height: 1.8; color: #333; }}
h1 {{ border-bottom: 2px solid #1890ff; padding-bottom: 8px; }}
h2 {{ border-bottom: 1px solid #e8e8e8; padding-bottom: 6px; }}
code {{ background: #f5f5f5; padding: 2px 6px; border-radius: 3px; font-size: 0.9em; }}
pre {{ background: #f5f5f5; padding: 16px; border-radius: 8px; overflow-x: auto; }}
blockquote {{ border-left: 4px solid #1890ff; margin: 16px 0; padding: 8px 16px; background: #f0f5ff; }}
table {{ border-collapse: collapse; width: 100%; }}
th, td {{ border: 1px solid #ddd; padding: 8px 12px; text-align: left; }}
th {{ background: #fafafa; }}
</style></head>
<body>{html_body}</body></html>"""
# 尝试 weasyprint
try:
from weasyprint import HTML
HTML(string=html_content).write_pdf(full_path)
return json.dumps({"file": full_path, "format": "pdf", "engine": "weasyprint"}, ensure_ascii=False)
except ImportError:
pass
# 降级:保存为 HTML
html_path = full_path.rsplit(".", 1)[0] + ".html"
with open(html_path, "w", encoding="utf-8") as f:
f.write(html_content)
return json.dumps({
"file": html_path,
"format": "html",
"engine": "builtin",
"note": "weasyprint 未安装,已生成 HTML 文件。安装 weasyprint 后可直接生成 PDF。",
}, ensure_ascii=False)
def _simple_md_to_html(md: str) -> str:
"""极简 Markdown→HTML支持标题/列表/代码块/粗体/斜体/链接/表格。"""
lines = md.split("\n")
out = []
in_code_block = False
in_table = False
table_rows = []
for line in lines:
# 代码块
if line.strip().startswith("```"):
if in_code_block:
out.append("</code></pre>")
in_code_block = False
else:
out.append('<pre><code>')
in_code_block = True
continue
if in_code_block:
out.append(line)
continue
# 表格
if "|" in line and line.strip().startswith("|"):
cells = [c.strip() for c in line.strip().strip("|").split("|")]
if not in_table:
in_table = True
# 判断是否分隔行
if all(set(c) <= {"-", ":", " "} for c in cells):
continue
out.append("<table>")
table_rows.append(cells)
continue
elif in_table:
# 结束表格
if table_rows:
out.append("<thead><tr>" + "".join(f"<th>{c}</th>" for c in table_rows[0]) + "</tr></thead>")
for row in table_rows[1:]:
out.append("<tr>" + "".join(f"<td>{c}</td>" for c in row) + "</tr>")
out.append("</table>")
in_table = False
table_rows = []
# 标题
if line.startswith("# "):
out.append(f"<h1>{line[2:]}</h1>")
elif line.startswith("## "):
out.append(f"<h2>{line[3:]}</h2>")
elif line.startswith("### "):
out.append(f"<h3>{line[4:]}</h3>")
elif line.startswith("#### "):
out.append(f"<h4>{line[5:]}</h4>")
# 无序列表
elif line.strip().startswith("- "):
out.append(f"<li>{_inline_md(line.strip()[2:])}</li>")
elif line.strip().startswith("* "):
out.append(f"<li>{_inline_md(line.strip()[2:])}</li>")
# 引用
elif line.startswith("> "):
out.append(f"<blockquote>{_inline_md(line[2:])}</blockquote>")
# 分隔线
elif line.strip() in ("---", "***"):
out.append("<hr>")
# 空行
elif not line.strip():
out.append("<br>")
# 普通段落
else:
out.append(f"<p>{_inline_md(line)}</p>")
if in_table:
if table_rows:
out.append("<thead><tr>" + "".join(f"<th>{c}</th>" for c in table_rows[0]) + "</tr></thead>")
for row in table_rows[1:]:
out.append("<tr>" + "".join(f"<td>{c}</td>" for c in row) + "</tr>")
out.append("</table>")
return "\n".join(out)
def _inline_md(text: str) -> str:
"""处理行内 Markdown粗体/斜体/代码/链接/图片。"""
import re as _re
text = _re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
text = _re.sub(r"\*(.+?)\*", r"<em>\1</em>", text)
text = _re.sub(r"`(.+?)`", r"<code>\1</code>", text)
text = _re.sub(r"!\[([^\]]*)\]\(([^)]+)\)", r'<img src="\2" alt="\1">', text)
text = _re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r'<a href="\2">\1</a>', text)
return text
PDF_GENERATE_SCHEMA = {
"type": "function",
"function": {
"name": "pdf_generate",
"description": "将 Markdown 内容生成 PDF 报告(需 weasyprint或降级为 HTML 文件。适合生成项目文档、分析报告、周报等。",
"parameters": {
"type": "object",
"properties": {
"markdown": {"type": "string", "description": "Markdown 格式的报告内容"},
"output_path": {"type": "string", "description": "输出文件路径(可选,默认自动生成)"},
"title": {"type": "string", "description": "报告标题(可选)"},
},
"required": ["markdown"],
},
},
}
# ── project_scaffold ─────────────────────────────────────────
_PROJECT_TEMPLATES = {
"fastapi": {
"description": "FastAPI 后端项目模板",
"files": {
"main.py": "from fastapi import FastAPI\n\napp = FastAPI()\n\n@app.get('/')\ndef root():\n return {'message': 'Hello'}\n",
"requirements.txt": "fastapi\nuvicorn\n",
"Dockerfile": "FROM python:3.12-slim\nWORKDIR /app\nCOPY . .\nRUN pip install -r requirements.txt\nCMD [\"uvicorn\", \"main:app\", \"--host\", \"0.0.0.0\", \"--port\", \"8000\"]\n",
},
},
"vue": {
"description": "Vue 3 + Vite 前端项目",
"files": {
"src/App.vue": "<template>\n <div id=\"app\">\n <h1>{{ title }}</h1>\n </div>\n</template>\n\n<script setup>\nconst title = 'Vue App'\n</script>\n",
"src/main.js": "import { createApp } from 'vue'\nimport App from './App.vue'\n\ncreateApp(App).mount('#app')\n",
"index.html": "<!DOCTYPE html>\n<html>\n<head><title>Vue App</title></head>\n<body>\n <div id=\"app\"></div>\n <script type=\"module\" src=\"/src/main.js\"></script>\n</body>\n</html>\n",
"package.json": '{"name":"my-app","scripts":{"dev":"vite","build":"vite build"},"dependencies":{"vue":"^3.4"},"devDependencies":{"vite":"^5","@vitejs/plugin-vue":"^5"}}\n',
"vite.config.js": "import { defineConfig } from 'vite'\nimport vue from '@vitejs/plugin-vue'\n\nexport default defineConfig({\n plugins: [vue()],\n})\n",
},
},
"react": {
"description": "React + Vite 项目模板",
"files": {
"src/App.jsx": "export default function App() {\n return <h1>React App</h1>\n}\n",
"src/main.jsx": "import React from 'react'\nimport ReactDOM from 'react-dom/client'\nimport App from './App'\n\nReactDOM.createRoot(document.getElementById('root')).render(<App />)\n",
"index.html": "<!DOCTYPE html>\n<html>\n<head><title>React App</title></head>\n<body>\n <div id=\"root\"></div>\n <script type=\"module\" src=\"/src/main.jsx\"></script>\n</body>\n</html>\n",
"package.json": '{"name":"my-app","scripts":{"dev":"vite","build":"vite build"},"dependencies":{"react":"^18","react-dom":"^18"},"devDependencies":{"vite":"^5","@vitejs/plugin-react":"^4"}}\n',
"vite.config.js": "import { defineConfig } from 'vite'\nimport react from '@vitejs/plugin-react'\n\nexport default defineConfig({\n plugins: [react()],\n})\n",
},
},
"python_cli": {
"description": "Python CLI 项目模板",
"files": {
"main.py": "#!/usr/bin/env python3\n\"\"\"CLI 工具描述\"\"\"\nimport argparse\n\ndef main():\n parser = argparse.ArgumentParser()\n parser.add_argument('input', help='输入文件')\n args = parser.parse_args()\n print(f'Processing: {args.input}')\n\nif __name__ == '__main__':\n main()\n",
"requirements.txt": "",
},
},
"shell": {
"description": "Shell 脚本项目模板",
"files": {
"run.sh": "#!/bin/bash\nset -euo pipefail\n\necho \"Script started\"\n",
},
},
}
async def project_scaffold_tool(template: str, project_name: str, target_dir: str = "") -> str:
"""生成项目脚手架。"""
tmpl = template.lower().strip()
if tmpl not in _PROJECT_TEMPLATES:
return json.dumps({
"error": f"未知模板: {template}",
"available": sorted(_PROJECT_TEMPLATES.keys()),
"description": {k: v["description"] for k, v in _PROJECT_TEMPLATES.items()},
}, ensure_ascii=False)
root = _local_file_workspace_root()
base = os.path.join(target_dir, project_name) if target_dir else os.path.join(root, project_name)
base = os.path.abspath(base)
if os.path.exists(base):
return json.dumps({"error": f"目录已存在: {base}"}, ensure_ascii=False)
tpl = _PROJECT_TEMPLATES[tmpl]
created = []
for rel_path, content in tpl["files"].items():
full = os.path.join(base, rel_path)
os.makedirs(os.path.dirname(full), exist_ok=True)
with open(full, "w", encoding="utf-8") as f:
f.write(content)
created.append(rel_path)
return json.dumps({
"project_dir": base,
"template": tmpl,
"description": tpl["description"],
"files_created": created,
"count": len(created),
}, ensure_ascii=False)
PROJECT_SCAFFOLD_SCHEMA = {
"type": "function",
"function": {
"name": "project_scaffold",
"description": "按模板生成项目目录结构。支持 fastapi、vue、react、python_cli、shell。适合快速搭项目骨架。",
"parameters": {
"type": "object",
"properties": {
"template": {
"type": "string",
"enum": sorted(_PROJECT_TEMPLATES.keys()),
"description": "项目模板名称",
},
"project_name": {"type": "string", "description": "项目名称(将作为目录名)"},
"target_dir": {"type": "string", "description": "目标父目录(可选,默认工作区根目录)"},
},
"required": ["template", "project_name"],
},
},
}
# ── task_plan ────────────────────────────────────────────────
_TASK_PLAN_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "data", "task_plans")
async def task_plan_tool(action: str, plan_id: str = "", title: str = "", steps_json: str = "", step_index: int = -1, status: str = "") -> str:
"""任务规划:创建/查看/更新/完成/删除 任务计划。"""
os.makedirs(_TASK_PLAN_DIR, exist_ok=True)
action = action.lower().strip()
if action == "list":
plans = []
for fname in sorted(os.listdir(_TASK_PLAN_DIR)):
if fname.endswith(".json"):
fpath = os.path.join(_TASK_PLAN_DIR, fname)
try:
with open(fpath, "r", encoding="utf-8") as f:
p = json.load(f)
plans.append({
"id": p.get("id", fname.replace(".json", "")),
"title": p.get("title", ""),
"total_steps": len(p.get("steps", [])),
"completed": sum(1 for s in p.get("steps", []) if s.get("status") == "done"),
"created_at": p.get("created_at", ""),
})
except Exception:
pass
return json.dumps({"plans": plans, "count": len(plans)}, ensure_ascii=False)
if action == "create":
if not title or not steps_json:
return json.dumps({"error": "create 操作需要 title 和 steps_json 参数"}, ensure_ascii=False)
try:
steps = json.loads(steps_json)
except json.JSONDecodeError:
return json.dumps({"error": "steps_json 不是有效 JSON"}, ensure_ascii=False)
plan_id = plan_id or f"plan_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
plan = {
"id": plan_id,
"title": title,
"steps": [{"index": i, "title": s, "status": "pending", "note": ""} for i, s in enumerate(steps)],
"created_at": datetime.now().isoformat(),
"updated_at": datetime.now().isoformat(),
}
fpath = os.path.join(_TASK_PLAN_DIR, f"{plan_id}.json")
with open(fpath, "w", encoding="utf-8") as f:
json.dump(plan, f, ensure_ascii=False, indent=2)
return json.dumps({"plan": plan, "file": fpath}, ensure_ascii=False)
if not plan_id:
return json.dumps({"error": "需要 plan_id 参数"}, ensure_ascii=False)
fpath = os.path.join(_TASK_PLAN_DIR, f"{plan_id}.json")
if not os.path.exists(fpath):
return json.dumps({"error": f"任务计划不存在: {plan_id}"}, ensure_ascii=False)
with open(fpath, "r", encoding="utf-8") as f:
plan = json.load(f)
if action == "view":
return json.dumps(plan, ensure_ascii=False)
if action == "update_step":
if step_index < 0 or status not in ("pending", "in_progress", "done", "blocked"):
return json.dumps({"error": "需要有效的 step_index 和 status (pending/in_progress/done/blocked)"}, ensure_ascii=False)
for s in plan["steps"]:
if s["index"] == step_index:
s["status"] = status
plan["updated_at"] = datetime.now().isoformat()
break
with open(fpath, "w", encoding="utf-8") as f:
json.dump(plan, f, ensure_ascii=False, indent=2)
return json.dumps({"plan": plan, "updated_step": step_index, "new_status": status}, ensure_ascii=False)
if action == "delete":
os.remove(fpath)
return json.dumps({"deleted": plan_id}, ensure_ascii=False)
return json.dumps({"error": f"不支持的操作: {action},支持 create/list/view/update_step/delete"}, ensure_ascii=False)
TASK_PLAN_SCHEMA = {
"type": "function",
"function": {
"name": "task_plan",
"description": "管理复杂任务的分解和进度跟踪。可创建计划、查看进度、更新步骤状态。适合多步骤项目(如「开发用户系统」拆成 7 步逐一完成)。",
"parameters": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["create", "list", "view", "update_step", "delete"],
"description": "操作create=创建计划, list=列出所有, view=查看详情, update_step=更新步骤状态, delete=删除",
},
"plan_id": {"type": "string", "description": "计划 IDcreate 可选,其他必填)"},
"title": {"type": "string", "description": "计划标题create 时必填)"},
"steps_json": {"type": "string", "description": "步骤列表 JSON 字符串,如 [\"分析需求\",\"设计架构\",\"编码实现\",\"测试验证\"]create 时必填)"},
"step_index": {"type": "integer", "description": "步骤序号update_step 时必填,从 0 开始)"},
"status": {"type": "string", "enum": ["pending", "in_progress", "done", "blocked"], "description": "步骤新状态"},
},
"required": ["action"],
},
},
}
# ── excel_process ─────────────────────────────────────────────
async def excel_process_tool(
file_path: str,
action: str = "read",
sheet_name: str = "",
data_json: str = "",
chart_config_json: str = "",
) -> str:
"""高级 Excel 处理:读写、创建图表、数据透视。"""
if not file_path:
return json.dumps({"error": "缺少 file_path"}, ensure_ascii=False)
if not os.path.isabs(file_path):
file_path = os.path.join(_local_file_workspace_root(), file_path)
try:
import openpyxl
from openpyxl.chart import BarChart, LineChart, PieChart, Reference
from openpyxl.utils import get_column_letter
except ImportError:
return json.dumps({"error": "openpyxl 未安装,请执行 pip install openpyxl"}, ensure_ascii=False)
action = action.lower().strip()
if action == "read":
try:
wb = openpyxl.load_workbook(file_path, data_only=True)
except FileNotFoundError:
return json.dumps({"error": f"文件不存在: {file_path}"}, ensure_ascii=False)
result = {"sheets": {}}
sheets = [sheet_name] if sheet_name else wb.sheetnames
for sn in sheets:
ws = wb[sn]
rows = []
for row in ws.iter_rows(min_row=1, max_row=min(ws.max_row, 200), max_col=min(ws.max_column, 50), values_only=True):
rows.append(list(row))
result["sheets"][sn] = {
"rows": rows,
"row_count": len(rows),
"col_count": len(rows[0]) if rows else 0,
}
wb.close()
return json.dumps(result, ensure_ascii=False, default=str)
if action == "write":
try:
data = json.loads(data_json)
except json.JSONDecodeError:
return json.dumps({"error": "data_json 不是有效 JSON"}, ensure_ascii=False)
wb = openpyxl.Workbook()
for sn, rows in data.items() if isinstance(data, dict) else {"Sheet": data}.items():
if sn != list((data if isinstance(data, dict) else {"Sheet": data}).keys())[0]:
wb.create_sheet(sn)
ws = wb[sn]
for row_idx, row in enumerate(rows, 1):
for col_idx, val in enumerate(row, 1):
ws.cell(row=row_idx, column=col_idx, value=val)
wb.save(file_path)
wb.close()
return json.dumps({"file": file_path, "message": "写入成功"}, ensure_ascii=False)
if action == "chart":
try:
cfg = json.loads(chart_config_json) if chart_config_json else {}
except json.JSONDecodeError:
return json.dumps({"error": "chart_config_json 不是有效 JSON"}, ensure_ascii=False)
wb = openpyxl.load_workbook(file_path)
ws = wb[sheet_name] if sheet_name else wb.active
chart_type = cfg.get("type", "bar")
if chart_type == "bar":
chart = BarChart()
elif chart_type == "line":
chart = LineChart()
elif chart_type == "pie":
chart = PieChart()
else:
wb.close()
return json.dumps({"error": f"不支持的图表类型: {chart_type}"}, ensure_ascii=False)
chart.title = cfg.get("title", "Chart")
data = Reference(ws, min_col=cfg.get("data_min_col", 1), min_row=cfg.get("data_min_row", 1),
max_col=cfg.get("data_max_col", 2), max_row=cfg.get("data_max_row", ws.max_row))
chart.add_data(data, titles_from_data=True)
if "categories_min_col" in cfg:
cats = Reference(ws, min_col=cfg["categories_min_col"], min_row=cfg.get("data_min_row", 2),
max_row=cfg.get("data_max_row", ws.max_row))
chart.set_categories(cats)
chart_row = cfg.get("chart_row", ws.max_row + 3)
ws.add_chart(chart, f"A{chart_row}")
wb.save(file_path)
wb.close()
return json.dumps({"file": file_path, "chart_type": chart_type, "position": f"A{chart_row}", "message": "图表已添加"}, ensure_ascii=False)
return json.dumps({"error": f"不支持的操作: {action},支持 read/write/chart"}, ensure_ascii=False)
EXCEL_PROCESS_SCHEMA = {
"type": "function",
"function": {
"name": "excel_process",
"description": "高级 Excel 处理:读写数据、添加图表(柱状图/折线图/饼图)。需要 openpyxl。适合数据分析、报表生成。",
"parameters": {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "Excel 文件路径"},
"action": {
"type": "string",
"enum": ["read", "write", "chart"],
"description": "操作read=读取, write=写入, chart=添加图表",
},
"sheet_name": {"type": "string", "description": "工作表名称"},
"data_json": {"type": "string", "description": "写入数据 JSONwrite 时,格式 {\"Sheet1\": [[1,2],[3,4]]}"},
"chart_config_json": {"type": "string", "description": "图表配置 JSONchart 时,如 {\"type\":\"bar\",\"title\":\"销售额\"}"},
},
"required": ["file_path", "action"],
},
},
}
# ── browser_use ──────────────────────────────────────────────
async def browser_use_tool(
url: str,
action: str = "screenshot",
selector: str = "",
script: str = "",
wait_ms: int = 2000,
) -> str:
"""无头浏览器操控。需要 playwrightpip install playwright && playwright install chromium"""
import asyncio as _asyncio
try:
from playwright.async_api import async_playwright
except ImportError:
return json.dumps({
"error": "playwright 未安装",
"help": "安装方法: pip install playwright && playwright install chromium",
}, ensure_ascii=False)
action = action.lower().strip()
result = {}
async with async_playwright() as p:
try:
browser = await p.chromium.launch(headless=True)
except Exception:
return json.dumps({
"error": "无法启动 Chromium",
"help": "请运行: playwright install chromium",
}, ensure_ascii=False)
page = await browser.new_page()
try:
await page.goto(url, wait_until="networkidle", timeout=30000)
await _asyncio.sleep(wait_ms / 1000.0)
if action == "screenshot":
import tempfile
ss_path = os.path.join(
tempfile.gettempdir(),
f"screenshot_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png",
)
await page.screenshot(path=ss_path, full_page=True)
result["screenshot"] = ss_path
elif action == "content":
html = await page.content()
# 提取文本
text = await page.evaluate("() => document.body.innerText")
result["text"] = text[:8000]
result["text_length"] = len(text)
elif action == "click" and selector:
await page.click(selector)
result["clicked"] = selector
elif action == "fill" and selector and script:
await page.fill(selector, script)
result["filled"] = selector
elif action == "evaluate" and script:
val = await page.evaluate(script)
result["evaluate_result"] = str(val)[:4000]
else:
result["error"] = f"不支持的操作: {action},支持 screenshot/content/click/fill/evaluate"
except Exception as e:
result["error"] = str(e)
finally:
await browser.close()
return json.dumps(result, ensure_ascii=False)
BROWSER_USE_SCHEMA = {
"type": "function",
"function": {
"name": "browser_use",
"description": "无头浏览器操控:截图、提取内容、点击、填表、执行 JS。需要 playwright。适合网页测试、动态内容抓取。",
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "目标 URL"},
"action": {
"type": "string",
"enum": ["screenshot", "content", "click", "fill", "evaluate"],
"description": "操作类型",
},
"selector": {"type": "string", "description": "CSS 选择器click/fill 时必填)"},
"script": {"type": "string", "description": "要填入的文本或要执行的 JS 代码"},
"wait_ms": {"type": "integer", "description": "页面加载后等待毫秒数(默认 2000", "default": 2000},
},
"required": ["url", "action"],
},
},
}
# ── docker_manage ────────────────────────────────────────────
_DOCKER_ALLOWED_COMMANDS = [
"docker", "ps", "images", "logs", "stats", "inspect", "version", "info",
"compose", "ps", "logs", "config", "top",
]
async def docker_manage_tool(operation: str, resource: str = "", options: str = "") -> str:
"""Docker 管理操作(只读为主)。"""
import asyncio as _asyncio
op_parts = operation.strip().split()
# 检查命令是否安全
if not op_parts:
return json.dumps({"error": "缺少操作参数"}, ensure_ascii=False)
base = op_parts[0]
allowed_prefixes = {
"ps": ["docker", "ps"],
"images": ["docker", "images"],
"logs": ["docker", "logs"],
"stats": ["docker", "stats"],
"inspect": ["docker", "inspect"],
"version": ["docker", "version"],
"info": ["docker", "info"],
"compose": ["docker", "compose"],
}
if base not in allowed_prefixes:
return json.dumps({
"error": f"不支持的操作: {base}",
"allowed": sorted(allowed_prefixes.keys()),
}, ensure_ascii=False)
cmd = list(allowed_prefixes[base])
if resource:
cmd.append(resource)
# 解析额外选项(从 options 字符串拆分)
if options:
import shlex
try:
cmd.extend(shlex.split(options))
except ValueError:
cmd.extend(options.split())
# compose 只读子命令限制
if base == "compose" and resource not in ("ps", "logs", "config", "top", ""):
return json.dumps({"error": f"docker compose {resource} 不被允许(仅限只读操作)"}, ensure_ascii=False)
try:
proc = await _asyncio.create_subprocess_exec(
*cmd,
stdout=_asyncio.subprocess.PIPE,
stderr=_asyncio.subprocess.PIPE,
)
stdout, stderr = await _asyncio.wait_for(proc.communicate(), timeout=30)
return json.dumps({
"stdout": stdout.decode("utf-8", errors="replace")[:8000],
"stderr": stderr.decode("utf-8", errors="replace")[:2000],
"returncode": proc.returncode,
}, ensure_ascii=False)
except FileNotFoundError:
return json.dumps({"error": "Docker 未安装或不在 PATH 中"}, ensure_ascii=False)
except _asyncio.TimeoutError:
return json.dumps({"error": "Docker 操作超时"}, ensure_ascii=False)
DOCKER_MANAGE_SCHEMA = {
"type": "function",
"function": {
"name": "docker_manage",
"description": "Docker 管理(只读为主):查看容器列表、镜像、日志、资源使用、容器详情。需要 Docker CLI。",
"parameters": {
"type": "object",
"properties": {
"operation": {
"type": "string",
"enum": ["ps", "images", "logs", "stats", "inspect", "version", "info", "compose"],
"description": "操作类型",
},
"resource": {"type": "string", "description": "容器名/镜像名/服务名"},
"options": {"type": "string", "description": "额外命令行选项"},
},
"required": ["operation"],
},
},
}
# ── deploy_push ──────────────────────────────────────────────
async def deploy_push_tool(
source_path: str,
target: str,
method: str = "copy",
exclude: str = ".git,__pycache__,node_modules,.venv,venv",
) -> str:
"""文件部署本地复制、SCP 或 RSYNC。"""
import asyncio as _asyncio
if not os.path.isabs(source_path):
source_path = os.path.join(_local_file_workspace_root(), source_path)
source_path = os.path.abspath(source_path)
if not os.path.exists(source_path):
return json.dumps({"error": f"源路径不存在: {source_path}"}, ensure_ascii=False)
method = method.lower().strip()
if method == "copy":
import shutil
target = os.path.abspath(target) if target else os.path.join(_local_file_workspace_root(), "deploy")
try:
if os.path.isdir(source_path):
if os.path.exists(target):
shutil.rmtree(target)
# 构建 rsync-like exclude
exclude_set = set(e.strip() for e in exclude.split(",") if e.strip())
shutil.copytree(source_path, target, ignore=shutil.ignore_patterns(*exclude_set))
else:
os.makedirs(os.path.dirname(target) or ".", exist_ok=True)
shutil.copy2(source_path, target)
return json.dumps({"method": "copy", "source": source_path, "target": target, "status": "ok"}, ensure_ascii=False)
except Exception as e:
return json.dumps({"error": f"复制失败: {e}"}, ensure_ascii=False)
elif method == "rsync":
exclude_args = []
for e in exclude.split(","):
e = e.strip()
if e:
exclude_args.extend(["--exclude", e])
cmd = ["rsync", "-avz", "--progress"] + exclude_args + [source_path + "/" if os.path.isdir(source_path) else source_path, target]
try:
proc = await _asyncio.create_subprocess_exec(
*cmd,
stdout=_asyncio.subprocess.PIPE,
stderr=_asyncio.subprocess.PIPE,
)
stdout, stderr = await _asyncio.wait_for(proc.communicate(), timeout=120)
return json.dumps({
"method": "rsync",
"source": source_path,
"target": target,
"stdout": stdout.decode("utf-8", errors="replace")[:4000],
"stderr": stderr.decode("utf-8", errors="replace")[:2000],
"returncode": proc.returncode,
}, ensure_ascii=False)
except FileNotFoundError:
return json.dumps({"error": "rsync 未安装"}, ensure_ascii=False)
except _asyncio.TimeoutError:
return json.dumps({"error": "rsync 超时"}, ensure_ascii=False)
else:
return json.dumps({"error": f"不支持的部署方式: {method},支持 copy/rsync"}, ensure_ascii=False)
DEPLOY_PUSH_SCHEMA = {
"type": "function",
"function": {
"name": "deploy_push",
"description": "部署文件到目标位置:本地复制(支持 exclude 规则)或 rsync 远程同步。适合将项目部署到服务器。",
"parameters": {
"type": "object",
"properties": {
"source_path": {"type": "string", "description": "源文件或目录路径"},
"target": {"type": "string", "description": "目标路径(本地目录或 rsync 远程地址如 user@host:/path"},
"method": {
"type": "string",
"enum": ["copy", "rsync"],
"description": "部署方式copy=本地复制, rsync=rsync 同步",
"default": "copy",
},
"exclude": {"type": "string", "description": "排除模式(逗号分隔,如 .git,node_modules", "default": ".git,__pycache__,node_modules,.venv,venv"},
},
"required": ["source_path", "target"],
},
},
}
# ── agent_create ─────────────────────────────────────────────
async def agent_create_tool(
name: str,
system_prompt: str,
description: str = "",
model: str = "deepseek-v4-flash",
provider: str = "deepseek",
temperature: float = 0.7,
) -> str:
"""
动态创建专业子 Agent。
当本 Agent 发现某个任务超出自身专业领域时,可创建一个专门的子 Agent 来处理,
创建后通过 agent_call 委派任务。新 Agent 会持久化到数据库,后续可直接复用。
Args:
name: 新 Agent 名称建议体现专业领域如「SQL优化专家」
system_prompt: 新 Agent 的系统提示词,定义其角色和专业能力
description: 简短描述
model: 使用的模型
provider: 模型提供商
temperature: 温度参数
"""
import uuid
# -- 参数校验 --
if not name or not name.strip():
return json.dumps({"error": "invalid_name", "message": "Agent 名称不能为空"}, ensure_ascii=False)
name = name.strip()
if not system_prompt or len(system_prompt.strip()) < 20:
return json.dumps({
"error": "invalid_prompt",
"message": f"system_prompt 太短({len(system_prompt) if system_prompt else 0}字符至少需要20字符才能定义有效的 Agent 角色",
}, ensure_ascii=False)
try:
from app.core.database import SessionLocal
from app.models.agent import Agent
from app.models.user import User
db = SessionLocal()
try:
# 检查重名
existing = db.query(Agent).filter(Agent.name == name).first()
if existing:
db.close()
return json.dumps({
"error": "name_conflict",
"message": f"Agent「{name}」已存在id={existing.id}),可直接通过 agent_call 调用",
"existing_id": existing.id,
}, ensure_ascii=False)
# 取第一个用户作为所有者
owner = db.query(User).first()
if not owner:
db.close()
return json.dumps({"error": "no_user", "message": "数据库中无用户,无法创建 Agent"}, ensure_ascii=False)
agent_id = str(uuid.uuid4())
agent = Agent(
id=agent_id,
name=name,
description=description or f"由 Agent 自主创建的专业子 Agent{name}",
workflow_config={
"nodes": [
{"id": "start-1", "type": "start", "position": {"x": 80, "y": 120}, "data": {}},
{
"id": "agent-1",
"type": "agent",
"position": {"x": 320, "y": 120},
"data": {
"label": name,
"system_prompt": system_prompt,
"model": model,
"provider": provider,
"temperature": temperature,
"max_iterations": 10,
"tools": [],
"memory": True,
},
},
{"id": "end-1", "type": "end", "position": {"x": 560, "y": 120}, "data": {}},
],
"edges": [
{"id": "e_start_agent", "source": "start-1", "target": "agent-1", "sourceHandle": "right", "targetHandle": "left"},
{"id": "e_agent_end", "source": "agent-1", "target": "end-1", "sourceHandle": "right", "targetHandle": "left"},
],
},
status="active",
user_id=owner.id,
)
db.add(agent)
db.commit()
result = {
"status": "created",
"agent": {
"id": agent_id,
"name": name,
"description": agent.description,
"model": model,
"provider": provider,
"temperature": temperature,
"tools": "ALL (34 builtin tools)",
"memory": "RAG enabled",
},
"hint": f"现在可以用 agent_call(agent_name=\"{name}\", query=\"...\") 来调用这个新 Agent",
}
return json.dumps(result, ensure_ascii=False)
finally:
db.close()
except Exception as e:
logger.error(f"agent_create 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": "creation_failed", "message": f"创建 Agent 失败: {e}"}, ensure_ascii=False)
AGENT_CREATE_SCHEMA = {
"type": "function",
"function": {
"name": "agent_create",
"description": (
"动态创建一个专业子 Agent。当本 Agent 发现某个任务超出自身专业领域时,"
"用此工具创建专门 Agent然后用 agent_call 委派任务。"
"新 Agent 会持久化,后续可直接复用。"
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "新 Agent 名称如「SQL优化专家」「日语翻译官」"},
"system_prompt": {"type": "string", "description": "新 Agent 的系统提示词,定义其角色、专业能力和行为准则"},
"description": {"type": "string", "description": "简短描述(可选)"},
"model": {"type": "string", "description": "使用的模型", "default": "deepseek-v4-flash"},
"provider": {"type": "string", "description": "模型提供商", "default": "deepseek"},
"temperature": {"type": "number", "description": "温度参数", "default": 0.7},
},
"required": ["name", "system_prompt"],
},
},
}
# ── tool_register ────────────────────────────────────────────
async def tool_register_tool(
name: str,
description: str,
method: str = "GET",
url: str = "",
headers_json: str = "{}",
body_template_json: str = "",
) -> str:
"""
动态注册 HTTP 工具。
当 Agent 发现实用的外部 API 时,将其注册为可复用工具。
工具写入数据库后立即加载到 tool_registry后续所有 Agent 均可直接调用。
Args:
name: 工具名称(英文,如 stock_price、weather_query
description: 工具功能描述
method: HTTP 方法
url: 请求 URL 模板,参数用 {param_name} 占位,如 https://api.example.com/stock?symbol={symbol}
headers_json: 请求头 JSON 字符串
body_template_json: 请求体模板 JSON 字符串
"""
import uuid
import re as _re
try:
from app.core.database import SessionLocal
from app.models.tool import Tool
from app.models.user import User
from app.services.tool_registry import tool_registry
# 解析 URL 模板参数 {param_name}
url_params = _re.findall(r"\{(\w+)\}", url)
body_params = []
if body_template_json:
try:
body_tpl = json.loads(body_template_json)
body_str = json.dumps(body_tpl)
body_params = _re.findall(r"\{(\w+)\}", body_str)
except json.JSONDecodeError:
return json.dumps({"error": "body_template_json 不是有效 JSON"}, ensure_ascii=False)
# 构建 OpenAI function schema
properties = {}
required = []
for p in sorted(set(url_params + body_params)):
properties[p] = {"type": "string", "description": f"参数 {p}"}
required.append(p)
# headers 参数
try:
hdrs = json.loads(headers_json)
for hk in hdrs:
if "{" in str(hdrs[hk]):
continue # 模板头跳过
except json.JSONDecodeError:
pass
function_schema = {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": {
"type": "object",
"properties": properties,
"required": required,
},
},
}
# URL 可达性验证(发送 HEAD 请求检测,不阻塞注册)
url_reachable = False
try:
# 用占位符替换模板参数为 test 值,构造测试 URL
test_url = url
for p in url_params:
test_url = test_url.replace(f"{{{p}}}", "test")
if not url_params:
test_url = url
resp = await httpx.AsyncClient(timeout=5.0).head(test_url, follow_redirects=True)
url_reachable = resp.status_code < 500
except Exception:
url_reachable = False # 不阻断注册,仅记录
db = SessionLocal()
try:
# 检查重名
existing = db.query(Tool).filter(Tool.name == name).first()
if existing:
db.close()
return json.dumps({
"error": "name_conflict",
"message": f"工具「{name}」已存在id={existing.id}",
"existing_id": existing.id,
}, ensure_ascii=False)
owner = db.query(User).first()
tool_id = str(uuid.uuid4())
tool = Tool(
id=tool_id,
name=name,
description=description,
category="agent_created",
function_schema=function_schema,
implementation_type="http",
implementation_config={
"url": url,
"method": method.upper(),
"headers": json.loads(headers_json) if headers_json else {},
"body_template": json.loads(body_template_json) if body_template_json else None,
},
is_public=False,
user_id=owner.id if owner else None,
)
db.add(tool)
db.commit()
# 立刻加载到 tool_registry
tool_registry.load_tools_from_db(db, tool_names=[name])
return json.dumps({
"status": "registered",
"tool": {
"id": tool_id,
"name": name,
"description": description,
"method": method.upper(),
"url_template": url,
"params": required,
},
"url_reachable": url_reachable,
"hint": f"工具已就绪,可直接调用 {name}({', '.join(p + '=...' for p in required) if required else ''})",
}, ensure_ascii=False)
finally:
db.close()
except Exception as e:
logger.error(f"tool_register 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": "registration_failed", "message": f"注册工具失败: {e}"}, ensure_ascii=False)
TOOL_REGISTER_SCHEMA = {
"type": "function",
"function": {
"name": "tool_register",
"description": (
"动态注册一个新 HTTP 工具。当 Agent 发现实用的外部 API 时,"
"将其注册为可复用工具。工具写入数据库后立即可用,后续所有 Agent 均可调用。"
"URL 中用 {param_name} 占位参数,如 https://api.example.com/weather?city={city}"
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "工具名称(英文,如 stock_price、weather_query"},
"description": {"type": "string", "description": "工具功能描述"},
"method": {"type": "string", "enum": ["GET", "POST", "PUT", "DELETE"], "description": "HTTP 方法", "default": "GET"},
"url": {"type": "string", "description": "请求 URL 模板,参数用 {param_name} 占位"},
"headers_json": {"type": "string", "description": "请求头 JSON可选", "default": "{}"},
"body_template_json": {"type": "string", "description": "请求体模板 JSONPOST/PUT 时可选)"},
},
"required": ["name", "description", "url"],
},
},
}
# ── capability_check ──────────────────────────────────────────
async def capability_check_tool(
task_description: str,
required_domains: str = "",
) -> str:
"""
能力自检工具:分析当前 Agent 的工具和子 Agent 是否足以完成给定任务。
Agent 在接到复杂/陌生任务时,应先调用此工具评估自身能力,
根据返回的差距分析决定是否需要创建子 Agent 或注册新工具。
Args:
task_description: 任务描述
required_domains: 任务涉及的领域,逗号分隔(如「数据库,性能优化,SQL」
"""
try:
from app.core.database import SessionLocal
from app.models.agent import Agent as AgentModel
from app.services.tool_registry import tool_registry
# 获取所有可用工具
all_tool_names = []
tool_categories = {}
for schema in tool_registry.get_all_tool_schemas():
fn = schema.get("function", schema)
tname = fn.get("name", "")
if tname:
all_tool_names.append(tname)
desc = fn.get("description", "")
# 简单分类
cat = desc.split("")[0] if "" in desc else desc[:30]
tool_categories[tname] = cat
# 查询数据库中的可用子 Agent
available_agents = []
try:
db = SessionLocal()
try:
agents_in_db = db.query(AgentModel).filter(AgentModel.status == "active").all()
available_agents = [
{"name": a.name, "id": a.id, "description": a.description or ""}
for a in agents_in_db
]
finally:
db.close()
except Exception:
pass
# 领域关键词映射(规则引擎,不调 LLM
domain_tool_map = {
"数据库": ["database_query"],
"SQL": ["database_query"],
"代码": ["code_execute", "git_operation"],
"编程": ["code_execute", "git_operation", "project_scaffold"],
"文件": ["file_read", "file_write", "excel_process"],
"网络": ["http_request", "url_parse", "web_search"],
"HTTP": ["http_request"],
"API": ["http_request", "tool_register"],
"数据": ["json_process", "excel_process", "text_analyze"],
"分析": ["text_analyze", "code_execute", "database_query"],
"报告": ["pdf_generate", "file_write"],
"文档": ["file_read", "pdf_generate", "file_write"],
"部署": ["deploy_push", "docker_manage"],
"容器": ["docker_manage"],
"Docker": ["docker_manage"],
"前端": ["project_scaffold"],
"后端": ["project_scaffold", "http_request"],
"搜索": ["web_search"],
"Git": ["git_operation"],
"测试": ["code_execute"],
"监控": ["http_request", "schedule_create"],
"调度": ["schedule_create", "schedule_list"],
"邮件": ["send_email"],
"Excel": ["excel_process"],
"PDF": ["pdf_generate", "file_read"],
"浏览器": ["browser_use"],
"Android": ["adb_log"],
"加密": ["crypto_util"],
"性能": ["database_query", "code_execute"],
"优化": ["database_query", "code_execute"],
}
# 工具能力覆盖关键词
tool_capability_keywords = {
"http_request": ["HTTP", "API", "网络", "请求"],
"file_read": ["文件", "读取"],
"file_write": ["文件", "写入", "保存"],
"text_analyze": ["文本", "分析"],
"datetime": ["时间", "日期"],
"math_calculate": ["数学", "计算"],
"system_info": ["系统", "信息"],
"json_process": ["JSON", "数据"],
"database_query": ["数据库", "SQL", "查询"],
"adb_log": ["Android", "ADB"],
"schedule_create": ["调度", "定时"],
"crypto_util": ["加密", "解密"],
"random_generate": ["随机", "数据"],
"send_email": ["邮件", "发送"],
"url_parse": ["URL", "解析"],
"regex_test": ["正则", "表达式"],
"agent_call": ["Agent", "委派"],
"code_execute": ["代码", "执行", "Python"],
"git_operation": ["Git", "版本"],
"web_search": ["搜索", "网页"],
"pdf_generate": ["PDF", "报告"],
"project_scaffold": ["项目", "模板"],
"task_plan": ["任务", "规划"],
"excel_process": ["Excel"],
"browser_use": ["浏览器"],
"docker_manage": ["Docker", "容器"],
"deploy_push": ["部署"],
"agent_create": ["创建Agent"],
"tool_register": ["注册工具"],
}
domains = [d.strip() for d in required_domains.split(",") if d.strip()]
if not domains:
# 从 task_description 中自动提取关键词
desc_lower = task_description
domains = [kw for kw in domain_tool_map if kw in desc_lower]
if not domains:
domains = ["通用"]
# 找出推荐的已有工具
recommended_existing = []
for domain in domains:
tools = domain_tool_map.get(domain, [])
for t in tools:
if t in all_tool_names and t not in recommended_existing:
recommended_existing.append(t)
# 差距分析:哪些领域没有匹配的工具
missing_domains = []
missing_tools = []
for domain in domains:
expected = domain_tool_map.get(domain)
if expected:
found = any(t in all_tool_names for t in expected)
if not found:
missing_domains.append(domain)
missing_tools.extend(expected)
missing_tools = sorted(set(missing_tools))
# 严重程度评估
if not missing_domains:
severity = "low"
elif len(missing_domains) <= 2:
severity = "medium"
else:
severity = "high"
# 生成建议
recommendations = []
if missing_domains:
rec_name = f"{domains[0]}专家" if domains else "领域专家"
recommendations.append(f"使用 agent_create 创建「{rec_name}」Agent赋予其专业知识")
if missing_tools:
actionable = [t for t in missing_tools if t in tool_capability_keywords]
if actionable:
recommendations.append(
f"缺少以下能力工具:{', '.join(actionable[:5])}"
f"可通过 tool_register 注册外部 API或通过 code_tool_create 创建代码工具"
)
recommendations.append("使用 web_search 搜索相关的外部 API 或开源工具")
if available_agents:
agent_names = [a["name"] for a in available_agents[:5]]
recommendations.insert(0, f"可尝试调用现有 Agent{', '.join(agent_names)}")
result = {
"task": task_description,
"domains_analyzed": domains,
"available_tools_count": len(all_tool_names),
"available_tools_sample": all_tool_names[:15],
"available_agents": [a["name"] for a in available_agents],
"recommended_existing_tools": recommended_existing,
"gap_analysis": {
"missing_domain_knowledge": missing_domains,
"missing_tools": missing_tools,
"severity": severity,
},
"recommendations": recommendations,
}
return json.dumps(result, ensure_ascii=False)
except Exception as e:
logger.error(f"capability_check 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": "check_failed", "message": str(e)}, ensure_ascii=False)
CAPABILITY_CHECK_SCHEMA = {
"type": "function",
"function": {
"name": "capability_check",
"description": (
"能力自检工具:分析当前可用工具和子 Agent 是否足以完成给定任务。"
"在接到复杂或陌生任务时,先用此工具评估差距,再决定是否需要创建子 Agent 或注册新工具。"
"返回结构化差距分析和行动建议。"
),
"parameters": {
"type": "object",
"properties": {
"task_description": {"type": "string", "description": "任务描述如「分析MySQL慢查询日志并优化」"},
"required_domains": {"type": "string", "description": "任务涉及的领域,逗号分隔(如「数据库,性能优化,SQL」留空则自动提取"},
},
"required": ["task_description"],
},
},
}
# ── code_tool_create ──────────────────────────────────────────
async def code_tool_create_tool(
name: str,
description: str,
code: str,
language: str = "python",
parameter_schema: str = "{}",
test_input: str = "",
) -> str:
"""
将经过验证的代码持久化为可复用工具。
先用 code_execute 验证代码正确性,再写入 tools 表并加载到 tool_registry。
创建后所有 Agent 均可调用该工具。
Args:
name: 工具名称(英文,如 parse_slow_query
description: 工具功能描述
code: 完整的 Python/JS 函数代码
language: 代码语言python/javascript
parameter_schema: 参数 schema JSON{"param1": {"type": "string", "description": "..."}}
test_input: 测试输入 JSON用于沙箱验证可选
"""
import uuid
try:
from app.core.database import SessionLocal
from app.models.tool import Tool
from app.models.user import User
from app.services.tool_registry import tool_registry
# 解析参数 schema
try:
param_schema = json.loads(parameter_schema) if parameter_schema else {}
except json.JSONDecodeError:
return json.dumps({"error": "invalid_schema", "message": "parameter_schema 不是有效 JSON"}, ensure_ascii=False)
# 构建 OpenAI function schema
properties = {}
required_params = []
for pname, pinfo in param_schema.items():
prop = {"description": pinfo.get("description", f"参数 {pname}")}
ptype = pinfo.get("type", "string")
if ptype == "integer":
prop["type"] = "integer"
elif ptype == "number":
prop["type"] = "number"
elif ptype == "boolean":
prop["type"] = "boolean"
else:
prop["type"] = "string"
properties[pname] = prop
if pinfo.get("required", True):
required_params.append(pname)
function_schema = {
"type": "function",
"function": {
"name": name,
"description": description,
"parameters": {
"type": "object",
"properties": properties,
"required": required_params,
},
},
}
# 沙箱测试(如果提供了 test_input
test_result = None
if test_input and language == "python":
try:
test_params = json.loads(test_input) if isinstance(test_input, str) else test_input
# 构建测试调用代码
args_str = ", ".join(
f"{k}={repr(v)}" if not isinstance(v, (int, float, bool)) else f"{k}={v}"
for k, v in test_params.items()
)
test_code = f"{code}\n\n# test\nresult = {name}({args_str})\nprint(json.dumps(result, ensure_ascii=False, default=str))"
proc = await asyncio.create_subprocess_exec(
sys.executable, "-c", test_code,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=15.0)
if proc.returncode == 0:
test_result = {"passed": True, "output": stdout.decode("utf-8", errors="replace").strip()[:2000]}
else:
return json.dumps({
"error": "test_failed",
"message": f"沙箱测试失败,请修正代码后重试",
"stderr": stderr.decode("utf-8", errors="replace")[:1000],
}, ensure_ascii=False)
except asyncio.TimeoutError:
return json.dumps({"error": "test_timeout", "message": "沙箱测试超时15秒请优化代码"}, ensure_ascii=False)
except Exception as e:
return json.dumps({"error": "test_error", "message": f"沙箱测试异常: {e}"}, ensure_ascii=False)
# 写入 tools 表
db = SessionLocal()
try:
existing = db.query(Tool).filter(Tool.name == name).first()
if existing:
db.close()
return json.dumps({
"error": "name_conflict",
"message": f"工具「{name}」已存在id={existing.id}",
"existing_id": existing.id,
}, ensure_ascii=False)
owner = db.query(User).first()
tool_id = str(uuid.uuid4())
tool = Tool(
id=tool_id,
name=name,
description=description,
category="agent_created",
function_schema=function_schema,
implementation_type="code",
implementation_config={
"code": code,
"language": language,
"parameter_schema": param_schema,
},
is_public=False,
user_id=owner.id if owner else None,
)
db.add(tool)
db.commit()
# 加载到 tool_registry
tool_registry.load_tools_from_db(db, tool_names=[name])
result = {
"status": "created",
"tool": {
"id": tool_id,
"name": name,
"description": description,
"language": language,
"params": required_params,
},
"sandbox_test": test_result,
"hint": f"工具已就绪,可直接调用 {name}({', '.join(p + '=...' for p in required_params) if required_params else ''})",
}
return json.dumps(result, ensure_ascii=False)
finally:
db.close()
except Exception as e:
logger.error(f"code_tool_create 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": "creation_failed", "message": f"创建代码工具失败: {e}"}, ensure_ascii=False)
CODE_TOOL_CREATE_SCHEMA = {
"type": "function",
"function": {
"name": "code_tool_create",
"description": (
"将经过验证的代码持久化为可复用的内置工具。"
"先用 code_execute 验证代码,确认正确后调用此工具将其注册为永久工具。"
"创建后所有 Agent 均可直接调用该工具。"
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "工具名称(英文,如 parse_slow_query、csv_cleaner"},
"description": {"type": "string", "description": "工具功能描述中文如「解析MySQL慢查询日志提取最慢的N条SQL」"},
"code": {"type": "string", "description": "完整的 Python 函数代码(包含 def 定义和依赖 import"},
"language": {"type": "string", "description": "代码语言", "default": "python"},
"parameter_schema": {"type": "string", "description": "参数 JSON schema{\"log_path\": {\"type\": \"string\", \"description\": \"日志路径\"}}", "default": "{}"},
"test_input": {"type": "string", "description": "测试输入 JSON用于沙箱验证可选但推荐提供"},
},
"required": ["name", "description", "code"],
},
},
}
# ── extension_log ────────────────────────────────────────────
async def extension_log_tool(
action: str = "list",
name: str = "",
extension_type: str = "",
reason: str = "",
detail_json: str = "{}",
success_rating: str = "",
note: str = "",
limit: int = 10,
) -> str:
"""
记录和查询 Agent 自主扩展历史。
Agent 创建子 Agent / 注册工具 / 创建代码工具后,用此工具记录。
后续可查询历史扩展记录进行自我反思。
Args:
action: "list" 查询历史 / "log" 记录新扩展 / "evaluate" 评价已有扩展
name: 扩展名称log/evaluate 时必填)
extension_type: 扩展类型agent_created/tool_registered/code_tool_created
reason: 创建原因
detail_json: 扩展详情 JSON
success_rating: 效果评价success/partial/failed
note: 备注
limit: list 时返回的最大条数
"""
import uuid
from datetime import datetime
try:
from app.core.database import SessionLocal
from app.models.agent import AgentExtension
from app.models.user import User
db = SessionLocal()
try:
if action == "list":
query = db.query(AgentExtension).order_by(AgentExtension.created_at.desc())
if extension_type:
query = query.filter(AgentExtension.extension_type == extension_type)
if name:
query = query.filter(AgentExtension.name.contains(name))
extensions = query.limit(min(limit, 50)).all()
items = []
for ext in extensions:
items.append({
"id": ext.id,
"type": ext.extension_type,
"name": ext.name,
"reason": ext.reason,
"success_rating": ext.success_rating,
"note": ext.note,
"created_at": ext.created_at.isoformat() if ext.created_at else None,
})
return json.dumps({
"extensions": items,
"count": len(items),
"hint": "使用 extension_log(action='evaluate', name='...', success_rating='success') 评价扩展效果",
}, ensure_ascii=False)
elif action == "log":
if not name or not extension_type:
return json.dumps({"error": "missing_fields", "message": "log 操作需要 name 和 extension_type 参数"}, ensure_ascii=False)
owner = db.query(User).first()
ext_id = str(uuid.uuid4())
try:
detail = json.loads(detail_json) if detail_json else {}
except json.JSONDecodeError:
detail = {"raw": detail_json}
ext_record = AgentExtension(
id=ext_id,
extension_type=extension_type,
name=name,
reason=reason,
detail=detail,
user_id=owner.id if owner else None,
)
db.add(ext_record)
db.commit()
return json.dumps({
"status": "logged",
"extension": {"id": ext_id, "type": extension_type, "name": name},
"hint": "扩展已记录。后续可用 extension_log(action='evaluate', ...) 评价效果",
}, ensure_ascii=False)
elif action == "evaluate":
if not name:
return json.dumps({"error": "missing_fields", "message": "evaluate 操作需要 name 参数"}, ensure_ascii=False)
# 查找最近的匹配记录
ext_record = (
db.query(AgentExtension)
.filter(AgentExtension.name == name)
.order_by(AgentExtension.created_at.desc())
.first()
)
if not ext_record:
return json.dumps({
"error": "not_found",
"message": f"未找到名为「{name}」的扩展记录,请先用 action='log' 记录",
}, ensure_ascii=False)
if success_rating:
ext_record.success_rating = success_rating
if note:
ext_record.note = note
db.commit()
return json.dumps({
"status": "evaluated",
"extension": {"id": ext_record.id, "name": name, "success_rating": success_rating, "note": note},
}, ensure_ascii=False)
else:
return json.dumps({"error": "unknown_action", "message": f"未知操作: {action},支持 list/log/evaluate"}, ensure_ascii=False)
finally:
db.close()
except Exception as e:
logger.error(f"extension_log 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": "extension_log_failed", "message": str(e)}, ensure_ascii=False)
EXTENSION_LOG_SCHEMA = {
"type": "function",
"function": {
"name": "extension_log",
"description": (
"记录和查询自主扩展历史。创建子 Agent / 注册工具 / 创建代码工具后,"
"用此工具记录扩展和原因,以便后续反思和优化。"
"支持 list查询、log记录、evaluate评价三种操作。"
),
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "log", "evaluate"], "description": "操作list 查询 / log 记录 / evaluate 评价", "default": "list"},
"name": {"type": "string", "description": "扩展名称Agent名或工具名log/evaluate 时必填)"},
"extension_type": {"type": "string", "enum": ["agent_created", "tool_registered", "code_tool_created"], "description": "扩展类型log 时必填)"},
"reason": {"type": "string", "description": "创建原因简短说明为什么要扩展log 时推荐填写)"},
"detail_json": {"type": "string", "description": "扩展详情 JSONlog 时可选)"},
"success_rating": {"type": "string", "enum": ["success", "partial", "failed"], "description": "效果评价evaluate 时使用)"},
"note": {"type": "string", "description": "反馈备注evaluate 时使用如「子Agent成功定位3条慢SQL」"},
"limit": {"type": "integer", "description": "list 时返回的最大条数", "default": 10},
},
"required": ["action"],
},
},
}
# ── self_review ──────────────────────────────────────────────
async def self_review_tool(
content: str,
criteria: str,
task_context: str = "",
) -> str:
"""
输出质量自检工具:用 LLM 评判一段内容是否满足质量标准。
Agent 或工作流节点在执行完毕后,可调用此工具对输出进行质量评估。
评分 >= 0.6 为通过,不通过时返回具体问题和改进建议,供修正参考。
Args:
content: 待评估的内容Agent 回答、报告、代码等)
criteria: 评判标准如「回答必须包含索引优化建议和具体SQL示例」
task_context: 原始任务上下文(可选,帮助评判器理解背景)
"""
try:
# 构建评判 prompt
judge_prompt = (
"你是严格的内容质量评审专家。请根据以下标准对内容进行评分。\n\n"
f"【评判标准】\n{criteria}\n\n"
f"【待评审内容】\n{content[:8000]}\n"
)
if task_context:
judge_prompt += f"\n【任务背景】\n{task_context[:2000]}\n"
judge_prompt += (
"\n请以 JSON 格式返回评审结果(严格只返回 JSON不要任何其他文字\n"
'{"score": 0.75, "passed": true, "issues": ["问题1", "问题2"], '
'"suggestions": ["建议1", "建议2"], "summary": "一句话总结"}\n\n'
"评分规则:\n"
"- 1.0 完美满足所有标准\n"
"- 0.8 良好,有小的改进空间\n"
"- 0.6 基本满足,但存在明显不足\n"
"- 0.4 大部分标准未满足\n"
"- 0.2 完全不满足\n"
"- score >= 0.6 时 passed=true否则 passed=false\n"
)
# 调用轻量 LLM 评审
from app.services.llm_service import LLMService
llm = LLMService()
messages = [{"role": "user", "content": judge_prompt}]
resp = await llm.chat(
provider="deepseek",
model="deepseek-v4-flash",
messages=messages,
temperature=0.1,
max_tokens=800,
)
judge_text = resp.get("content", "") if isinstance(resp, dict) else str(resp)
# 解析 JSON 响应
try:
# 清理可能的 markdown 包裹
judge_text_clean = judge_text.strip()
if judge_text_clean.startswith("```"):
lines = judge_text_clean.split("\n")
judge_text_clean = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:])
result = json.loads(judge_text_clean)
except json.JSONDecodeError:
# 尝试从文本中提取 JSON
import re as _re2
m = _re2.search(r'\{[^{}]*"score"\s*:\s*[\d.]+[^{}]*\}', judge_text, _re2.DOTALL)
if m:
try:
result = json.loads(m.group())
except json.JSONDecodeError:
result = {"score": 0.5, "passed": False, "issues": ["无法解析评审结果"], "suggestions": [], "summary": judge_text[:200]}
else:
result = {"score": 0.5, "passed": False, "issues": ["无法解析评审结果"], "suggestions": [], "summary": judge_text[:200]}
score = float(result.get("score", 0.5))
passed = result.get("passed", score >= 0.6)
if not passed and score >= 0.6:
passed = True
if passed and score < 0.6:
passed = False
return json.dumps({
"score": score,
"passed": passed,
"threshold": 0.6,
"issues": result.get("issues", []),
"suggestions": result.get("suggestions", []),
"summary": result.get("summary", ""),
}, ensure_ascii=False)
except Exception as e:
logger.error(f"self_review 工具执行失败: {e}", exc_info=True)
return json.dumps({"error": "review_failed", "message": str(e), "score": 0, "passed": False}, ensure_ascii=False)
SELF_REVIEW_SCHEMA = {
"type": "function",
"function": {
"name": "self_review",
"description": (
"输出质量自检工具:用 LLM 评判一段内容的输出质量。"
"在完成任务后调用此工具检查输出是否满足标准,"
"不通过时会给出具体问题和改进建议。"
),
"parameters": {
"type": "object",
"properties": {
"content": {"type": "string", "description": "待评估的内容Agent 回答、报告、代码等)"},
"criteria": {"type": "string", "description": "评判标准如「回答必须包含索引优化建议和具体SQL示例」"},
"task_context": {"type": "string", "description": "原始任务上下文(可选,帮助评判器理解背景)"},
},
"required": ["content", "criteria"],
},
},
}
# ── knowledge_graph_search ────────────────────────────────────
async def knowledge_graph_search_tool(
query: str,
top_k: int = 5,
include_graph: bool = True,
scope_id: str = "",
) -> str:
"""知识图谱混合检索:向量语义搜索 + 图谱邻居展开。
当用户询问学习相关问题时,用此工具搜索已构建的知识图谱,
同时获取语义相似的知识实体和图谱中关联的邻居知识点。
Args:
query: 搜索查询(用户的问题或关键词)
top_k: 返回的向量匹配实体数(默认 5
include_graph: 是否展开图谱邻居(默认 true
scope_id: 作用域 ID默认使用当前 Agent ID
"""
import asyncio as _asyncio
from app.services.knowledge_graph_service import hybrid_search
try:
result = await hybrid_search(
query=query,
scope_kind="agent",
scope_id=scope_id or "learning_assistant",
top_k=top_k,
include_neighbors=include_graph,
)
formatted = result.get("formatted_context", "")
vector_count = len(result.get("vector_matches", []))
graph_count = len(result.get("graph_expansion", {}).get("entities", []))
return json.dumps({
"query": query,
"vector_matches_count": vector_count,
"graph_entities_count": graph_count,
"context": formatted,
"raw": {
"vector_matches": result.get("vector_matches", []),
"graph_expansion": result.get("graph_expansion", {}),
},
}, ensure_ascii=False)
except Exception as e:
logger.error(f"knowledge_graph_search 失败: {e}")
return json.dumps({"error": f"知识图谱搜索失败: {e}"}, ensure_ascii=False)
KNOWLEDGE_GRAPH_SEARCH_SCHEMA = {
"type": "function",
"function": {
"name": "knowledge_graph_search",
"description": (
"知识图谱混合检索:用向量语义搜索找到相关知识实体,"
"再展开图谱邻居获取关联知识点。适合学习场景中的知识检索、"
"概念关联、前置知识查找。返回结构化的知识上下文。"
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "搜索查询(用户问题或关键词)"},
"top_k": {"type": "integer", "description": "返回的向量匹配实体数", "default": 5},
"include_graph": {"type": "boolean", "description": "是否展开图谱邻居", "default": True},
"scope_id": {"type": "string", "description": "作用域 ID默认 learning_assistant"},
},
"required": ["query"],
},
},
}
# ── knowledge_graph_add ────────────────────────────────────────
async def knowledge_graph_add_tool(
text: str,
scope_id: str = "",
) -> str:
"""从文本中提取知识点实体和关系,写入知识图谱。
当用户分享学习内容、知识点总结、对话中有价值的信息时,
调用此工具自动提取实体和关系并持久化到知识图谱。
Args:
text: 要提取知识的文本内容
scope_id: 作用域 ID默认使用当前 Agent ID
"""
import asyncio as _asyncio
from app.services.knowledge_graph_service import extract_from_text
if not text or len(text.strip()) < 20:
return json.dumps({
"error": "文本太短(至少需要 20 个字符)",
"entity_count": 0,
"relation_count": 0,
}, ensure_ascii=False)
try:
result = await extract_from_text(
text=text,
scope_kind="agent",
scope_id=scope_id or "learning_assistant",
)
return json.dumps({
"entity_count": result.get("entity_count", 0),
"relation_count": result.get("relation_count", 0),
"entities": [
{"name": e["name"], "type": e["entity_type"], "description": e.get("description", "")[:200]}
for e in result.get("entities", [])
],
"relations": result.get("relations", []),
"hint": f"已从文本中提取 {result.get('entity_count', 0)} 个实体和 {result.get('relation_count', 0)} 个关系,可使用 knowledge_graph_search 检索",
}, ensure_ascii=False)
except Exception as e:
logger.error(f"knowledge_graph_add 失败: {e}")
return json.dumps({"error": f"知识图谱添加失败: {e}"}, ensure_ascii=False)
KNOWLEDGE_GRAPH_ADD_SCHEMA = {
"type": "function",
"function": {
"name": "knowledge_graph_add",
"description": (
"从文本中提取知识点实体和关系,写入知识图谱。"
"自动识别概念、公式、术语、事实等实体类型,"
"并建立前置/扩展/包含/示例/应用等语义关系。"
"适合将学习材料、对话内容转化为结构化知识网络。"
),
"parameters": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "从中提取知识的文本内容(学习材料、知识点总结等)"},
"scope_id": {"type": "string", "description": "作用域 ID默认 learning_assistant"},
},
"required": ["text"],
},
},
}
# ── entity_search ──────────────────────────────────────────────
async def entity_search_tool(
keyword: str = "",
entity_type: str = "",
scope_id: str = "",
limit: int = 10,
) -> str:
"""关键词搜索知识图谱中的实体。
Args:
keyword: 搜索关键词(在名称和描述中查找)
entity_type: 实体类型筛选concept/formula/fact/term/task/skill留空=全部
scope_id: 作用域 ID
limit: 返回数量
"""
from app.services.knowledge_graph_service import search_entities
from app.core.database import SessionLocal
db = None
try:
db = SessionLocal()
entities = search_entities(
db,
keyword=keyword,
entity_type=entity_type or None,
scope_kind="agent",
scope_id=scope_id or "learning_assistant",
limit=limit,
)
return json.dumps({
"keyword": keyword,
"count": len(entities),
"entities": [
{
"id": e.id,
"name": e.name,
"type": e.entity_type,
"description": e.description,
"confidence": e.confidence,
}
for e in entities
],
}, ensure_ascii=False)
except Exception as e:
logger.error(f"entity_search 失败: {e}")
return json.dumps({"error": f"实体搜索失败: {e}"}, ensure_ascii=False)
finally:
if db:
db.close()
ENTITY_SEARCH_SCHEMA = {
"type": "function",
"function": {
"name": "entity_search",
"description": (
"在知识图谱中按关键词搜索实体。"
"可按实体类型(概念/公式/术语/事实/任务/技能)筛选。"
"适合查找特定知识点或浏览知识库。"
),
"parameters": {
"type": "object",
"properties": {
"keyword": {"type": "string", "description": "搜索关键词(在名称和描述中查找)", "default": ""},
"entity_type": {
"type": "string",
"enum": ["concept", "formula", "fact", "term", "task", "skill"],
"description": "实体类型筛选,留空=全部",
},
"scope_id": {"type": "string", "description": "作用域 ID"},
"limit": {"type": "integer", "description": "返回数量", "default": 10},
},
"required": [],
},
},
}
# ── learning_path ─────────────────────────────────────────────
async def learning_path_tool(
entity_names: str,
scope_id: str = "",
) -> str:
"""基于知识图谱推荐学习路径。
给定一组目标知识点名称(逗号分隔),分析其前置依赖关系,
返回建议的学习顺序。
Args:
entity_names: 目标知识点名称,逗号分隔(如 "微积分,导数,极限"
scope_id: 作用域 ID
"""
from app.services.knowledge_graph_service import search_entities, recommend_learning_path
from app.core.database import SessionLocal
if not entity_names or not entity_names.strip():
return json.dumps({"error": "请提供至少一个知识点名称"}, ensure_ascii=False)
names = [n.strip() for n in entity_names.split(",") if n.strip()]
db = None
try:
db = SessionLocal()
entity_ids = []
found_names = []
for name in names:
entities = search_entities(
db, keyword=name,
scope_kind="agent", scope_id=scope_id or "learning_assistant",
limit=3,
)
for e in entities:
if e.name == name or name in e.name:
entity_ids.append(e.id)
found_names.append(e.name)
break
if not entity_ids:
return json.dumps({
"message": f"未在知识图谱中找到这些知识点: {', '.join(names)}",
"hint": "请先用 knowledge_graph_add 添加相关知识,或使用更通用的名称搜索",
}, ensure_ascii=False)
path = recommend_learning_path(
db, entity_ids,
scope_kind="agent", scope_id=scope_id or "learning_assistant",
)
return json.dumps({
"target_entities": names,
"found_entities": found_names,
"suggested_order": path.get("suggested_order", []),
"summary": path.get("summary", ""),
"prerequisites": [
{
"prerequisite": p["prerequisite"]["name"],
"target": p["target"]["name"],
"relation": p["relation"].get("description", ""),
}
for p in path.get("prerequisites", [])
],
}, ensure_ascii=False)
except Exception as e:
logger.error(f"learning_path 失败: {e}")
return json.dumps({"error": f"学习路径推荐失败: {e}"}, ensure_ascii=False)
finally:
if db:
db.close()
LEARNING_PATH_SCHEMA = {
"type": "function",
"function": {
"name": "learning_path",
"description": (
"基于知识图谱推荐学习路径。给定一组目标知识点,"
"分析前置依赖关系,给出建议的学习顺序。"
"适合制定学习计划、了解知识点间的先后关系。"
),
"parameters": {
"type": "object",
"properties": {
"entity_names": {"type": "string", "description": "目标知识点名称,逗号分隔(如 \"微积分,导数,极限\""},
"scope_id": {"type": "string", "description": "作用域 ID"},
},
"required": ["entity_names"],
},
},
}
# ═══════════════════════════════════════════════════════════════
# 多模态工具:图片 OCR / 视觉理解 / 语音转文字 / 文字转语音
# ═══════════════════════════════════════════════════════════════
async def image_ocr_tool(file_path: str) -> str:
"""图片 OCR 工具:对图片文件执行 Tesseract 光学字符识别。
支持 PNG / JPG / JPEG / WebP / BMP / TIFF 等常见图片格式。
中文识别需安装 Tesseract 及 chi_sim 语言包。
Args:
file_path: 图片文件路径(相对工作区根或绝对路径)
Returns:
JSONfile_path, text, size或 error
"""
try:
max_bytes = int(getattr(settings, "LOCAL_FILE_READ_MAX_BYTES", 2_097_152) or 2_097_152)
path, err = _resolve_path_under_workspace(file_path)
if err:
return json.dumps({"error": err}, ensure_ascii=False)
if not path.is_file():
return json.dumps({"error": f"文件不存在或不是普通文件: {path}"}, ensure_ascii=False)
suffix = path.suffix.lower()
if suffix not in _FILE_READ_IMAGE_SUFFIXES:
return json.dumps(
{"error": f"不支持的文件格式: {suffix},支持的图片格式: {', '.join(sorted(_FILE_READ_IMAGE_SUFFIXES))}"},
ensure_ascii=False,
)
fsize = path.stat().st_size
if fsize > max_bytes:
return json.dumps(
{"error": f"图片过大({fsize} 字节),上限 {max_bytes}"},
ensure_ascii=False,
)
try:
text = await asyncio.to_thread(_read_image_ocr_sync, path, max_bytes)
except ImportError as e:
return json.dumps(
{"error": f"图片识别依赖未安装: {e}。请在后端环境执行 pip install Pillow pytesseract"},
ensure_ascii=False,
)
except Exception as e:
err_low = str(e).lower()
if "tesseract" in err_low or "tesseractnotfound" in type(e).__name__.lower():
return json.dumps(
{
"error": (
"未找到 Tesseract OCR。请安装 TesseractWindows 可装官方安装包),"
"并在 .env 中配置 TESSERACT_CMD 指向 tesseract.exe或将其加入 PATH"
"中文识别需额外下载 chi_sim 语言包。"
),
"file_path": str(path),
},
ensure_ascii=False,
)
logger.warning("图片 OCR 失败: %s", e)
return json.dumps({"error": f"图片识别失败: {e}", "file_path": str(path)}, ensure_ascii=False)
return json.dumps({
"file_path": str(path),
"size": fsize,
"text": text,
}, ensure_ascii=False)
except Exception as e:
logger.error(f"image_ocr 工具异常: {e}", exc_info=True)
return json.dumps({"error": f"image_ocr 失败: {e}"}, ensure_ascii=False)
async def image_vision_tool(image_path: str, prompt: str = "请详细描述这张图片的内容") -> str:
"""图片视觉理解工具调用多模态大模型GPT-4V / Claude Vision分析图片内容。
将图片编码为 base64发送给视觉语言模型进行分析。
需要配置 OPENAI_API_KEY兼容 OpenAI 视觉 API 格式的端点均可使用)。
Args:
image_path: 图片文件路径(相对工作区根或绝对路径)
prompt: 分析提示词,如 "图中有什么物体?""请提取图中的表格数据"
Returns:
JSONimage_path, model, analysis或 error
"""
import base64
try:
path, err = _resolve_path_under_workspace(image_path)
if err:
return json.dumps({"error": err}, ensure_ascii=False)
if not path.is_file():
return json.dumps({"error": f"文件不存在或不是普通文件: {path}"}, ensure_ascii=False)
suffix = path.suffix.lower()
if suffix not in _FILE_READ_IMAGE_SUFFIXES:
return json.dumps(
{"error": f"不支持的图片格式: {suffix},支持: {', '.join(sorted(_FILE_READ_IMAGE_SUFFIXES))}"},
ensure_ascii=False,
)
api_key = (getattr(settings, "OPENAI_API_KEY", "") or "").strip()
base_url = (getattr(settings, "OPENAI_BASE_URL", "https://api.openai.com/v1") or "https://api.openai.com/v1").strip()
if not api_key:
return json.dumps(
{"error": "未配置 OPENAI_API_KEY无法调用视觉模型。请在 .env 中配置 OPENAI_API_KEY。"},
ensure_ascii=False,
)
# 读取图片并 base64 编码
fsize = path.stat().st_size
max_bytes = int(getattr(settings, "LOCAL_FILE_READ_MAX_BYTES", 2_097_152) or 2_097_152)
if fsize > max_bytes:
return json.dumps({"error": f"图片过大({fsize} 字节),上限 {max_bytes}"}, ensure_ascii=False)
image_data = path.read_bytes()
b64 = base64.b64encode(image_data).decode("ascii")
# 根据后缀确定 MIME 类型
mime_map = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".bmp": "image/bmp",
".tif": "image/tiff",
".tiff": "image/tiff",
}
mime_type = mime_map.get(suffix, "image/png")
# 使用 httpx 调用视觉模型 API兼容 OpenAI / DeepSeek / SiliconFlow 等)
vision_model = "gpt-4o"
messages = [
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {
"url": f"data:{mime_type};base64,{b64}",
"detail": "auto",
},
},
],
}
]
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(
f"{base_url.rstrip('/')}/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": vision_model,
"messages": messages,
"max_tokens": 2048,
},
)
if resp.status_code != 200:
logger.warning(f"视觉模型 API 返回 {resp.status_code}: {resp.text[:500]}")
return json.dumps({
"error": f"视觉模型 API 调用失败 (HTTP {resp.status_code})",
"detail": resp.text[:1000],
}, ensure_ascii=False)
data = resp.json()
analysis = data["choices"][0]["message"]["content"]
return json.dumps({
"image_path": str(path),
"size": fsize,
"model": vision_model,
"analysis": analysis,
}, ensure_ascii=False)
except Exception as e:
logger.error(f"image_vision 工具异常: {e}", exc_info=True)
return json.dumps({"error": f"image_vision 失败: {e}"}, ensure_ascii=False)
async def speech_to_text_tool(audio_path: str, language: str = "zh") -> str:
"""语音转文字工具:调用 Whisper API 将音频文件转为文字。
支持 mp3 / wav / webm / m4a / ogg 等常见音频格式。
需要配置 OPENAI_API_KEY兼容 OpenAI Whisper API 格式的端点均可)。
Args:
audio_path: 音频文件路径(相对工作区根或绝对路径)
language: 语言代码zh=中文, en=英文, ja=日文 等)
Returns:
JSONaudio_path, text, language或 error
"""
try:
path, err = _resolve_path_under_workspace(audio_path)
if err:
return json.dumps({"error": err}, ensure_ascii=False)
if not path.is_file():
return json.dumps({"error": f"文件不存在或不是普通文件: {path}"}, ensure_ascii=False)
api_key = (getattr(settings, "OPENAI_API_KEY", "") or "").strip()
base_url = (getattr(settings, "OPENAI_BASE_URL", "https://api.openai.com/v1") or "https://api.openai.com/v1").strip()
if not api_key:
return json.dumps(
{"error": "未配置 OPENAI_API_KEY无法调用 Whisper API。请在 .env 中配置 OPENAI_API_KEY。"},
ensure_ascii=False,
)
fsize = path.stat().st_size
max_mb = 25
if fsize > max_mb * 1024 * 1024:
return json.dumps(
{"error": f"音频文件过大({fsize / 1024 / 1024:.1f} MB上限 {max_mb} MBWhisper API 限制)"},
ensure_ascii=False,
)
async with httpx.AsyncClient(timeout=120) as client:
with open(path, "rb") as f:
files = {
"file": (path.name, f),
"model": (None, "whisper-1"),
"language": (None, language),
}
resp = await client.post(
f"{base_url.rstrip('/')}/audio/transcriptions",
headers={"Authorization": f"Bearer {api_key}"},
files=files,
)
if resp.status_code != 200:
logger.warning(f"Whisper API 返回 {resp.status_code}: {resp.text[:500]}")
return json.dumps({
"error": f"Whisper API 调用失败 (HTTP {resp.status_code})",
"detail": resp.text[:1000],
}, ensure_ascii=False)
data = resp.json()
text = data.get("text", "")
return json.dumps({
"audio_path": str(path),
"text": text,
"language": language,
}, ensure_ascii=False)
except Exception as e:
logger.error(f"speech_to_text 工具异常: {e}", exc_info=True)
return json.dumps({"error": f"speech_to_text 失败: {e}"}, ensure_ascii=False)
async def text_to_speech_tool(text: str, voice: str = "alloy", output_path: str = "") -> str:
"""文字转语音工具:调用 TTS API 将文字合成为语音文件。
需要配置 OPENAI_API_KEY兼容 OpenAI TTS API 格式的端点均可)。
生成的 MP3 文件保存到工作区根目录下。
Args:
text: 要合成的文字内容
voice: 语音风格alloy / echo / fable / onyx / nova / shimmer
output_path: 输出文件路径(可选,默认为 agent_workspaces/tts_output.mp3
Returns:
JSONoutput_path, text_length, voice或 error
"""
try:
api_key = (getattr(settings, "OPENAI_API_KEY", "") or "").strip()
base_url = (getattr(settings, "OPENAI_BASE_URL", "https://api.openai.com/v1") or "https://api.openai.com/v1").strip()
if not api_key:
return json.dumps(
{"error": "未配置 OPENAI_API_KEY无法调用 TTS API。请在 .env 中配置 OPENAI_API_KEY。"},
ensure_ascii=False,
)
if not text or not text.strip():
return json.dumps({"error": "text 参数不能为空"}, ensure_ascii=False)
text = text.strip()
# 限制文字长度TTS 通常限制 4096 字符)
max_chars = 4000
if len(text) > max_chars:
text = text[:max_chars] + "..."
logger.warning("TTS 文字超长,已截断到 %d 字符", max_chars)
valid_voices = {"alloy", "echo", "fable", "onyx", "nova", "shimmer"}
if voice not in valid_voices:
voice = "alloy"
# 确定输出路径
if output_path:
out, err = _resolve_path_under_workspace(output_path)
if err:
return json.dumps({"error": f"输出路径无效: {err}"}, ensure_ascii=False)
else:
root = _local_file_workspace_root()
tts_dir = root / "agent_workspaces" / "tts_outputs"
tts_dir.mkdir(parents=True, exist_ok=True)
import time
out = tts_dir / f"tts_{int(time.time())}.mp3"
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(
f"{base_url.rstrip('/')}/audio/speech",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": "tts-1",
"voice": voice,
"input": text,
},
)
if resp.status_code != 200:
logger.warning(f"TTS API 返回 {resp.status_code}: {resp.text[:500]}")
return json.dumps({
"error": f"TTS API 调用失败 (HTTP {resp.status_code})",
"detail": resp.text[:1000],
}, ensure_ascii=False)
# 保存音频文件
out.write_bytes(resp.content)
return json.dumps({
"output_path": str(out),
"text_length": len(text),
"voice": voice,
}, ensure_ascii=False)
except Exception as e:
logger.error(f"text_to_speech 工具异常: {e}", exc_info=True)
return json.dumps({"error": f"text_to_speech 失败: {e}"}, ensure_ascii=False)
IMAGE_OCR_SCHEMA = {
"type": "function",
"function": {
"name": "image_ocr",
"description": (
"对图片文件执行 OCR光学字符识别提取图片中的文字。"
"适用于:作业拍照提取文字、截图文字识别、手写体识别、表格图片文字提取等场景。"
"需要本机安装 Tesseract OCR 及中文语言包。"
),
"parameters": {
"type": "object",
"properties": {
"file_path": {"type": "string", "description": "图片文件路径(相对工作区根或绝对路径)"},
},
"required": ["file_path"],
},
},
}
IMAGE_VISION_SCHEMA = {
"type": "function",
"function": {
"name": "image_vision",
"description": (
"调用多模态视觉大模型分析图片内容。可以理解图片中的物体、场景、"
"文字、图表、人物表情等深层语义信息,超越简单 OCR。"
"适用于:图片内容理解、图表分析、场景描述、视觉问答、手写批注解读等。"
"需要配置 OPENAI_API_KEY。"
),
"parameters": {
"type": "object",
"properties": {
"image_path": {"type": "string", "description": "图片文件路径(相对工作区根或绝对路径)"},
"prompt": {"type": "string", "description": "分析提示词,如'图中有什么物体?''请提取表格数据''描述人物情绪'"},
},
"required": ["image_path"],
},
},
}
SPEECH_TO_TEXT_SCHEMA = {
"type": "function",
"function": {
"name": "speech_to_text",
"description": (
"将音频文件mp3/wav/webm/m4a/ogg转为文字。使用 Whisper 模型进行语音识别。"
"适用于:语音消息转录、会议录音转文字、语音笔记提取等场景。"
"需要配置 OPENAI_API_KEY。"
),
"parameters": {
"type": "object",
"properties": {
"audio_path": {"type": "string", "description": "音频文件路径(相对工作区根或绝对路径)"},
"language": {"type": "string", "description": "语言代码zh=中文, en=英文, ja=日文(默认 zh"},
},
"required": ["audio_path"],
},
},
}
# ═══════════════════════════════════════════════════════════════
# Main Agent 专有工具Goal/Task 管理
# ═══════════════════════════════════════════════════════════════
async def main_agent_create_task(
goal_id: str,
title: str,
description: str = "",
priority: int = 5,
depends_on: Optional[List[str]] = None,
assigned_agent_id: Optional[str] = None,
assigned_agent_name: Optional[str] = None,
) -> str:
"""Main Agent 工具:创建一个子任务。
将目标分解为可执行的子任务,写入数据库。
"""
from app.core.database import SessionLocal
from app.services.goal_service import create_task, get_goal
db = None
try:
db = SessionLocal()
get_goal(db, goal_id) # 验证 Goal 存在
task = create_task(
db=db,
goal_id=goal_id,
title=title,
description=description,
priority=priority,
depends_on=depends_on or [],
assigned_agent_id=assigned_agent_id,
assigned_agent_name=assigned_agent_name,
)
return json.dumps({
"task_id": task.id,
"title": task.title,
"status": task.status,
"message": f"任务 '{title}' 创建成功",
}, ensure_ascii=False)
except Exception as e:
logger.error(f"main_agent_create_task 失败: {e}", exc_info=True)
return json.dumps({"error": f"创建任务失败: {e}"}, ensure_ascii=False)
finally:
if db:
db.close()
async def main_agent_assign_task(
task_id: str,
assigned_agent_id: str,
assigned_agent_name: str = "",
) -> str:
"""Main Agent 工具:将任务分配给特定 Agent。"""
from app.core.database import SessionLocal
from app.services.goal_service import update_task, get_task
db = None
try:
db = SessionLocal()
get_task(db, task_id) # 验证 Task 存在
update_task(
db=db,
task_id=task_id,
assigned_agent_id=assigned_agent_id,
assigned_agent_name=assigned_agent_name,
)
return json.dumps({
"task_id": task_id,
"assigned_agent_id": assigned_agent_id,
"assigned_agent_name": assigned_agent_name,
"message": f"任务已分配给 {assigned_agent_name or assigned_agent_id}",
}, ensure_ascii=False)
except Exception as e:
logger.error(f"main_agent_assign_task 失败: {e}", exc_info=True)
return json.dumps({"error": f"分配任务失败: {e}"}, ensure_ascii=False)
finally:
if db:
db.close()
async def main_agent_check_progress(goal_id: str) -> str:
"""Main Agent 工具:查看目标下所有任务的执行进度。"""
from app.core.database import SessionLocal
from app.services.goal_service import get_goal, list_tasks, get_goal_task_tree
db = None
try:
db = SessionLocal()
goal = get_goal(db, goal_id)
tasks = list_tasks(db, goal_id=goal_id, limit=200)
by_status = {}
for t in tasks:
by_status.setdefault(t.status, 0)
by_status[t.status] += 1
task_summaries = []
for t in tasks:
task_summaries.append({
"id": t.id,
"title": t.title,
"status": t.status,
"assigned_agent_name": t.assigned_agent_name,
"has_error": bool(t.error_message),
})
return json.dumps({
"goal_id": goal_id,
"goal_title": goal.title,
"goal_status": goal.status,
"progress": goal.progress,
"total_tasks": len(tasks),
"by_status": by_status,
"tasks": task_summaries,
}, ensure_ascii=False)
except Exception as e:
logger.error(f"main_agent_check_progress 失败: {e}", exc_info=True)
return json.dumps({"error": f"检查进度失败: {e}"}, ensure_ascii=False)
finally:
if db:
db.close()
async def main_agent_notify_user(
user_id: str,
message: str,
notification_type: str = "info",
) -> str:
"""Main Agent 工具:向用户发送通知(站内消息)。"""
from app.core.database import SessionLocal
from app.models.notification import Notification
db = None
try:
db = SessionLocal()
notif = Notification(
user_id=user_id,
title="Main Agent 通知",
content=message,
type=notification_type,
ref_type="goal",
ref_id="",
is_read=False,
)
db.add(notif)
db.commit()
logger.info(f"Main Agent 通知已发送: user={user_id}, type={notification_type}, len={len(message)}")
return json.dumps({
"sent": True,
"notification_type": notification_type,
"message_preview": message[:200],
}, ensure_ascii=False)
except Exception as e:
logger.error(f"main_agent_notify_user 失败: {e}", exc_info=True)
return json.dumps({"error": f"发送通知失败: {e}"}, ensure_ascii=False)
finally:
if db:
db.close()
MAIN_AGENT_CREATE_TASK_SCHEMA = {
"type": "function",
"function": {
"name": "create_task",
"description": "创建一个新任务。将目标分解为可执行的子任务,写入数据库。每个任务应具体、可衡量、有明确完成标准。",
"parameters": {
"type": "object",
"properties": {
"goal_id": {"type": "string", "description": "所属目标 ID"},
"title": {"type": "string", "description": "任务标题"},
"description": {"type": "string", "description": "任务详细描述"},
"priority": {"type": "integer", "description": "优先级 1-10数字越小越优先"},
"depends_on": {"type": "array", "items": {"type": "string"}, "description": "前置依赖任务 ID 列表"},
"assigned_agent_id": {"type": "string", "description": "分配的 Agent ID可为空"},
"assigned_agent_name": {"type": "string", "description": "分配的 Agent 名称"},
},
"required": ["goal_id", "title"],
},
},
}
MAIN_AGENT_ASSIGN_TASK_SCHEMA = {
"type": "function",
"function": {
"name": "assign_task",
"description": "将任务分配给特定的 Specialist Agent。只有已发布状态的 Agent 才能被分配。",
"parameters": {
"type": "object",
"properties": {
"task_id": {"type": "string", "description": "任务 ID"},
"assigned_agent_id": {"type": "string", "description": "分配的 Agent ID"},
"assigned_agent_name": {"type": "string", "description": "Agent 名称"},
},
"required": ["task_id", "assigned_agent_id"],
},
},
}
MAIN_AGENT_CHECK_PROGRESS_SCHEMA = {
"type": "function",
"function": {
"name": "check_progress",
"description": "查看目标下所有任务的执行进度,包括各状态统计和每个任务的详细信息。",
"parameters": {
"type": "object",
"properties": {
"goal_id": {"type": "string", "description": "目标 ID"},
},
"required": ["goal_id"],
},
},
}
MAIN_AGENT_NOTIFY_USER_SCHEMA = {
"type": "function",
"function": {
"name": "notify_user",
"description": "向用户发送站内通知消息。在目标完成、任务失败、需要审批等关键节点主动通知用户。",
"parameters": {
"type": "object",
"properties": {
"user_id": {"type": "string", "description": "接收通知的用户 ID"},
"message": {"type": "string", "description": "通知内容(支持 Markdown"},
"notification_type": {"type": "string", "description": "通知类型: info / success / warning / error默认 info"},
},
"required": ["user_id", "message"],
},
},
}
# ═══════════════════════════════════════════════════════════════
# 飞书操作工具:文档/日程/审批/通讯录
# ═══════════════════════════════════════════════════════════════
def _get_feishu_token() -> Optional[str]:
"""获取飞书 tenant_access_token带缓存"""
import time
app_id = (getattr(settings, "FEISHU_APP_ID", "") or "").strip()
app_secret = (getattr(settings, "FEISHU_APP_SECRET", "") or "").strip()
if not app_id or not app_secret:
return None
now = time.time()
cache = getattr(_get_feishu_token, "_cache", None)
if cache and now < cache.get("expires_at", 0) - 300:
return cache["token"]
try:
with httpx.Client(timeout=10) as client:
resp = client.post(
"https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal",
json={"app_id": app_id, "app_secret": app_secret},
)
result = resp.json()
if resp.is_success and result.get("code") == 0:
token = result["tenant_access_token"]
expire = result.get("expire", 7200)
_get_feishu_token._cache = {"token": token, "expires_at": now + expire}
return token
except Exception:
pass
return None
async def feishu_create_doc_tool(title: str, content: str = "", folder_token: str = "") -> str:
"""飞书工具:创建飞书文档。
使用飞书开放 API 创建在线文档,适合记录会议纪要、报告、知识库。
Args:
title: 文档标题
content: 文档内容(纯文本或 Markdown会转为飞书 Doc 格式)
folder_token: 文件夹 token可选指定存放位置
"""
token = _get_feishu_token()
if not token:
return json.dumps({
"error": "飞书应用未配置FEISHU_APP_ID / FEISHU_APP_SECRET无法创建文档。请在 .env 中配置飞书应用。"
}, ensure_ascii=False)
try:
async with httpx.AsyncClient(timeout=15) as client:
# 创建文档
body = {"title": title}
if folder_token:
body["folder_token"] = folder_token
resp = await client.post(
"https://open.feishu.cn/open-apis/docx/v1/documents",
headers={"Authorization": f"Bearer {token}"},
json=body,
)
result = resp.json()
if result.get("code") != 0:
return json.dumps({
"error": f"创建文档失败: {result.get('msg', result)}",
"code": result.get("code"),
}, ensure_ascii=False)
doc_id = result["data"]["document"]["document_id"]
doc_url = f"https://bytedance.feishu.cn/docx/{doc_id}"
# 如果有内容,写入文档
if content.strip():
await client.post(
f"https://open.feishu.cn/open-apis/docx/v1/documents/{doc_id}/blocks/{doc_id}/children",
headers={"Authorization": f"Bearer {token}"},
json={
"children": [
{
"block_type": 2, # 文本块
"text": {
"elements": [{"text_run": {"content": content[:5000]}}],
"style": {},
},
}
],
"index": 0,
},
)
return json.dumps({
"document_id": doc_id,
"url": doc_url,
"title": title,
"message": f"飞书文档 '{title}' 创建成功",
}, ensure_ascii=False)
except Exception as e:
logger.error(f"feishu_create_doc 失败: {e}", exc_info=True)
return json.dumps({"error": f"创建飞书文档失败: {e}"}, ensure_ascii=False)
async def feishu_create_calendar_event_tool(
summary: str,
start_time: str = "",
end_time: str = "",
description: str = "",
) -> str:
"""飞书工具:创建飞书日历日程。
Args:
summary: 日程标题
start_time: 开始时间ISO 格式,如 2026-05-09T10:00:00默认为当前时间+1小时
end_time: 结束时间ISO 格式,默认为开始时间+1小时
description: 日程描述(可选)
"""
from datetime import datetime as dt, timedelta
token = _get_feishu_token()
if not token:
return json.dumps({
"error": "飞书应用未配置,无法创建日程。"
}, ensure_ascii=False)
# 处理时间
if not start_time:
start_dt = dt.now() + timedelta(hours=1)
else:
start_dt = dt.fromisoformat(start_time)
if not end_time:
end_dt = start_dt + timedelta(hours=1)
else:
end_dt = dt.fromisoformat(end_time)
try:
async with httpx.AsyncClient(timeout=15) as client:
resp = await client.post(
"https://open.feishu.cn/open-apis/calendar/v4/calendars/primary/events",
headers={"Authorization": f"Bearer {token}"},
json={
"summary": summary,
"description": description[:2000] if description else "",
"start_time": {
"timestamp": str(int(start_dt.timestamp())),
"timezone": "Asia/Shanghai",
},
"end_time": {
"timestamp": str(int(end_dt.timestamp())),
"timezone": "Asia/Shanghai",
},
},
)
result = resp.json()
if result.get("code") != 0:
return json.dumps({
"error": f"创建日程失败: {result.get('msg', result)}",
}, ensure_ascii=False)
event = result["data"]["event"]
return json.dumps({
"event_id": event["event_id"],
"summary": summary,
"start_time": start_dt.isoformat(),
"end_time": end_dt.isoformat(),
"message": f"飞书日程 '{summary}' 创建成功",
}, ensure_ascii=False)
except Exception as e:
logger.error(f"feishu_create_calendar_event 失败: {e}", exc_info=True)
return json.dumps({"error": f"创建日程失败: {e}"}, ensure_ascii=False)
async def feishu_search_contacts_tool(keyword: str, search_type: str = "user") -> str:
"""飞书工具:搜索通讯录用户/部门。
Args:
keyword: 搜索关键词(姓名、邮箱、部门名)
search_type: 搜索类型 user用户/ department部门"""
token = _get_feishu_token()
if not token:
return json.dumps({"error": "飞书应用未配置,无法搜索通讯录。"}, ensure_ascii=False)
try:
async with httpx.AsyncClient(timeout=10) as client:
if search_type == "department":
resp = await client.get(
"https://open.feishu.cn/open-apis/contact/v3/departments/search",
headers={"Authorization": f"Bearer {token}"},
params={"query": keyword, "page_size": 10},
)
else:
# 先按邮箱查
resp = await client.post(
"https://open.feishu.cn/open-apis/contact/v3/users/batch_get_id",
headers={"Authorization": f"Bearer {token}"},
json={"emails": [keyword] if "@" in keyword else []},
)
result = resp.json()
if result.get("code") == 0 and result.get("data", {}).get("user_list"):
users = result["data"]["user_list"]
user_list = [{"email": u.get("email", ""), "open_id": u.get("open_id", "")} for u in users]
return json.dumps({"found": True, "users": user_list, "search_type": "email"}, ensure_ascii=False)
# 否则按姓名模糊搜索
resp = await client.get(
"https://open.feishu.cn/open-apis/contact/v3/users",
headers={"Authorization": f"Bearer {token}"},
params={"page_size": 10},
)
result = resp.json()
if result.get("code") != 0:
return json.dumps({"found": False, "error": result.get("msg", str(result))}, ensure_ascii=False)
items = result.get("data", {}).get("items", [])
if search_type == "department":
results = [{"name": d["name"], "department_id": d["department_id"]} for d in items]
else:
results = [{"name": u.get("name", ""), "email": u.get("email", ""), "open_id": u.get("open_id", "")} for u in items]
return json.dumps({"found": True, "count": len(results), "results": results[:10]}, ensure_ascii=False)
except Exception as e:
logger.error(f"feishu_search_contacts 失败: {e}", exc_info=True)
return json.dumps({"error": f"搜索通讯录失败: {e}"}, ensure_ascii=False)
async def feishu_send_approval_tool(
approver_open_id: str,
title: str,
content: str,
approval_type: str = "general",
) -> str:
"""飞书工具:向指派人发送审批卡片消息。
在飞书对话中发送交互式审批卡片,让审批人可以一键通过/驳回。
Args:
approver_open_id: 审批人的飞书 open_id
title: 审批标题
content: 审批内容说明
approval_type: 审批类型 general通用/ expense报销/ leave请假
"""
token = _get_feishu_token()
if not token:
return json.dumps({"error": "飞书应用未配置,无法发送审批。"}, ensure_ascii=False)
card = {
"config": {"wide_screen_mode": True},
"header": {
"title": {"tag": "plain_text", "content": f"📋 {title}"},
"template": "blue",
},
"elements": [
{"tag": "markdown", "content": content[:2000]},
{"tag": "hr"},
{"tag": "note", "elements": [{"tag": "plain_text", "content": "请在飞书中回复「通过」或「驳回」来处理此审批"}]},
],
}
try:
async with httpx.AsyncClient(timeout=10) as client:
resp = await client.post(
"https://open.feishu.cn/open-apis/im/v1/messages?receive_id_type=open_id",
headers={"Authorization": f"Bearer {token}"},
json={
"receive_id": approver_open_id,
"msg_type": "interactive",
"content": json.dumps(card, ensure_ascii=False),
},
)
result = resp.json()
if resp.is_success and result.get("code") == 0:
return json.dumps({
"sent": True,
"message_id": result["data"]["message_id"],
"approver_open_id": approver_open_id,
"message": f"审批卡片已发送至 {approver_open_id}",
}, ensure_ascii=False)
else:
return json.dumps({"error": f"发送审批卡片失败: {result.get('msg', result)}"}, ensure_ascii=False)
except Exception as e:
logger.error(f"feishu_send_approval 失败: {e}", exc_info=True)
return json.dumps({"error": f"发送审批失败: {e}"}, ensure_ascii=False)
FEISHU_CREATE_DOC_SCHEMA = {
"type": "function",
"function": {
"name": "feishu_create_doc",
"description": "创建飞书在线文档。适合记录会议纪要、报告、知识库等场景。需要配置飞书应用 (FEISHU_APP_ID / FEISHU_APP_SECRET)。",
"parameters": {
"type": "object",
"properties": {
"title": {"type": "string", "description": "文档标题"},
"content": {"type": "string", "description": "文档内容(纯文本/Markdown"},
"folder_token": {"type": "string", "description": "目标文件夹 token可选"},
},
"required": ["title"],
},
},
}
FEISHU_CREATE_CALENDAR_EVENT_SCHEMA = {
"type": "function",
"function": {
"name": "feishu_create_calendar_event",
"description": "创建飞书日历日程。适合安排会议、提醒、任务截止日期。需要配置飞书应用。",
"parameters": {
"type": "object",
"properties": {
"summary": {"type": "string", "description": "日程标题"},
"start_time": {"type": "string", "description": "开始时间 ISO 格式 (2026-05-09T10:00:00),默认当前+1小时"},
"end_time": {"type": "string", "description": "结束时间,默认开始+1小时"},
"description": {"type": "string", "description": "日程描述(可选)"},
},
"required": ["summary"],
},
},
}
FEISHU_SEARCH_CONTACTS_SCHEMA = {
"type": "function",
"function": {
"name": "feishu_search_contacts",
"description": "搜索飞书通讯录用户或部门。可按姓名、邮箱、部门名搜索。需要配置飞书应用。",
"parameters": {
"type": "object",
"properties": {
"keyword": {"type": "string", "description": "搜索关键词(姓名/邮箱/部门名)"},
"search_type": {"type": "string", "description": "user用户或 department部门默认 user"},
},
"required": ["keyword"],
},
},
}
FEISHU_SEND_APPROVAL_SCHEMA = {
"type": "function",
"function": {
"name": "feishu_send_approval",
"description": "向飞书用户发送审批卡片消息,审批人可在飞书直接通过/驳回。需要配置飞书应用。",
"parameters": {
"type": "object",
"properties": {
"approver_open_id": {"type": "string", "description": "审批人的飞书 open_id"},
"title": {"type": "string", "description": "审批标题"},
"content": {"type": "string", "description": "审批内容说明"},
"approval_type": {"type": "string", "description": "审批类型: general/expense/leave默认 general"},
},
"required": ["approver_open_id", "title", "content"],
},
},
}
TEXT_TO_SPEECH_SCHEMA = {
"type": "function",
"function": {
"name": "text_to_speech",
"description": (
"将文字合成为语音文件MP3。支持多种语音风格。"
"适用于:将 Agent 回复朗读为语音、生成有声读物、语音播报通知等场景。"
"需要配置 OPENAI_API_KEY。"
),
"parameters": {
"type": "object",
"properties": {
"text": {"type": "string", "description": "要合成的文字内容"},
"voice": {"type": "string", "description": "语音风格alloy / echo / fable / onyx / nova / shimmer默认 alloy"},
"output_path": {"type": "string", "description": "输出 MP3 文件路径(可选,默认保存到 agent_workspaces/tts_outputs/"},
},
"required": ["text"],
},
},
}