feat: add 8 builtin tools, AgentSchedules management page, Celery Beat integration
- Add 3 schedule tools (create/list/delete) and 5 utility tools (crypto, random, email, URL, regex) - Add frontend AgentSchedules.vue page with full CRUD, cron presets, manual trigger - Integrate Celery Beat for automatic schedule execution - Update startup scripts with Celery Beat launch - Fix schedule list API to show all schedules for admin users - Add celrybeat-schedule.* to .gitignore Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -27,6 +27,9 @@ tessdata/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
|
||||
# Celery Beat 运行时文件
|
||||
celerybeat-schedule.*
|
||||
|
||||
# 本机运行产物 / 大文件(勿提交)
|
||||
agent_workspaces/
|
||||
uploads/
|
||||
|
||||
@@ -72,12 +72,10 @@ async def list_schedules(
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""获取当前用户的所有定时任务。"""
|
||||
schedules = (
|
||||
db.query(AgentSchedule)
|
||||
.filter(AgentSchedule.user_id == current_user.id)
|
||||
.order_by(AgentSchedule.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
query = db.query(AgentSchedule)
|
||||
if current_user.role != "admin":
|
||||
query = query.filter(AgentSchedule.user_id == current_user.id)
|
||||
schedules = query.order_by(AgentSchedule.created_at.desc()).all()
|
||||
return schedules
|
||||
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
_registered = False
|
||||
|
||||
_EXPECTED_BUILTIN = 10
|
||||
_EXPECTED_BUILTIN = 18
|
||||
|
||||
|
||||
def ensure_builtin_tools_registered() -> None:
|
||||
@@ -28,6 +28,14 @@ def ensure_builtin_tools_registered() -> None:
|
||||
json_process_tool,
|
||||
database_query_tool,
|
||||
adb_log_tool,
|
||||
schedule_create_tool,
|
||||
schedule_list_tool,
|
||||
schedule_delete_tool,
|
||||
crypto_util_tool,
|
||||
random_generate_tool,
|
||||
send_email_tool,
|
||||
url_parse_tool,
|
||||
regex_test_tool,
|
||||
HTTP_REQUEST_SCHEMA,
|
||||
FILE_READ_SCHEMA,
|
||||
FILE_WRITE_SCHEMA,
|
||||
@@ -38,6 +46,14 @@ def ensure_builtin_tools_registered() -> None:
|
||||
JSON_PROCESS_SCHEMA,
|
||||
DATABASE_QUERY_SCHEMA,
|
||||
ADB_LOG_SCHEMA,
|
||||
SCHEDULE_CREATE_SCHEMA,
|
||||
SCHEDULE_LIST_SCHEMA,
|
||||
SCHEDULE_DELETE_SCHEMA,
|
||||
CRYPTO_UTIL_SCHEMA,
|
||||
RANDOM_GENERATE_SCHEMA,
|
||||
SEND_EMAIL_SCHEMA,
|
||||
URL_PARSE_SCHEMA,
|
||||
REGEX_TEST_SCHEMA,
|
||||
)
|
||||
|
||||
tool_registry.register_builtin_tool("http_request", http_request_tool, HTTP_REQUEST_SCHEMA)
|
||||
@@ -50,6 +66,14 @@ def ensure_builtin_tools_registered() -> None:
|
||||
tool_registry.register_builtin_tool("json_process", json_process_tool, JSON_PROCESS_SCHEMA)
|
||||
tool_registry.register_builtin_tool("database_query", database_query_tool, DATABASE_QUERY_SCHEMA)
|
||||
tool_registry.register_builtin_tool("adb_log", adb_log_tool, ADB_LOG_SCHEMA)
|
||||
tool_registry.register_builtin_tool("schedule_create", schedule_create_tool, SCHEDULE_CREATE_SCHEMA)
|
||||
tool_registry.register_builtin_tool("schedule_list", schedule_list_tool, SCHEDULE_LIST_SCHEMA)
|
||||
tool_registry.register_builtin_tool("schedule_delete", schedule_delete_tool, SCHEDULE_DELETE_SCHEMA)
|
||||
tool_registry.register_builtin_tool("crypto_util", crypto_util_tool, CRYPTO_UTIL_SCHEMA)
|
||||
tool_registry.register_builtin_tool("random_generate", random_generate_tool, RANDOM_GENERATE_SCHEMA)
|
||||
tool_registry.register_builtin_tool("send_email", send_email_tool, SEND_EMAIL_SCHEMA)
|
||||
tool_registry.register_builtin_tool("url_parse", url_parse_tool, URL_PARSE_SCHEMA)
|
||||
tool_registry.register_builtin_tool("regex_test", regex_test_tool, REGEX_TEST_SCHEMA)
|
||||
_registered = True
|
||||
|
||||
n = tool_registry.builtin_tool_count()
|
||||
|
||||
@@ -1343,3 +1343,804 @@ DATABASE_QUERY_SCHEMA = {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ─── 加密/哈希/编码工具 ──────────────────────────────────
|
||||
|
||||
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)
|
||||
|
||||
# 尝试计算下次执行时间
|
||||
try:
|
||||
next_run = compute_next_run(cron_expression)
|
||||
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) -> str:
|
||||
"""
|
||||
删除指定的定时任务。
|
||||
|
||||
Args:
|
||||
schedule_id: 定时任务 ID
|
||||
|
||||
Returns:
|
||||
删除结果
|
||||
"""
|
||||
try:
|
||||
from app.core.database import SessionLocal
|
||||
from app.models.agent_schedule import AgentSchedule
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
schedule = db.query(AgentSchedule).filter(AgentSchedule.id == schedule_id).first()
|
||||
if not schedule:
|
||||
return json.dumps({"error": f"定时任务不存在: {schedule_id}"}, ensure_ascii=False)
|
||||
|
||||
name = schedule.name
|
||||
db.delete(schedule)
|
||||
db.commit()
|
||||
|
||||
return json.dumps({
|
||||
"success": True,
|
||||
"message": f"定时任务「{name}」已删除",
|
||||
}, ensure_ascii=False)
|
||||
finally:
|
||||
db.close()
|
||||
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": "删除指定的定时任务。",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"schedule_id": {
|
||||
"type": "string",
|
||||
"description": "定时任务 ID(可从 schedule_list 查询获得)",
|
||||
},
|
||||
},
|
||||
"required": ["schedule_id"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ kafka-python==2.0.2 # Kafka
|
||||
|
||||
# Utilities
|
||||
python-dateutil==2.8.2
|
||||
croniter==2.0.7
|
||||
|
||||
# file_read:PDF / Word / Excel / 图片 OCR
|
||||
pypdf==4.0.1
|
||||
|
||||
@@ -35,6 +35,10 @@
|
||||
<el-icon><ChatLineSquare /></el-icon>
|
||||
<span>Agent对话</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="agent-schedules" @click="router.push('/agent-schedules')">
|
||||
<el-icon><Clock /></el-icon>
|
||||
<span>定时任务</span>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="executions">
|
||||
<el-icon><List /></el-icon>
|
||||
<span>执行历史</span>
|
||||
@@ -94,7 +98,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { Document, User, List, Connection, Setting, Star, Lock, Monitor, Bell, Grid, DataAnalysis, Tools } from '@element-plus/icons-vue'
|
||||
import { Document, User, List, Connection, Setting, Star, Lock, Monitor, Bell, Grid, DataAnalysis, Tools, Clock } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@@ -117,6 +121,7 @@ const activeMenu = computed(() => {
|
||||
if (route.path === '/agent-monitoring') return 'agent-monitoring'
|
||||
if (route.path === '/alert-rules') return 'alert-rules'
|
||||
if (route.path === '/agent-chat' || route.path.startsWith('/agent-chat/')) return 'agent-chat'
|
||||
if (route.path === '/agent-schedules') return 'agent-schedules'
|
||||
return 'workflows'
|
||||
})
|
||||
|
||||
@@ -148,6 +153,8 @@ const handleMenuSelect = (key: string) => {
|
||||
router.push('/agent-monitoring')
|
||||
} else if (key === 'alert-rules') {
|
||||
router.push('/alert-rules')
|
||||
} else if (key === 'agent-schedules') {
|
||||
router.push('/agent-schedules')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -129,6 +129,12 @@ const router = createRouter({
|
||||
name: 'agent-chat-with-agent',
|
||||
component: () => import('@/views/AgentChat.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/agent-schedules',
|
||||
name: 'agent-schedules',
|
||||
component: () => import('@/views/AgentSchedules.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
432
frontend/src/views/AgentSchedules.vue
Normal file
432
frontend/src/views/AgentSchedules.vue
Normal file
@@ -0,0 +1,432 @@
|
||||
<template>
|
||||
<MainLayout>
|
||||
<div class="schedules-page">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<h2>Agent 定时任务管理</h2>
|
||||
<el-button type="primary" @click="openCreateDialog">
|
||||
<el-icon><Plus /></el-icon>
|
||||
创建定时任务
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
style="margin-bottom: 16px"
|
||||
>
|
||||
<template #title>
|
||||
定时任务按 cron 表达式周期执行 Agent,执行结果可通过飞书 Webhook 推送通知。
|
||||
Celery Beat 每分钟检查一次到期任务。
|
||||
</template>
|
||||
</el-alert>
|
||||
|
||||
<el-table v-loading="loading" :data="schedules" stripe>
|
||||
<el-table-column prop="name" label="任务名称" min-width="140" />
|
||||
<el-table-column label="关联 Agent" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ agentName(row.agent_id) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="cron_expression" label="Cron 表达式" width="130" />
|
||||
<el-table-column label="下次执行" width="170">
|
||||
<template #default="{ row }">
|
||||
{{ formatTime(row.next_run_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上次执行" width="170">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.last_run_at">
|
||||
{{ formatTime(row.last_run_at) }}
|
||||
<el-tag
|
||||
:type="row.last_run_status === 'success' ? 'success' : 'danger'"
|
||||
size="small"
|
||||
style="margin-left: 4px"
|
||||
>
|
||||
{{ row.last_run_status }}
|
||||
</el-tag>
|
||||
</span>
|
||||
<span v-else style="color: #999">未执行</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用" width="70">
|
||||
<template #default="{ row }">
|
||||
<el-switch
|
||||
v-model="row.enabled"
|
||||
@change="(val: boolean) => toggleEnabled(row, val)"
|
||||
/>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="280" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" type="success" @click="triggerNow(row)">
|
||||
手动触发
|
||||
</el-button>
|
||||
<el-button size="small" @click="openEditDialog(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-popconfirm
|
||||
title="确定删除该定时任务?"
|
||||
@confirm="handleDelete(row.id)"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button size="small" type="danger">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-empty v-if="!loading && schedules.length === 0" description="暂无定时任务" />
|
||||
</el-card>
|
||||
|
||||
<!-- 创建/编辑对话框 -->
|
||||
<el-dialog
|
||||
v-model="dialogVisible"
|
||||
:title="editingSchedule ? '编辑定时任务' : '创建定时任务'"
|
||||
width="580px"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-width="110px"
|
||||
@submit.prevent="handleSubmit"
|
||||
>
|
||||
<el-form-item label="任务名称" prop="name">
|
||||
<el-input v-model="form.name" placeholder="如:每日早报推送" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="关联 Agent" prop="agent_id">
|
||||
<el-select
|
||||
v-model="form.agent_id"
|
||||
placeholder="选择要执行的 Agent"
|
||||
filterable
|
||||
style="width: 100%"
|
||||
:disabled="!!editingSchedule"
|
||||
>
|
||||
<el-option
|
||||
v-for="agent in agents"
|
||||
:key="agent.id"
|
||||
:label="agent.name"
|
||||
:value="agent.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="Cron 表达式" prop="cron_expression">
|
||||
<el-input
|
||||
v-model="form.cron_expression"
|
||||
placeholder="如:0 9 * * * (每天9点)"
|
||||
/>
|
||||
<div class="cron-hints">
|
||||
<span class="hint-label">快捷选择:</span>
|
||||
<el-tag
|
||||
v-for="preset in cronPresets"
|
||||
:key="preset.value"
|
||||
size="small"
|
||||
class="cron-tag"
|
||||
:type="form.cron_expression === preset.value ? 'primary' : ''"
|
||||
@click="form.cron_expression = preset.value"
|
||||
>
|
||||
{{ preset.label }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div
|
||||
v-if="form.cron_expression.trim()"
|
||||
class="cron-field-count"
|
||||
:class="{
|
||||
'cron-ok': cronFieldCount >= 5 && cronFieldCount <= 7,
|
||||
'cron-err': cronFieldCount < 5 || cronFieldCount > 7
|
||||
}"
|
||||
>
|
||||
当前 {{ cronFieldCount }} 个字段(需要 5~7 个,空格分隔)
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="执行消息" prop="input_message">
|
||||
<el-input
|
||||
v-model="form.input_message"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
placeholder="每次定时执行时发送给 Agent 的消息内容"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="飞书 Webhook">
|
||||
<el-input
|
||||
v-model="form.webhook_url"
|
||||
placeholder="可选,执行完成后推送到飞书机器人"
|
||||
/>
|
||||
<div style="color: #999; font-size: 12px; margin-top: 4px">
|
||||
填入飞书机器人 Webhook URL,任务执行完成后自动推送通知
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="handleSubmit">
|
||||
{{ editingSchedule ? '保存' : '创建' }}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</MainLayout>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus } from '@element-plus/icons-vue'
|
||||
import MainLayout from '@/components/MainLayout.vue'
|
||||
import api from '@/api'
|
||||
|
||||
// ── 类型 ──────────────────────────────────
|
||||
interface AgentSchedule {
|
||||
id: string
|
||||
agent_id: string
|
||||
name: string
|
||||
cron_expression: string
|
||||
input_message: string
|
||||
timezone: string
|
||||
enabled: boolean
|
||||
webhook_url?: string | null
|
||||
last_run_at?: string | null
|
||||
last_run_status?: string | null
|
||||
next_run_at: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
interface AgentItem {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
// ── 数据 ──────────────────────────────────
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const schedules = ref<AgentSchedule[]>([])
|
||||
const agents = ref<AgentItem[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingSchedule = ref<AgentSchedule | null>(null)
|
||||
const formRef = ref()
|
||||
|
||||
const form = reactive({
|
||||
agent_id: '',
|
||||
name: '',
|
||||
cron_expression: '',
|
||||
input_message: '',
|
||||
webhook_url: '',
|
||||
})
|
||||
|
||||
const cronPresets = [
|
||||
{ label: '每分钟', value: '* * * * *' },
|
||||
{ label: '每5分钟', value: '*/5 * * * *' },
|
||||
{ label: '每小时', value: '0 * * * *' },
|
||||
{ label: '每天9点', value: '0 9 * * *' },
|
||||
{ label: '每天18点', value: '0 18 * * *' },
|
||||
{ label: '每周一9点', value: '0 9 * * 1' },
|
||||
{ label: '每月1号9点', value: '0 9 1 * *' },
|
||||
]
|
||||
|
||||
const cronFieldCount = computed(() => {
|
||||
const v = form.cron_expression?.trim() || ''
|
||||
if (!v) return 0
|
||||
return v.split(/\s+/).length
|
||||
})
|
||||
|
||||
function validateCron(_rule: any, value: string, cb: any) {
|
||||
if (!value || !value.trim()) {
|
||||
cb(new Error('请输入 cron 表达式'))
|
||||
return
|
||||
}
|
||||
const parts = value.trim().split(/\s+/)
|
||||
if (parts.length < 5 || parts.length > 7) {
|
||||
cb(new Error(`cron 表达式需要 5~7 个字段,当前有 ${parts.length} 个(用空格分隔)`))
|
||||
return
|
||||
}
|
||||
cb()
|
||||
}
|
||||
|
||||
const rules = {
|
||||
name: [{ required: true, message: '请输入任务名称', trigger: 'blur' }],
|
||||
agent_id: [{ required: true, message: '请选择 Agent', trigger: 'change' }],
|
||||
cron_expression: [{ required: true, validator: validateCron, trigger: 'blur' }],
|
||||
input_message: [{ required: true, message: '请输入执行消息', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
// ── 方法 ──────────────────────────────────
|
||||
function agentName(id: string) {
|
||||
const a = agents.value.find((x) => x.id === id)
|
||||
return a ? a.name : id
|
||||
}
|
||||
|
||||
function formatTime(ts: string | null) {
|
||||
if (!ts) return '-'
|
||||
const d = new Date(ts)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||
}
|
||||
|
||||
async function loadAgents() {
|
||||
try {
|
||||
const { data } = await api.get('/api/v1/agents', { params: { limit: 100 } })
|
||||
agents.value = data
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSchedules() {
|
||||
loading.value = true
|
||||
try {
|
||||
const { data } = await api.get('/api/v1/agent-schedules')
|
||||
schedules.value = data
|
||||
} catch {
|
||||
// 错误由拦截器处理
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
editingSchedule.value = null
|
||||
form.agent_id = ''
|
||||
form.name = ''
|
||||
form.cron_expression = ''
|
||||
form.input_message = ''
|
||||
form.webhook_url = ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditDialog(row: AgentSchedule) {
|
||||
editingSchedule.value = row
|
||||
form.agent_id = row.agent_id
|
||||
form.name = row.name
|
||||
form.cron_expression = row.cron_expression
|
||||
form.input_message = row.input_message
|
||||
form.webhook_url = row.webhook_url || ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
agent_id: form.agent_id,
|
||||
name: form.name,
|
||||
cron_expression: form.cron_expression,
|
||||
input_message: form.input_message,
|
||||
}
|
||||
|
||||
if (editingSchedule.value) {
|
||||
await api.put(`/api/v1/agent-schedules/${editingSchedule.value.id}`, payload)
|
||||
ElMessage.success('定时任务已更新')
|
||||
} else {
|
||||
await api.post('/api/v1/agent-schedules', payload)
|
||||
ElMessage.success('定时任务已创建')
|
||||
}
|
||||
|
||||
dialogVisible.value = false
|
||||
await loadSchedules()
|
||||
} catch {
|
||||
// 错误由拦截器处理
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(row: AgentSchedule, val: boolean) {
|
||||
try {
|
||||
await api.put(`/api/v1/agent-schedules/${row.id}`, { enabled: val })
|
||||
ElMessage.success(val ? '已启用' : '已停用')
|
||||
} catch {
|
||||
row.enabled = !val
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerNow(row: AgentSchedule) {
|
||||
try {
|
||||
await api.post(`/api/v1/agent-schedules/${row.id}/trigger`)
|
||||
ElMessage.success('已触发执行,请到执行历史查看')
|
||||
await loadSchedules()
|
||||
} catch {
|
||||
// 错误由拦截器处理
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
try {
|
||||
await api.delete(`/api/v1/agent-schedules/${id}`)
|
||||
ElMessage.success('定时任务已删除')
|
||||
await loadSchedules()
|
||||
} catch {
|
||||
// 错误由拦截器处理
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadAgents(), loadSchedules()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.schedules-page {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cron-hints {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.hint-label {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.cron-tag {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cron-field-count {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.cron-field-count.cron-ok {
|
||||
color: #67c23a;
|
||||
background: #f0f9eb;
|
||||
}
|
||||
|
||||
.cron-field-count.cron-err {
|
||||
color: #f56c6c;
|
||||
background: #fef0f0;
|
||||
}
|
||||
</style>
|
||||
@@ -69,6 +69,13 @@ Start-Process powershell -ArgumentList @(
|
||||
"cd '$Backend'; .\venv\Scripts\Activate.ps1; python -m celery -A app.core.celery_app worker --loglevel=info --pool=threads --concurrency=8"
|
||||
)
|
||||
|
||||
Write-Host '[RUN] Starting Celery Beat (scheduler) ...' -ForegroundColor Yellow
|
||||
Start-Process powershell -ArgumentList @(
|
||||
"-NoExit",
|
||||
"-Command",
|
||||
"cd '$Backend'; .\venv\Scripts\Activate.ps1; python -m celery -A app.core.celery_app beat --loglevel=info"
|
||||
)
|
||||
|
||||
Write-Host ('[RUN] Starting frontend on {0} (proxy -> {1}) ...' -f $FrontendPort, $ApiBase) -ForegroundColor Yellow
|
||||
Start-Process powershell -ArgumentList @(
|
||||
"-NoExit",
|
||||
|
||||
@@ -76,6 +76,18 @@ Start-Process powershell `
|
||||
)
|
||||
Write-Log "Celery worker starting (log: $celeryLog)"
|
||||
|
||||
# ── 3.5) Celery Beat (scheduler) ──────────────
|
||||
Write-Log "Starting Celery Beat ..."
|
||||
$beatLog = Join-Path $LogDir "celery_beat.log"
|
||||
Start-Process powershell `
|
||||
-WindowStyle Hidden `
|
||||
-ArgumentList @(
|
||||
"-NoProfile", "-ExecutionPolicy", "Bypass",
|
||||
"-Command",
|
||||
"Set-Location '$Backend'; .\venv\Scripts\Activate.ps1; python -m celery -A app.core.celery_app beat --loglevel=info 2>&1 | Out-File -Append '$beatLog' -Encoding UTF8"
|
||||
)
|
||||
Write-Log "Celery Beat starting (log: $beatLog)"
|
||||
|
||||
# ── 4) Frontend (pnpm dev) ────────────────────
|
||||
$frontendPort = 3001
|
||||
$frontendListening = netstat -ano | Select-String ":$frontendPort\s+.*LISTENING"
|
||||
|
||||
Reference in New Issue
Block a user