diff --git a/项目初始文档.md b/(红头)项目初始文档.md similarity index 100% rename from 项目初始文档.md rename to (红头)项目初始文档.md diff --git a/.gitignore b/.gitignore index 5907892..6e80ced 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,9 @@ tessdata/ __pycache__/ *.py[cod] +# Celery Beat 运行时文件 +celerybeat-schedule.* + # 本机运行产物 / 大文件(勿提交) agent_workspaces/ uploads/ diff --git a/backend/app/api/agent_schedules.py b/backend/app/api/agent_schedules.py index 2e0e2bb..5df7031 100644 --- a/backend/app/api/agent_schedules.py +++ b/backend/app/api/agent_schedules.py @@ -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 diff --git a/backend/app/core/tools_bootstrap.py b/backend/app/core/tools_bootstrap.py index 649e759..9cbc47c 100644 --- a/backend/app/core/tools_bootstrap.py +++ b/backend/app/core/tools_bootstrap.py @@ -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() diff --git a/backend/app/services/builtin_tools.py b/backend/app/services/builtin_tools.py index c25b93b..28214fa 100644 --- a/backend/app/services/builtin_tools.py +++ b/backend/app/services/builtin_tools.py @@ -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"], + }, + }, +} diff --git a/backend/requirements.txt b/backend/requirements.txt index 9b1df6b..151a2af 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/frontend/src/components/MainLayout.vue b/frontend/src/components/MainLayout.vue index accf1f0..8fb66e3 100644 --- a/frontend/src/components/MainLayout.vue +++ b/frontend/src/components/MainLayout.vue @@ -35,6 +35,10 @@ Agent对话 + + + 定时任务 + 执行历史 @@ -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') } } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 7970243..4abecf4 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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 } } ] }) diff --git a/frontend/src/views/AgentSchedules.vue b/frontend/src/views/AgentSchedules.vue new file mode 100644 index 0000000..fe8732a --- /dev/null +++ b/frontend/src/views/AgentSchedules.vue @@ -0,0 +1,432 @@ + + + + + + + Agent 定时任务管理 + + + 创建定时任务 + + + + + + + 定时任务按 cron 表达式周期执行 Agent,执行结果可通过飞书 Webhook 推送通知。 + Celery Beat 每分钟检查一次到期任务。 + + + + + + + + {{ agentName(row.agent_id) }} + + + + + + {{ formatTime(row.next_run_at) }} + + + + + + {{ formatTime(row.last_run_at) }} + + {{ row.last_run_status }} + + + 未执行 + + + + + toggleEnabled(row, val)" + /> + + + + + + 手动触发 + + + 编辑 + + + + 删除 + + + + + + + + + + + + + + + + + + + + + + + + + + 快捷选择: + + {{ preset.label }} + + + + 当前 {{ cronFieldCount }} 个字段(需要 5~7 个,空格分隔) + + + + + + + + + + + 填入飞书机器人 Webhook URL,任务执行完成后自动推送通知 + + + + + + 取消 + + {{ editingSchedule ? '保存' : '创建' }} + + + + + + + + + + diff --git a/start_aiagent.ps1 b/start_aiagent.ps1 index 1f295be..948ac18 100644 --- a/start_aiagent.ps1 +++ b/start_aiagent.ps1 @@ -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", diff --git a/start_aiagent_background.ps1 b/start_aiagent_background.ps1 index 9976b59..b0aded8 100644 --- a/start_aiagent_background.ps1 +++ b/start_aiagent_background.ps1 @@ -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"