增强编排 + 飞书深度集成: - 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>
5773 lines
224 KiB
Python
5773 lines
224 KiB
Python
"""
|
||
内置工具实现
|
||
"""
|
||
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:
|
||
JSON:file_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。请安装 Tesseract(Windows 可装官方安装包),"
|
||
"并在 .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-8,w 覆盖 / 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_root:file_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=解析JSON,stringify=序列化为JSON,validate=验证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: 构建 URL(url=基础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,最少8;string 默认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": "参数 JSON(build 操作时使用),如 {\"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": "计划 ID(create 可选,其他必填)"},
|
||
"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": "写入数据 JSON(write 时,格式 {\"Sheet1\": [[1,2],[3,4]]})"},
|
||
"chart_config_json": {"type": "string", "description": "图表配置 JSON(chart 时,如 {\"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:
|
||
"""无头浏览器操控。需要 playwright(pip 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": "请求体模板 JSON(POST/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": "扩展详情 JSON(log 时可选)"},
|
||
"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:
|
||
JSON:file_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。请安装 Tesseract(Windows 可装官方安装包),"
|
||
"并在 .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:
|
||
JSON:image_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:
|
||
JSON:audio_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} MB(Whisper 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:
|
||
JSON:output_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"],
|
||
},
|
||
},
|
||
}
|