mirror of
https://github.com/AstrBotDevs/AstrBot.git
synced 2026-09-24 16:39:52 +08:00
perf: merge 3 cron tools into 1 cron manage tool, and add edit capability for cron tool. (#7445)
* perf: replace cron tools with FutureTaskTool for improved task management * feat: enhance FutureTaskTool with edit functionality and improve descriptions * feat: add edit functionality for cron jobs and update related UI components
This commit is contained in:
@@ -59,11 +59,7 @@ from astrbot.core.provider.entities import ProviderRequest
|
||||
from astrbot.core.skills.skill_manager import SkillManager, build_skills_prompt
|
||||
from astrbot.core.star.context import Context
|
||||
from astrbot.core.star.star_handler import star_map
|
||||
from astrbot.core.tools.cron_tools import (
|
||||
CreateActiveCronTool,
|
||||
DeleteCronJobTool,
|
||||
ListCronJobsTool,
|
||||
)
|
||||
from astrbot.core.tools.cron_tools import FutureTaskTool
|
||||
from astrbot.core.tools.knowledge_base_tools import (
|
||||
KnowledgeBaseQueryTool,
|
||||
retrieve_knowledge_base,
|
||||
@@ -1064,9 +1060,7 @@ def _proactive_cron_job_tools(req: ProviderRequest, plugin_context: Context) ->
|
||||
if req.func_tool is None:
|
||||
req.func_tool = ToolSet()
|
||||
tool_mgr = plugin_context.get_llm_tool_manager()
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(CreateActiveCronTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(DeleteCronJobTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(ListCronJobsTool))
|
||||
req.func_tool.add_tool(tool_mgr.get_builtin_tool(FutureTaskTool))
|
||||
|
||||
|
||||
async def _apply_web_search_tools(
|
||||
|
||||
+171
-131
@@ -18,40 +18,58 @@ def _extract_job_session(job: Any) -> str | None:
|
||||
return str(session) if session is not None else None
|
||||
|
||||
|
||||
def _parse_run_at(run_at: Any) -> datetime | None:
|
||||
if run_at in (None, ""):
|
||||
return None
|
||||
return datetime.fromisoformat(str(run_at))
|
||||
|
||||
|
||||
@builtin_tool
|
||||
@dataclass
|
||||
class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "create_future_task"
|
||||
class FutureTaskTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "future_task"
|
||||
description: str = (
|
||||
"Create a future task for your future. Supports recurring cron expressions or one-time run_at datetime. "
|
||||
"Use this when you or the user want scheduled follow-up or proactive actions."
|
||||
"Manage your future tasks. "
|
||||
"Use action='create' to schedule a recurring cron task or one-time run_at task. "
|
||||
"Use action='edit' to update an existing task. "
|
||||
"Use action='list' to inspect existing tasks. "
|
||||
"Use action='delete' to remove a task by job_id."
|
||||
)
|
||||
parameters: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["create", "edit", "delete", "list"],
|
||||
"description": "Action to perform. 'list' takes no parameters. 'delete' requires only 'job_id'. 'edit' requires 'job_id' plus the fields to change.",
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Optional task label.",
|
||||
},
|
||||
"cron_expression": {
|
||||
"type": "string",
|
||||
"description": "Cron expression defining recurring schedule (e.g., '0 8 * * *' or '0 23 * * mon-fri'). Prefer named weekdays like 'mon-fri' or 'sat,sun' instead of numeric day-of-week ranges such as '1-5' to avoid ambiguity across cron implementations.",
|
||||
},
|
||||
"run_at": {
|
||||
"type": "string",
|
||||
"description": "ISO datetime for one-time execution, e.g., 2026-02-02T08:00:00+08:00. Use with run_once=true.",
|
||||
"description": "Cron expression for a recurring schedule, e.g. '0 8 * * *' or '0 23 * * mon-fri'. Prefer named weekdays like 'mon-fri' or 'sat,sun' over numeric ranges like '1-5'.",
|
||||
},
|
||||
"note": {
|
||||
"type": "string",
|
||||
"description": "Detailed instructions for your future agent to execute when it wakes.",
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Optional label to recognize this future task.",
|
||||
},
|
||||
"run_once": {
|
||||
"type": "boolean",
|
||||
"description": "If true, the task will run only once and then be deleted. Use run_at to specify the time.",
|
||||
"description": "Run only once and delete after execution. Use with run_at.",
|
||||
},
|
||||
"run_at": {
|
||||
"type": "string",
|
||||
"description": "ISO datetime for one-time execution, e.g. 2026-02-02T08:00:00+08:00.",
|
||||
},
|
||||
"job_id": {
|
||||
"type": "string",
|
||||
"description": "Task ID. Required for 'delete' and 'edit'.",
|
||||
},
|
||||
},
|
||||
"required": ["note"],
|
||||
"required": ["action"],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -62,130 +80,152 @@ class CreateActiveCronTool(FunctionTool[AstrAgentContext]):
|
||||
if cron_mgr is None:
|
||||
return "error: cron manager is not available."
|
||||
|
||||
cron_expression = kwargs.get("cron_expression")
|
||||
run_at = kwargs.get("run_at")
|
||||
run_once = bool(kwargs.get("run_once", False))
|
||||
note = str(kwargs.get("note", "")).strip()
|
||||
name = str(kwargs.get("name") or "").strip() or "active_agent_task"
|
||||
action = str(kwargs.get("action") or "").strip().lower()
|
||||
if action == "create":
|
||||
cron_expression = kwargs.get("cron_expression")
|
||||
run_at = kwargs.get("run_at")
|
||||
run_once = bool(kwargs.get("run_once", False))
|
||||
note = str(kwargs.get("note", "")).strip()
|
||||
name = str(kwargs.get("name") or "").strip() or "active_agent_task"
|
||||
|
||||
if not note:
|
||||
return "error: note is required."
|
||||
if run_once and not run_at:
|
||||
return "error: run_at is required when run_once=true."
|
||||
if (not run_once) and not cron_expression:
|
||||
return "error: cron_expression is required when run_once=false."
|
||||
if run_once and cron_expression:
|
||||
cron_expression = None
|
||||
run_at_dt = None
|
||||
if run_at:
|
||||
if not note:
|
||||
return "error: note is required when action=create."
|
||||
if run_once and not run_at:
|
||||
return "error: run_at is required when run_once=true."
|
||||
if (not run_once) and not cron_expression:
|
||||
return "error: cron_expression is required when run_once=false."
|
||||
if run_once and cron_expression:
|
||||
cron_expression = None
|
||||
try:
|
||||
run_at_dt = datetime.fromisoformat(str(run_at))
|
||||
run_at_dt = _parse_run_at(run_at)
|
||||
except Exception:
|
||||
return "error: run_at must be ISO datetime, e.g., 2026-02-02T08:00:00+08:00"
|
||||
|
||||
payload = {
|
||||
"session": context.context.event.unified_msg_origin,
|
||||
"sender_id": context.context.event.get_sender_id(),
|
||||
"note": note,
|
||||
"origin": "tool",
|
||||
}
|
||||
payload = {
|
||||
"session": context.context.event.unified_msg_origin,
|
||||
"sender_id": context.context.event.get_sender_id(),
|
||||
"note": note,
|
||||
"origin": "tool",
|
||||
}
|
||||
|
||||
job = await cron_mgr.add_active_job(
|
||||
name=name,
|
||||
cron_expression=str(cron_expression) if cron_expression else None,
|
||||
payload=payload,
|
||||
description=note,
|
||||
run_once=run_once,
|
||||
run_at=run_at_dt,
|
||||
)
|
||||
next_run = job.next_run_time or run_at_dt
|
||||
suffix = (
|
||||
f"one-time at {next_run}"
|
||||
if run_once
|
||||
else f"expression '{cron_expression}' (next {next_run})"
|
||||
)
|
||||
return f"Scheduled future task {job.job_id} ({job.name}) {suffix}."
|
||||
|
||||
|
||||
@builtin_tool
|
||||
@dataclass
|
||||
class DeleteCronJobTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "delete_future_task"
|
||||
description: str = "Delete a future task (cron job) by its job_id."
|
||||
parameters: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_id": {
|
||||
"type": "string",
|
||||
"description": "The job_id returned when the job was created.",
|
||||
}
|
||||
},
|
||||
"required": ["job_id"],
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], **kwargs
|
||||
) -> ToolExecResult:
|
||||
cron_mgr = context.context.context.cron_manager
|
||||
if cron_mgr is None:
|
||||
return "error: cron manager is not available."
|
||||
current_umo = context.context.event.unified_msg_origin
|
||||
job_id = kwargs.get("job_id")
|
||||
if not job_id:
|
||||
return "error: job_id is required."
|
||||
job = await cron_mgr.db.get_cron_job(str(job_id))
|
||||
if not job:
|
||||
return f"error: cron job {job_id} not found."
|
||||
if _extract_job_session(job) != current_umo:
|
||||
return "error: you can only delete future tasks in the current umo."
|
||||
await cron_mgr.delete_job(str(job_id))
|
||||
return f"Deleted cron job {job_id}."
|
||||
|
||||
|
||||
@builtin_tool
|
||||
@dataclass
|
||||
class ListCronJobsTool(FunctionTool[AstrAgentContext]):
|
||||
name: str = "list_future_tasks"
|
||||
description: str = "List existing future tasks (cron jobs) for inspection."
|
||||
parameters: dict = Field(
|
||||
default_factory=lambda: {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"job_type": {
|
||||
"type": "string",
|
||||
"description": "Optional filter: basic or active_agent.",
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
async def call(
|
||||
self, context: ContextWrapper[AstrAgentContext], **kwargs
|
||||
) -> ToolExecResult:
|
||||
cron_mgr = context.context.context.cron_manager
|
||||
if cron_mgr is None:
|
||||
return "error: cron manager is not available."
|
||||
current_umo = context.context.event.unified_msg_origin
|
||||
job_type = kwargs.get("job_type")
|
||||
jobs = [
|
||||
job
|
||||
for job in await cron_mgr.list_jobs(job_type)
|
||||
if _extract_job_session(job) == current_umo
|
||||
]
|
||||
if not jobs:
|
||||
return "No cron jobs found."
|
||||
lines = []
|
||||
for j in jobs:
|
||||
lines.append(
|
||||
f"{j.job_id} | {j.name} | {j.job_type} | run_once={getattr(j, 'run_once', False)} | enabled={j.enabled} | next={j.next_run_time}"
|
||||
job = await cron_mgr.add_active_job(
|
||||
name=name,
|
||||
cron_expression=str(cron_expression) if cron_expression else None,
|
||||
payload=payload,
|
||||
description=note,
|
||||
run_once=run_once,
|
||||
run_at=run_at_dt,
|
||||
)
|
||||
return "\n".join(lines)
|
||||
next_run = job.next_run_time or run_at_dt
|
||||
suffix = (
|
||||
f"one-time at {next_run}"
|
||||
if run_once
|
||||
else f"expression '{cron_expression}' (next {next_run})"
|
||||
)
|
||||
return f"Scheduled future task {job.job_id} ({job.name}) {suffix}."
|
||||
|
||||
current_umo = context.context.event.unified_msg_origin
|
||||
if action == "edit":
|
||||
job_id = kwargs.get("job_id")
|
||||
if not job_id:
|
||||
return "error: job_id is required when action=edit."
|
||||
if not any(
|
||||
key in kwargs
|
||||
for key in ("name", "note", "run_once", "cron_expression", "run_at")
|
||||
):
|
||||
return "error: no editable fields were provided."
|
||||
|
||||
job = await cron_mgr.db.get_cron_job(str(job_id))
|
||||
if not job:
|
||||
return f"error: cron job {job_id} not found."
|
||||
if _extract_job_session(job) != current_umo:
|
||||
return "error: you can only edit future tasks in the current umo."
|
||||
|
||||
payload = dict(job.payload) if isinstance(job.payload, dict) else {}
|
||||
|
||||
updates: dict[str, Any] = {}
|
||||
if "name" in kwargs:
|
||||
name = str(kwargs.get("name") or "").strip()
|
||||
if not name:
|
||||
return "error: name cannot be empty when action=edit."
|
||||
updates["name"] = name
|
||||
|
||||
if "note" in kwargs:
|
||||
note = str(kwargs.get("note") or "").strip()
|
||||
if not note:
|
||||
return "error: note cannot be empty when action=edit."
|
||||
payload["note"] = note
|
||||
updates["description"] = note
|
||||
|
||||
current_run_at = payload.get("run_at")
|
||||
run_once = (
|
||||
bool(kwargs["run_once"]) if "run_once" in kwargs else bool(job.run_once)
|
||||
)
|
||||
cron_expression = (
|
||||
str(kwargs.get("cron_expression") or "").strip()
|
||||
if "cron_expression" in kwargs
|
||||
else job.cron_expression
|
||||
)
|
||||
cron_expression = cron_expression or None
|
||||
|
||||
try:
|
||||
run_at_dt = (
|
||||
_parse_run_at(kwargs.get("run_at"))
|
||||
if "run_at" in kwargs
|
||||
else _parse_run_at(current_run_at)
|
||||
)
|
||||
except Exception:
|
||||
return "error: run_at must be ISO datetime, e.g., 2026-02-02T08:00:00+08:00"
|
||||
|
||||
if run_once:
|
||||
if run_at_dt is None:
|
||||
return "error: run_at is required when run_once=true."
|
||||
cron_expression = None
|
||||
payload["run_at"] = run_at_dt.isoformat()
|
||||
else:
|
||||
if not cron_expression:
|
||||
return "error: cron_expression is required when run_once=false."
|
||||
payload.pop("run_at", None)
|
||||
|
||||
updates["run_once"] = run_once
|
||||
updates["cron_expression"] = cron_expression
|
||||
updates["payload"] = payload
|
||||
|
||||
job = await cron_mgr.update_job(str(job_id), **updates)
|
||||
if not job:
|
||||
return f"error: cron job {job_id} not found."
|
||||
return f"Updated future task {job.job_id} ({job.name})."
|
||||
|
||||
if action == "delete":
|
||||
job_id = kwargs.get("job_id")
|
||||
if not job_id:
|
||||
return "error: job_id is required when action=delete."
|
||||
job = await cron_mgr.db.get_cron_job(str(job_id))
|
||||
if not job:
|
||||
return f"error: cron job {job_id} not found."
|
||||
if _extract_job_session(job) != current_umo:
|
||||
return "error: you can only delete future tasks in the current umo."
|
||||
await cron_mgr.delete_job(str(job_id))
|
||||
return f"Deleted cron job {job_id}."
|
||||
|
||||
if action == "list":
|
||||
jobs = [
|
||||
job
|
||||
for job in await cron_mgr.list_jobs()
|
||||
if _extract_job_session(job) == current_umo
|
||||
]
|
||||
if not jobs:
|
||||
return "No cron jobs found."
|
||||
lines = []
|
||||
for j in jobs:
|
||||
lines.append(
|
||||
f"{j.job_id} | {j.name} | {j.job_type} | run_once={getattr(j, 'run_once', False)} | enabled={j.enabled} | next={j.next_run_time}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
return "error: action must be one of create, edit, delete, or list."
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CreateActiveCronTool",
|
||||
"DeleteCronJobTool",
|
||||
"ListCronJobsTool",
|
||||
"FutureTaskTool",
|
||||
]
|
||||
|
||||
@@ -135,22 +135,127 @@ class CronRoute(Route):
|
||||
if not isinstance(payload, dict):
|
||||
return jsonify(Response().error("Invalid payload").__dict__)
|
||||
|
||||
updates = {
|
||||
"name": payload.get("name"),
|
||||
"cron_expression": payload.get("cron_expression"),
|
||||
"description": payload.get("description"),
|
||||
"enabled": payload.get("enabled"),
|
||||
"timezone": payload.get("timezone"),
|
||||
"run_once": payload.get("run_once"),
|
||||
"payload": payload.get("payload"),
|
||||
}
|
||||
# remove None values to avoid unwanted resets
|
||||
updates = {k: v for k, v in updates.items() if v is not None}
|
||||
if "run_at" in payload:
|
||||
updates.setdefault("payload", {})
|
||||
if updates["payload"] is None:
|
||||
updates["payload"] = {}
|
||||
updates["payload"]["run_at"] = payload.get("run_at")
|
||||
job = await cron_mgr.db.get_cron_job(job_id)
|
||||
if not job:
|
||||
return jsonify(Response().error("Job not found").__dict__)
|
||||
|
||||
updates = {}
|
||||
if "name" in payload:
|
||||
name = str(payload.get("name") or "").strip()
|
||||
if not name:
|
||||
return jsonify(Response().error("name cannot be empty").__dict__)
|
||||
updates["name"] = name
|
||||
|
||||
if "enabled" in payload:
|
||||
updates["enabled"] = bool(payload.get("enabled"))
|
||||
|
||||
if "timezone" in payload:
|
||||
timezone = payload.get("timezone")
|
||||
updates["timezone"] = str(timezone).strip() or None
|
||||
|
||||
next_run_once = (
|
||||
bool(payload.get("run_once"))
|
||||
if "run_once" in payload
|
||||
else bool(job.run_once)
|
||||
)
|
||||
|
||||
if job.job_type == "active_agent":
|
||||
merged_payload = (
|
||||
dict(job.payload) if isinstance(job.payload, dict) else {}
|
||||
)
|
||||
if "payload" in payload and isinstance(payload.get("payload"), dict):
|
||||
merged_payload.update(payload["payload"])
|
||||
|
||||
if "session" in payload:
|
||||
session = str(payload.get("session") or "").strip()
|
||||
if not session:
|
||||
return jsonify(
|
||||
Response().error("session cannot be empty").__dict__
|
||||
)
|
||||
merged_payload["session"] = session
|
||||
|
||||
note_updated = False
|
||||
if "note" in payload:
|
||||
note = str(payload.get("note") or "").strip()
|
||||
if not note:
|
||||
return jsonify(
|
||||
Response().error("note cannot be empty").__dict__
|
||||
)
|
||||
merged_payload["note"] = note
|
||||
updates["description"] = note
|
||||
note_updated = True
|
||||
elif "description" in payload:
|
||||
description = str(payload.get("description") or "").strip()
|
||||
if not description:
|
||||
return jsonify(
|
||||
Response().error("description cannot be empty").__dict__
|
||||
)
|
||||
updates["description"] = description
|
||||
merged_payload["note"] = description
|
||||
note_updated = True
|
||||
|
||||
if not note_updated and updates.get("description") is None:
|
||||
existing_note = str(
|
||||
merged_payload.get("note") or job.description or ""
|
||||
).strip()
|
||||
if existing_note:
|
||||
merged_payload["note"] = existing_note
|
||||
|
||||
next_cron_expression = (
|
||||
payload.get("cron_expression")
|
||||
if "cron_expression" in payload
|
||||
else job.cron_expression
|
||||
)
|
||||
if next_cron_expression is not None:
|
||||
next_cron_expression = str(next_cron_expression).strip() or None
|
||||
|
||||
run_at_raw = (
|
||||
payload.get("run_at")
|
||||
if "run_at" in payload
|
||||
else merged_payload.get("run_at")
|
||||
)
|
||||
run_at_iso = None
|
||||
if run_at_raw:
|
||||
try:
|
||||
run_at_iso = datetime.fromisoformat(str(run_at_raw)).isoformat()
|
||||
except Exception:
|
||||
return jsonify(
|
||||
Response().error("run_at must be ISO datetime").__dict__
|
||||
)
|
||||
|
||||
if next_run_once:
|
||||
if not run_at_iso:
|
||||
return jsonify(
|
||||
Response()
|
||||
.error("run_at is required when run_once=true")
|
||||
.__dict__
|
||||
)
|
||||
next_cron_expression = None
|
||||
merged_payload["run_at"] = run_at_iso
|
||||
else:
|
||||
if not next_cron_expression:
|
||||
return jsonify(
|
||||
Response()
|
||||
.error("cron_expression is required when run_once=false")
|
||||
.__dict__
|
||||
)
|
||||
merged_payload.pop("run_at", None)
|
||||
|
||||
updates["run_once"] = next_run_once
|
||||
updates["cron_expression"] = next_cron_expression
|
||||
updates["payload"] = merged_payload
|
||||
else:
|
||||
if "cron_expression" in payload:
|
||||
cron_expression = str(payload.get("cron_expression") or "").strip()
|
||||
if not cron_expression:
|
||||
return jsonify(
|
||||
Response().error("cron_expression cannot be empty").__dict__
|
||||
)
|
||||
updates["cron_expression"] = cron_expression
|
||||
|
||||
if "description" in payload:
|
||||
description = str(payload.get("description") or "").strip()
|
||||
updates["description"] = description or None
|
||||
|
||||
job = await cron_mgr.update_job(job_id, **updates)
|
||||
if not job:
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
},
|
||||
"actions": {
|
||||
"create": "New Task",
|
||||
"edit": "Edit",
|
||||
"refresh": "Refresh",
|
||||
"delete": "Delete",
|
||||
"cancel": "Cancel",
|
||||
"save": "Save",
|
||||
"submit": "Create"
|
||||
},
|
||||
"overview": {
|
||||
@@ -77,6 +79,7 @@
|
||||
},
|
||||
"form": {
|
||||
"title": "New Task",
|
||||
"editTitle": "Edit Task",
|
||||
"chatHint": "You can ask AstrBot in chat to create future tasks instead of adding them here.",
|
||||
"runOnce": "One-off task",
|
||||
"name": "Task name",
|
||||
@@ -90,6 +93,7 @@
|
||||
},
|
||||
"messages": {
|
||||
"loadFailed": "Failed to load tasks",
|
||||
"updateSuccess": "Updated successfully",
|
||||
"updateFailed": "Failed to update",
|
||||
"deleteSuccess": "Deleted",
|
||||
"deleteFailed": "Failed to delete",
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
},
|
||||
"actions": {
|
||||
"create": "Новая задача",
|
||||
"edit": "Изменить",
|
||||
"refresh": "Обновить",
|
||||
"delete": "Удалить",
|
||||
"cancel": "Отмена",
|
||||
"save": "Сохранить",
|
||||
"submit": "Создать"
|
||||
},
|
||||
"overview": {
|
||||
@@ -77,6 +79,7 @@
|
||||
},
|
||||
"form": {
|
||||
"title": "Создать задачу",
|
||||
"editTitle": "Редактировать задачу",
|
||||
"chatHint": "Вы можете ставить задачи прямо в чате, AstrBot создаст их автоматически без заполнения этой формы.",
|
||||
"runOnce": "Разовая задача",
|
||||
"name": "Имя задачи",
|
||||
@@ -90,6 +93,7 @@
|
||||
},
|
||||
"messages": {
|
||||
"loadFailed": "Ошибка загрузки задач",
|
||||
"updateSuccess": "Задача обновлена",
|
||||
"updateFailed": "Ошибка обновления",
|
||||
"deleteSuccess": "Удалено",
|
||||
"deleteFailed": "Ошибка удаления",
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
},
|
||||
"actions": {
|
||||
"create": "新建",
|
||||
"edit": "编辑",
|
||||
"refresh": "刷新",
|
||||
"delete": "删除",
|
||||
"cancel": "取消",
|
||||
"save": "保存",
|
||||
"submit": "创建"
|
||||
},
|
||||
"overview": {
|
||||
@@ -77,6 +79,7 @@
|
||||
},
|
||||
"form": {
|
||||
"title": "新建任务",
|
||||
"editTitle": "编辑任务",
|
||||
"chatHint": "你可以直接通过聊天的方式来让 AstrBot 创建未来任务,而不必在此添加。",
|
||||
"runOnce": "一次性任务",
|
||||
"name": "任务名称",
|
||||
@@ -90,6 +93,7 @@
|
||||
},
|
||||
"messages": {
|
||||
"loadFailed": "获取任务失败",
|
||||
"updateSuccess": "更新成功",
|
||||
"updateFailed": "更新失败",
|
||||
"deleteSuccess": "已删除",
|
||||
"deleteFailed": "删除失败",
|
||||
|
||||
@@ -136,18 +136,31 @@
|
||||
</td>
|
||||
<td class="actions-col">
|
||||
<div class="table-actions">
|
||||
<v-switch
|
||||
v-model="item.enabled"
|
||||
inset
|
||||
density="compact"
|
||||
hide-details
|
||||
color="primary"
|
||||
class="mt-0"
|
||||
@change="toggleJob(item)"
|
||||
/>
|
||||
<v-btn size="small" variant="text" color="error" @click="deleteJob(item)">
|
||||
{{ tm('actions.delete') }}
|
||||
</v-btn>
|
||||
<div class="table-actions-toggle">
|
||||
<v-switch
|
||||
v-model="item.enabled"
|
||||
inset
|
||||
density="compact"
|
||||
hide-details
|
||||
color="primary"
|
||||
class="table-actions-switch mt-0"
|
||||
@change="toggleJob(item)"
|
||||
/>
|
||||
</div>
|
||||
<div class="table-actions-buttons">
|
||||
<v-btn
|
||||
v-if="item.job_type === 'active_agent'"
|
||||
size="small"
|
||||
variant="text"
|
||||
color="primary"
|
||||
@click="openEdit(item)"
|
||||
>
|
||||
{{ tm('actions.edit') }}
|
||||
</v-btn>
|
||||
<v-btn size="small" variant="text" color="error" @click="deleteJob(item)">
|
||||
{{ tm('actions.delete') }}
|
||||
</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -162,7 +175,7 @@
|
||||
|
||||
<v-dialog v-model="createDialog" max-width="640">
|
||||
<v-card class="dashboard-dialog-card">
|
||||
<v-card-title class="text-h6 pt-5 px-5">{{ tm('form.title') }}</v-card-title>
|
||||
<v-card-title class="text-h6 pt-5 px-5">{{ dialogTitle }}</v-card-title>
|
||||
<v-card-subtitle class="px-5 text-body-2 text-medium-emphasis">
|
||||
{{ tm('form.chatHint') }}
|
||||
</v-card-subtitle>
|
||||
@@ -206,8 +219,8 @@
|
||||
</v-card-text>
|
||||
<v-card-actions class="justify-end px-5 pb-5">
|
||||
<v-btn variant="text" @click="createDialog = false">{{ tm('actions.cancel') }}</v-btn>
|
||||
<v-btn variant="tonal" color="primary" :loading="creating" @click="createJob">
|
||||
{{ tm('actions.submit') }}
|
||||
<v-btn variant="tonal" color="primary" :loading="creating" @click="submitJob">
|
||||
{{ dialogSubmitText }}
|
||||
</v-btn>
|
||||
</v-card-actions>
|
||||
</v-card>
|
||||
@@ -231,6 +244,7 @@ const jobs = ref<any[]>([])
|
||||
const proactivePlatforms = ref<{ id: string; name: string; display_name?: string }[]>([])
|
||||
const createDialog = ref(false)
|
||||
const creating = ref(false)
|
||||
const editingJobId = ref('')
|
||||
const newJob = ref({
|
||||
run_once: false,
|
||||
name: '',
|
||||
@@ -286,6 +300,10 @@ const overviewCards = computed(() => [
|
||||
}
|
||||
])
|
||||
|
||||
const isEditing = computed(() => !!editingJobId.value)
|
||||
const dialogTitle = computed(() => tm(isEditing.value ? 'form.editTitle' : 'form.title'))
|
||||
const dialogSubmitText = computed(() => tm(isEditing.value ? 'actions.save' : 'actions.submit'))
|
||||
|
||||
function toast(message: string, color: 'success' | 'error' | 'warning' = 'success') {
|
||||
snackbar.value = { show: true, message, color }
|
||||
}
|
||||
@@ -394,10 +412,26 @@ async function deleteJob(job: any) {
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingJobId.value = ''
|
||||
resetNewJob()
|
||||
createDialog.value = true
|
||||
}
|
||||
|
||||
function toDatetimeLocalValue(value: any): string {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return ''
|
||||
const offset = date.getTimezoneOffset()
|
||||
const local = new Date(date.getTime() - offset * 60_000)
|
||||
return local.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
function toIsoDatetime(value: string): string {
|
||||
if (!value) return ''
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? value : date.toISOString()
|
||||
}
|
||||
|
||||
function resetNewJob() {
|
||||
newJob.value = {
|
||||
run_once: false,
|
||||
@@ -411,6 +445,21 @@ function resetNewJob() {
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(job: any) {
|
||||
editingJobId.value = job.job_id
|
||||
newJob.value = {
|
||||
run_once: !!job.run_once,
|
||||
name: job.name || '',
|
||||
note: job.note || job.description || '',
|
||||
cron_expression: job.cron_expression || '',
|
||||
run_at: toDatetimeLocalValue(job.run_at),
|
||||
session: job.session || job?.payload?.session || '',
|
||||
timezone: job.timezone || '',
|
||||
enabled: job.enabled !== false
|
||||
}
|
||||
createDialog.value = true
|
||||
}
|
||||
|
||||
async function createJob() {
|
||||
if (!newJob.value.session) {
|
||||
toast(tm('messages.sessionRequired'), 'warning')
|
||||
@@ -431,10 +480,15 @@ async function createJob() {
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const res = await axios.post('/api/cron/jobs', { ...newJob.value })
|
||||
const payload = {
|
||||
...newJob.value,
|
||||
run_at: newJob.value.run_once ? toIsoDatetime(newJob.value.run_at) : ''
|
||||
}
|
||||
const res = await axios.post('/api/cron/jobs', payload)
|
||||
if (res.data.status === 'ok') {
|
||||
toast(tm('messages.createSuccess'))
|
||||
createDialog.value = false
|
||||
editingJobId.value = ''
|
||||
resetNewJob()
|
||||
await loadJobs()
|
||||
} else {
|
||||
@@ -447,6 +501,59 @@ async function createJob() {
|
||||
}
|
||||
}
|
||||
|
||||
async function updateJob() {
|
||||
if (!editingJobId.value) {
|
||||
return
|
||||
}
|
||||
if (!newJob.value.session) {
|
||||
toast(tm('messages.sessionRequired'), 'warning')
|
||||
return
|
||||
}
|
||||
if (!newJob.value.note) {
|
||||
toast(tm('messages.noteRequired'), 'warning')
|
||||
return
|
||||
}
|
||||
if (!newJob.value.run_once && !newJob.value.cron_expression) {
|
||||
toast(tm('messages.cronRequired'), 'warning')
|
||||
return
|
||||
}
|
||||
if (newJob.value.run_once && !newJob.value.run_at) {
|
||||
toast(tm('messages.runAtRequired'), 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
creating.value = true
|
||||
try {
|
||||
const payload = {
|
||||
...newJob.value,
|
||||
run_at: newJob.value.run_once ? toIsoDatetime(newJob.value.run_at) : '',
|
||||
description: newJob.value.note
|
||||
}
|
||||
const res = await axios.patch(`/api/cron/jobs/${editingJobId.value}`, payload)
|
||||
if (res.data.status === 'ok') {
|
||||
toast(tm('messages.updateSuccess'))
|
||||
createDialog.value = false
|
||||
editingJobId.value = ''
|
||||
resetNewJob()
|
||||
await loadJobs()
|
||||
} else {
|
||||
toast(res.data.message || tm('messages.updateFailed'), 'error')
|
||||
}
|
||||
} catch (e: any) {
|
||||
toast(e?.response?.data?.message || tm('messages.updateFailed'), 'error')
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitJob() {
|
||||
if (isEditing.value) {
|
||||
await updateJob()
|
||||
return
|
||||
}
|
||||
await createJob()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadJobs()
|
||||
loadPlatforms()
|
||||
@@ -513,7 +620,7 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.task-table .col-actions {
|
||||
width: 170px;
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.task-table th {
|
||||
@@ -571,15 +678,34 @@ onMounted(() => {
|
||||
}
|
||||
|
||||
.actions-col {
|
||||
width: 170px;
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
justify-items: start;
|
||||
min-width: 190px;
|
||||
}
|
||||
|
||||
.table-actions-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.table-actions-switch {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.cron-page :deep(.table-actions-switch .v-selection-control) {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.table-actions-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.state-panel {
|
||||
@@ -600,6 +726,11 @@ onMounted(() => {
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.table-actions {
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.table-actions-buttons,
|
||||
.table-actions-toggle {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for astr_main_agent module."""
|
||||
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, call, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -402,26 +402,16 @@ class TestBuiltinToolInjection:
|
||||
req = ProviderRequest()
|
||||
tool_mgr = MagicMock()
|
||||
|
||||
create_tool = MagicMock(spec=FunctionTool)
|
||||
create_tool.name = "create_future_task"
|
||||
delete_tool = MagicMock(spec=FunctionTool)
|
||||
delete_tool.name = "delete_future_task"
|
||||
list_tool = MagicMock(spec=FunctionTool)
|
||||
list_tool.name = "list_future_tasks"
|
||||
tool_mgr.get_builtin_tool.side_effect = [create_tool, delete_tool, list_tool]
|
||||
future_task_tool = MagicMock(spec=FunctionTool)
|
||||
future_task_tool.name = "future_task"
|
||||
tool_mgr.get_builtin_tool.return_value = future_task_tool
|
||||
mock_context.get_llm_tool_manager.return_value = tool_mgr
|
||||
|
||||
module._proactive_cron_job_tools(req, mock_context)
|
||||
|
||||
assert tool_mgr.get_builtin_tool.call_args_list == [
|
||||
call(module.CreateActiveCronTool),
|
||||
call(module.DeleteCronJobTool),
|
||||
call(module.ListCronJobsTool),
|
||||
]
|
||||
tool_mgr.get_builtin_tool.assert_called_once_with(module.FutureTaskTool)
|
||||
assert req.func_tool is not None
|
||||
assert req.func_tool.get_tool("create_future_task") is create_tool
|
||||
assert req.func_tool.get_tool("delete_future_task") is delete_tool
|
||||
assert req.func_tool.get_tool("list_future_tasks") is list_tool
|
||||
assert req.func_tool.get_tool("future_task") is future_task_tool
|
||||
|
||||
|
||||
class TestApplyFileExtract:
|
||||
@@ -621,9 +611,10 @@ class TestEnsurePersonaAndSkills:
|
||||
tmgr = mock_context.get_llm_tool_manager.return_value
|
||||
tmgr.func_list = [tool_a, tool_b]
|
||||
tmgr.get_full_tool_set.return_value = ToolSet([tool_a, tool_b])
|
||||
tmgr.get_func.side_effect = lambda name: {"tool_a": tool_a, "tool_b": tool_b}.get(
|
||||
name
|
||||
)
|
||||
tmgr.get_func.side_effect = lambda name: {
|
||||
"tool_a": tool_a,
|
||||
"tool_b": tool_b,
|
||||
}.get(name)
|
||||
|
||||
handoff = MagicMock()
|
||||
handoff.name = "transfer_to_planner"
|
||||
|
||||
@@ -1,15 +1,126 @@
|
||||
"""Tests for cron tool metadata."""
|
||||
|
||||
from astrbot.core.tools.cron_tools import CreateActiveCronTool
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from astrbot.core.tools.cron_tools import FutureTaskTool
|
||||
|
||||
|
||||
def test_create_future_task_cron_description_prefers_named_weekdays():
|
||||
"""The cron tool should steer users toward unambiguous named weekdays."""
|
||||
tool = CreateActiveCronTool()
|
||||
def test_future_task_schema_has_action_and_create_cron_guidance():
|
||||
"""The merged tool should expose action routing and unambiguous cron guidance."""
|
||||
tool = FutureTaskTool()
|
||||
|
||||
assert tool.name == "future_task"
|
||||
assert tool.parameters["required"] == ["action"]
|
||||
assert tool.parameters["properties"]["action"]["enum"] == [
|
||||
"create",
|
||||
"edit",
|
||||
"delete",
|
||||
"list",
|
||||
]
|
||||
|
||||
description = tool.parameters["properties"]["cron_expression"]["description"]
|
||||
|
||||
assert "mon-fri" in description
|
||||
assert "sat,sun" in description
|
||||
assert "1-5" in description
|
||||
assert "avoid ambiguity" in description
|
||||
assert "Prefer named weekdays" in description
|
||||
|
||||
|
||||
def test_future_task_schema_has_no_job_type_and_delete_job_id():
|
||||
"""The merged tool should remove job_type and document delete requirements."""
|
||||
tool = FutureTaskTool()
|
||||
|
||||
assert "job_type" not in tool.parameters["properties"]
|
||||
action_description = tool.parameters["properties"]["action"]["description"]
|
||||
job_id_description = tool.parameters["properties"]["job_id"]["description"]
|
||||
|
||||
assert "'edit' requires 'job_id'" in action_description
|
||||
assert "Required for 'delete' and 'edit'" in job_id_description
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_future_task_edit_requires_job_id():
|
||||
"""Edit mode should require job_id."""
|
||||
tool = FutureTaskTool()
|
||||
cron_mgr = SimpleNamespace()
|
||||
context = SimpleNamespace(
|
||||
context=SimpleNamespace(
|
||||
context=SimpleNamespace(cron_manager=cron_mgr),
|
||||
event=SimpleNamespace(
|
||||
unified_msg_origin="test:private:session",
|
||||
get_sender_id=lambda: "user-1",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
result = await tool.call(context, action="edit")
|
||||
|
||||
assert result == "error: job_id is required when action=edit."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_future_task_edit_updates_existing_job():
|
||||
"""Edit mode should update note and one-time scheduling fields."""
|
||||
tool = FutureTaskTool()
|
||||
existing_job = SimpleNamespace(
|
||||
job_id="job-1",
|
||||
name="old name",
|
||||
job_type="active_agent",
|
||||
run_once=False,
|
||||
cron_expression="0 8 * * *",
|
||||
payload={
|
||||
"session": "test:private:session",
|
||||
"sender_id": "user-1",
|
||||
"note": "old note",
|
||||
"origin": "tool",
|
||||
},
|
||||
)
|
||||
updated_job = SimpleNamespace(
|
||||
job_id="job-1",
|
||||
name="new name",
|
||||
run_once=True,
|
||||
cron_expression=None,
|
||||
next_run_time=None,
|
||||
)
|
||||
cron_mgr = SimpleNamespace(
|
||||
db=SimpleNamespace(get_cron_job=AsyncMock(return_value=existing_job)),
|
||||
update_job=AsyncMock(return_value=updated_job),
|
||||
)
|
||||
context = SimpleNamespace(
|
||||
context=SimpleNamespace(
|
||||
context=SimpleNamespace(cron_manager=cron_mgr),
|
||||
event=SimpleNamespace(
|
||||
unified_msg_origin="test:private:session",
|
||||
get_sender_id=lambda: "user-1",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
result = await tool.call(
|
||||
context,
|
||||
action="edit",
|
||||
job_id="job-1",
|
||||
name="new name",
|
||||
note="new note",
|
||||
run_once=True,
|
||||
run_at="2026-02-02T08:00:00+08:00",
|
||||
)
|
||||
|
||||
cron_mgr.update_job.assert_awaited_once_with(
|
||||
"job-1",
|
||||
name="new name",
|
||||
description="new note",
|
||||
run_once=True,
|
||||
cron_expression=None,
|
||||
payload={
|
||||
"session": "test:private:session",
|
||||
"sender_id": "user-1",
|
||||
"note": "new note",
|
||||
"origin": "tool",
|
||||
"run_at": "2026-02-02T08:00:00+08:00",
|
||||
},
|
||||
)
|
||||
assert result == "Updated future task job-1 (new name)."
|
||||
|
||||
Reference in New Issue
Block a user