(WIP) feat: Task Scheduler Management UI/UX and tools - Part 3
Rafael Uzarowski committed
Apr 30, 2025 at 20:59 UTC
eb275d9c9cd1fefd9da00af7923386fd9a0474e6
37 files changed
+3422
-678
agent.py
+24
-3
@@ -27,6 +27,7 @@ from python.helpers.dirty_json import DirtyJson
27
from python.helpers.defer import DeferredTask
28
from typing import Callable
29
from python.helpers.history import OutputMessage
30
+from python.helpers.localization import Localization
31
32
33
class AgentContext:
@@ -80,6 +81,21 @@ class AgentContext:
81
context.task.kill()
82
return context
83
84
+ def serialize(self):
85
+ return {
86
+ "id": self.id,
87
+ "name": self.name,
88
+ "created_at": (
89
+ Localization.get().serialize_datetime(self.created_at)
90
+ if self.created_at else Localization.get().serialize_datetime(datetime.fromtimestamp(0))
91
+ ),
92
+ "no": self.no,
93
+ "log_guid": self.log.guid,
94
+ "log_version": len(self.log.updates),
95
+ "log_length": len(self.log.logs),
96
+ "paused": self.paused,
97
+ }
98
+
99
def get_created_at(self):
100
return self.created_at
101
@@ -654,8 +670,13 @@ class Agent:
670
671
if tool_request is not None:
672
tool_name = tool_request.get("tool_name", "")
673
+ tool_method = None
674
tool_args = tool_request.get("tool_args", {})
658
- tool = self.get_tool(tool_name, tool_args, msg)
675
+
676
+ if ":" in tool_name:
677
+ tool_name, tool_method = tool_name.split(":", 1)
678
+
679
+ tool = self.get_tool(name=tool_name, method=tool_method, args=tool_args, message=msg)
680
681
await self.handle_intervention() # wait if paused and handle intervention message if needed
682
await tool.before_execution(**tool_args)
@@ -685,7 +706,7 @@ class Agent:
706
except Exception as e:
707
pass
708
688
- def get_tool(self, name: str, args: dict, message: str, **kwargs):
709
+ def get_tool(self, name: str, method: str | None, args: dict, message: str, **kwargs):
710
from python.tools.unknown import Unknown
711
from python.helpers.tool import Tool
712
@@ -693,7 +714,7 @@ class Agent:
714
"python/tools", name + ".py", Tool
715
)
716
tool_class = classes[0] if classes else Unknown
696
- return tool_class(agent=self, name=name, args=args, message=message, **kwargs)
717
+ return tool_class(agent=self, name=name, method=method, args=args, message=message, **kwargs)
718
719
async def call_extensions(self, folder: str, **kwargs) -> Any:
720
from python.helpers.extension import Extension
docker/run/Dockerfile
+13
@@ -6,6 +6,19 @@ ARG BRANCH
6
RUN if [ -z "$BRANCH" ]; then echo "ERROR: BRANCH is not set!" >&2; exit 1; fi
7
ENV BRANCH=$BRANCH
8
9
+# Set locale to en_US.UTF-8 and timezone to UTC
10
+RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y locales tzdata
11
+RUN sed -i -e 's/# \(en_US\.UTF-8 .*\)/\1/' /etc/locale.gen && \
12
+ dpkg-reconfigure --frontend=noninteractive locales && \
13
+ update-locale LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8
14
+RUN ln -sf /usr/share/zoneinfo/UTC /etc/localtime
15
+RUN echo "UTC" > /etc/timezone
16
+RUN dpkg-reconfigure -f noninteractive tzdata
17
+ENV LANG=en_US.UTF-8
18
+ENV LANGUAGE=en_US:en
19
+ENV LC_ALL=en_US.UTF-8
20
+ENV TZ=UTC
21
+
22
# Copy contents of the project to /a0
23
COPY ./fs/ /
24
docker/run/DockerfileKali
+13
@@ -6,6 +6,19 @@ ARG BRANCH
6
RUN if [ -z "$BRANCH" ]; then echo "ERROR: BRANCH is not set!" >&2; exit 1; fi
7
ENV BRANCH=$BRANCH
8
9
+# Set locale to en_US.UTF-8 and timezone to UTC
10
+RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y locales tzdata
11
+RUN sed -i -e 's/# \(en_US\.UTF-8 .*\)/\1/' /etc/locale.gen && \
12
+ dpkg-reconfigure --frontend=noninteractive locales && \
13
+ update-locale LANG=en_US.UTF-8 LANGUAGE=en_US:en LC_ALL=en_US.UTF-8
14
+RUN ln -sf /usr/share/zoneinfo/UTC /etc/localtime
15
+RUN echo "UTC" > /etc/timezone
16
+RUN dpkg-reconfigure -f noninteractive tzdata
17
+ENV LANG=en_US.UTF-8
18
+ENV LANGUAGE=en_US:en
19
+ENV LC_ALL=en_US.UTF-8
20
+ENV TZ=UTC
21
+
22
# Copy contents of the project to /a0
23
COPY ./fs/ /
24
prompts/default/agent.system.datetime.md
new
+3
@@ -0,0 +1,3 @@
1
+# Current system date and time of user
2
+- Current Date and Time is: {{date_time}}
3
+- !!! rely solely on this information for time-sensitive tasks as it is always up to date
prompts/default/agent.system.main.environment.md
-1
@@ -1,4 +1,3 @@
1
## Environment
2
live in debian linux docker container
3
agent zero framework is python project in /a0 folder
4
-
prompts/default/agent.system.main.md
+1
-1
@@ -8,4 +8,4 @@
8
9
{{ include "./agent.system.main.solving.md" }}
10
11
-{{ include "./agent.system.main.tips.md" }}
\ No newline at end of file
11
+{{ include "./agent.system.main.tips.md" }}
prompts/default/agent.system.tool.scheduler.md
new
+215
@@ -0,0 +1,215 @@
1
+## Task Scheduler Subsystem:
2
+The task scheduler is a part of agent-zero enabling the system to execute
3
+arbitrary tasks defined by a "system prompt" and "user prompt".
4
+
5
+When the task is executed the prompts are being run in the background in a context
6
+conversation with the goal of completing the task described in the prompts.
7
+
8
+Dedicated context means the task will run in it's own chat. If task is created without the
9
+dedicated_context flag then the task will run in the chat it was created in including entire history.
10
+
11
+There are manual and automatically executed tasks.
12
+Automatic execution happens by a schedule defined when creating the task.
13
+
14
+### Types of scheduler tasks
15
+There are 3 types of scheduler tasks:
16
+
17
+#### Scheduled - type="scheduled"
18
+This type of task is run by a recurring schedule defined in the crontab syntax with 5 fields (ex. */5 * * * * means every 5 minutes).
19
+It is recurring and started automatically when the crontab syntax requires next execution..
20
+
21
+#### Planned - type="planned"
22
+This type of task is run by a linear schedule defined as discrete datetimes of the upcoming executions.
23
+It is started automatically when a scheduled time elapses.
24
+
25
+#### AdHoc - type="adhoc"
26
+This type of task is run manually and does not follow any schedule. It can be run explicitly by "scheduler:run_task" agent tool or by the user in the UI.
27
+
28
+### Tools to manage the task scheduler system and it's tasks
29
+
30
+#### scheduler:list_tasks
31
+List all tasks present in the system with their 'uuid', 'name', 'type', 'state', 'schedule' and 'next_run'.
32
+All runnable tasks can be listed and filtered here. The arbuments a filter fields.
33
+
34
+##### Arguments:
35
+* state: list(str) (Optional) - The state filter, one of "idle", "running", "disabled", "error". To only show tasks in given state.
36
+* type: list(str) (Optional) - The task type filter, one of "adhoc", "planned", "scheduled"
37
+* next_run_within: int (Optional) - The next run of the task must be within this many minutes
38
+* next_run_after: int (Optional) - The next run of the task must be after not less than this many minutes
39
+
40
+##### Usage:
41
+~~~json
42
+{
43
+ "thoughts": [
44
+ "I must look for planned runnable tasks with name ... and state idle or error",
45
+ "The tasks should run within next 20 minutes"
46
+ ],
47
+ "tool_name": "scheduler:list_tasks",
48
+ "tool_args": {
49
+ "state": ["idle", "error"],
50
+ "type": ["planned"],
51
+ "next_run_within": 20
52
+ }
53
+}
54
+~~~
55
+
56
+
57
+#### scheduler:show_task
58
+Show task details for scheduler task with the given uuid.
59
+
60
+##### Arguments:
61
+* uuid: string - The uuid of the task to display
62
+
63
+##### Usage (execute task with uuid "xyz-123"):
64
+~~~json
65
+{
66
+ "thoughts": [
67
+ "I need details of task xxx-yyy-zzz",
68
+ ],
69
+ "tool_name": "scheduler:show_task",
70
+ "tool_args": {
71
+ "uuid": "xxx-yyy-zzz",
72
+ }
73
+}
74
+~~~
75
+
76
+
77
+#### scheduler:run_task
78
+Execute a task manually which is not in "running" state
79
+This can be used to trigger tasks manually.
80
+Normally you should only "run" tasks manually if they are in the "idle" state.
81
+It is also advised to only run "adhoc" tasks manually but every task type can be triggered by this tool.
82
+
83
+##### Arguments:
84
+* uuid: string - The uuid of the task to run. Can be retrieved for example from "scheduler:tasks_list"
85
+
86
+##### Usage (execute task with uuid "xyz-123"):
87
+~~~json
88
+{
89
+ "thoughts": [
90
+ "I must run task xyz-123",
91
+ ],
92
+ "tool_name": "scheduler:run_task",
93
+ "tool_args": {
94
+ "uuid": "xyz-123",
95
+ }
96
+}
97
+~~~
98
+
99
+
100
+#### scheduler:delete_task
101
+Delete the task defined by the given uuid from the system.
102
+
103
+##### Arguments:
104
+* uuid: string - The uuid of the task to run. Can be retrieved for example from "scheduler:tasks_list"
105
+
106
+##### Usage (execute task with uuid "xyz-123"):
107
+~~~json
108
+{
109
+ "thoughts": [
110
+ "I must delete task xyz-123",
111
+ ],
112
+ "tool_name": "scheduler:delete_task",
113
+ "tool_args": {
114
+ "uuid": "xyz-123",
115
+ }
116
+}
117
+~~~
118
+
119
+
120
+#### scheduler:create_scheduled_task
121
+Create a task within the scheduler system with the type "scheduled".
122
+The scheduled type of tasks is being run by a cron schedule that you must provide.
123
+
124
+##### Arguments:
125
+* name: str - The name of the task, will also be displayed when listing tasks
126
+* system_prompt: str - The system prompt to be used when executing the task
127
+* prompt: str - The actual prompt with the task definition
128
+* schedule: dict[str,str] - the dict of all cron schedule values. The keys are descriptive: minute, hour, day, month, weekday. The values are cron syntax fields named by the keys.
129
+* attachments: list[str] - Here you can add message attachments, valid are filesystem paths and internet urls
130
+* dedicated_context: bool - if false, then the task will run in the context it was created in. If true, the task will have it's own context. If unspecified then false is assumed. The tasks run in the context they were created in by default.
131
+
132
+##### Usage:
133
+~~~json
134
+{
135
+ "thoughts": [
136
+ "I must create new scheduled task with name XXX running every 20 minutes in a separate chat"
137
+ ],
138
+ "tool_name": "scheduler:create_scheduled_task",
139
+ "tool_args": {
140
+ "name": "XXX",
141
+ "system_prompt": "You are a software developer",
142
+ "prompt": "Send the user an email with a greeting using python and smtp. The user's address is: xxx@yyy.zzz",
143
+ "attachments": [],
144
+ "schedule": {
145
+ "minute": "*/20",
146
+ "hour": "*",
147
+ "day": "*",
148
+ "month": "*",
149
+ "weekday": "*",
150
+ },
151
+ "dedicated_context": true
152
+ }
153
+}
154
+~~~
155
+
156
+
157
+#### scheduler:create_adhoc_task
158
+Create a task within the scheduler system with the type "adhoc".
159
+The adhoc type of tasks is being run manually by "scheduler:run_task" tool or by the user via ui.
160
+
161
+##### Arguments:
162
+* name: str - The name of the task, will also be displayed when listing tasks
163
+* system_prompt: str - The system prompt to be used when executing the task
164
+* prompt: str - The actual prompt with the task definition
165
+* attachments: list[str] - Here you can add message attachments, valid are filesystem paths and internet urls
166
+* dedicated_context: bool - if false, then the task will run in the context it was created in. If true, the task will have it's own context. If unspecified then false is assumed. The tasks run in the context they were created in by default.
167
+
168
+##### Usage:
169
+~~~json
170
+{
171
+ "thoughts": [
172
+ "I must create new scheduled task with name XXX running every 20 minutes"
173
+ ],
174
+ "tool_name": "scheduler:create_adhoc_task",
175
+ "tool_args": {
176
+ "name": "XXX",
177
+ "system_prompt": "You are a software developer",
178
+ "prompt": "Send the user an email with a greeting using python and smtp. The user's address is: xxx@yyy.zzz",
179
+ "attachments": [],
180
+ "dedicated_context": false
181
+ }
182
+}
183
+~~~
184
+
185
+
186
+#### scheduler:create_planned_task
187
+Create a task within the scheduler system with the type "planned".
188
+The planned type of tasks is being run by a fixed plan, a list of datetimes that you must provide.
189
+
190
+##### Arguments:
191
+* name: str - The name of the task, will also be displayed when listing tasks
192
+* system_prompt: str - The system prompt to be used when executing the task
193
+* prompt: str - The actual prompt with the task definition
194
+* plan: list(iso datetime string) - the list of all execution timestamps. The dates should be in the 24 hour (!) strftime iso format: "%Y-%m-%dT%H:%M:%S"
195
+* attachments: list[str] - Here you can add message attachments, valid are filesystem paths and internet urls
196
+* dedicated_context: bool - if false, then the task will run in the context it was created in. If true, the task will have it's own context. If unspecified then false is assumed. The tasks run in the context they were created in by default.
197
+
198
+##### Usage:
199
+~~~json
200
+{
201
+ "thoughts": [
202
+ "I must create new planned task to run tomorow at 6:25 PM",
203
+ "Today is 2025-04-29 according to system prompt"
204
+ ],
205
+ "tool_name": "scheduler:create_planned_task",
206
+ "tool_args": {
207
+ "name": "XXX",
208
+ "system_prompt": "You are a software developer",
209
+ "prompt": "Send the user an email with a greeting using python and smtp. The user's address is: xxx@yyy.zzz",
210
+ "attachments": [],
211
+ "plan": ["2025-04-29T18:25:00"],
212
+ "dedicated_context": false
213
+ }
214
+}
215
+~~~
prompts/default/agent.system.tools.md
+2
@@ -15,3 +15,5 @@
15
{{ include './agent.system.tool.input.md' }}
16
17
{{ include './agent.system.tool.browser.md' }}
18
+
19
+{{ include './agent.system.tool.scheduler.md' }}
prompts/default/fw.intervention.md
+1
-1
@@ -1,7 +1,7 @@
1
```json
2
{
3
- "user_intervention": {{message}},
3
"system_message": {{system_message}},
4
+ "user_intervention": {{message}},
5
"attachments": {{attachments}}
6
}
7
```
prompts/default/fw.user_message.md
+1
-1
@@ -1,7 +1,7 @@
1
```json
2
{
3
- "user_message": {{message}},
3
"system_message": {{system_message}},
4
+ "user_message": {{message}},
5
"attachments": {{attachments}}
6
}
7
```
python/api/chat_remove.py
+13
-1
@@ -1,16 +1,28 @@
1
from python.helpers.api import ApiHandler, Input, Output, Request, Response
2
from agent import AgentContext
3
from python.helpers import persist_chat
4
+from python.helpers.task_scheduler import TaskScheduler
5
6
7
class RemoveChat(ApiHandler):
8
async def process(self, input: Input, request: Request) -> Output:
9
ctxid = input.get("context", "")
10
10
- # context instance - get or create
11
+ context = AgentContext.get(ctxid)
12
+ if context:
13
+ # stop processing any tasks
14
+ context.reset()
15
+
16
AgentContext.remove(ctxid)
17
persist_chat.remove_chat(ctxid)
18
19
+ scheduler = TaskScheduler.get()
20
+ await scheduler.reload()
21
+
22
+ tasks = scheduler.get_tasks_by_context_id(ctxid)
23
+ for task in tasks:
24
+ await scheduler.remove_task_by_uuid(task.uuid)
25
+
26
return {
27
"message": "Context removed.",
28
}
python/api/poll.py
+22
-18
@@ -1,5 +1,5 @@
1
import time
2
-
2
+from datetime import datetime
3
from python.helpers.api import ApiHandler
4
from flask import Request, Response
5
@@ -7,6 +7,8 @@ from agent import AgentContext
7
8
from python.helpers import persist_chat
9
from python.helpers.task_scheduler import TaskScheduler
10
+from python.helpers.localization import Localization
11
+from python.helpers.dotenv import get_dotenv_value
12
13
14
class Poll(ApiHandler):
@@ -15,6 +17,10 @@ class Poll(ApiHandler):
17
ctxid = input.get("context", None)
18
from_no = input.get("log_from", 0)
19
20
+ # Get timezone from input (default to dotenv default or UTC if not provided)
21
+ timezone = input.get("timezone", get_dotenv_value("DEFAULT_USER_TIMEZONE", "UTC"))
22
+ Localization.get().set_timezone(timezone)
23
+
24
# context instance - get or create
25
context = self.get_context(ctxid)
26
@@ -28,7 +34,7 @@ class Poll(ApiHandler):
34
# Always reload the scheduler on each poll to ensure we have the latest task state
35
await scheduler.reload()
36
31
- # loop AgentContext._contexts and number unnamed chats
37
+ # loop AgentContext._contexts and divide into contexts and tasks
38
39
ctxs = []
40
tasks = []
@@ -42,21 +48,15 @@ class Poll(ApiHandler):
48
continue
49
50
# Create the base context data that will be returned
45
- context_data = {
46
- "id": ctx.id,
47
- "name": ctx.name,
48
- "created_at": ctx.created_at,
49
- "no": ctx.no,
50
- "log_guid": ctx.log.guid,
51
- "log_version": len(ctx.log.updates),
52
- "log_length": len(ctx.log.logs),
53
- "paused": ctx.paused,
54
- }
55
-
56
- # Determine if this is a task by checking if a task with this UUID exists
57
- is_task = scheduler.get_task_by_uuid(ctx.id) is not None
58
-
59
- if not is_task:
51
+ context_data = ctx.serialize()
52
+
53
+ context_task = scheduler.get_task_by_uuid(ctx.id)
54
+ # Determine if this is a task-dedicated context by checking if a task with this UUID exists
55
+ is_task_context = (
56
+ context_task is not None and context_task.context_id == ctx.id
57
+ )
58
+
59
+ if not is_task_context:
60
ctxs.append(context_data)
61
else:
62
# If this is a task, get task details from the scheduler
@@ -65,6 +65,7 @@ class Poll(ApiHandler):
65
# Add task details to context_data with the same field names
66
# as used in scheduler endpoints to maintain UI compatibility
67
context_data.update({
68
+ "task_name": task_details.get("name"), # name is for context, task_name for the task name
69
"uuid": task_details.get("uuid"),
70
"state": task_details.get("state"),
71
"type": task_details.get("type"),
@@ -72,12 +73,15 @@ class Poll(ApiHandler):
73
"prompt": task_details.get("prompt"),
74
"last_run": task_details.get("last_run"),
75
"last_result": task_details.get("last_result"),
75
- "attachments": task_details.get("attachments", [])
76
+ "attachments": task_details.get("attachments", []),
77
+ "context_id": task_details.get("context_id"),
78
})
79
80
# Add type-specific fields
81
if task_details.get("type") == "scheduled":
82
context_data["schedule"] = task_details.get("schedule")
83
+ elif task_details.get("type") == "planned":
84
+ context_data["plan"] = task_details.get("plan")
85
else:
86
context_data["token"] = task_details.get("token")
87
python/api/scheduler_adhoc.py_
deleted
-5
@@ -1,5 +0,0 @@
1
-## JSON Body
2
-# api_key
3
-# task_id
4
-# params
5
-# callback
python/api/scheduler_task_create.py
+61
-4
@@ -1,8 +1,11 @@
1
from python.helpers.api import ApiHandler, Input, Output, Request
2
from python.helpers.task_scheduler import (
3
- TaskScheduler, ScheduledTask, AdHocTask, TaskSchedule,
4
- serialize_task, parse_task_schedule
3
+ TaskScheduler, ScheduledTask, AdHocTask, PlannedTask, TaskSchedule,
4
+ serialize_task, parse_task_schedule, parse_task_plan, TaskType
5
)
6
+from python.helpers.localization import Localization
7
+from python.helpers.print_style import PrintStyle
8
+import random
9
10
11
class SchedulerTaskCreate(ApiHandler):
@@ -10,6 +13,12 @@ class SchedulerTaskCreate(ApiHandler):
13
"""
14
Create a new task in the scheduler
15
"""
16
+ printer = PrintStyle(italic=True, font_color="blue", padding=False)
17
+
18
+ # Get timezone from input (do not set if not provided, we then rely on poll() to set it)
19
+ if timezone := input.get("timezone", None):
20
+ Localization.get().set_timezone(timezone)
21
+
22
scheduler = TaskScheduler.get()
23
await scheduler.reload()
24
@@ -18,11 +27,22 @@ class SchedulerTaskCreate(ApiHandler):
27
system_prompt = input.get("system_prompt")
28
prompt = input.get("prompt")
29
attachments = input.get("attachments", [])
30
+ context_id = input.get("context_id", None)
31
32
# Check if schedule is provided (for ScheduledTask)
33
schedule = input.get("schedule", {})
34
token: str = input.get("token", "")
35
36
+ # Debug log the token value
37
+ printer.print(f"Token received from frontend: '{token}' (type: {type(token)}, length: {len(token) if token else 0})")
38
+
39
+ # Generate a random token if empty or not provided
40
+ if not token:
41
+ token = str(random.randint(1000000000000000000, 9999999999999999999))
42
+ printer.print(f"Generated new token: '{token}'")
43
+
44
+ plan = input.get("plan", {})
45
+
46
# Validate required fields
47
if not name or not system_prompt or not prompt:
48
return {"error": "Missing required fields: name, system_prompt, prompt"}
@@ -55,24 +75,61 @@ class SchedulerTaskCreate(ApiHandler):
75
system_prompt=system_prompt,
76
prompt=prompt,
77
schedule=task_schedule,
58
- attachments=attachments
78
+ attachments=attachments,
79
+ context_id=context_id,
80
+ timezone=timezone
81
+ )
82
+ elif plan:
83
+ # Create a planned task
84
+ try:
85
+ # Use our standardized parsing function
86
+ task_plan = parse_task_plan(plan)
87
+ except ValueError as e:
88
+ return {"error": str(e)}
89
+
90
+ task = PlannedTask.create(
91
+ name=name,
92
+ system_prompt=system_prompt,
93
+ prompt=prompt,
94
+ plan=task_plan,
95
+ attachments=attachments,
96
+ context_id=context_id
97
)
98
else:
99
# Create an ad-hoc task
100
+ printer.print(f"Creating AdHocTask with token: '{token}'")
101
task = AdHocTask.create(
102
name=name,
103
system_prompt=system_prompt,
104
prompt=prompt,
105
token=token,
67
- attachments=attachments
106
+ attachments=attachments,
107
+ context_id=context_id
108
)
109
+ # Verify token after creation
110
+ if isinstance(task, AdHocTask):
111
+ printer.print(f"AdHocTask created with token: '{task.token}'")
112
113
# Add the task to the scheduler
114
await scheduler.add_task(task)
115
116
+ # Verify the task was added correctly - retrieve by UUID to check persistence
117
+ saved_task = scheduler.get_task_by_uuid(task.uuid)
118
+ if saved_task:
119
+ if saved_task.type == TaskType.AD_HOC and isinstance(saved_task, AdHocTask):
120
+ printer.print(f"Task verified after save, token: '{saved_task.token}'")
121
+ else:
122
+ printer.print("Task verified after save, not an adhoc task")
123
+ else:
124
+ printer.print("WARNING: Task not found after save!")
125
+
126
# Return the created task using our standardized serialization function
127
task_dict = serialize_task(task)
128
129
+ # Debug log the serialized task
130
+ if task_dict and task_dict.get('type') == 'adhoc':
131
+ printer.print(f"Serialized adhoc task, token in response: '{task_dict.get('token')}'")
132
+
133
return {
134
"task": task_dict
135
}
python/api/scheduler_task_delete.py
+26
-1
@@ -1,5 +1,8 @@
1
from python.helpers.api import ApiHandler, Input, Output, Request
2
-from python.helpers.task_scheduler import TaskScheduler
2
+from python.helpers.task_scheduler import TaskScheduler, TaskState
3
+from python.helpers.localization import Localization
4
+from agent import AgentContext
5
+from python.helpers import persist_chat
6
7
8
class SchedulerTaskDelete(ApiHandler):
@@ -7,6 +10,10 @@ class SchedulerTaskDelete(ApiHandler):
10
"""
11
Delete a task from the scheduler by ID
12
"""
13
+ # Get timezone from input (do not set if not provided, we then rely on poll() to set it)
14
+ if timezone := input.get("timezone", None):
15
+ Localization.get().set_timezone(timezone)
16
+
17
scheduler = TaskScheduler.get()
18
await scheduler.reload()
19
@@ -21,6 +28,24 @@ class SchedulerTaskDelete(ApiHandler):
28
if not task:
29
return {"error": f"Task with ID {task_id} not found"}
30
31
+ context = None
32
+ if task.context_id:
33
+ context = self.get_context(task.context_id)
34
+
35
+ # If the task is running, update its state to IDLE first
36
+ if task.state == TaskState.RUNNING:
37
+ if context:
38
+ context.reset()
39
+ # Update the state to IDLE so any ongoing processes know to terminate
40
+ await scheduler.update_task(task_id, state=TaskState.IDLE)
41
+ # Force a save to ensure the state change is persisted
42
+ await scheduler.save()
43
+
44
+ # This is a dedicated context for the task, so we remove it
45
+ if context and context.id == task.uuid:
46
+ AgentContext.remove(context.id)
47
+ persist_chat.remove_chat(context.id)
48
+
49
# Remove the task
50
await scheduler.remove_task_by_uuid(task_id)
51
python/api/scheduler_task_run.py
+4
@@ -1,6 +1,7 @@
1
from python.helpers.api import ApiHandler, Input, Output, Request
2
from python.helpers.task_scheduler import TaskScheduler, TaskState
3
from python.helpers.print_style import PrintStyle
4
+from python.helpers.localization import Localization
5
6
7
class SchedulerTaskRun(ApiHandler):
@@ -11,6 +12,9 @@ class SchedulerTaskRun(ApiHandler):
12
"""
13
Manually run a task from the scheduler by ID
14
"""
15
+ # Get timezone from input (do not set if not provided, we then rely on poll() to set it)
16
+ if timezone := input.get("timezone", None):
17
+ Localization.get().set_timezone(timezone)
18
19
# Get task ID from input
20
task_id: str = input.get("task_id", "")
python/api/scheduler_task_update.py
+26
-4
@@ -1,8 +1,9 @@
1
from python.helpers.api import ApiHandler, Input, Output, Request
2
from python.helpers.task_scheduler import (
3
- TaskScheduler, ScheduledTask, AdHocTask, TaskState,
4
- serialize_task, parse_task_schedule
3
+ TaskScheduler, ScheduledTask, AdHocTask, PlannedTask, TaskState,
4
+ serialize_task, parse_task_schedule, parse_task_plan
5
)
6
+from python.helpers.localization import Localization
7
8
9
class SchedulerTaskUpdate(ApiHandler):
@@ -10,6 +11,10 @@ class SchedulerTaskUpdate(ApiHandler):
11
"""
12
Update an existing task in the scheduler
13
"""
14
+ # Get timezone from input (do not set if not provided, we then rely on poll() to set it)
15
+ if timezone := input.get("timezone", None):
16
+ Localization.get().set_timezone(timezone)
17
+
18
scheduler = TaskScheduler.get()
19
await scheduler.reload()
20
@@ -47,11 +52,28 @@ class SchedulerTaskUpdate(ApiHandler):
52
if isinstance(task, ScheduledTask) and "schedule" in input:
53
schedule_data = input.get("schedule", {})
54
try:
50
- update_params["schedule"] = parse_task_schedule(schedule_data)
55
+ # Parse the schedule with timezone handling
56
+ task_schedule = parse_task_schedule(schedule_data)
57
+
58
+ # Set the timezone from the request if not already in schedule_data
59
+ if not schedule_data.get('timezone', None) and timezone:
60
+ task_schedule.timezone = timezone
61
+
62
+ update_params["schedule"] = task_schedule
63
except ValueError as e:
64
return {"error": f"Invalid schedule format: {str(e)}"}
65
elif isinstance(task, AdHocTask) and "token" in input:
54
- update_params["token"] = input.get("token", "")
66
+ token_value = input.get("token", "")
67
+ if token_value: # Only update if non-empty
68
+ update_params["token"] = token_value
69
+ elif isinstance(task, PlannedTask) and "plan" in input:
70
+ plan_data = input.get("plan", {})
71
+ try:
72
+ # Parse the plan data
73
+ task_plan = parse_task_plan(plan_data)
74
+ update_params["plan"] = task_plan
75
+ except ValueError as e:
76
+ return {"error": f"Invalid plan format: {str(e)}"}
77
78
# Use atomic update method to apply changes
79
updated_task = await scheduler.update_task(task_id, **update_params)
python/api/scheduler_tasks_list.py
+5
@@ -2,6 +2,7 @@ from python.helpers.api import ApiHandler, Input, Output, Request
2
from python.helpers.task_scheduler import TaskScheduler
3
import traceback
4
from python.helpers.print_style import PrintStyle
5
+from python.helpers.localization import Localization
6
7
8
class SchedulerTasksList(ApiHandler):
@@ -10,6 +11,10 @@ class SchedulerTasksList(ApiHandler):
11
List all tasks in the scheduler with their types
12
"""
13
try:
14
+ # Get timezone from input (do not set if not provided, we then rely on poll() to set it)
15
+ if timezone := input.get("timezone", None):
16
+ Localization.get().set_timezone(timezone)
17
+
18
# Get task scheduler
19
scheduler = TaskScheduler.get()
20
await scheduler.reload()
python/api/scheduler_tick.py
+6
-1
@@ -3,14 +3,19 @@ from datetime import datetime
3
from python.helpers.api import ApiHandler, Input, Output, Request
4
from python.helpers.print_style import PrintStyle
5
from python.helpers.task_scheduler import TaskScheduler
6
+from python.helpers.localization import Localization
7
8
9
class SchedulerTick(ApiHandler):
10
@classmethod
10
- def requires_loopback(cls):
11
+ def requires_loopback(cls) -> bool:
12
return True
13
14
async def process(self, input: Input, request: Request) -> Output:
15
+ # Get timezone from input (do not set if not provided, we then rely on poll() to set it)
16
+ if timezone := input.get("timezone", None):
17
+ Localization.get().set_timezone(timezone)
18
+
19
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
20
printer = PrintStyle(font_color="green", padding=False)
21
printer.print(f"Scheduler tick - API: {timestamp}")
python/extensions/message_loop_prompts/_60_include_current_datetime.py
new
+22
@@ -0,0 +1,22 @@
1
+from datetime import datetime, timezone
2
+from python.helpers.extension import Extension
3
+from agent import LoopData
4
+from python.helpers.localization import Localization
5
+
6
+
7
+class IncludeCurrentDatetime(Extension):
8
+ async def execute(self, loop_data: LoopData = LoopData(), **kwargs):
9
+ # get current datetime
10
+ current_datetime = Localization.get().utc_dt_to_localtime_str(datetime.now(timezone.utc), sep=" ", timespec="seconds")
11
+ # remove timezone offset
12
+ current_datetime = current_datetime.split("+")[0]
13
+
14
+ # remove old current datetime from loop data
15
+ if "current_datetime" in loop_data.extras_temporary:
16
+ del loop_data.extras_temporary["current_datetime"]
17
+
18
+ # read prompt
19
+ datetime_prompt = self.agent.read_prompt("agent.system.datetime.md", date_time=current_datetime)
20
+
21
+ # add current datetime to the loop data
22
+ loop_data.extras_temporary["current_datetime"] = datetime_prompt
python/extensions/system_prompt/_10_system_prompt.py
+7
-2
@@ -1,6 +1,7 @@
1
-from datetime import datetime
1
+from datetime import datetime, timezone
2
from python.helpers.extension import Extension
3
from agent import Agent, LoopData
4
+from python.helpers.localization import Localization
5
6
7
class SystemPrompt(Extension):
@@ -28,8 +29,12 @@ def get_prompt(file: str, agent: Agent):
29
# variables for system prompts
30
# TODO: move variables to the end of chain
31
# variables in system prompt would break prompt caching, better to add them to the last message in conversation
32
+ # get current datetime
33
+ current_datetime = Localization.get().utc_dt_to_localtime_str(datetime.now(timezone.utc), sep=" ", timespec="seconds")
34
+ # remove timezone offset
35
+ current_datetime = current_datetime.split("+")[0]
36
vars = {
32
- "date_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
37
+ "date_time": current_datetime,
38
"agent_name": agent.agent_name,
39
}
40
return agent.read_prompt(file, **vars)
python/helpers/api.py
+4
-4
@@ -12,7 +12,7 @@ from werkzeug.serving import make_server
12
13
14
Input = dict
15
-Output = Union[Dict[str, Any], Response, TypedDict]
15
+Output = Union[Dict[str, Any], Response, TypedDict] # type: ignore
16
17
18
class ApiHandler:
@@ -21,15 +21,15 @@ class ApiHandler:
21
self.thread_lock = thread_lock
22
23
@classmethod
24
- def requires_loopback(cls):
24
+ def requires_loopback(cls) -> bool:
25
return False
26
27
@classmethod
28
- def requires_api_key(cls):
28
+ def requires_api_key(cls) -> bool:
29
return False
30
31
@classmethod
32
- def requires_auth(cls):
32
+ def requires_auth(cls) -> bool:
33
return True
34
35
@abstractmethod
python/helpers/chat_names.py
deleted
-137
@@ -1,137 +0,0 @@
1
-import json
2
-import os
3
-import time
4
-from typing import Dict, Any
5
-
6
-from python.helpers import files
7
-
8
-
9
-class ChatNames:
10
- _instance = None
11
- _names_file = "chat_names.json"
12
- _metadata_file = "chat_metadata.json"
13
-
14
- @classmethod
15
- def get_instance(cls):
16
- if cls._instance is None:
17
- cls._instance = ChatNames()
18
- cls._instance._names_file = files.get_abs_path("tmp") + "/" + cls._names_file
19
- cls._instance._metadata_file = files.get_abs_path("tmp") + "/" + cls._metadata_file
20
- return cls._instance
21
-
22
- def _read_names(self) -> dict:
23
- """Read current names from file"""
24
- try:
25
- if os.path.exists(self._names_file):
26
- with open(self._names_file, 'r') as f:
27
- return json.load(f)
28
- except Exception as e:
29
- print(f"Error reading chat names: {e}")
30
- return {}
31
-
32
- def _save_names(self, names: dict):
33
- """Save names to file"""
34
- try:
35
- with open(self._names_file, 'w') as f:
36
- json.dump(names, f, indent=2)
37
- except Exception as e:
38
- print(f"Error saving chat names: {e}")
39
-
40
- def _read_metadata(self) -> dict:
41
- """Read context metadata from file"""
42
- try:
43
- if os.path.exists(self._metadata_file):
44
- with open(self._metadata_file, 'r') as f:
45
- return json.load(f)
46
- except Exception as e:
47
- print(f"Error reading context metadata: {e}")
48
- return {}
49
-
50
- def _save_metadata(self, metadata: dict):
51
- """Save metadata to file"""
52
- try:
53
- with open(self._metadata_file, 'w') as f:
54
- json.dump(metadata, f, indent=2)
55
- except Exception as e:
56
- print(f"Error saving context metadata: {e}")
57
-
58
- def set_name(self, chat_id: str, name: str):
59
- """Set name for a chat/task"""
60
- names = self._read_names()
61
- names[chat_id] = name
62
- self._save_names(names)
63
-
64
- # Also update the name in metadata if it exists
65
- self.update_metadata(chat_id, {"name": name})
66
-
67
- def get_name(self, chat_id: str) -> str:
68
- """Get name for a chat/task"""
69
- names = self._read_names()
70
- return names.get(chat_id, f"Chat #{chat_id[:8]}")
71
-
72
- def set_metadata(self, chat_id: str, metadata_dict: Dict[str, Any]):
73
- """Set complete metadata for a chat/task"""
74
- all_metadata = self._read_metadata()
75
-
76
- # Ensure we have created_at timestamp if not provided
77
- if "created_at" not in metadata_dict:
78
- metadata_dict["created_at"] = int(time.time())
79
-
80
- all_metadata[chat_id] = metadata_dict
81
- self._save_metadata(all_metadata)
82
-
83
- # Also update the name in the names file for backward compatibility
84
- if "name" in metadata_dict:
85
- self.set_name(chat_id, metadata_dict["name"])
86
-
87
- def update_metadata(self, chat_id: str, metadata_update: Dict[str, Any]):
88
- """Update specific metadata fields for a chat/task"""
89
- all_metadata = self._read_metadata()
90
-
91
- if chat_id in all_metadata:
92
- all_metadata[chat_id].update(metadata_update)
93
- else:
94
- # Initialize with current timestamp if creating new metadata
95
- metadata_update["created_at"] = metadata_update.get("created_at", int(time.time()))
96
- all_metadata[chat_id] = metadata_update
97
-
98
- self._save_metadata(all_metadata)
99
-
100
- def get_metadata(self, chat_id: str) -> Dict[str, Any]:
101
- """Get metadata for a chat/task"""
102
- all_metadata = self._read_metadata()
103
-
104
- # Return metadata if it exists
105
- if chat_id in all_metadata:
106
- return all_metadata[chat_id]
107
-
108
- # Otherwise create default metadata with just the name
109
- name = self.get_name(chat_id)
110
- default_metadata = {
111
- "name": name,
112
- "created_at": int(time.time()) # Current time as fallback
113
- }
114
- return default_metadata
115
-
116
- def get_created_at(self, chat_id: str) -> int:
117
- """Get creation timestamp for a chat/task"""
118
- metadata = self.get_metadata(chat_id)
119
- return metadata.get("created_at", 0)
120
-
121
- def remove_chat(self, chat_id: str):
122
- """Remove chat/task and its metadata"""
123
- print("Removing chat name and metadata: ", chat_id)
124
-
125
- # Remove from names file
126
- names = self._read_names()
127
- if chat_id in names:
128
- del names[chat_id]
129
- print("Chat name removed: ", chat_id)
130
- self._save_names(names)
131
-
132
- # Remove from metadata file
133
- metadata = self._read_metadata()
134
- if chat_id in metadata:
135
- del metadata[chat_id]
136
- print("Chat metadata removed: ", chat_id)
137
- self._save_metadata(metadata)
python/helpers/localization.py
new
+123
@@ -0,0 +1,123 @@
1
+from datetime import datetime
2
+import pytz # type: ignore
3
+
4
+from python.helpers.print_style import PrintStyle
5
+from python.helpers.dotenv import get_dotenv_value, save_dotenv_value
6
+
7
+
8
+class Localization:
9
+ """
10
+ Localization class for handling timezone conversions between UTC and local time.
11
+ """
12
+
13
+ # singleton
14
+ _instance = None
15
+
16
+ @classmethod
17
+ def get(cls, *args, **kwargs):
18
+ if cls._instance is None:
19
+ cls._instance = cls(*args, **kwargs)
20
+ return cls._instance
21
+
22
+ def __init__(self, timezone: str | None = None):
23
+ if timezone is not None:
24
+ self.set_timezone(timezone) # Use the setter to validate
25
+ else:
26
+ timezone = str(get_dotenv_value("DEFAULT_USER_TIMEZONE", "UTC"))
27
+ self.set_timezone(timezone)
28
+
29
+ def get_timezone(self) -> str:
30
+ return self.timezone
31
+
32
+ def set_timezone(self, timezone: str) -> None:
33
+ """Set the timezone, with validation."""
34
+ # Validate timezone
35
+ try:
36
+ pytz.timezone(timezone)
37
+ if timezone != getattr(self, 'timezone', None):
38
+ PrintStyle.debug(f"Changing timezone from {getattr(self, 'timezone', 'None')} to {timezone}")
39
+ self.timezone = timezone
40
+ save_dotenv_value("DEFAULT_USER_TIMEZONE", timezone)
41
+ except pytz.exceptions.UnknownTimeZoneError:
42
+ PrintStyle.error(f"Unknown timezone: {timezone}, defaulting to UTC")
43
+ self.timezone = "UTC"
44
+ # save the default timezone to the environment variable to avoid future errors on startup
45
+ save_dotenv_value("DEFAULT_USER_TIMEZONE", "UTC")
46
+
47
+ def localtime_str_to_utc_dt(self, localtime_str: str | None) -> datetime | None:
48
+ """
49
+ Convert a local time ISO string to a UTC datetime object.
50
+ Returns None if input is None or invalid.
51
+ """
52
+ if not localtime_str:
53
+ return None
54
+
55
+ try:
56
+ # Handle both with and without timezone info
57
+ try:
58
+ # Try parsing with timezone info first
59
+ local_datetime_obj = datetime.fromisoformat(localtime_str)
60
+ if local_datetime_obj.tzinfo is None:
61
+ # If no timezone info, assume it's in the configured timezone
62
+ local_datetime_obj = pytz.timezone(self.timezone).localize(local_datetime_obj)
63
+ except ValueError:
64
+ # If timezone parsing fails, try without timezone
65
+ local_datetime_obj = datetime.fromisoformat(localtime_str.split('Z')[0].split('+')[0])
66
+ local_datetime_obj = pytz.timezone(self.timezone).localize(local_datetime_obj)
67
+
68
+ # Convert to UTC
69
+ return local_datetime_obj.astimezone(pytz.utc)
70
+ except Exception as e:
71
+ PrintStyle.error(f"Error converting localtime string to UTC: {e}")
72
+ return None
73
+
74
+ def utc_dt_to_localtime_str(self, utc_dt: datetime | None, sep: str = "T", timespec: str = "auto") -> str | None:
75
+ """
76
+ Convert a UTC datetime object to a local time ISO string.
77
+ Returns None if input is None.
78
+ """
79
+ if utc_dt is None:
80
+ return None
81
+
82
+ # At this point, utc_dt is definitely not None
83
+ assert utc_dt is not None
84
+
85
+ try:
86
+ # Ensure datetime is timezone aware
87
+ if utc_dt.tzinfo is None:
88
+ utc_dt = pytz.utc.localize(utc_dt)
89
+ elif utc_dt.tzinfo != pytz.utc:
90
+ utc_dt = utc_dt.astimezone(pytz.utc)
91
+
92
+ # Convert to local time
93
+ local_datetime_obj = utc_dt.astimezone(pytz.timezone(self.timezone))
94
+ # Return the local time string
95
+ return local_datetime_obj.isoformat(sep=sep, timespec=timespec)
96
+ except Exception as e:
97
+ PrintStyle.error(f"Error converting UTC datetime to localtime string: {e}")
98
+ return None
99
+
100
+ def serialize_datetime(self, dt: datetime | None) -> str | None:
101
+ """
102
+ Serialize a datetime object to ISO format string in the user's timezone.
103
+ This ensures the frontend receives dates in the correct timezone for display.
104
+ """
105
+ if dt is None:
106
+ return None
107
+
108
+ # At this point, dt is definitely not None
109
+ assert dt is not None
110
+
111
+ try:
112
+ # Ensure datetime is timezone aware (if not, assume UTC)
113
+ if dt.tzinfo is None:
114
+ dt = pytz.utc.localize(dt)
115
+
116
+ # Convert to the user's timezone
117
+ local_timezone = pytz.timezone(self.timezone)
118
+ local_dt = dt.astimezone(local_timezone)
119
+
120
+ return local_dt.isoformat()
121
+ except Exception as e:
122
+ PrintStyle.error(f"Error serializing datetime: {e}")
123
+ return None
python/helpers/persist_chat.py
+11
@@ -1,4 +1,5 @@
1
from collections import OrderedDict
2
+from datetime import datetime
3
from typing import Any
4
import uuid
5
from agent import Agent, AgentConfig, AgentContext
@@ -104,6 +105,10 @@ def _serialize_context(context: AgentContext):
105
return {
106
"id": context.id,
107
"name": context.name,
108
+ "created_at": (
109
+ context.created_at.isoformat() if context.created_at
110
+ else datetime.fromtimestamp(0).isoformat()
111
+ ),
112
"agents": agents,
113
"streaming_agent": (
114
context.streaming_agent.number if context.streaming_agent else 0
@@ -143,6 +148,12 @@ def _deserialize_context(data):
148
config=config,
149
id=data.get("id", None), # get new id
150
name=data.get("name", None),
151
+ created_at=(
152
+ datetime.fromisoformat(
153
+ # older chats may not have created_at - backcompat
154
+ data.get("created_at", datetime.fromtimestamp(0).isoformat())
155
+ )
156
+ ),
157
log=log,
158
paused=False,
159
# agent0=agent0,
python/helpers/task_scheduler.py
+598
-169
@@ -1,15 +1,13 @@
1
import asyncio
2
from datetime import datetime, timezone, timedelta
3
-import json
3
import os
4
import random
5
import threading
6
from urllib.parse import urlparse
7
import uuid
9
-from dataclasses import dataclass
8
from enum import Enum
9
from os.path import exists
12
-from typing import Any, Callable, Coroutine, Dict, Literal, Optional, Type, TypeVar, Union, cast, ClassVar
10
+from typing import Any, Callable, Dict, Literal, Optional, Type, TypeVar, Union, cast, ClassVar
11
12
import nest_asyncio
13
nest_asyncio.apply()
@@ -17,22 +15,23 @@ nest_asyncio.apply()
15
from crontab import CronTab
16
from pydantic import BaseModel, Field, PrivateAttr
17
20
-from agent import Agent, AgentConfig, AgentContext, UserMessage
18
+from agent import Agent, AgentContext, UserMessage
19
from initialize import initialize
22
-from python.helpers.persist_chat import load_tmp_chats, save_tmp_chat
20
+from python.helpers.persist_chat import save_tmp_chat
21
from python.helpers.print_style import PrintStyle
22
from python.helpers.defer import DeferredTask
25
-from python.helpers.files import get_abs_path, list_files, make_dirs, read_file, write_file
26
-from python.helpers.persist_chat import load_tmp_chats, save_tmp_chat
27
-from python.helpers.print_style import PrintStyle
28
-
29
-SCHEDULER_FOLDER = "memory/scheduler"
23
+from python.helpers.files import get_abs_path, make_dirs, read_file, write_file
24
+from python.helpers.localization import Localization
25
+import pytz
26
+from typing import Annotated
27
28
+SCHEDULER_FOLDER = "tmp/scheduler"
29
30
# ----------------------
31
# Task Models
32
# ----------------------
33
34
+
35
class TaskState(str, Enum):
36
IDLE = "idle"
37
RUNNING = "running"
@@ -40,49 +39,100 @@ class TaskState(str, Enum):
39
ERROR = "error"
40
41
42
+class TaskType(str, Enum):
43
+ AD_HOC = "adhoc"
44
+ SCHEDULED = "scheduled"
45
+ PLANNED = "planned"
46
+
47
+
48
class TaskSchedule(BaseModel):
49
minute: str
50
hour: str
51
day: str
52
month: str
53
weekday: str
54
+ timezone: str = Field(default_factory=lambda: Localization.get().get_timezone())
55
56
def to_crontab(self) -> str:
57
return f"{self.minute} {self.hour} {self.day} {self.month} {self.weekday}"
58
59
54
-class AdHocTask(BaseModel):
60
+class TaskPlan(BaseModel):
61
+ todo: list[datetime] = Field(default_factory=list)
62
+ in_progress: datetime | None = None
63
+ done: list[datetime] = Field(default_factory=list)
64
+
65
+ @classmethod
66
+ def create(cls, todo: list[datetime] = list(), in_progress: datetime | None = None, done: list[datetime] = list()):
67
+ if todo:
68
+ for idx, dt in enumerate(todo):
69
+ if dt.tzinfo is None:
70
+ todo[idx] = pytz.timezone("UTC").localize(dt)
71
+ if in_progress:
72
+ if in_progress.tzinfo is None:
73
+ in_progress = pytz.timezone("UTC").localize(in_progress)
74
+ if done:
75
+ for idx, dt in enumerate(done):
76
+ if dt.tzinfo is None:
77
+ done[idx] = pytz.timezone("UTC").localize(dt)
78
+ return cls(todo=todo, in_progress=in_progress, done=done)
79
+
80
+ def add_todo(self, launch_time: datetime):
81
+ if launch_time.tzinfo is None:
82
+ launch_time = pytz.timezone("UTC").localize(launch_time)
83
+ self.todo.append(launch_time)
84
+ self.todo = sorted(self.todo)
85
+
86
+ def set_in_progress(self, launch_time: datetime):
87
+ if launch_time.tzinfo is None:
88
+ launch_time = pytz.timezone("UTC").localize(launch_time)
89
+ if launch_time not in self.todo:
90
+ raise ValueError(f"Launch time {launch_time} not in todo list")
91
+ self.todo.remove(launch_time)
92
+ self.todo = sorted(self.todo)
93
+ self.in_progress = launch_time
94
+
95
+ def set_done(self, launch_time: datetime):
96
+ if launch_time.tzinfo is None:
97
+ launch_time = pytz.timezone("UTC").localize(launch_time)
98
+ if launch_time != self.in_progress:
99
+ raise ValueError(f"Launch time {launch_time} is not the same as in progress time {self.in_progress}")
100
+ if launch_time in self.done:
101
+ raise ValueError(f"Launch time {launch_time} already in done list")
102
+ self.in_progress = None
103
+ self.done.append(launch_time)
104
+ self.done = sorted(self.done)
105
+
106
+ def get_next_launch_time(self) -> datetime | None:
107
+ return self.todo[0] if self.todo else None
108
+
109
+ def should_launch(self) -> datetime | None:
110
+ next_launch_time = self.get_next_launch_time()
111
+ if next_launch_time is None:
112
+ return None
113
+ # return next launch time if current datetime utc is later than next launch time
114
+ if datetime.now(timezone.utc) > next_launch_time:
115
+ return next_launch_time
116
+ return None
117
+
118
+
119
+class BaseTask(BaseModel):
120
uuid: str = Field(default_factory=lambda: str(uuid.uuid4()))
121
+ context_id: Optional[str] = Field(default=None)
122
state: TaskState = Field(default=TaskState.IDLE)
123
name: str = Field()
124
system_prompt: str
125
prompt: str
126
attachments: list[str] = Field(default_factory=list)
61
- token: str = Field(default_factory=lambda: str(random.randint(1000000000000000000, 9999999999999999999)))
127
created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
128
updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
129
last_run: datetime | None = None
130
last_result: str | None = None
131
67
- # lock: Optional[threading.Lock] = Field(exclude=True, default=threading.Lock())
68
-
69
- @classmethod
70
- def create(
71
- cls,
72
- name: str,
73
- system_prompt: str,
74
- prompt: str,
75
- token: str,
76
- attachments: list[str] = list()
77
- ):
78
- return cls(name=name,
79
- system_prompt=system_prompt,
80
- prompt=prompt,
81
- attachments=attachments,
82
- token=token)
83
-
132
def __init__(self, *args, **kwargs):
133
super().__init__(*args, **kwargs)
134
+ if not self.context_id:
135
+ self.context_id = self.uuid
136
self._lock = threading.RLock()
137
138
def update(self,
@@ -93,7 +143,8 @@ class AdHocTask(BaseModel):
143
attachments: list[str] | None = None,
144
last_run: datetime | None = None,
145
last_result: str | None = None,
96
- token: str | None = None):
146
+ context_id: str | None = None,
147
+ **kwargs):
148
with self._lock:
149
if name is not None:
150
self.name = name
@@ -116,29 +167,73 @@ class AdHocTask(BaseModel):
167
if last_result is not None:
168
self.last_result = last_result
169
self.updated_at = datetime.now(timezone.utc)
119
- if token is not None:
120
- self.token = token
170
+ if context_id is not None:
171
+ self.context_id = context_id
172
self.updated_at = datetime.now(timezone.utc)
173
+ for key, value in kwargs.items():
174
+ if value is not None:
175
+ setattr(self, key, value)
176
+ self.updated_at = datetime.now(timezone.utc)
177
178
def check_schedule(self, frequency_seconds: float = 60.0) -> bool:
124
- with self._lock:
125
- return False
179
+ return False
180
181
+ def get_next_run(self) -> datetime | None:
182
+ return None
183
128
-class ScheduledTask(BaseModel):
129
- uuid: str = Field(default_factory=lambda: str(uuid.uuid4()))
130
- state: TaskState = Field(default=TaskState.IDLE)
131
- name: str
132
- schedule: TaskSchedule
133
- system_prompt: str
134
- prompt: str
135
- attachments: list[str] = Field(default_factory=list)
136
- created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
137
- updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
138
- last_run: datetime | None = None
139
- last_result: str | None = None
184
+ def get_next_run_minutes(self) -> int | None:
185
+ next_run = self.get_next_run()
186
+ if next_run is None:
187
+ return None
188
+ return int((next_run - datetime.now(timezone.utc)).total_seconds() / 60)
189
+
190
+ async def on_run(self):
191
+ pass
192
+
193
+ async def on_finish(self):
194
+ # Ensure that updated_at is refreshed to reflect completion time
195
+ # This helps track when the task actually finished, regardless of success/error
196
+ await TaskScheduler.get().update_task(
197
+ self.uuid,
198
+ updated_at=datetime.now(timezone.utc)
199
+ )
200
+
201
+ async def on_error(self, error: str):
202
+ # Update task state to ERROR and set last result
203
+ scheduler = TaskScheduler.get()
204
+ await scheduler.reload() # Ensure we have the latest state
205
+ updated_task = await scheduler.update_task(
206
+ self.uuid,
207
+ state=TaskState.ERROR,
208
+ last_run=datetime.now(timezone.utc),
209
+ last_result=f"ERROR: {error}"
210
+ )
211
+ if not updated_task:
212
+ PrintStyle(italic=True, font_color="red", padding=False).print(
213
+ f"Failed to update task {self.uuid} state to ERROR after error: {error}"
214
+ )
215
+ await scheduler.save() # Force save after update
216
+
217
+ async def on_success(self, result: str):
218
+ # Update task state to IDLE and set last result
219
+ scheduler = TaskScheduler.get()
220
+ await scheduler.reload() # Ensure we have the latest state
221
+ updated_task = await scheduler.update_task(
222
+ self.uuid,
223
+ state=TaskState.IDLE,
224
+ last_run=datetime.now(timezone.utc),
225
+ last_result=result
226
+ )
227
+ if not updated_task:
228
+ PrintStyle(italic=True, font_color="red", padding=False).print(
229
+ f"Failed to update task {self.uuid} state to IDLE after success"
230
+ )
231
+ await scheduler.save() # Force save after update
232
141
- # lock: Optional[threading.Lock] = Field(exclude=True, default=threading.Lock())
233
+
234
+class AdHocTask(BaseTask):
235
+ type: Literal[TaskType.AD_HOC] = TaskType.AD_HOC
236
+ token: str = Field(default_factory=lambda: str(random.randint(1000000000000000000, 9999999999999999999)))
237
238
@classmethod
239
def create(
@@ -146,18 +241,67 @@ class ScheduledTask(BaseModel):
241
name: str,
242
system_prompt: str,
243
prompt: str,
149
- schedule: TaskSchedule,
150
- attachments: list[str] = []
244
+ token: str,
245
+ attachments: list[str] = list(),
246
+ context_id: str | None = None
247
):
248
return cls(name=name,
249
system_prompt=system_prompt,
250
prompt=prompt,
251
attachments=attachments,
156
- schedule=schedule)
252
+ token=token,
253
+ context_id=context_id)
254
158
- def __init__(self, *args, **kwargs):
159
- super().__init__(*args, **kwargs)
160
- self._lock = threading.RLock()
255
+ def update(self,
256
+ name: str | None = None,
257
+ state: TaskState | None = None,
258
+ system_prompt: str | None = None,
259
+ prompt: str | None = None,
260
+ attachments: list[str] | None = None,
261
+ last_run: datetime | None = None,
262
+ last_result: str | None = None,
263
+ context_id: str | None = None,
264
+ token: str | None = None,
265
+ **kwargs):
266
+ super().update(name=name,
267
+ state=state,
268
+ system_prompt=system_prompt,
269
+ prompt=prompt,
270
+ attachments=attachments,
271
+ last_run=last_run,
272
+ last_result=last_result,
273
+ context_id=context_id,
274
+ token=token,
275
+ **kwargs)
276
+
277
+
278
+class ScheduledTask(BaseTask):
279
+ type: Literal[TaskType.SCHEDULED] = TaskType.SCHEDULED
280
+ schedule: TaskSchedule
281
+
282
+ @classmethod
283
+ def create(
284
+ cls,
285
+ name: str,
286
+ system_prompt: str,
287
+ prompt: str,
288
+ schedule: TaskSchedule,
289
+ attachments: list[str] = list(),
290
+ context_id: str | None = None,
291
+ timezone: str | None = None
292
+ ):
293
+ # Set timezone in schedule if provided
294
+ if timezone is not None:
295
+ schedule.timezone = timezone
296
+ else:
297
+ schedule.timezone = Localization.get().get_timezone()
298
+
299
+ return cls(name=name,
300
+ system_prompt=system_prompt,
301
+ prompt=prompt,
302
+ attachments=attachments,
303
+ schedule=schedule,
304
+ context_id=context_id)
305
306
def update(self,
307
name: str | None = None,
@@ -165,55 +309,140 @@ class ScheduledTask(BaseModel):
309
system_prompt: str | None = None,
310
prompt: str | None = None,
311
attachments: list[str] | None = None,
168
- schedule: TaskSchedule | None = None,
312
last_run: datetime | None = None,
170
- last_result: str | None = None):
171
- with self._lock:
172
- if name is not None:
173
- self.name = name
174
- self.updated_at = datetime.now(timezone.utc)
175
- if state is not None:
176
- self.state = state
177
- self.updated_at = datetime.now(timezone.utc)
178
- if system_prompt is not None:
179
- self.system_prompt = system_prompt
180
- self.updated_at = datetime.now(timezone.utc)
181
- if prompt is not None:
182
- self.prompt = prompt
183
- self.updated_at = datetime.now(timezone.utc)
184
- if attachments is not None:
185
- self.attachments = attachments
186
- self.updated_at = datetime.now(timezone.utc)
187
- if schedule is not None:
188
- self.schedule = schedule
189
- self.updated_at = datetime.now(timezone.utc)
190
- if last_run is not None:
191
- self.last_run = last_run
192
- self.updated_at = datetime.now(timezone.utc)
193
- if last_result is not None:
194
- self.last_result = last_result
195
- self.updated_at = datetime.now(timezone.utc)
313
+ last_result: str | None = None,
314
+ context_id: str | None = None,
315
+ schedule: TaskSchedule | None = None,
316
+ **kwargs):
317
+ super().update(name=name,
318
+ state=state,
319
+ system_prompt=system_prompt,
320
+ prompt=prompt,
321
+ attachments=attachments,
322
+ last_run=last_run,
323
+ last_result=last_result,
324
+ context_id=context_id,
325
+ schedule=schedule,
326
+ **kwargs)
327
328
def check_schedule(self, frequency_seconds: float = 60.0) -> bool:
329
with self._lock:
199
- crontab = CronTab(crontab=self.schedule.to_crontab())
330
+ crontab = CronTab(crontab=self.schedule.to_crontab()) # type: ignore
331
+
332
+ # Get the timezone from the schedule or use UTC as fallback
333
+ task_timezone = pytz.timezone(self.schedule.timezone or Localization.get().get_timezone())
334
+
335
+ # Get reference time in task's timezone (by default now - frequency_seconds)
336
+ reference_time = datetime.now(timezone.utc) - timedelta(seconds=frequency_seconds)
337
+ reference_time = reference_time.astimezone(task_timezone)
338
+
339
# Get next run time as seconds until next execution
201
- # Set reference time to now - 1 minute to avoid off-by-one
202
- next_run_seconds: Optional[float] = crontab.next(
203
- now=datetime.now(timezone.utc) - timedelta(seconds=frequency_seconds),
340
+ next_run_seconds: Optional[float] = crontab.next( # type: ignore
341
+ now=reference_time,
342
return_datetime=False
343
) # type: ignore
344
+
345
if next_run_seconds is None:
346
return False
347
+
348
return next_run_seconds < frequency_seconds
349
210
- def run(self):
211
- pass
350
+ def get_next_run(self) -> datetime | None:
351
+ with self._lock:
352
+ crontab = CronTab(crontab=self.schedule.to_crontab()) # type: ignore
353
+ return crontab.next(now=datetime.now(timezone.utc), return_datetime=True) # type: ignore
354
355
214
-class SchedulerTaskList(BaseModel):
215
- tasks: list[Union[ScheduledTask, AdHocTask]]
356
+class PlannedTask(BaseTask):
357
+ type: Literal[TaskType.PLANNED] = TaskType.PLANNED
358
+ plan: TaskPlan
359
+
360
+ @classmethod
361
+ def create(
362
+ cls,
363
+ name: str,
364
+ system_prompt: str,
365
+ prompt: str,
366
+ plan: TaskPlan,
367
+ attachments: list[str] = list(),
368
+ context_id: str | None = None
369
+ ):
370
+ return cls(name=name,
371
+ system_prompt=system_prompt,
372
+ prompt=prompt,
373
+ plan=plan,
374
+ attachments=attachments,
375
+ context_id=context_id)
376
+
377
+ def update(self,
378
+ name: str | None = None,
379
+ state: TaskState | None = None,
380
+ system_prompt: str | None = None,
381
+ prompt: str | None = None,
382
+ attachments: list[str] | None = None,
383
+ last_run: datetime | None = None,
384
+ last_result: str | None = None,
385
+ context_id: str | None = None,
386
+ plan: TaskPlan | None = None,
387
+ **kwargs):
388
+ super().update(name=name,
389
+ state=state,
390
+ system_prompt=system_prompt,
391
+ prompt=prompt,
392
+ attachments=attachments,
393
+ last_run=last_run,
394
+ last_result=last_result,
395
+ context_id=context_id,
396
+ plan=plan,
397
+ **kwargs)
398
+
399
+ def check_schedule(self, frequency_seconds: float = 60.0) -> bool:
400
+ with self._lock:
401
+ return self.plan.should_launch() is not None
402
+
403
+ def get_next_run(self) -> datetime | None:
404
+ with self._lock:
405
+ return self.plan.get_next_launch_time()
406
+
407
+ async def on_run(self):
408
+ with self._lock:
409
+ # Get the next launch time and set it as in_progress
410
+ next_launch_time = self.plan.should_launch()
411
+ if next_launch_time is not None:
412
+ self.plan.set_in_progress(next_launch_time)
413
+ await super().on_run()
414
+
415
+ async def on_finish(self):
416
+ # Handle plan item progression regardless of success or error
417
+ plan_updated = False
418
+
419
+ with self._lock:
420
+ # If there's an in_progress time, mark it as done
421
+ if self.plan.in_progress is not None:
422
+ self.plan.set_done(self.plan.in_progress)
423
+ plan_updated = True
424
425
+ # If we updated the plan, make sure to persist it
426
+ if plan_updated:
427
+ scheduler = TaskScheduler.get()
428
+ await scheduler.reload()
429
+ await scheduler.update_task(self.uuid, plan=self.plan)
430
+ await scheduler.save() # Force save
431
+
432
+ # Call the parent implementation for any additional cleanup
433
+ await super().on_finish()
434
+
435
+ async def on_success(self, result: str):
436
+ # Call parent implementation to update state, etc.
437
+ await super().on_success(result)
438
+
439
+ async def on_error(self, error: str):
440
+ # Call parent implementation to update state, etc.
441
+ await super().on_error(error)
442
+
443
+
444
+class SchedulerTaskList(BaseModel):
445
+ tasks: list[Annotated[Union[ScheduledTask, AdHocTask, PlannedTask], Field(discriminator="type")]] = Field(default_factory=list)
446
# Singleton instance
447
__instance: ClassVar[Optional["SchedulerTaskList"]] = PrivateAttr(default=None)
448
@@ -245,7 +474,7 @@ class SchedulerTaskList(BaseModel):
474
self.tasks.extend(data.tasks)
475
return self
476
248
- async def add_task(self, task: Union[ScheduledTask, AdHocTask]) -> "SchedulerTaskList":
477
+ async def add_task(self, task: Union[ScheduledTask, AdHocTask, PlannedTask]) -> "SchedulerTaskList":
478
with self._lock:
479
self.tasks.append(task)
480
await self.save()
@@ -253,13 +482,50 @@ class SchedulerTaskList(BaseModel):
482
483
async def save(self) -> "SchedulerTaskList":
484
with self._lock:
485
+ # Debug: check for AdHocTasks with null tokens before saving
486
+ for task in self.tasks:
487
+ if isinstance(task, AdHocTask):
488
+ if task.token is None or task.token == "":
489
+ PrintStyle(italic=True, font_color="red", padding=False).print(
490
+ f"WARNING: AdHocTask {task.name} ({task.uuid}) has a null or empty token before saving: '{task.token}'"
491
+ )
492
+ # Generate a new token to prevent errors
493
+ task.token = str(random.randint(1000000000000000000, 9999999999999999999))
494
+ PrintStyle(italic=True, font_color="red", padding=False).print(
495
+ f"Fixed: Generated new token '{task.token}' for task {task.name}"
496
+ )
497
+
498
path = get_abs_path(SCHEDULER_FOLDER, "tasks.json")
499
if not exists(path):
500
make_dirs(path)
259
- write_file(path, self.model_dump_json())
501
+
502
+ # Get the JSON string before writing
503
+ json_data = self.model_dump_json()
504
+
505
+ # Debug: check if 'null' appears as token value in JSON
506
+ if '"type": "adhoc"' in json_data and '"token": null' in json_data:
507
+ PrintStyle(italic=True, font_color="red", padding=False).print(
508
+ "ERROR: Found null token in JSON output for an adhoc task"
509
+ )
510
+
511
+ write_file(path, json_data)
512
+
513
+ # Debug: Verify after saving
514
+ if exists(path):
515
+ loaded_json = read_file(path)
516
+ if '"type": "adhoc"' in loaded_json and '"token": null' in loaded_json:
517
+ PrintStyle(italic=True, font_color="red", padding=False).print(
518
+ "ERROR: Null token persisted in JSON file for an adhoc task"
519
+ )
520
+
521
return self
522
262
- async def update_task_by_uuid(self, task_uuid: str, updater_func) -> Union[ScheduledTask, AdHocTask] | None:
523
+ async def update_task_by_uuid(
524
+ self,
525
+ task_uuid: str,
526
+ updater_func: Callable[[Union[ScheduledTask, AdHocTask, PlannedTask]], None],
527
+ verify_func: Callable[[Union[ScheduledTask, AdHocTask, PlannedTask]], bool] = lambda task: True
528
+ ) -> Union[ScheduledTask, AdHocTask, PlannedTask] | None:
529
"""
530
Atomically update a task by UUID using the provided updater function.
531
@@ -273,7 +539,7 @@ class SchedulerTaskList(BaseModel):
539
await self.reload()
540
541
# Find the task
276
- task = next((task for task in self.tasks if task.uuid == task_uuid), None)
542
+ task = next((task for task in self.tasks if task.uuid == task_uuid and verify_func(task)), None)
543
if task is None:
544
return None
545
@@ -281,27 +547,35 @@ class SchedulerTaskList(BaseModel):
547
updater_func(task)
548
549
# Save the changes
284
- path = get_abs_path(SCHEDULER_FOLDER, "tasks.json")
285
- if not exists(path):
286
- make_dirs(path)
287
- write_file(path, self.model_dump_json())
550
+ await self.save()
551
552
return task
553
291
- def get_tasks(self) -> list[Union[ScheduledTask, AdHocTask]]:
554
+ def get_tasks(self) -> list[Union[ScheduledTask, AdHocTask, PlannedTask]]:
555
with self._lock:
556
return self.tasks
557
295
- async def get_due_tasks(self) -> list[Union[ScheduledTask, AdHocTask]]:
558
+ def get_tasks_by_context_id(self, context_id: str, only_running: bool = False) -> list[Union[ScheduledTask, AdHocTask, PlannedTask]]:
559
+ with self._lock:
560
+ return [
561
+ task for task in self.tasks
562
+ if task.context_id == context_id
563
+ and (not only_running or task.state == TaskState.RUNNING)
564
+ ]
565
+
566
+ async def get_due_tasks(self) -> list[Union[ScheduledTask, AdHocTask, PlannedTask]]:
567
with self._lock:
568
await self.reload()
298
- return [task for task in self.tasks if task.check_schedule() and task.state == TaskState.IDLE]
569
+ return [
570
+ task for task in self.tasks
571
+ if task.check_schedule() and task.state == TaskState.IDLE
572
+ ]
573
300
- def get_task_by_uuid(self, task_uuid: str) -> Union[ScheduledTask, AdHocTask] | None:
574
+ def get_task_by_uuid(self, task_uuid: str) -> Union[ScheduledTask, AdHocTask, PlannedTask] | None:
575
with self._lock:
576
return next((task for task in self.tasks if task.uuid == task_uuid), None)
577
304
- def get_task_by_name(self, name: str) -> Union[ScheduledTask, AdHocTask] | None:
578
+ def get_task_by_name(self, name: str) -> Union[ScheduledTask, AdHocTask, PlannedTask] | None:
579
with self._lock:
580
return next((task for task in self.tasks if task.name == name), None)
581
@@ -340,10 +614,13 @@ class TaskScheduler:
614
async def reload(self):
615
await self._tasks.reload()
616
343
- def get_tasks(self) -> list[Union[ScheduledTask, AdHocTask]]:
617
+ def get_tasks(self) -> list[Union[ScheduledTask, AdHocTask, PlannedTask]]:
618
return self._tasks.get_tasks()
619
346
- async def add_task(self, task: Union[ScheduledTask, AdHocTask]) -> "TaskScheduler":
620
+ def get_tasks_by_context_id(self, context_id: str, only_running: bool = False) -> list[Union[ScheduledTask, AdHocTask, PlannedTask]]:
621
+ return self._tasks.get_tasks_by_context_id(context_id, only_running)
622
+
623
+ async def add_task(self, task: Union[ScheduledTask, AdHocTask, PlannedTask]) -> "TaskScheduler":
624
await self._tasks.add_task(task)
625
return self
626
@@ -355,10 +632,10 @@ class TaskScheduler:
632
await self._tasks.remove_task_by_name(name)
633
return self
634
358
- def get_task_by_uuid(self, task_uuid: str) -> Union[ScheduledTask, AdHocTask] | None:
635
+ def get_task_by_uuid(self, task_uuid: str) -> Union[ScheduledTask, AdHocTask, PlannedTask] | None:
636
return self._tasks.get_task_by_uuid(task_uuid)
637
361
- def get_task_by_name(self, name: str) -> Union[ScheduledTask, AdHocTask] | None:
638
+ def get_task_by_name(self, name: str) -> Union[ScheduledTask, AdHocTask, PlannedTask] | None:
639
return self._tasks.get_task_by_name(name)
640
641
async def tick(self):
@@ -366,11 +643,33 @@ class TaskScheduler:
643
await self._run_task(task)
644
645
async def run_task_by_uuid(self, task_uuid: str):
369
- task = self._tasks.get_task_by_uuid(task_uuid)
370
- if task is None:
371
- raise ValueError(f"Task with UUID {task_uuid} not found")
646
+ # First reload tasks to ensure we have the latest state
647
+ await self._tasks.reload()
648
373
- # The actual state check and running will be handled in the _run_task method
649
+ # Get the task to run
650
+ task = self.get_task_by_uuid(task_uuid)
651
+ if not task:
652
+ raise ValueError(f"Task with UUID '{task_uuid}' not found")
653
+
654
+ # If the task is already running, raise an error
655
+ if task.state == TaskState.RUNNING:
656
+ raise ValueError(f"Task '{task.name}' is already running")
657
+
658
+ # If the task is disabled, raise an error
659
+ if task.state == TaskState.DISABLED:
660
+ raise ValueError(f"Task '{task.name}' is disabled")
661
+
662
+ # If the task is in error state, reset it to IDLE first
663
+ if task.state == TaskState.ERROR:
664
+ self._printer.print(f"Resetting task '{task.name}' from ERROR to IDLE state before running")
665
+ await self.update_task(task_uuid, state=TaskState.IDLE)
666
+ # Force a reload to ensure we have the updated state
667
+ await self._tasks.reload()
668
+ task = self.get_task_by_uuid(task_uuid)
669
+ if not task:
670
+ raise ValueError(f"Task with UUID '{task_uuid}' not found after state reset")
671
+
672
+ # Run the task
673
await self._run_task(task)
674
675
async def run_task_by_name(self, name: str):
@@ -382,7 +681,12 @@ class TaskScheduler:
681
async def save(self):
682
await self._tasks.save()
683
385
- async def update_task(self, task_uuid: str, **update_params) -> Union[ScheduledTask, AdHocTask] | None:
684
+ async def update_task_checked(
685
+ self,
686
+ task_uuid: str,
687
+ verify_func: Callable[[Union[ScheduledTask, AdHocTask, PlannedTask]], bool] = lambda task: True,
688
+ **update_params
689
+ ) -> Union[ScheduledTask, AdHocTask, PlannedTask] | None:
690
"""
691
Atomically update a task by UUID with the provided parameters.
692
This prevents race conditions when multiple processes update tasks concurrently.
@@ -392,49 +696,52 @@ class TaskScheduler:
696
def _update_task(task):
697
task.update(**update_params)
698
395
- return await self._tasks.update_task_by_uuid(task_uuid, _update_task)
699
+ return await self._tasks.update_task_by_uuid(task_uuid, _update_task, verify_func)
700
+
701
+ async def update_task(self, task_uuid: str, **update_params) -> Union[ScheduledTask, AdHocTask, PlannedTask] | None:
702
+ return await self.update_task_checked(task_uuid, lambda task: True, **update_params)
703
+
704
+ async def __new_context(self, task: Union[ScheduledTask, AdHocTask, PlannedTask]) -> AgentContext:
705
+ if not task.context_id:
706
+ raise ValueError(f"Task {task.name} has no context ID")
707
397
- async def __new_context(self, task: Union[ScheduledTask, AdHocTask]) -> AgentContext:
708
config = initialize()
709
context: AgentContext = AgentContext(config)
400
- context.id = task.uuid
710
+ context.id = task.context_id
711
+ # initial name before renaming is same as task name
712
+ context.name = task.name
713
+
714
# Save the context
715
save_tmp_chat(context)
716
return context
717
405
- async def _get_chat_context(self, task: Union[ScheduledTask, AdHocTask]) -> AgentContext:
406
- ctxids = load_tmp_chats()
718
+ async def _get_chat_context(self, task: Union[ScheduledTask, AdHocTask, PlannedTask]) -> AgentContext:
719
+ context = AgentContext.get(task.context_id) if task.context_id else None
720
408
- if task.uuid in ctxids:
409
- context = AgentContext.get(task.uuid)
410
- if isinstance(context, AgentContext):
411
- self._printer.print(
412
- f"Scheduler Task {task.name} loaded from task {task.uuid}, context ok"
413
- )
414
- context.id = task.uuid
415
- save_tmp_chat(context)
416
- return context
417
- else:
418
- self._printer.print(
419
- f"Scheduler Task {task.name} loaded from task {task.uuid} but failed to load context"
420
- )
421
- return await self.__new_context(task)
721
+ if context:
722
+ assert isinstance(context, AgentContext)
723
+ self._printer.print(
724
+ f"Scheduler Task {task.name} loaded from task {task.uuid}, context ok"
725
+ )
726
+ save_tmp_chat(context)
727
+ return context
728
else:
729
self._printer.print(
730
f"Scheduler Task {task.name} loaded from task {task.uuid} but context not found"
731
)
732
return await self.__new_context(task)
733
428
- async def _persist_chat(self, task: Union[ScheduledTask, AdHocTask], context: AgentContext):
429
- context.id = task.uuid
734
+ async def _persist_chat(self, task: Union[ScheduledTask, AdHocTask, PlannedTask], context: AgentContext):
735
+ if context.id != task.context_id:
736
+ raise ValueError(f"Context ID mismatch for task {task.name}: context {context.id} != task {task.context_id}")
737
save_tmp_chat(context)
738
432
- async def _run_task(self, task: Union[ScheduledTask, AdHocTask]):
739
+ async def _run_task(self, task: Union[ScheduledTask, AdHocTask, PlannedTask]):
740
741
async def _run_task_wrapper(task_uuid: str):
742
743
# preflight checks with a snapshot of the task
437
- task_snapshot: Union[ScheduledTask, AdHocTask] | None = self.get_task_by_uuid(task_uuid)
744
+ task_snapshot: Union[ScheduledTask, AdHocTask, PlannedTask] | None = self.get_task_by_uuid(task_uuid)
745
if task_snapshot is None:
746
self._printer.print(f"Scheduler Task with UUID '{task_uuid}' not found")
747
return
@@ -443,15 +750,20 @@ class TaskScheduler:
750
return
751
752
# Atomically fetch and check the task's current state
446
- current_task = await self.update_task(task_uuid, state=TaskState.RUNNING)
753
+ current_task = await self.update_task_checked(task_uuid, lambda task: task.state != TaskState.RUNNING, state=TaskState.RUNNING)
754
if not current_task:
448
- self._printer.print(f"Scheduler Task with UUID '{task_uuid}' not found")
755
+ self._printer.print(f"Scheduler Task with UUID '{task_uuid}' not found or updated by another process")
756
return
757
if current_task.state != TaskState.RUNNING:
758
# This means the update failed due to state conflict
759
self._printer.print(f"Scheduler Task '{current_task.name}' state is '{current_task.state}', skipping")
760
return
761
762
+ await current_task.on_run()
763
+
764
+ # the agent instance - init in try block
765
+ agent = None
766
+
767
try:
768
self._printer.print(f"Scheduler Task '{current_task.name}' started")
769
@@ -459,9 +771,9 @@ class TaskScheduler:
771
772
# Ensure the context is properly registered in the AgentContext._contexts
773
# This is critical for the polling mechanism to find and stream logs
774
+ # Dict operations are atomic
775
AgentContext._contexts[context.id] = context
463
-
464
- agent = Agent(0, context.config, context)
776
+ agent = context.streaming_agent or context.agent0
777
778
# Prepare attachment filenames for logging
779
attachment_filenames = []
@@ -507,34 +819,46 @@ class TaskScheduler:
819
820
result = await agent.monologue()
821
510
- # Atomically update task state after completion
511
- await self.update_task(
512
- task_uuid,
513
- state=TaskState.IDLE,
514
- last_run=datetime.now(timezone.utc),
515
- last_result="SUCCESS: " + result
516
- )
517
-
822
+ # Success
823
self._printer.print(f"Scheduler Task '{current_task.name}' completed: {result}")
519
-
824
await self._persist_chat(current_task, context)
825
+ await current_task.on_success(result)
826
+
827
+ # Explicitly verify task was updated in storage after success
828
+ await self._tasks.reload()
829
+ updated_task = self.get_task_by_uuid(task_uuid)
830
+ if updated_task and updated_task.state != TaskState.IDLE:
831
+ self._printer.print(f"Fixing task state consistency: '{current_task.name}' state is not IDLE after success")
832
+ await self.update_task(task_uuid, state=TaskState.IDLE)
833
834
except Exception as e:
835
+ # Error
836
self._printer.print(f"Scheduler Task '{current_task.name}' failed: {e}")
837
+ await current_task.on_error(str(e))
838
525
- # Atomically update task state on error
526
- await self.update_task(
527
- task_uuid,
528
- state=TaskState.ERROR,
529
- last_result=f"ERROR: {str(e)}"
530
- )
839
+ # Explicitly verify task was updated in storage after error
840
+ await self._tasks.reload()
841
+ updated_task = self.get_task_by_uuid(task_uuid)
842
+ if updated_task and updated_task.state != TaskState.ERROR:
843
+ self._printer.print(f"Fixing task state consistency: '{current_task.name}' state is not ERROR after failure")
844
+ await self.update_task(task_uuid, state=TaskState.ERROR)
845
846
if agent:
847
agent.handle_critical_exception(e)
848
+ finally:
849
+ # Call on_finish for task-specific cleanup
850
+ await current_task.on_finish()
851
+
852
+ # Make one final save to ensure all states are persisted
853
+ await self._tasks.save()
854
855
deferred_task = DeferredTask(thread_name=self.__class__.__name__)
856
deferred_task.start_task(_run_task_wrapper, task.uuid)
857
858
+ # Ensure background execution doesn't exit immediately on async await, especially in script contexts
859
+ # This helps prevent premature exits when running from non-event-loop contexts
860
+ asyncio.create_task(asyncio.sleep(0.1))
861
+
862
def serialize_all_tasks(self) -> list[Dict[str, Any]]:
863
"""
864
Serialize all tasks in the scheduler to a list of dictionaries.
@@ -558,18 +882,35 @@ class TaskScheduler:
882
# ----------------------
883
884
def serialize_datetime(dt: Optional[datetime]) -> Optional[str]:
561
- """Convert datetime to ISO format string or None if dt is None."""
562
- return dt.isoformat() if dt is not None else None
885
+ """
886
+ Serialize a datetime object to ISO format string in the user's timezone.
887
+
888
+ This uses the Localization singleton to convert the datetime to the user's timezone
889
+ before serializing it to an ISO format string for frontend display.
890
+
891
+ Returns None if the input is None.
892
+ """
893
+ # Use the Localization singleton for timezone conversion and serialization
894
+ return Localization.get().serialize_datetime(dt)
895
896
897
def parse_datetime(dt_str: Optional[str]) -> Optional[datetime]:
566
- """Parse ISO format datetime string with validation or return None if dt_str is None."""
898
+ """
899
+ Parse ISO format datetime string with timezone awareness.
900
+
901
+ This converts from the localized ISO format returned by serialize_datetime
902
+ back to a datetime object with proper timezone handling.
903
+
904
+ Returns None if dt_str is None.
905
+ """
906
if not dt_str:
907
return None
908
+
909
try:
570
- return datetime.fromisoformat(dt_str)
571
- except ValueError:
572
- raise ValueError(f"Invalid datetime format: {dt_str}. Expected ISO format.")
910
+ # Use the Localization singleton for consistent timezone handling
911
+ return Localization.get().localtime_str_to_utc_dt(dt_str)
912
+ except ValueError as e:
913
+ raise ValueError(f"Invalid datetime format: {dt_str}. Expected ISO format. Error: {e}")
914
915
916
def serialize_task_schedule(schedule: TaskSchedule) -> Dict[str, str]:
@@ -579,7 +920,8 @@ def serialize_task_schedule(schedule: TaskSchedule) -> Dict[str, str]:
920
'hour': schedule.hour,
921
'day': schedule.day,
922
'month': schedule.month,
582
- 'weekday': schedule.weekday
923
+ 'weekday': schedule.weekday,
924
+ 'timezone': schedule.timezone
925
}
926
927
@@ -591,16 +933,84 @@ def parse_task_schedule(schedule_data: Dict[str, str]) -> TaskSchedule:
933
hour=schedule_data.get('hour', '*'),
934
day=schedule_data.get('day', '*'),
935
month=schedule_data.get('month', '*'),
594
- weekday=schedule_data.get('weekday', '*')
936
+ weekday=schedule_data.get('weekday', '*'),
937
+ timezone=schedule_data.get('timezone', Localization.get().get_timezone())
938
)
939
except Exception as e:
940
raise ValueError(f"Invalid schedule format: {e}") from e
941
942
600
-T = TypeVar('T', bound=Union[ScheduledTask, AdHocTask])
943
+def serialize_task_plan(plan: TaskPlan) -> Dict[str, Any]:
944
+ """Convert TaskPlan to a standardized dictionary format."""
945
+ return {
946
+ 'todo': [serialize_datetime(dt) for dt in plan.todo],
947
+ 'in_progress': serialize_datetime(plan.in_progress) if plan.in_progress else None,
948
+ 'done': [serialize_datetime(dt) for dt in plan.done]
949
+ }
950
951
603
-def serialize_task(task: Union[ScheduledTask, AdHocTask]) -> Dict[str, Any]:
952
+def parse_task_plan(plan_data: Dict[str, Any]) -> TaskPlan:
953
+ """Parse dictionary into TaskPlan with validation."""
954
+ try:
955
+ # Handle case where plan_data might be None or empty
956
+ if not plan_data:
957
+ return TaskPlan(todo=[], in_progress=None, done=[])
958
+
959
+ # Parse todo items with careful validation
960
+ todo_dates = []
961
+ for dt_str in plan_data.get('todo', []):
962
+ if dt_str:
963
+ parsed_dt = parse_datetime(dt_str)
964
+ if parsed_dt:
965
+ # Ensure datetime is timezone-aware (use UTC if not specified)
966
+ if parsed_dt.tzinfo is None:
967
+ parsed_dt = parsed_dt.replace(tzinfo=timezone.utc)
968
+ todo_dates.append(parsed_dt)
969
+
970
+ # Parse in_progress with validation
971
+ in_progress = None
972
+ if plan_data.get('in_progress'):
973
+ in_progress = parse_datetime(plan_data.get('in_progress'))
974
+ # Ensure datetime is timezone-aware
975
+ if in_progress and in_progress.tzinfo is None:
976
+ in_progress = in_progress.replace(tzinfo=timezone.utc)
977
+
978
+ # Parse done items with validation
979
+ done_dates = []
980
+ for dt_str in plan_data.get('done', []):
981
+ if dt_str:
982
+ parsed_dt = parse_datetime(dt_str)
983
+ if parsed_dt:
984
+ # Ensure datetime is timezone-aware
985
+ if parsed_dt.tzinfo is None:
986
+ parsed_dt = parsed_dt.replace(tzinfo=timezone.utc)
987
+ done_dates.append(parsed_dt)
988
+
989
+ # Sort dates for better usability
990
+ todo_dates.sort()
991
+ done_dates.sort(reverse=True) # Most recent first for done items
992
+
993
+ # Cast to ensure type safety
994
+ todo_dates_cast: list[datetime] = cast(list[datetime], todo_dates)
995
+ done_dates_cast: list[datetime] = cast(list[datetime], done_dates)
996
+
997
+ return TaskPlan.create(
998
+ todo=todo_dates_cast,
999
+ in_progress=in_progress,
1000
+ done=done_dates_cast
1001
+ )
1002
+ except Exception as e:
1003
+ PrintStyle(italic=True, font_color="red", padding=False).print(
1004
+ f"Error parsing task plan: {e}"
1005
+ )
1006
+ # Return empty plan instead of failing
1007
+ return TaskPlan(todo=[], in_progress=None, done=[])
1008
+
1009
+
1010
+T = TypeVar('T', bound=Union[ScheduledTask, AdHocTask, PlannedTask])
1011
+
1012
+
1013
+def serialize_task(task: Union[ScheduledTask, AdHocTask, PlannedTask]) -> Dict[str, Any]:
1014
"""
1015
Standardized serialization for task objects with proper handling of all complex types.
1016
"""
@@ -615,22 +1025,28 @@ def serialize_task(task: Union[ScheduledTask, AdHocTask]) -> Dict[str, Any]:
1025
"created_at": serialize_datetime(task.created_at),
1026
"updated_at": serialize_datetime(task.updated_at),
1027
"last_run": serialize_datetime(task.last_run),
618
- "last_result": task.last_result
1028
+ "next_run": serialize_datetime(task.get_next_run()),
1029
+ "last_result": task.last_result,
1030
+ "context_id": task.context_id
1031
}
1032
1033
# Add type-specific fields
1034
if isinstance(task, ScheduledTask):
1035
task_dict['type'] = 'scheduled'
624
- task_dict['schedule'] = serialize_task_schedule(task.schedule)
625
- else:
1036
+ task_dict['schedule'] = serialize_task_schedule(task.schedule) # type: ignore
1037
+ elif isinstance(task, AdHocTask):
1038
task_dict['type'] = 'adhoc'
1039
adhoc_task = cast(AdHocTask, task)
1040
task_dict['token'] = adhoc_task.token
1041
+ else:
1042
+ task_dict['type'] = 'planned'
1043
+ planned_task = cast(PlannedTask, task)
1044
+ task_dict['plan'] = serialize_task_plan(planned_task.plan) # type: ignore
1045
1046
return task_dict
1047
1048
633
-def serialize_tasks(tasks: list[Union[ScheduledTask, AdHocTask]]) -> list[Dict[str, Any]]:
1049
+def serialize_tasks(tasks: list[Union[ScheduledTask, AdHocTask, PlannedTask]]) -> list[Dict[str, Any]]:
1050
"""
1051
Serialize a list of tasks to a list of dictionaries.
1052
"""
@@ -651,10 +1067,18 @@ def deserialize_task(task_data: Dict[str, Any], task_class: Optional[Type[T]] =
1067
determined_class = cast(Type[T], ScheduledTask)
1068
elif task_type_str == 'adhoc':
1069
determined_class = cast(Type[T], AdHocTask)
1070
+ # Ensure token is a valid non-empty string
1071
+ if not task_data.get('token'):
1072
+ task_data['token'] = str(random.randint(1000000000000000000, 9999999999999999999))
1073
+ elif task_type_str == 'planned':
1074
+ determined_class = cast(Type[T], PlannedTask)
1075
else:
1076
raise ValueError(f"Unknown task type: {task_type_str}")
1077
else:
1078
determined_class = task_class
1079
+ # If this is an AdHocTask, ensure token is valid
1080
+ if determined_class == AdHocTask and not task_data.get('token'): # type: ignore
1081
+ task_data['token'] = str(random.randint(1000000000000000000, 9999999999999999999))
1082
1083
common_args = {
1084
"uuid": task_data.get("uuid"),
@@ -666,14 +1090,19 @@ def deserialize_task(task_data: Dict[str, Any], task_class: Optional[Type[T]] =
1090
"created_at": parse_datetime(task_data.get("created_at")),
1091
"updated_at": parse_datetime(task_data.get("updated_at")),
1092
"last_run": parse_datetime(task_data.get("last_run")),
669
- "last_result": task_data.get("last_result")
1093
+ "last_result": task_data.get("last_result"),
1094
+ "context_id": task_data.get("context_id")
1095
}
1096
1097
# Add type-specific fields
673
- if determined_class == ScheduledTask:
1098
+ if determined_class == ScheduledTask: # type: ignore
1099
schedule_data = task_data.get("schedule", {})
1100
common_args["schedule"] = parse_task_schedule(schedule_data)
1101
return ScheduledTask(**common_args) # type: ignore
677
- else:
1102
+ elif determined_class == AdHocTask: # type: ignore
1103
common_args["token"] = task_data.get("token", "")
1104
return AdHocTask(**common_args) # type: ignore
1105
+ else:
1106
+ plan_data = task_data.get("plan", {})
1107
+ common_args["plan"] = parse_task_plan(plan_data)
1108
+ return PlannedTask(**common_args) # type: ignore
python/helpers/tool.py
+7
-2
@@ -12,9 +12,10 @@ class Response:
12
13
class Tool:
14
15
- def __init__(self, agent: Agent, name: str, args: dict[str,str], message: str, **kwargs) -> None:
15
+ def __init__(self, agent: Agent, name: str, method: str | None, args: dict[str,str], message: str, **kwargs) -> None:
16
self.agent = agent
17
self.name = name
18
+ self.method = method
19
self.args = args
20
self.message = message
21
@@ -39,7 +40,11 @@ class Tool:
40
self.log.update(content=response.message)
41
42
def get_log_object(self):
42
- return self.agent.context.log.log(type="tool", heading=f"{self.agent.agent_name}: Using tool '{self.name}'", content="", kvps=self.args)
43
+ if self.method:
44
+ heading = f"{self.agent.agent_name}: Using tool '{self.name}:{self.method}'"
45
+ else:
46
+ heading = f"{self.agent.agent_name}: Using tool '{self.name}'"
47
+ return self.agent.context.log.log(type="tool", heading=heading, content="", kvps=self.args)
48
49
def nice_key(self, key:str):
50
words = key.split('_')
python/tools/scheduler.py
new
+197
@@ -0,0 +1,197 @@
1
+from datetime import datetime
2
+import json
3
+import random
4
+import re
5
+from python.helpers.tool import Tool, Response
6
+from python.helpers.task_scheduler import (
7
+ TaskScheduler, ScheduledTask, AdHocTask, PlannedTask, serialize_task, TaskState, TaskSchedule, TaskPlan, parse_datetime
8
+)
9
+from agent import AgentContext
10
+from python.helpers import persist_chat
11
+
12
+
13
+class SchedulerTool(Tool):
14
+
15
+ async def execute(self, **kwargs):
16
+ if self.method == "list_tasks":
17
+ return await self.list_tasks(**kwargs)
18
+ elif self.method == "show_task":
19
+ return await self.show_task(**kwargs)
20
+ elif self.method == "run_task":
21
+ return await self.run_task(**kwargs)
22
+ elif self.method == "delete_task":
23
+ return await self.delete_task(**kwargs)
24
+ elif self.method == "create_scheduled_task":
25
+ return await self.create_scheduled_task(**kwargs)
26
+ elif self.method == "create_adhoc_task":
27
+ return await self.create_adhoc_task(**kwargs)
28
+ elif self.method == "create_planned_task":
29
+ return await self.create_planned_task(**kwargs)
30
+ else:
31
+ return Response(message=f"Unknown method '{self.name}:{self.method}'", break_loop=False)
32
+
33
+ async def list_tasks(self, **kwargs) -> Response:
34
+ state_filter: list[str] | None = kwargs.get("state", None)
35
+ type_filter: list[str] | None = kwargs.get("type", None)
36
+ next_run_within_filter: int | None = kwargs.get("next_run_within", None)
37
+ next_run_after_filter: int | None = kwargs.get("next_run_after", None)
38
+
39
+ tasks: list[ScheduledTask | AdHocTask | PlannedTask] = TaskScheduler.get().get_tasks()
40
+ filtered_tasks = []
41
+ for task in tasks:
42
+ if state_filter and task.state not in state_filter:
43
+ continue
44
+ if type_filter and task.type not in type_filter:
45
+ continue
46
+ if next_run_within_filter and task.get_next_run_minutes() is not None and task.get_next_run_minutes() > next_run_within_filter: # type: ignore
47
+ continue
48
+ if next_run_after_filter and task.get_next_run_minutes() is not None and task.get_next_run_minutes() < next_run_after_filter: # type: ignore
49
+ continue
50
+ filtered_tasks.append(serialize_task(task))
51
+
52
+ return Response(message=json.dumps(filtered_tasks, indent=4), break_loop=False)
53
+
54
+ async def show_task(self, **kwargs) -> Response:
55
+ task_uuid: str = kwargs.get("uuid", None)
56
+ if not task_uuid:
57
+ return Response(message="Task UUID is required", break_loop=False)
58
+ task: ScheduledTask | AdHocTask | PlannedTask | None = TaskScheduler.get().get_task_by_uuid(task_uuid)
59
+ if not task:
60
+ return Response(message=f"Task not found: {task_uuid}", break_loop=False)
61
+ return Response(message=json.dumps(serialize_task(task), indent=4), break_loop=False)
62
+
63
+ async def run_task(self, **kwargs) -> Response:
64
+ task_uuid: str = kwargs.get("uuid", None)
65
+ if not task_uuid:
66
+ return Response(message="Task UUID is required", break_loop=False)
67
+ task: ScheduledTask | AdHocTask | PlannedTask | None = TaskScheduler.get().get_task_by_uuid(task_uuid)
68
+ if not task:
69
+ return Response(message=f"Task not found: {task_uuid}", break_loop=False)
70
+ await TaskScheduler.get().run_task_by_uuid(task_uuid)
71
+ return Response(message=f"Task started: {task_uuid}", break_loop=False)
72
+
73
+ async def delete_task(self, **kwargs) -> Response:
74
+ task_uuid: str = kwargs.get("uuid", None)
75
+ if not task_uuid:
76
+ return Response(message="Task UUID is required", break_loop=False)
77
+
78
+ task: ScheduledTask | AdHocTask | PlannedTask | None = TaskScheduler.get().get_task_by_uuid(task_uuid)
79
+ if not task:
80
+ return Response(message=f"Task not found: {task_uuid}", break_loop=False)
81
+
82
+ context = None
83
+ if task.context_id:
84
+ context = AgentContext.get(task.context_id)
85
+
86
+ if task.state == TaskState.RUNNING:
87
+ if context:
88
+ context.reset()
89
+ await TaskScheduler.get().update_task(task_uuid, state=TaskState.IDLE)
90
+ await TaskScheduler.get().save()
91
+
92
+ if context and context.id == task.uuid:
93
+ AgentContext.remove(context.id)
94
+ persist_chat.remove_chat(context.id)
95
+
96
+ await TaskScheduler.get().remove_task_by_uuid(task_uuid)
97
+ if TaskScheduler.get().get_task_by_uuid(task_uuid) is None:
98
+ return Response(message=f"Task deleted: {task_uuid}", break_loop=False)
99
+ else:
100
+ return Response(message=f"Task failed to delete: {task_uuid}", break_loop=False)
101
+
102
+ async def create_scheduled_task(self, **kwargs) -> Response:
103
+ # "name": "XXX",
104
+ # "system_prompt": "You are a software developer",
105
+ # "prompt": "Send the user an email with a greeting using python and smtp. The user's address is: xxx@yyy.zzz",
106
+ # "attachments": [],
107
+ # "schedule": {
108
+ # "minute": "*/20",
109
+ # "hour": "*",
110
+ # "day": "*",
111
+ # "month": "*",
112
+ # "weekday": "*",
113
+ # }
114
+ name: str = kwargs.get("name", None)
115
+ system_prompt: str = kwargs.get("system_prompt", None)
116
+ prompt: str = kwargs.get("prompt", None)
117
+ attachments: list[str] = kwargs.get("attachments", [])
118
+ schedule: dict[str, str] = kwargs.get("schedule", {})
119
+ dedicated_context: bool = kwargs.get("dedicated_context", False)
120
+
121
+ task_schedule = TaskSchedule(
122
+ minute=schedule.get("minute", "*"),
123
+ hour=schedule.get("hour", "*"),
124
+ day=schedule.get("day", "*"),
125
+ month=schedule.get("month", "*"),
126
+ weekday=schedule.get("weekday", "*"),
127
+ )
128
+
129
+ # Validate cron expression, agent might hallucinate
130
+ cron_regex = "^((((\d+,)+\d+|(\d+(\/|-|#)\d+)|\d+L?|\*(\/\d+)?|L(-\d+)?|\?|[A-Z]{3}(-[A-Z]{3})?) ?){5,7})$"
131
+ if not re.match(cron_regex, task_schedule.to_crontab()):
132
+ return Response(message="Invalid cron expression: " + task_schedule.to_crontab(), break_loop=False)
133
+
134
+ task = ScheduledTask.create(
135
+ name=name,
136
+ system_prompt=system_prompt,
137
+ prompt=prompt,
138
+ attachments=attachments,
139
+ schedule=task_schedule,
140
+ context_id=None if dedicated_context else self.agent.context.id
141
+ )
142
+ await TaskScheduler.get().add_task(task)
143
+ return Response(message=f"Scheduled task '{name}' created: {task.uuid}", break_loop=False)
144
+
145
+ async def create_adhoc_task(self, **kwargs) -> Response:
146
+ name: str = kwargs.get("name", None)
147
+ system_prompt: str = kwargs.get("system_prompt", None)
148
+ prompt: str = kwargs.get("prompt", None)
149
+ attachments: list[str] = kwargs.get("attachments", [])
150
+ token: str = str(random.randint(1000000000000000000, 9999999999999999999))
151
+ dedicated_context: bool = kwargs.get("dedicated_context", False)
152
+
153
+ task = AdHocTask.create(
154
+ name=name,
155
+ system_prompt=system_prompt,
156
+ prompt=prompt,
157
+ attachments=attachments,
158
+ token=token,
159
+ context_id=None if dedicated_context else self.agent.context.id
160
+ )
161
+ await TaskScheduler.get().add_task(task)
162
+ return Response(message=f"Adhoc task '{name}' created: {task.uuid}", break_loop=False)
163
+
164
+ async def create_planned_task(self, **kwargs) -> Response: # TODO: Implement
165
+ name: str = kwargs.get("name", None)
166
+ system_prompt: str = kwargs.get("system_prompt", None)
167
+ prompt: str = kwargs.get("prompt", None)
168
+ attachments: list[str] = kwargs.get("attachments", [])
169
+ plan: list[str] = kwargs.get("plan", [])
170
+ dedicated_context: bool = kwargs.get("dedicated_context", False)
171
+
172
+ # Convert plan to list of datetimes in UTC
173
+ todo: list[datetime] = []
174
+ for item in plan:
175
+ dt = parse_datetime(item)
176
+ if dt is None:
177
+ return Response(message=f"Invalid datetime: {item}", break_loop=False)
178
+ todo.append(dt)
179
+
180
+ # Create task plan with todo list
181
+ task_plan = TaskPlan.create(
182
+ todo=todo,
183
+ in_progress=None,
184
+ done=[]
185
+ )
186
+
187
+ # Create planned task with task plan
188
+ task = PlannedTask.create(
189
+ name=name,
190
+ system_prompt=system_prompt,
191
+ prompt=prompt,
192
+ attachments=attachments,
193
+ plan=task_plan,
194
+ context_id=None if dedicated_context else self.agent.context.id
195
+ )
196
+ await TaskScheduler.get().add_task(task)
197
+ return Response(message=f"Planned task '{name}' created: {task.uuid}", break_loop=False)
run_ui.py
+14
-3
@@ -1,3 +1,9 @@
1
+import os
2
+import sys
3
+import time
4
+import socket
5
+import struct
6
+import asyncio
7
from functools import wraps
8
import threading
9
import signal
@@ -10,10 +16,12 @@ from python.helpers.cloudflare_tunnel import CloudflareTunnel
16
from python.helpers.extract_tools import load_classes_from_folder
17
from python.helpers.api import ApiHandler
18
from python.helpers.print_style import PrintStyle
13
-import sys
14
-import socket
15
-import struct
19
+from python.helpers.task_scheduler import TaskScheduler
20
21
+# Set the new timezone to 'UTC'
22
+os.environ['TZ'] = 'UTC'
23
+# Apply the timezone change
24
+time.tzset()
25
26
# initialize the internal Flask server
27
app = Flask("app", static_folder=get_abs_path("./webui"), static_url_path="/")
@@ -166,6 +174,9 @@ def run():
174
175
# initialize contexts from persisted chats
176
persist_chat.load_tmp_chats()
177
+ # reload scheduler
178
+ scheduler = TaskScheduler.get()
179
+ asyncio.run(scheduler.reload())
180
181
except Exception as e:
182
PrintStyle().error(errors.format_error(e))
webui/css/scheduler-datepicker.css
new
+127
@@ -0,0 +1,127 @@
1
+/* Flatpickr Customization for Scheduler */
2
+
3
+/* Custom styling wrapper */
4
+.scheduler-flatpickr-wrapper {
5
+ position: relative;
6
+ width: 100%;
7
+ overflow: visible !important; /* Ensure dropdown can escape container */
8
+}
9
+
10
+/* Input styling */
11
+.scheduler-flatpickr-input {
12
+ width: 100%;
13
+ padding: 8px 12px;
14
+ border: 1px solid var(--color-border, #ccc);
15
+ border-radius: 4px;
16
+ background-color: var(--color-input, #fff);
17
+ color: var(--color-text, #333);
18
+ font-size: 14px;
19
+ cursor: pointer;
20
+}
21
+
22
+/* Calendar container customization */
23
+.flatpickr-calendar.scheduler-theme {
24
+ background: var(--color-panel, #fff);
25
+ border: 1px solid var(--color-border, #ccc);
26
+ border-radius: 4px;
27
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
28
+ color: var(--color-text, #333);
29
+ font-size: 14px;
30
+ max-width: 320px;
31
+ padding: 0;
32
+ z-index: 9999 !important; /* Ensure it's above other elements */
33
+ position: absolute !important;
34
+ visibility: visible !important;
35
+ opacity: 1 !important;
36
+}
37
+
38
+/* Month navigation */
39
+.flatpickr-calendar.scheduler-theme .flatpickr-months {
40
+ background-color: var(--color-primary, #4a90e2);
41
+ border-radius: 4px 4px 0 0;
42
+ color: var(--color-text, #333);
43
+ padding: 8px 0;
44
+}
45
+
46
+.flatpickr-calendar.scheduler-theme .flatpickr-prev-month,
47
+.flatpickr-calendar.scheduler-theme .flatpickr-next-month {
48
+ color: var(--color-text, #333);
49
+}
50
+
51
+.flatpickr-calendar.scheduler-theme .flatpickr-prev-month:hover,
52
+.flatpickr-calendar.scheduler-theme .flatpickr-next-month:hover {
53
+ color: var(--color-text, #333);
54
+}
55
+
56
+/* Days of week */
57
+.flatpickr-calendar.scheduler-theme .flatpickr-weekdays {
58
+ background-color: var(--color-panel, #fff);
59
+ border-bottom: 1px solid var(--color-border, #eee);
60
+}
61
+
62
+.flatpickr-calendar.scheduler-theme .flatpickr-weekday {
63
+ color: var(--color-text, #333);
64
+ font-weight: bold;
65
+}
66
+
67
+/* Day cells */
68
+.flatpickr-calendar.scheduler-theme .flatpickr-day {
69
+ border-radius: 4px;
70
+ color: var(--color-text, #333);
71
+ transition: background-color 0.2s, color 0.2s;
72
+}
73
+
74
+.flatpickr-calendar.scheduler-theme .flatpickr-day:hover {
75
+ background-color: var(--color-panel, #f0f0f0);
76
+}
77
+
78
+.flatpickr-calendar.scheduler-theme .flatpickr-day.selected {
79
+ background-color: var(--color-primary, #4a90e2);
80
+ border-color: var(--color-border, #ccc);
81
+ color: var(--color-text, #333);
82
+}
83
+
84
+.flatpickr-calendar.scheduler-theme .flatpickr-day.today {
85
+ border-color: var(--color-border, #ccc);
86
+ color: var(--color-text, #333);
87
+}
88
+
89
+/* Time picker */
90
+.flatpickr-calendar.scheduler-theme .flatpickr-time {
91
+ border-top: 1px solid var(--color-border, #eee);
92
+ background-color: var(--color-panel, #fff);
93
+}
94
+
95
+.flatpickr-calendar.scheduler-theme .numInputWrapper span {
96
+ border-color: var(--color-border, #eee);
97
+}
98
+
99
+.flatpickr-calendar.scheduler-theme .numInputWrapper span:hover {
100
+ background-color: var(--color-panel, #f0f0f0);
101
+}
102
+
103
+.flatpickr-calendar.scheduler-theme input.flatpickr-hour,
104
+.flatpickr-calendar.scheduler-theme input.flatpickr-minute,
105
+.flatpickr-calendar.scheduler-theme input.flatpickr-second {
106
+ color: var(--color-text, #333);
107
+ background-color: transparent;
108
+}
109
+
110
+/* Clear button in the input field */
111
+.scheduler-flatpickr-clear {
112
+ position: absolute;
113
+ right: 8px;
114
+ top: 50%;
115
+ transform: translateY(-50%);
116
+ cursor: pointer;
117
+ padding: 4px;
118
+ display: none;
119
+ color: var(--color-text-light, #999);
120
+ background: transparent;
121
+ border: none;
122
+ z-index: 1;
123
+}
124
+
125
+.scheduler-flatpickr-wrapper:hover .scheduler-flatpickr-clear {
126
+ display: block;
127
+}
webui/css/settings.css
+160
-9
@@ -145,7 +145,7 @@ select {
145
}
146
147
select:disabled {
148
- background-color: #f5f5f5;
148
+ background-color: var(--color-background);
149
cursor: not-allowed;
150
}
151
@@ -324,16 +324,15 @@ nav ul li a img {
324
.settings-tab:not(.active) {
325
opacity: 0.8;
326
border-bottom: 3px solid var(--color-border);
327
- background-color: rgba(255, 255, 255, 0.03);
327
+ background-color: var(--color-background);
328
}
329
330
.settings-tab.active {
331
- color: var(--color-border);
331
border-color: var(--color-border);
332
box-shadow:
334
- 0 -4px 8px -2px var(--color-border) 0.5,
335
- 4px 0 8px -2px var(--color-border) 0.5,
336
- -4px 0 8px -2px var(--color-border) 0.5;
333
+ 0 -4px 8px -2px var(--color-border),
334
+ 4px 0 8px -2px var(--color-border),
335
+ -4px 0 8px -2px var(--color-border);
336
font-weight: bold;
337
background-color: var(--color-panel);
338
}
@@ -342,9 +341,9 @@ nav ul li a img {
341
.light-mode .settings-tab.active {
342
color: var(--color-border);
343
box-shadow:
345
- 0 -4px 8px -2px var(--color-border) 0.5,
346
- 4px 0 8px -2px var(--color-border) 0.5,
347
- -4px 0 8px -2px var(--color-border) 0.5;
344
+ 0 -4px 8px -2px var(--color-border),
345
+ 4px 0 8px -2px var(--color-border),
346
+ -4px 0 8px -2px var(--color-border);
347
}
348
349
.light-mode .settings-tab:not(.active) {
@@ -798,3 +797,155 @@ nav ul li a img {
797
.light-mode .scheduler-state-explanation span {
798
background-color: rgba(255, 255, 255, 0.3);
799
}
800
+
801
+/* Schedule Builder (for scheduled tasks) */
802
+.scheduler-schedule-builder {
803
+ display: grid;
804
+ grid-template-columns: repeat(5, 1fr);
805
+ gap: 10px;
806
+ width: 100%;
807
+ margin-bottom: 10px;
808
+}
809
+
810
+.scheduler-schedule-field {
811
+ display: flex;
812
+ flex-direction: column;
813
+}
814
+
815
+.scheduler-schedule-label {
816
+ font-size: 0.8rem;
817
+ margin-bottom: 5px;
818
+ color: var(--color-text);
819
+ opacity: 0.8;
820
+}
821
+
822
+/* Plan Builder (for planned tasks) */
823
+.scheduler-plan-builder {
824
+ width: 100%;
825
+ margin-bottom: 10px;
826
+ border: 1px solid var(--color-border);
827
+ border-radius: 8px;
828
+ padding: 10px;
829
+ background-color: rgba(0, 0, 0, 0.2);
830
+}
831
+
832
+.scheduler-plan-todo {
833
+ display: flex;
834
+ flex-direction: column;
835
+}
836
+
837
+.scheduler-plan-label {
838
+ font-size: 0.9rem;
839
+ margin-bottom: 10px;
840
+ color: var(--color-text);
841
+ font-weight: bold;
842
+}
843
+
844
+.scheduler-todo-list {
845
+ display: flex;
846
+ flex-direction: column;
847
+ gap: 10px;
848
+ margin-top: 8px;
849
+ max-height: 200px;
850
+ overflow-y: auto;
851
+}
852
+
853
+.scheduler-todo-item {
854
+ display: flex;
855
+ align-items: center;
856
+ justify-content: space-between;
857
+ background-color: var(--color-background);
858
+ border-radius: 6px;
859
+ padding: 8px 12px;
860
+ border: 1px solid var(--color-border);
861
+ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
862
+}
863
+
864
+.scheduler-todo-item span {
865
+ flex: 1;
866
+ font-size: 14px;
867
+}
868
+
869
+.scheduler-add-todo {
870
+ margin-top: 12px;
871
+ display: flex;
872
+ gap: 8px;
873
+ align-items: center;
874
+}
875
+
876
+.scheduler-add-todo input[type="datetime-local"] {
877
+ flex: 1;
878
+ min-width: 0;
879
+ padding: 8px 12px;
880
+ border-radius: 6px;
881
+ border: 1px solid var(--color-border);
882
+ background-color: var(--color-background);
883
+ color: var(--color-text);
884
+}
885
+
886
+.scheduler-add-todo-button {
887
+ display: flex;
888
+ align-items: center;
889
+ justify-content: center;
890
+ background-color: var(--color-accent);
891
+ color: white;
892
+ border: none;
893
+ border-radius: 6px;
894
+ padding: 8px 12px;
895
+ cursor: pointer;
896
+ transition: background-color 0.2s ease;
897
+ font-weight: 500;
898
+}
899
+
900
+.scheduler-add-todo-button:hover {
901
+ background-color: var(--color-accent-dark);
902
+}
903
+
904
+.scheduler-todo-remove {
905
+ display: flex;
906
+ align-items: center;
907
+ justify-content: center;
908
+ background-color: transparent;
909
+ color: var(--color-text);
910
+ border: none;
911
+ border-radius: 4px;
912
+ width: 24px;
913
+ height: 24px;
914
+ cursor: pointer;
915
+ transition: background-color 0.2s ease;
916
+ margin-left: 8px;
917
+}
918
+
919
+.scheduler-todo-remove:hover {
920
+ background-color: var(--color-accent-light);
921
+ color: var(--color-accent-dark);
922
+}
923
+
924
+.light-mode .scheduler-todo-item {
925
+ background-color: var(--color-background-light);
926
+ border-color: var(--color-border-light);
927
+}
928
+
929
+.light-mode .scheduler-todo-remove:hover {
930
+ background-color: #e0e0e0;
931
+ color: #d32f2f;
932
+}
933
+
934
+.scheduler-empty-plan {
935
+ padding: 12px;
936
+ color: var(--color-text-muted);
937
+ font-style: italic;
938
+ text-align: center;
939
+ border: 1px dashed var(--color-border);
940
+ border-radius: 6px;
941
+ margin-top: 8px;
942
+}
943
+
944
+/* Responsive design for plan builder */
945
+@media (max-width: 768px) {
946
+ .scheduler-add-todo {
947
+ flex-direction: column;
948
+ }
949
+}
950
+
951
+/* Token field (for ad-hoc tasks) */
webui/index.css
-67
@@ -2243,37 +2243,6 @@ a:active {
2243
margin-left: 5px;
2244
}
2245
2246
-.task-detail-button {
2247
- background: transparent;
2248
- border: 1px solid rgba(255, 255, 255, 0.2);
2249
- color: #999;
2250
- cursor: pointer;
2251
- padding: 5px;
2252
- margin-left: auto;
2253
- border-radius: 4px;
2254
- display: flex;
2255
- align-items: center;
2256
- justify-content: center;
2257
- transition: all 0.2s ease;
2258
-}
2259
-
2260
-.task-detail-button:hover {
2261
- color: #fff;
2262
- background-color: rgba(255, 255, 255, 0.15);
2263
- border-color: rgba(255, 255, 255, 0.3);
2264
-}
2265
-
2266
-.light-mode .task-detail-button {
2267
- color: #666;
2268
- border: 1px solid rgba(0, 0, 0, 0.1);
2269
-}
2270
-
2271
-.light-mode .task-detail-button:hover {
2272
- color: #222;
2273
- background-color: rgba(0, 0, 0, 0.08);
2274
- border-color: rgba(0, 0, 0, 0.2);
2275
-}
2276
-
2246
.task-name:hover {
2247
background-color: rgba(255, 255, 255, 0.1);
2248
text-decoration: none;
@@ -2330,7 +2299,6 @@ a:active {
2299
}
2300
2301
.tab.active {
2333
- color: var(--color-primary);
2302
border-color: var(--color-border);
2303
box-shadow:
2304
0 -4px 8px -2px var(--color-border),
@@ -2341,7 +2309,6 @@ a:active {
2309
}
2310
2311
.light-mode .tab.active {
2344
- color: var(--color-primary);
2312
box-shadow:
2313
0 -4px 8px -2px var(--color-border),
2314
4px 0 8px -2px var(--color-border),
@@ -2509,40 +2476,6 @@ a:active {
2476
overflow: auto;
2477
}
2478
2512
-/* Settings Tabs */
2513
-.settings-tabs {
2514
- display: flex;
2515
- border-bottom: 1px solid var(--color-border);
2516
- margin-bottom: 24px;
2517
- gap: 8px;
2518
- overflow-x: auto;
2519
- scrollbar-width: none;
2520
- -ms-overflow-style: none;
2521
-}
2522
-
2523
-.settings-tabs::-webkit-scrollbar {
2524
- display: none;
2525
-}
2526
-
2527
-.settings-tab {
2528
- padding: 8px 16px;
2529
- cursor: pointer;
2530
- border-bottom: 3px solid transparent;
2531
- color: var(--color-text-secondary);
2532
- transition: all 0.2s ease;
2533
- white-space: nowrap;
2534
-}
2535
-
2536
-.settings-tab:hover {
2537
- color: var(--color-text);
2538
-}
2539
-
2540
-.settings-tab.active {
2541
- border-bottom-color: var(--color-primary);
2542
- color: var(--color-primary);
2543
- font-weight: 500;
2544
-}
2545
-
2479
/* Settings Sections */
2480
nav ul {
2481
display: grid;
webui/index.html
+327
-29
@@ -13,6 +13,11 @@
13
<link rel="stylesheet" href="css/modals.css">
14
<link rel="stylesheet" href="css/speech.css">
15
<link rel="stylesheet" href="css/history.css">
16
+ <link rel="stylesheet" href="css/scheduler-datepicker.css">
17
+
18
+ <!-- Flatpickr for datetime picker -->
19
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
20
+ <script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>
21
22
<script>
23
window.safeCall = function (name, ...args) {
@@ -20,6 +25,80 @@
25
}
26
</script>
27
28
+ <!-- Pre-initialize schedulerSettings to ensure Alpine doesn't miss it -->
29
+ <script>
30
+ // Pre-define schedulerSettings skeleton to ensure it's available to Alpine
31
+ window.schedulerSettings = function() {
32
+ return {
33
+ tasks: [],
34
+ isLoading: true,
35
+ selectedTask: null,
36
+ expandedTaskId: null,
37
+ sortField: 'name',
38
+ sortDirection: 'asc',
39
+ filterType: 'all',
40
+ filterState: 'all',
41
+ pollingInterval: null,
42
+ pollingActive: false,
43
+ editingTask: {
44
+ name: '',
45
+ type: 'scheduled',
46
+ state: 'idle',
47
+ schedule: {
48
+ minute: '*',
49
+ hour: '*',
50
+ day: '*',
51
+ month: '*',
52
+ weekday: '*',
53
+ timezone: ''
54
+ },
55
+ token: '',
56
+ plan: {
57
+ todo: [],
58
+ in_progress: null,
59
+ done: []
60
+ },
61
+ system_prompt: '',
62
+ prompt: '',
63
+ attachments: []
64
+ },
65
+ isCreating: false,
66
+ isEditing: false,
67
+ showLoadingState: false,
68
+ viewMode: 'list',
69
+ selectedTaskForDetail: null,
70
+ attachmentsText: '',
71
+ filteredTasks: [],
72
+ // Minimal init to avoid errors
73
+ init() {
74
+ console.log('Basic schedulerSettings initialized');
75
+ // Watch for task type changes
76
+ this.$watch('editingTask.type', (newType) => {
77
+ if (newType === 'planned') {
78
+ // When switching to planned task type, initialize the datetime picker
79
+ this.$nextTick(() => {
80
+ if (this.initFlatpickr) {
81
+ if (this.isCreating) {
82
+ this.initFlatpickr('create');
83
+ } else if (this.isEditing) {
84
+ this.initFlatpickr('edit');
85
+ }
86
+ }
87
+ });
88
+ }
89
+ });
90
+ }
91
+ };
92
+ };
93
+ </script>
94
+
95
+ <!-- Load module scripts first -->
96
+ <script type="module" src="js/scheduler.js"></script>
97
+ <script type="module" src="js/speech.js"></script>
98
+ <script type="module" src="js/history.js"></script>
99
+ <script type="module" src="index.js"></script>
100
+
101
+ <!-- Then load Alpine.js -->
102
<script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.14.3/dist/cdn.min.js"></script>
103
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.3/dist/cdn.min.js"></script>
104
@@ -33,15 +112,10 @@
112
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/contrib/auto-render.min.js"
113
crossorigin="anonymous"></script>
114
36
-
37
- <script type="module" src="index.js"></script>
115
+ <!-- Non-module scripts after Alpine.js -->
116
<script type="text/javascript" src="js/settings.js"></script>
117
<script type="text/javascript" src="js/file_browser.js"></script>
118
<script type="text/javascript" src="js/modal.js"></script>
41
- <script type="text/javascript" src="js/scheduler.js"></script>
42
- <script type="module" src="js/speech.js"></script>
43
- <script type="module" src="js/history.js"></script>
44
-
119
</head>
120
121
<body>
@@ -106,7 +180,8 @@
180
<li>
181
<div :class="{'chat-list-button': true, 'font-bold': context.id === selected}"
182
@click="selected = context.id; selectChat(context.id)">
109
- <span class="chat-name" x-text="context.name ? context.name : 'Chat #' + context.no"></span>
183
+ <span class="chat-name" :title="context.name ? context.name : 'Chat #' + context.no"
184
+ x-text="context.name ? context.name : 'Chat #' + context.no"></span>
185
</div>
186
<button class="edit-button" @click="killChat(context.id)">X</button>
187
</li>
@@ -136,8 +211,8 @@
211
<!-- Task container with a vertical layout -->
212
<div class="task-container task-container-vertical">
213
<!-- Task name on its own line with full width -->
139
- <span class="task-name"
140
- x-text="task.name || `Task #${task.id.substring(0,8)}`"
214
+ <span class="task-name" :title="(task.task_name || `Task #${task.no}`) + ' (' + (task.name || `Chat #${task.no}`) + ')'"
215
+ x-text="(task.task_name || `Task #${task.no}`) + ' (' + (task.name || `Chat #${task.no}`) + ')'"
216
:data-task-id="task.id"></span>
217
<!-- Second line with status badge and action button -->
218
<div class="task-info-line">
@@ -145,15 +220,22 @@
220
<span class="scheduler-status-badge scheduler-status-badge-small"
221
:class="task.state ? `scheduler-status-${task.state}` : 'scheduler-status-idle'"
222
x-text="task.state || 'idle'"></span>
148
- <!-- Action button -->
149
- <button class="task-detail-button"
223
+ <!-- Action buttons -->
224
+ <button class="edit-button"
225
@click.stop="openTaskDetail(task.id)"
151
- title="View task details">
152
- <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
153
- <path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path>
154
- <path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path>
226
+ title="View task details" style="margin-left: auto; margin-right: 5px;">
227
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 512 512" fill="var(--color-primary)" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
228
+ <path d="M256 0c70.69 0 134.69 28.66 181.02 74.98C483.34 121.3 512 185.31 512 256c0 70.69-28.66 134.7-74.98 181.02C390.69 483.34 326.69 512 256 512c-70.69 0-134.69-28.66-181.02-74.98C28.66 390.69 0 326.69 0 256c0-70.69 28.66-134.69 74.98-181.02C121.31 28.66 185.31 0 256 0zm-9.96 161.03c0-4.28.76-8.26 2.27-11.91 1.5-3.63 3.77-6.94 6.79-9.91 3-2.95 6.29-5.2 9.84-6.7 3.57-1.5 7.41-2.28 11.52-2.28 4.12 0 7.96.78 11.49 2.27 3.54 1.51 6.78 3.76 9.75 6.73 2.95 2.97 5.16 6.26 6.64 9.91 1.49 3.63 2.22 7.61 2.22 11.89 0 4.17-.73 8.08-2.21 11.69-1.48 3.6-3.68 6.94-6.65 9.97-2.94 3.03-6.18 5.32-9.72 6.84-3.54 1.51-7.38 2.29-11.52 2.29-4.22 0-8.14-.76-11.75-2.26-3.58-1.51-6.86-3.79-9.83-6.79-2.94-3.02-5.16-6.34-6.63-9.97-1.48-3.62-2.21-7.54-2.21-11.77zm13.4 178.16c-1.11 3.97-3.35 11.76 3.3 11.76 1.44 0 3.27-.81 5.46-2.4 2.37-1.71 5.09-4.31 8.13-7.75 3.09-3.5 6.32-7.65 9.67-12.42 3.33-4.76 6.84-10.22 10.49-16.31.37-.65 1.23-.87 1.89-.48l12.36 9.18c.6.43.73 1.25.35 1.86-5.69 9.88-11.44 18.51-17.26 25.88-5.85 7.41-11.79 13.57-17.8 18.43l-.1.06c-6.02 4.88-12.19 8.55-18.51 11.01-17.58 6.81-45.36 5.7-53.32-14.83-5.02-12.96-.9-27.69 3.06-40.37l19.96-60.44c1.28-4.58 2.89-9.62 3.47-14.33.97-7.87-2.49-12.96-11.06-12.96h-17.45c-.76 0-1.38-.62-1.38-1.38l.08-.48 4.58-16.68c.16-.62.73-1.04 1.35-1.02l89.12-2.79c.76-.03 1.41.57 1.44 1.33l-.07.43-37.76 124.7zm158.3-244.93c-41.39-41.39-98.58-67-161.74-67-63.16 0-120.35 25.61-161.74 67-41.39 41.39-67 98.58-67 161.74 0 63.16 25.61 120.35 67 161.74 41.39 41.39 98.58 67 161.74 67 63.16 0 120.35-25.61 161.74-67 41.39-41.39 67-98.58 67-161.74 0-63.16-25.61-120.35-67-161.74z"/>
229
+ </svg>
230
+ </button>
231
+ <button class="edit-button"
232
+ @click.stop="resetChat(task.id)"
233
+ title="Reset chat">
234
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 122.88 121.1" fill="var(--color-primary)" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
235
+ <path d="M62.89,56.03c1.11-0.35,2.34-0.25,3.72,0.37l10.4,7.87c2.26,1.71,4.24,3.78,2.73,6.9 c-0.51,1.06-1.4,2.1-2.38,3.49l-0.53,0.75c-1.97,2.8-2.61,2-5.71,1.83c0.56,13.37,1.75,27.82-2.64,40.88 c-0.87,2.7-3.32,3.44-6.95,2.71l-6.1-2.03c4.11-6.14,6.16-13.85,6.44-22.89c-3.46,8.58-6.8,16.96-10.68,20.86l-6.28-2.08 c0.61-3.05,1.05-5.43,0.35-6.9l-4.07,4.24l-9.33-5.77c6.36-3.36,11.62-7.87,15.6-13.73c-6.69,5.01-12.76,8.1-18.14,8.99 c-2.75,0.83-4.49,0.35-5.16-1.53c-0.48-1.34-0.05-1.77,0.81-2.86c1.11-1.41,2.61-2.67,4.35-3.79c-3.13,1.1-4.64,0.95-6.37,1.51 c-4.9,1.59-9.94-1.86-8.26-6.9c1.07-3.23,3.54-3.09,6.67-4.07l5.42-1.69c-5.19,0.28-10.32,0.45-15.02-0.25 c-5.4-0.8-5.31-0.99-8.24-5.38c-3.94-5.91-6.25-11.45,2.52-9.16c16.73,3.18,33.56,5.34,51.25-0.98c-0.76-1.32-0.9-2.57-0.5-3.73 C57.37,60.94,61.13,56.58,62.89,56.03L62.89,56.03z M113.8,2.42L74.45,51.53c-4.71,6.68,3.2,11.91,8.39,5.64l39.2-49.27 C125.12,1.86,119.13-3.16,113.8,2.42L113.8,2.42z"/>
236
</svg>
237
</button>
238
+
239
</div>
240
</div>
241
</div>
@@ -653,15 +735,16 @@
735
<input type="text" x-model="editingTask.name" placeholder="Enter task name">
736
</div>
737
656
- <!-- Task Type (only editable when creating) -->
738
+ <!-- Task Type Selection -->
739
<div class="scheduler-form-field">
740
<div class="label-help-wrapper">
659
- <label class="scheduler-form-label">Task Type</label>
660
- <div class="scheduler-form-help">Task type cannot be changed after creation</div>
741
+ <label class="scheduler-form-label">Type</label>
742
+ <div class="scheduler-form-help">Task execution method</div>
743
</div>
662
- <select x-model="editingTask.type" :disabled="!isCreating">
663
- <option value="scheduled">Scheduled Task</option>
664
- <option value="adhoc">Ad-hoc Task</option>
744
+ <select x-model="editingTask.type">
745
+ <option value="scheduled">Scheduled (Cron)</option>
746
+ <option value="adhoc">Ad-hoc (Manual)</option>
747
+ <option value="planned">Planned (Specific Times)</option>
748
</select>
749
</div>
750
@@ -695,11 +778,11 @@
778
</div>
779
</div>
780
698
- <!-- Schedule (for scheduled tasks) -->
699
- <div class="scheduler-form-field full-width" x-show="editingTask && editingTask.type === 'scheduled'">
781
+ <!-- Schedule (only for scheduled tasks) -->
782
+ <div class="scheduler-form-field full-width" x-show="editingTask.type === 'scheduled'">
783
<div class="label-help-wrapper">
701
- <label class="scheduler-form-label">Schedule (Cron Expression)</label>
702
- <div class="scheduler-form-help">Format: minute hour day month weekday (e.g., "* * * * *" for every minute)</div>
784
+ <label class="scheduler-form-label">Schedule</label>
785
+ <div class="scheduler-form-help">Cron schedule for automated execution (minute hour day month weekday)</div>
786
</div>
787
<div class="scheduler-schedule-builder">
788
<div class="scheduler-schedule-field">
@@ -725,6 +808,94 @@
808
</div>
809
</div>
810
811
+ <!-- Plan (for planned tasks) -->
812
+ <div class="scheduler-form-field full-width" x-show="editingTask.type === 'planned'">
813
+ <div class="label-help-wrapper">
814
+ <label class="scheduler-form-label">Plan</label>
815
+ <div class="scheduler-form-help">Specific execution times for this task</div>
816
+ </div>
817
+ <div class="scheduler-plan-builder">
818
+ <div class="scheduler-plan-todo">
819
+ <span class="scheduler-plan-label">Upcoming Executions</span>
820
+ <div class="scheduler-todo-list">
821
+ <template x-if="editingTask.plan && Array.isArray(editingTask.plan.todo) && editingTask.plan.todo.length > 0">
822
+ <template x-for="(time, index) in editingTask.plan.todo" :key="index">
823
+ <div class="scheduler-todo-item">
824
+ <span x-text="formatDate(time)"></span>
825
+ <button @click.prevent="editingTask.plan.todo.splice(index, 1)" class="scheduler-todo-remove">
826
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
827
+ <line x1="18" y1="6" x2="6" y2="18"></line>
828
+ <line x1="6" y1="6" x2="18" y2="18"></line>
829
+ </svg>
830
+ </button>
831
+ </div>
832
+ </template>
833
+ </template>
834
+ <template x-if="!editingTask.plan || !Array.isArray(editingTask.plan.todo) || editingTask.plan.todo.length === 0">
835
+ <div class="scheduler-empty-plan">
836
+ No scheduled execution times yet. Add one below.
837
+ </div>
838
+ </template>
839
+ <div class="scheduler-add-todo">
840
+ <!-- Create form planned task input -->
841
+ <input type="text" id="newPlannedTime-create"
842
+ x-ref="plannedTimeCreate"
843
+ class="scheduler-flatpickr-input"
844
+ placeholder="Select date and time">
845
+ <!-- Create Task Form Add Time Button -->
846
+ <button @click.prevent="
847
+ const input = $refs.plannedTimeCreate;
848
+ if (!input) {
849
+ console.error('Input reference not found for plannedTimeCreate');
850
+ return;
851
+ }
852
+
853
+ // Ensure plan structure exists
854
+ if (!editingTask.plan) {
855
+ editingTask.plan = { todo: [], in_progress: null, done: [] };
856
+ }
857
+ if (!Array.isArray(editingTask.plan.todo)) {
858
+ editingTask.plan.todo = [];
859
+ }
860
+
861
+ // Get date from Flatpickr if available
862
+ let selectedDate;
863
+ if (input._flatpickr && input._flatpickr.selectedDates.length > 0) {
864
+ selectedDate = input._flatpickr.selectedDates[0];
865
+ } else if (input.value) {
866
+ selectedDate = new Date(input.value);
867
+ }
868
+
869
+ if (!selectedDate || isNaN(selectedDate.getTime())) {
870
+ alert('Please select a valid date and time');
871
+ return;
872
+ }
873
+
874
+ // Convert to ISO string and add to plan
875
+ editingTask.plan.todo.push(selectedDate.toISOString());
876
+
877
+ // Sort by date (earliest first)
878
+ editingTask.plan.todo.sort();
879
+
880
+ // Clear the input
881
+ if (input._flatpickr) {
882
+ input._flatpickr.clear();
883
+ } else {
884
+ input.value = '';
885
+ }
886
+ " class="scheduler-add-todo-button">
887
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 4px;">
888
+ <line x1="12" y1="5" x2="12" y2="19"></line>
889
+ <line x1="5" y1="12" x2="19" y2="12"></line>
890
+ </svg>
891
+ Add Time
892
+ </button>
893
+ </div>
894
+ </div>
895
+ </div>
896
+ </div>
897
+ </div>
898
+
899
<!-- Token (for ad-hoc tasks) -->
900
<div class="scheduler-form-field full-width" x-show="editingTask.type === 'adhoc'">
901
<div class="label-help-wrapper">
@@ -801,6 +972,7 @@
972
<select x-model="editingTask.type" disabled>
973
<option value="scheduled">Scheduled Task</option>
974
<option value="adhoc">Ad-hoc Task</option>
975
+ <option value="planned">Planned Task</option>
976
</select>
977
</div>
978
@@ -864,6 +1036,94 @@
1036
</div>
1037
</div>
1038
1039
+ <!-- Plan (for planned tasks) -->
1040
+ <div class="scheduler-form-field full-width" x-show="editingTask.type === 'planned'">
1041
+ <div class="label-help-wrapper">
1042
+ <label class="scheduler-form-label">Plan</label>
1043
+ <div class="scheduler-form-help">Specific execution times for this task</div>
1044
+ </div>
1045
+ <div class="scheduler-plan-builder">
1046
+ <div class="scheduler-plan-todo">
1047
+ <span class="scheduler-plan-label">Upcoming Executions</span>
1048
+ <div class="scheduler-todo-list">
1049
+ <template x-if="editingTask.plan && Array.isArray(editingTask.plan.todo) && editingTask.plan.todo.length > 0">
1050
+ <template x-for="(time, index) in editingTask.plan.todo" :key="index">
1051
+ <div class="scheduler-todo-item">
1052
+ <span x-text="formatDate(time)"></span>
1053
+ <button @click.prevent="editingTask.plan.todo.splice(index, 1)" class="scheduler-todo-remove">
1054
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
1055
+ <line x1="18" y1="6" x2="6" y2="18"></line>
1056
+ <line x1="6" y1="6" x2="18" y2="18"></line>
1057
+ </svg>
1058
+ </button>
1059
+ </div>
1060
+ </template>
1061
+ </template>
1062
+ <template x-if="!editingTask.plan || !Array.isArray(editingTask.plan.todo) || editingTask.plan.todo.length === 0">
1063
+ <div class="scheduler-empty-plan">
1064
+ No scheduled execution times yet. Add one below.
1065
+ </div>
1066
+ </template>
1067
+ <div class="scheduler-add-todo">
1068
+ <!-- Edit form planned task input -->
1069
+ <input type="text" id="newPlannedTime-edit"
1070
+ x-ref="plannedTimeEdit"
1071
+ class="scheduler-flatpickr-input"
1072
+ placeholder="Select date and time">
1073
+ <!-- Edit Task Form Add Time Button -->
1074
+ <button @click.prevent="
1075
+ const input = $refs.plannedTimeEdit;
1076
+ if (!input) {
1077
+ console.error('Input reference not found for plannedTimeEdit');
1078
+ return;
1079
+ }
1080
+
1081
+ // Ensure plan structure exists
1082
+ if (!editingTask.plan) {
1083
+ editingTask.plan = { todo: [], in_progress: null, done: [] };
1084
+ }
1085
+ if (!Array.isArray(editingTask.plan.todo)) {
1086
+ editingTask.plan.todo = [];
1087
+ }
1088
+
1089
+ // Get date from Flatpickr if available
1090
+ let selectedDate;
1091
+ if (input._flatpickr && input._flatpickr.selectedDates.length > 0) {
1092
+ selectedDate = input._flatpickr.selectedDates[0];
1093
+ } else if (input.value) {
1094
+ selectedDate = new Date(input.value);
1095
+ }
1096
+
1097
+ if (!selectedDate || isNaN(selectedDate.getTime())) {
1098
+ alert('Please select a valid date and time');
1099
+ return;
1100
+ }
1101
+
1102
+ // Convert to ISO string and add to plan
1103
+ editingTask.plan.todo.push(selectedDate.toISOString());
1104
+
1105
+ // Sort by date (earliest first)
1106
+ editingTask.plan.todo.sort();
1107
+
1108
+ // Clear the input
1109
+ if (input._flatpickr) {
1110
+ input._flatpickr.clear();
1111
+ } else {
1112
+ input.value = '';
1113
+ }
1114
+ " class="scheduler-add-todo-button">
1115
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right: 4px;">
1116
+ <line x1="12" y1="5" x2="12" y2="19"></line>
1117
+ <line x1="5" y1="12" x2="19" y2="12"></line>
1118
+ </svg>
1119
+ Add Time
1120
+ </button>
1121
+ </div>
1122
+ </div>
1123
+ </div>
1124
+ </div>
1125
+ </div>
1126
+
1127
<!-- Token (for ad-hoc tasks) -->
1128
<div class="scheduler-form-field full-width" x-show="editingTask.type === 'adhoc'">
1129
<div class="label-help-wrapper">
@@ -927,6 +1187,7 @@
1187
<option value="all">All Types</option>
1188
<option value="scheduled">Scheduled</option>
1189
<option value="adhoc">Ad-hoc</option>
1190
+ <option value="planned">Planned</option>
1191
</select>
1192
</div>
1193
@@ -943,19 +1204,23 @@
1204
</div>
1205
1206
<!-- Loading State -->
946
- <div class="scheduler-loading" x-show="isLoading">
1207
+ <!-- <div class="scheduler-loading" x-show="isLoading">
1208
Loading tasks...
948
- </div>
1209
+ </div> -->
1210
1211
<!-- Empty State -->
951
- <div class="scheduler-empty" x-show="!isLoading && filteredTasks.length === 0">
1212
+ <div class="scheduler-empty"
1213
+ x-show="!isLoading && filteredTasks.length === 0"
1214
+ x-effect="$el.style.display = (!isLoading && filteredTasks.length === 0) ? '' : 'none'">
1215
<div class="scheduler-empty-icon">📋</div>
1216
<div class="scheduler-empty-text">No tasks found</div>
1217
<button class="btn btn-ok" @click="startCreateTask()">Create your first task</button>
1218
</div>
1219
1220
<!-- Task List Table -->
958
- <table class="scheduler-task-list" x-show="!isLoading && filteredTasks.length > 0">
1221
+ <table class="scheduler-task-list"
1222
+ x-show="!isLoading && filteredTasks.length > 0"
1223
+ x-effect="$el.style.display = (!isLoading && filteredTasks.length > 0) ? '' : 'none'">
1224
<thead>
1225
<tr>
1226
<th @click="changeSort('name')">
@@ -991,6 +1256,7 @@
1256
<td>
1257
<span x-show="task.type === 'scheduled'" x-text="formatSchedule(task)"></span>
1258
<span x-show="task.type === 'adhoc'" class="scheduler-no-schedule">—</span>
1259
+ <span x-show="task.type === 'planned'" x-html="formatPlan(task).replace(/\n/g, '<br>')"></span>
1260
</td>
1261
<td x-text="formatDate(task.last_run)"></td>
1262
<td @click.stop>
@@ -1054,6 +1320,38 @@
1320
<div class="scheduler-details-label" x-show="selectedTaskForDetail && selectedTaskForDetail.type === 'adhoc'">Token:</div>
1321
<div class="scheduler-details-value" x-show="selectedTaskForDetail && selectedTaskForDetail.type === 'adhoc'" x-text="selectedTaskForDetail ? selectedTaskForDetail.token : ''"></div>
1322
1323
+ <div class="scheduler-details-label" x-show="selectedTaskForDetail && selectedTaskForDetail.type === 'planned'">Plan:</div>
1324
+ <div class="scheduler-details-value" x-show="selectedTaskForDetail && selectedTaskForDetail.type === 'planned'">
1325
+ <div x-show="selectedTaskForDetail && selectedTaskForDetail.plan">
1326
+ <div><strong>Upcoming:</strong></div>
1327
+ <template x-if="selectedTaskForDetail && selectedTaskForDetail.plan && selectedTaskForDetail.plan.todo && selectedTaskForDetail.plan.todo.length > 0">
1328
+ <div>
1329
+ <template x-for="(time, index) in selectedTaskForDetail.plan.todo" :key="index">
1330
+ <div x-text="formatDate(time)"></div>
1331
+ </template>
1332
+ </div>
1333
+ </template>
1334
+ <template x-if="!selectedTaskForDetail || !selectedTaskForDetail.plan || !selectedTaskForDetail.plan.todo || selectedTaskForDetail.plan.todo.length === 0">
1335
+ <div>No upcoming executions</div>
1336
+ </template>
1337
+
1338
+ <div><strong>In Progress:</strong></div>
1339
+ <div x-text="selectedTaskForDetail && selectedTaskForDetail.plan && selectedTaskForDetail.plan.in_progress ? formatDate(selectedTaskForDetail.plan.in_progress) : 'None'"></div>
1340
+
1341
+ <div><strong>Completed:</strong></div>
1342
+ <template x-if="selectedTaskForDetail && selectedTaskForDetail.plan && selectedTaskForDetail.plan.done && selectedTaskForDetail.plan.done.length > 0">
1343
+ <div>
1344
+ <template x-for="(time, index) in selectedTaskForDetail.plan.done" :key="index">
1345
+ <div x-text="formatDate(time)"></div>
1346
+ </template>
1347
+ </div>
1348
+ </template>
1349
+ <template x-if="!selectedTaskForDetail || !selectedTaskForDetail.plan || !selectedTaskForDetail.plan.done || selectedTaskForDetail.plan.done.length === 0">
1350
+ <div>No completed executions</div>
1351
+ </template>
1352
+ </div>
1353
+ </div>
1354
+
1355
<div class="scheduler-details-label">Last Result:</div>
1356
<div class="scheduler-details-value" x-text="selectedTaskForDetail && selectedTaskForDetail.last_result ? selectedTaskForDetail.last_result : 'No results yet'"></div>
1357
webui/index.js
+55
-12
@@ -342,7 +342,17 @@ let lastSpokenNo = 0
342
async function poll() {
343
let updated = false
344
try {
345
- const response = await sendJsonData("/poll", { log_from: lastLogVersion, context });
345
+ // Get timezone from navigator
346
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
347
+
348
+ const response = await sendJsonData(
349
+ "/poll",
350
+ {
351
+ log_from: lastLogVersion,
352
+ context: context || null,
353
+ timezone: timezone
354
+ }
355
+ );
356
357
// Check if the response is valid
358
if (!response) {
@@ -421,7 +431,13 @@ async function poll() {
431
432
// If it doesn't exist in the chats list but we're in chats tab, try to select the first chat
433
if (!contextExists && contexts.length > 0) {
434
+ // Check if the current context is empty before creating a new one
435
+ // If there's already a current context and we're just updating UI, don't automatically
436
+ // create a new context by calling setContext
437
const firstChatId = contexts[0].id;
438
+
439
+ // Only create a new context if we're not currently in an existing context
440
+ // This helps prevent duplicate contexts when switching tabs
441
setContext(firstChatId);
442
chatsAD.selected = firstChatId;
443
localStorage.setItem('lastSelectedChat', firstChatId);
@@ -454,11 +470,18 @@ async function poll() {
470
} else if (contexts.length > 0 && localStorage.getItem('activeTab') === 'chats') {
471
// If we're in chats tab with no selection but have chats, select the first one
472
const firstChatId = contexts[0].id;
457
- setContext(firstChatId);
458
- chatsAD.selected = firstChatId;
459
- localStorage.setItem('lastSelectedChat', firstChatId);
473
+
474
+ // Only set context if we don't already have one to avoid duplicates
475
+ if (!context) {
476
+ setContext(firstChatId);
477
+ chatsAD.selected = firstChatId;
478
+ localStorage.setItem('lastSelectedChat', firstChatId);
479
+ }
480
}
481
482
+ lastLogVersion = response.log_version;
483
+ lastLogGuid = response.log_guid;
484
+
485
} catch (error) {
486
console.error('Error:', error);
487
setConnectionStatus(false)
@@ -509,12 +532,12 @@ window.pauseAgent = async function (paused) {
532
}
533
}
534
512
-window.resetChat = async function () {
535
+window.resetChat = async function (ctxid=null) {
536
try {
514
- const resp = await sendJsonData("/chat_reset", { context });
515
- updateAfterScroll()
537
+ const resp = await sendJsonData("/chat_reset", { "context": ctxid === null ? context : ctxid });
538
+ if (ctxid === null) updateAfterScroll();
539
} catch (e) {
517
- window.toastFetchError("Error resetting chat", e)
540
+ window.toastFetchError("Error resetting chat", e);
541
}
542
}
543
@@ -1193,10 +1216,20 @@ function activateTab(tabName) {
1216
chatsTab.classList.add('active');
1217
chatsSection.style.display = '';
1218
1219
+ // Get the available contexts from Alpine.js data
1220
+ const chatsAD = Alpine.$data(chatsSection);
1221
+ const availableContexts = chatsAD.contexts || [];
1222
+
1223
// Restore previous chat selection
1224
const lastSelectedChat = localStorage.getItem('lastSelectedChat');
1198
- if (lastSelectedChat && lastSelectedChat !== currentContext) {
1199
- // Only switch if there's a stored selection and it's different from current
1225
+
1226
+ // Only switch if:
1227
+ // 1. lastSelectedChat exists AND
1228
+ // 2. It's different from current context AND
1229
+ // 3. The context actually exists in our contexts list OR there are no contexts yet
1230
+ if (lastSelectedChat &&
1231
+ lastSelectedChat !== currentContext &&
1232
+ (availableContexts.some(ctx => ctx.id === lastSelectedChat) || availableContexts.length === 0)) {
1233
setContext(lastSelectedChat);
1234
}
1235
} else if (tabName === 'tasks') {
@@ -1204,10 +1237,20 @@ function activateTab(tabName) {
1237
tasksSection.style.display = 'flex';
1238
tasksSection.style.flexDirection = 'column';
1239
1240
+ // Get the available tasks from Alpine.js data
1241
+ const tasksAD = Alpine.$data(tasksSection);
1242
+ const availableTasks = tasksAD.tasks || [];
1243
+
1244
// Restore previous task selection
1245
const lastSelectedTask = localStorage.getItem('lastSelectedTask');
1209
- if (lastSelectedTask && lastSelectedTask !== currentContext) {
1210
- // Only switch if there's a stored selection and it's different from current
1246
+
1247
+ // Only switch if:
1248
+ // 1. lastSelectedTask exists AND
1249
+ // 2. It's different from current context AND
1250
+ // 3. The task actually exists in our tasks list
1251
+ if (lastSelectedTask &&
1252
+ lastSelectedTask !== currentContext &&
1253
+ availableTasks.some(task => task.id === lastSelectedTask)) {
1254
setContext(lastSelectedTask);
1255
}
1256
}
webui/js/scheduler.js
+1187
-202
@@ -3,141 +3,222 @@
3
* Manages scheduled and ad-hoc tasks through a dedicated settings tab
4
*/
5
6
-// Add a document ready event handler to ensure the scheduler tab can be clicked on first load
7
-document.addEventListener('DOMContentLoaded', function() {
8
- // Setup scheduler tab click handling
9
- const setupSchedulerTab = () => {
10
- const settingsModal = document.getElementById('settingsModal');
11
- if (!settingsModal) {
12
- setTimeout(setupSchedulerTab, 100);
13
- return;
14
- }
15
-
16
- // Create a global event listener for clicks on the scheduler tab
17
- document.addEventListener('click', function(e) {
18
- // Find if the click was on the scheduler tab or its children
19
- const schedulerTab = e.target.closest('.settings-tab[title="Task Scheduler"]');
20
- if (!schedulerTab) return;
21
-
22
- e.preventDefault();
23
- e.stopPropagation();
24
-
25
- // Get the settings modal data
26
- try {
27
- const modalData = Alpine.$data(settingsModal);
28
- if (modalData.activeTab !== 'scheduler') {
29
- // Directly call the modal's switchTab method
30
- modalData.switchTab('scheduler');
31
- }
32
- } catch (err) {
33
- console.error('Error handling scheduler tab click:', err);
34
- }
35
- }, true); // Use capture phase to intercept before Alpine.js handlers
36
- };
37
-
38
- // Initialize the tab handling
39
- setupSchedulerTab();
40
-});
41
-
42
-document.addEventListener('alpine:init', () => {
43
- // Register as an Alpine component
44
- Alpine.data('schedulerSettings', () => ({
6
+import { formatDateTime, getUserTimezone } from './time-utils.js';
7
+
8
+// Ensure the showToast function is available
9
+// if (typeof window.showToast !== 'function') {
10
+// window.showToast = function(message, type = 'info') {
11
+// console.log(`[Toast ${type}]: ${message}`);
12
+// // Create toast element if not already present
13
+// let toastContainer = document.getElementById('toast-container');
14
+// if (!toastContainer) {
15
+// toastContainer = document.createElement('div');
16
+// toastContainer.id = 'toast-container';
17
+// toastContainer.style.position = 'fixed';
18
+// toastContainer.style.bottom = '20px';
19
+// toastContainer.style.right = '20px';
20
+// toastContainer.style.zIndex = '9999';
21
+// document.body.appendChild(toastContainer);
22
+// }
23
+
24
+// // Create the toast
25
+// const toast = document.createElement('div');
26
+// toast.className = `toast toast-${type}`;
27
+// toast.style.padding = '10px 15px';
28
+// toast.style.margin = '5px 0';
29
+// toast.style.backgroundColor = type === 'error' ? '#f44336' :
30
+// type === 'success' ? '#4CAF50' :
31
+// type === 'warning' ? '#ff9800' : '#2196F3';
32
+// toast.style.color = 'white';
33
+// toast.style.borderRadius = '4px';
34
+// toast.style.boxShadow = '0 2px 5px rgba(0,0,0,0.2)';
35
+// toast.style.width = 'auto';
36
+// toast.style.maxWidth = '300px';
37
+// toast.style.wordWrap = 'break-word';
38
+
39
+// toast.innerHTML = message;
40
+
41
+// // Add to container
42
+// toastContainer.appendChild(toast);
43
+
44
+// // Auto remove after 3 seconds
45
+// setTimeout(() => {
46
+// if (toast.parentNode) {
47
+// toast.style.opacity = '0';
48
+// toast.style.transition = 'opacity 0.5s ease';
49
+// setTimeout(() => {
50
+// if (toast.parentNode) {
51
+// toast.parentNode.removeChild(toast);
52
+// }
53
+// }, 500);
54
+// }
55
+// }, 3000);
56
+// };
57
+// }
58
+
59
+// Add this near the top of the scheduler.js file, outside of any function
60
+const showToast = function(message, type = 'info') {
61
+ // Use the global toast function if available, otherwise fallback to console
62
+ if (typeof window.toast === 'function') {
63
+ window.toast(message, type);
64
+ } else {
65
+ console.log(`Toast (${type}): ${message}`);
66
+ }
67
+};
68
+
69
+// Define the full component implementation
70
+const fullComponentImplementation = function() {
71
+ return {
72
tasks: [],
73
isLoading: true,
74
selectedTask: null,
75
expandedTaskId: null,
76
sortField: 'name',
77
sortDirection: 'asc',
51
- filterType: 'all', // all, scheduled, adhoc
78
+ filterType: 'all', // all, scheduled, adhoc, planned
79
filterState: 'all', // all, idle, running, disabled, error
80
pollingInterval: null,
81
pollingActive: false, // Track if polling is currently active
55
- editingTask: null,
82
+ editingTask: {
83
+ name: '',
84
+ type: 'scheduled',
85
+ state: 'idle',
86
+ schedule: {
87
+ minute: '*',
88
+ hour: '*',
89
+ day: '*',
90
+ month: '*',
91
+ weekday: '*',
92
+ timezone: getUserTimezone()
93
+ },
94
+ token: '',
95
+ plan: {
96
+ todo: [],
97
+ in_progress: null,
98
+ done: []
99
+ },
100
+ system_prompt: '',
101
+ prompt: '',
102
+ attachments: []
103
+ },
104
isCreating: false,
105
isEditing: false,
106
showLoadingState: false,
107
viewMode: 'list', // Controls whether to show list or detail view
108
selectedTaskForDetail: null, // Task object for detail view
109
+ attachmentsText: '',
110
+ filteredTasks: [],
111
+ hasNoTasks: true, // Add explicit reactive property
112
113
// Initialize the component
114
init() {
64
- // Initialize editingTask with default values but ensure we're not in editing mode
115
+ // Initialize component data
116
+ this.tasks = [];
117
+ this.isLoading = true;
118
+ this.hasNoTasks = true; // Add explicit reactive property
119
+ this.filterType = 'all';
120
+ this.filterState = 'all';
121
+ this.sortField = 'name';
122
+ this.sortDirection = 'asc';
123
+ this.pollingInterval = null;
124
+ this.pollingActive = false;
125
+
126
+ // Start polling for tasks
127
+ this.startPolling();
128
+
129
+ // Refresh initial data
130
+ this.fetchTasks();
131
+
132
+ // Set up event handler for tab selection to ensure view is refreshed when tab becomes visible
133
+ document.addEventListener('click', (event) => {
134
+ // Check if a tab was clicked
135
+ const clickedTab = event.target.closest('.settings-tab');
136
+ if (clickedTab && clickedTab.getAttribute('data-tab') === 'scheduler') {
137
+ setTimeout(() => {
138
+ this.fetchTasks();
139
+ }, 100);
140
+ }
141
+ });
142
+
143
+ // Watch for changes to the tasks array to update UI
144
+ this.$watch('tasks', (newTasks) => {
145
+ this.updateTasksUI();
146
+ });
147
+
148
+ this.$watch('filterType', () => {
149
+ this.updateTasksUI();
150
+ });
151
+
152
+ this.$watch('filterState', () => {
153
+ this.updateTasksUI();
154
+ });
155
+
156
+ // Set up default configuration
157
+ this.viewMode = localStorage.getItem('scheduler_view_mode') || 'list';
158
+ this.selectedTask = null;
159
+ this.expandedTaskId = null;
160
this.editingTask = {
161
name: '',
162
type: 'scheduled',
68
- state: 'idle', // Initialize with idle state
163
+ state: 'idle',
164
schedule: {
165
minute: '*',
166
hour: '*',
167
day: '*',
168
month: '*',
74
- weekday: '*'
169
+ weekday: '*',
170
+ timezone: getUserTimezone()
171
+ },
172
+ token: this.generateRandomToken ? this.generateRandomToken() : '',
173
+ plan: {
174
+ todo: [],
175
+ in_progress: null,
176
+ done: []
177
},
76
- token: '',
178
system_prompt: '',
179
prompt: '',
180
attachments: []
181
};
182
82
- // Make sure we're in "list view" mode by default
83
- this.isCreating = false;
84
- this.isEditing = false;
85
-
86
- // Add a watcher for task type changes to initialize the appropriate properties
87
- this.$watch('editingTask.type', (newType) => {
88
- if (newType === 'scheduled' && !this.editingTask.schedule) {
89
- // Initialize schedule if changing to scheduled type
90
- this.editingTask.schedule = {
91
- minute: '*',
92
- hour: '*',
93
- day: '*',
94
- month: '*',
95
- weekday: '*'
96
- };
97
- } else if (newType === 'adhoc' && !this.editingTask.token) {
98
- // Initialize token if changing to adhoc type
99
- this.editingTask.token = this.generateRandomToken();
100
- }
183
+ // Initialize Flatpickr for date/time pickers after Alpine is fully initialized
184
+ this.$nextTick(() => {
185
+ // Wait until DOM is updated
186
+ setTimeout(() => {
187
+ if (this.isCreating) {
188
+ this.initFlatpickr('create');
189
+ } else if (this.isEditing) {
190
+ this.initFlatpickr('edit');
191
+ }
192
+ }, 100);
193
});
194
103
- // Use a small delay to ensure Alpine.js has fully initialized
104
- // before fetching tasks, which helps prevent layout shift
105
- setTimeout(() => {
106
- // Initial fetch to populate the list
107
- this.fetchTasks();
195
+ // Cleanup on component destruction
196
+ this.$cleanup = () => {
197
+ console.log('Cleaning up schedulerSettings component');
198
+ this.stopPolling();
199
109
- // Only start polling if the modal is actually open
110
- const store = Alpine.store('root');
111
- if (store && store.isOpen === true) {
112
- this.startPolling();
200
+ // Clean up any Flatpickr instances
201
+ const createInput = document.getElementById('newPlannedTime-create');
202
+ if (createInput && createInput._flatpickr) {
203
+ createInput._flatpickr.destroy();
204
}
114
- }, 100);
205
116
- // Initialize safe watchers with defensive checks
117
- this.$nextTick(() => {
118
- // Watch the modal state from the root store
119
- this.$watch('$store.root.isOpen', (isOpen) => {
120
- // Only proceed if the value is not undefined
121
- if (typeof isOpen !== 'undefined') {
122
- if (isOpen === true) {
123
- // Modal just opened
124
- this.startPolling();
125
- } else if (isOpen === false) {
126
- // Modal closed, stop polling
127
- this.stopPolling();
128
- }
129
- }
130
- });
131
- });
206
+ const editInput = document.getElementById('newPlannedTime-edit');
207
+ if (editInput && editInput._flatpickr) {
208
+ editInput._flatpickr.destroy();
209
+ }
210
+ };
211
},
212
213
// Start polling for task updates
214
startPolling() {
215
// Don't start if already polling
216
if (this.pollingInterval) {
217
+ console.log('Polling already active, not starting again');
218
return;
219
}
220
221
+ console.log('Starting task polling');
222
this.pollingActive = true;
223
224
// Fetch immediately, then set up interval for every 2 seconds
@@ -151,6 +232,7 @@ document.addEventListener('alpine:init', () => {
232
233
// Stop polling when tab is inactive
234
stopPolling() {
235
+ console.log('Stopping task polling');
236
this.pollingActive = false;
237
238
if (this.pollingInterval) {
@@ -177,7 +259,10 @@ document.addEventListener('alpine:init', () => {
259
method: 'POST',
260
headers: {
261
'Content-Type': 'application/json'
180
- }
262
+ },
263
+ body: JSON.stringify({
264
+ timezone: getUserTimezone()
265
+ })
266
});
267
268
if (!response.ok) {
@@ -185,68 +270,58 @@ document.addEventListener('alpine:init', () => {
270
}
271
272
const data = await response.json();
188
- console.log('Tasks fetched from backend:', data.tasks);
189
- this.tasks = data.tasks || [];
273
+
274
+ // Check if data.tasks exists and is an array
275
+ if (!data || !data.tasks) {
276
+ console.error('Invalid response: data.tasks is missing', data);
277
+ this.tasks = [];
278
+ } else if (!Array.isArray(data.tasks)) {
279
+ console.error('Invalid response: data.tasks is not an array', data.tasks);
280
+ this.tasks = [];
281
+ } else {
282
+ // Verify each task has necessary properties
283
+ const validTasks = data.tasks.filter(task => {
284
+ if (!task || typeof task !== 'object') {
285
+ console.error('Invalid task (not an object):', task);
286
+ return false;
287
+ }
288
+ if (!task.uuid) {
289
+ console.error('Task missing uuid:', task);
290
+ return false;
291
+ }
292
+ if (!task.name) {
293
+ console.error('Task missing name:', task);
294
+ return false;
295
+ }
296
+ if (!task.type) {
297
+ console.error('Task missing type:', task);
298
+ return false;
299
+ }
300
+ return true;
301
+ });
302
+
303
+ if (validTasks.length !== data.tasks.length) {
304
+ console.warn(`Filtered out ${data.tasks.length - validTasks.length} invalid tasks`);
305
+ }
306
+
307
+ this.tasks = validTasks;
308
+
309
+ // Update UI using the shared function
310
+ this.updateTasksUI();
311
+ }
312
} catch (error) {
313
console.error('Error fetching tasks:', error);
314
// Only show toast for errors on manual refresh, not during polling
315
if (!this.pollingInterval) {
316
showToast('Failed to fetch tasks: ' + error.message, 'error');
317
}
318
+ // Reset tasks to empty array on error
319
+ this.tasks = [];
320
} finally {
321
this.isLoading = false;
322
}
323
},
324
201
- // Computed property for filtered tasks
202
- get filteredTasks() {
203
- return this.tasks
204
- .filter(task => {
205
- // Filter by type
206
- if (this.filterType !== 'all') {
207
- if (this.filterType === 'scheduled' && !task.schedule) return false;
208
- if (this.filterType === 'adhoc' && !task.token) return false;
209
- }
210
-
211
- // Filter by state
212
- if (this.filterState !== 'all' && task.state !== this.filterState) {
213
- return false;
214
- }
215
-
216
- return true;
217
- })
218
- .sort((a, b) => {
219
- // Handle sorting
220
- let valueA, valueB;
221
-
222
- switch (this.sortField) {
223
- case 'name':
224
- valueA = a.name.toLowerCase();
225
- valueB = b.name.toLowerCase();
226
- break;
227
- case 'state':
228
- valueA = a.state;
229
- valueB = b.state;
230
- break;
231
- case 'last_run':
232
- valueA = a.last_run ? new Date(a.last_run).getTime() : 0;
233
- valueB = b.last_run ? new Date(b.last_run).getTime() : 0;
234
- break;
235
- default:
236
- valueA = a.name.toLowerCase();
237
- valueB = b.name.toLowerCase();
238
- }
239
-
240
- // Determine sort direction
241
- const direction = this.sortDirection === 'asc' ? 1 : -1;
242
-
243
- // Compare values
244
- if (valueA < valueB) return -1 * direction;
245
- if (valueA > valueB) return 1 * direction;
246
- return 0;
247
- });
248
- },
249
-
325
// Change sort field/direction
326
changeSort(field) {
327
if (this.sortField === field) {
@@ -296,9 +371,38 @@ document.addEventListener('alpine:init', () => {
371
// Format date for display
372
formatDate(dateString) {
373
if (!dateString) return 'Never';
374
+ return formatDateTime(dateString, 'full');
375
+ },
376
+
377
+ // Format plan for display
378
+ formatPlan(task) {
379
+ if (!task || !task.plan) return 'No plan';
380
+
381
+ const todoCount = Array.isArray(task.plan.todo) ? task.plan.todo.length : 0;
382
+ const inProgress = task.plan.in_progress ? 'Yes' : 'No';
383
+ const doneCount = Array.isArray(task.plan.done) ? task.plan.done.length : 0;
384
+
385
+ let nextRun = '';
386
+ if (Array.isArray(task.plan.todo) && task.plan.todo.length > 0) {
387
+ try {
388
+ const nextTime = new Date(task.plan.todo[0]);
389
+
390
+ // Verify it's a valid date before formatting
391
+ if (!isNaN(nextTime.getTime())) {
392
+ nextRun = formatDateTime(nextTime, 'short');
393
+ } else {
394
+ nextRun = 'Invalid date';
395
+ console.warn(`Invalid date format in plan.todo[0]: ${task.plan.todo[0]}`);
396
+ }
397
+ } catch (error) {
398
+ console.error(`Error formatting next run time: ${error.message}`);
399
+ nextRun = 'Error';
400
+ }
401
+ } else {
402
+ nextRun = 'None';
403
+ }
404
300
- const date = new Date(dateString);
301
- return date.toLocaleString();
405
+ return `Next: ${nextRun}\nTodo: ${todoCount}\nIn Progress: ${inProgress}\nDone: ${doneCount}`;
406
},
407
408
// Format schedule for display
@@ -309,6 +413,7 @@ document.addEventListener('alpine:init', () => {
413
if (typeof task.schedule === 'string') {
414
schedule = task.schedule;
415
} else if (typeof task.schedule === 'object') {
416
+ // Display only the cron parts, not the timezone
417
schedule = `${task.schedule.minute || '*'} ${task.schedule.hour || '*'} ${task.schedule.day || '*'} ${task.schedule.month || '*'} ${task.schedule.weekday || '*'}`;
418
}
419
@@ -333,20 +438,31 @@ document.addEventListener('alpine:init', () => {
438
document.querySelector('[x-data="schedulerSettings"]')?.setAttribute('data-editing-state', 'creating');
439
this.editingTask = {
440
name: '',
336
- type: 'scheduled',
441
+ type: 'scheduled', // Default to scheduled
442
state: 'idle', // Initialize with idle state
443
schedule: {
444
minute: '*',
445
hour: '*',
446
day: '*',
447
month: '*',
343
- weekday: '*'
448
+ weekday: '*',
449
+ timezone: getUserTimezone()
450
},
451
token: this.generateRandomToken(), // Generate token even for scheduled tasks to prevent undefined errors
452
+ plan: { // Initialize plan for all task types to prevent undefined errors
453
+ todo: [],
454
+ in_progress: null,
455
+ done: []
456
+ },
457
system_prompt: '',
458
prompt: '',
459
attachments: [], // Always initialize as an empty array
460
};
461
+
462
+ // Set up Flatpickr after the component is visible
463
+ this.$nextTick(() => {
464
+ this.initFlatpickr('create');
465
+ });
466
},
467
468
// Edit an existing task
@@ -371,6 +487,38 @@ document.addEventListener('alpine:init', () => {
487
// Ensure state is set with a default if missing
488
if (!this.editingTask.state) this.editingTask.state = 'idle';
489
490
+ // Always initialize schedule to prevent UI errors
491
+ // All task types need this structure for the form to work properly
492
+ if (!this.editingTask.schedule || typeof this.editingTask.schedule === 'string') {
493
+ let scheduleObj = {
494
+ minute: '*',
495
+ hour: '*',
496
+ day: '*',
497
+ month: '*',
498
+ weekday: '*',
499
+ timezone: getUserTimezone()
500
+ };
501
+
502
+ // If it's a string, parse it
503
+ if (typeof this.editingTask.schedule === 'string') {
504
+ const parts = this.editingTask.schedule.split(' ');
505
+ if (parts.length >= 5) {
506
+ scheduleObj.minute = parts[0] || '*';
507
+ scheduleObj.hour = parts[1] || '*';
508
+ scheduleObj.day = parts[2] || '*';
509
+ scheduleObj.month = parts[3] || '*';
510
+ scheduleObj.weekday = parts[4] || '*';
511
+ }
512
+ }
513
+
514
+ this.editingTask.schedule = scheduleObj;
515
+ } else {
516
+ // Ensure timezone exists in the schedule
517
+ if (!this.editingTask.schedule.timezone) {
518
+ this.editingTask.schedule.timezone = getUserTimezone();
519
+ }
520
+ }
521
+
522
// Ensure attachments is always an array
523
if (!this.editingTask.attachments) {
524
this.editingTask.attachments = [];
@@ -387,50 +535,92 @@ document.addEventListener('alpine:init', () => {
535
536
// Ensure appropriate properties are initialized based on task type
537
if (this.editingTask.type === 'scheduled') {
390
- // Ensure proper structure for schedule
391
- if (typeof this.editingTask.schedule === 'string') {
392
- const parts = this.editingTask.schedule.split(' ');
393
- this.editingTask.schedule = {
394
- minute: parts[0] || '*',
395
- hour: parts[1] || '*',
396
- day: parts[2] || '*',
397
- month: parts[3] || '*',
398
- weekday: parts[4] || '*'
399
- };
400
- } else if (!this.editingTask.schedule) {
401
- // Initialize schedule if it doesn't exist
402
- this.editingTask.schedule = {
403
- minute: '*',
404
- hour: '*',
405
- day: '*',
406
- month: '*',
407
- weekday: '*'
408
- };
409
- }
538
// Initialize token for scheduled tasks to prevent undefined errors if UI accesses it
539
if (!this.editingTask.token) {
540
this.editingTask.token = '';
541
}
542
+
543
+ // Initialize plan stub for scheduled tasks to prevent undefined errors
544
+ if (!this.editingTask.plan) {
545
+ this.editingTask.plan = {
546
+ todo: [],
547
+ in_progress: null,
548
+ done: []
549
+ };
550
+ }
551
} else if (this.editingTask.type === 'adhoc') {
552
// Initialize token if it doesn't exist
553
if (!this.editingTask.token) {
554
this.editingTask.token = this.generateRandomToken();
555
+ console.log('Generated new token for adhoc task:', this.editingTask.token);
556
}
419
- // Initialize schedule for adhoc tasks to prevent undefined errors when UI accesses schedule properties
420
- if (!this.editingTask.schedule) {
421
- this.editingTask.schedule = {
422
- minute: '*',
423
- hour: '*',
424
- day: '*',
425
- month: '*',
426
- weekday: '*'
557
+
558
+ console.log('Setting token for adhoc task:', this.editingTask.token);
559
+
560
+ // Initialize plan stub for adhoc tasks to prevent undefined errors
561
+ if (!this.editingTask.plan) {
562
+ this.editingTask.plan = {
563
+ todo: [],
564
+ in_progress: null,
565
+ done: []
566
+ };
567
+ }
568
+ } else if (this.editingTask.type === 'planned') {
569
+ // Initialize plan if it doesn't exist
570
+ if (!this.editingTask.plan) {
571
+ this.editingTask.plan = {
572
+ todo: [],
573
+ in_progress: null,
574
+ done: []
575
};
576
}
577
+
578
+ // Ensure todo is an array
579
+ if (!Array.isArray(this.editingTask.plan.todo)) {
580
+ this.editingTask.plan.todo = [];
581
+ }
582
+
583
+ // Initialize token to prevent undefined errors
584
+ if (!this.editingTask.token) {
585
+ this.editingTask.token = '';
586
+ }
587
}
588
+
589
+ // Set up Flatpickr after the component is visible and task data is loaded
590
+ this.$nextTick(() => {
591
+ this.initFlatpickr('edit');
592
+ });
593
},
594
595
// Cancel editing
596
cancelEdit() {
597
+ // Clean up Flatpickr instances
598
+ const destroyFlatpickr = (inputId) => {
599
+ const input = document.getElementById(inputId);
600
+ if (input && input._flatpickr) {
601
+ console.log(`Destroying Flatpickr instance for ${inputId}`);
602
+ input._flatpickr.destroy();
603
+
604
+ // Also remove any wrapper elements that might have been created
605
+ const wrapper = input.closest('.scheduler-flatpickr-wrapper');
606
+ if (wrapper && wrapper.parentNode) {
607
+ // Move the input back to its original position
608
+ wrapper.parentNode.insertBefore(input, wrapper);
609
+ // Remove the wrapper
610
+ wrapper.parentNode.removeChild(wrapper);
611
+ }
612
+
613
+ // Remove any added classes
614
+ input.classList.remove('scheduler-flatpickr-input');
615
+ }
616
+ };
617
+
618
+ if (this.isCreating) {
619
+ destroyFlatpickr('newPlannedTime-create');
620
+ } else if (this.isEditing) {
621
+ destroyFlatpickr('newPlannedTime-edit');
622
+ }
623
+
624
// Reset to initial state but keep default values to prevent errors
625
this.editingTask = {
626
name: '',
@@ -441,9 +631,15 @@ document.addEventListener('alpine:init', () => {
631
hour: '*',
632
day: '*',
633
month: '*',
444
- weekday: '*'
634
+ weekday: '*',
635
+ timezone: getUserTimezone()
636
},
637
token: '',
638
+ plan: { // Initialize plan for planned tasks
639
+ todo: [],
640
+ in_progress: null,
641
+ done: []
642
+ },
643
system_prompt: '',
644
prompt: '',
645
attachments: [], // Always initialize as an empty array
@@ -469,7 +665,8 @@ document.addEventListener('alpine:init', () => {
665
name: this.editingTask.name,
666
system_prompt: this.editingTask.system_prompt || '',
667
prompt: this.editingTask.prompt || '',
472
- state: this.editingTask.state || 'idle' // Include state in task data
668
+ state: this.editingTask.state || 'idle', // Include state in task data
669
+ timezone: getUserTimezone()
670
};
671
672
// Process attachments - now always stored as array
@@ -479,7 +676,7 @@ document.addEventListener('alpine:init', () => {
676
.filter(line => line && line.trim().length > 0)
677
: [];
678
482
- // Handle schedule based on task type
679
+ // Handle task type specific data
680
if (this.editingTask.type === 'scheduled') {
681
// Ensure schedule is properly formatted as an object
682
if (typeof this.editingTask.schedule === 'string') {
@@ -490,19 +687,87 @@ document.addEventListener('alpine:init', () => {
687
hour: parts[1] || '*',
688
day: parts[2] || '*',
689
month: parts[3] || '*',
493
- weekday: parts[4] || '*'
690
+ weekday: parts[4] || '*',
691
+ timezone: getUserTimezone() // Add timezone to schedule object
692
};
693
} else {
496
- // Use object schedule directly
497
- taskData.schedule = this.editingTask.schedule;
694
+ // Use object schedule directly but ensure timezone is included
695
+ taskData.schedule = {
696
+ ...this.editingTask.schedule,
697
+ timezone: this.editingTask.schedule.timezone || getUserTimezone()
698
+ };
699
}
499
- // Don't send token for scheduled tasks
700
+ // Don't send token or plan for scheduled tasks
701
delete taskData.token;
501
- } else {
702
+ delete taskData.plan;
703
+ } else if (this.editingTask.type === 'adhoc') {
704
// Ad-hoc task with token
705
+ // Ensure token is a non-empty string, generate a new one if needed
706
+ if (!this.editingTask.token) {
707
+ this.editingTask.token = this.generateRandomToken();
708
+ console.log('Generated new token for adhoc task:', this.editingTask.token);
709
+ }
710
+
711
+ console.log('Setting token in taskData:', this.editingTask.token);
712
taskData.token = this.editingTask.token;
504
- // Don't send schedule for adhoc tasks
713
+
714
+ // Don't send schedule or plan for adhoc tasks
715
+ delete taskData.schedule;
716
+ delete taskData.plan;
717
+ } else if (this.editingTask.type === 'planned') {
718
+ // Planned task with plan
719
+ // Make sure plan exists and has required properties
720
+ if (!this.editingTask.plan) {
721
+ this.editingTask.plan = {
722
+ todo: [],
723
+ in_progress: null,
724
+ done: []
725
+ };
726
+ }
727
+
728
+ // Ensure todo and done are arrays
729
+ if (!Array.isArray(this.editingTask.plan.todo)) {
730
+ this.editingTask.plan.todo = [];
731
+ }
732
+
733
+ if (!Array.isArray(this.editingTask.plan.done)) {
734
+ this.editingTask.plan.done = [];
735
+ }
736
+
737
+ // Validate each date in the todo list to ensure it's a valid ISO string
738
+ const validatedTodo = [];
739
+ for (const dateStr of this.editingTask.plan.todo) {
740
+ try {
741
+ const date = new Date(dateStr);
742
+ if (!isNaN(date.getTime())) {
743
+ validatedTodo.push(date.toISOString());
744
+ } else {
745
+ console.warn(`Skipping invalid date in todo list: ${dateStr}`);
746
+ }
747
+ } catch (error) {
748
+ console.warn(`Error processing date: ${error.message}`);
749
+ }
750
+ }
751
+
752
+ // Replace with validated list
753
+ this.editingTask.plan.todo = validatedTodo;
754
+
755
+ // Sort the todo items by date (earliest first)
756
+ this.editingTask.plan.todo.sort();
757
+
758
+ // Set the plan in taskData
759
+ taskData.plan = {
760
+ todo: this.editingTask.plan.todo,
761
+ in_progress: this.editingTask.plan.in_progress,
762
+ done: this.editingTask.plan.done || []
763
+ };
764
+
765
+ // Log the plan data for debugging
766
+ console.log('Planned task plan data:', JSON.stringify(taskData.plan, null, 2));
767
+
768
+ // Don't send schedule or token for planned tasks
769
delete taskData.schedule;
770
+ delete taskData.token;
771
}
772
773
// Determine if creating or updating
@@ -513,6 +778,9 @@ document.addEventListener('alpine:init', () => {
778
taskData.task_id = this.editingTask.uuid;
779
}
780
781
+ // Debug: Log the final task data being sent
782
+ console.log('Final task data being sent to API:', JSON.stringify(taskData, null, 2));
783
+
784
// Make API request
785
const response = await fetch(apiEndpoint, {
786
method: 'POST',
@@ -527,14 +795,73 @@ document.addEventListener('alpine:init', () => {
795
throw new Error(errorData.error || 'Failed to save task');
796
}
797
798
+ // Parse response data to get the created/updated task
799
+ const responseData = await response.json();
800
+
801
// Show success message
802
showToast(this.isCreating ? 'Task created successfully' : 'Task updated successfully', 'success');
803
533
- // Refresh task list
534
- this.fetchTasks();
804
+ // Immediately update the UI if the response includes the task
805
+ if (responseData && responseData.task) {
806
+ console.log('Task received in response:', responseData.task);
807
+
808
+ // Update the tasks array
809
+ if (this.isCreating) {
810
+ // For new tasks, add to the array
811
+ this.tasks = [...this.tasks, responseData.task];
812
+ } else {
813
+ // For updated tasks, replace the existing one
814
+ this.tasks = this.tasks.map(t =>
815
+ t.uuid === responseData.task.uuid ? responseData.task : t
816
+ );
817
+ }
818
+
819
+ // Update UI using the shared function
820
+ this.updateTasksUI();
821
+ } else {
822
+ // Fallback to fetching tasks if no task in response
823
+ await this.fetchTasks();
824
+ }
825
536
- // Reset form to default state without setting to null
537
- this.cancelEdit();
826
+ // Clean up Flatpickr instances
827
+ const destroyFlatpickr = (inputId) => {
828
+ const input = document.getElementById(inputId);
829
+ if (input && input._flatpickr) {
830
+ input._flatpickr.destroy();
831
+ }
832
+ };
833
+
834
+ if (this.isCreating) {
835
+ destroyFlatpickr('newPlannedTime-create');
836
+ } else if (this.isEditing) {
837
+ destroyFlatpickr('newPlannedTime-edit');
838
+ }
839
+
840
+ // Reset task data and form state
841
+ this.editingTask = {
842
+ name: '',
843
+ type: 'scheduled',
844
+ state: 'idle',
845
+ schedule: {
846
+ minute: '*',
847
+ hour: '*',
848
+ day: '*',
849
+ month: '*',
850
+ weekday: '*',
851
+ timezone: getUserTimezone()
852
+ },
853
+ token: '',
854
+ plan: {
855
+ todo: [],
856
+ in_progress: null,
857
+ done: []
858
+ },
859
+ system_prompt: '',
860
+ prompt: '',
861
+ attachments: []
862
+ };
863
+ this.isCreating = false;
864
+ this.isEditing = false;
865
document.querySelector('[x-data="schedulerSettings"]')?.removeAttribute('data-editing-state');
866
} catch (error) {
867
console.error('Error saving task:', error);
@@ -550,7 +877,10 @@ document.addEventListener('alpine:init', () => {
877
headers: {
878
'Content-Type': 'application/json'
879
},
553
- body: JSON.stringify({ task_id: taskId })
880
+ body: JSON.stringify({
881
+ task_id: taskId,
882
+ timezone: getUserTimezone()
883
+ })
884
});
885
886
if (!response.ok) {
@@ -593,7 +923,8 @@ document.addEventListener('alpine:init', () => {
923
},
924
body: JSON.stringify({
925
task_id: taskId,
596
- state: 'idle' // Always reset to idle state
926
+ state: 'idle', // Always reset to idle state
927
+ timezone: getUserTimezone()
928
})
929
});
930
@@ -627,7 +958,10 @@ document.addEventListener('alpine:init', () => {
958
headers: {
959
'Content-Type': 'application/json'
960
},
630
- body: JSON.stringify({ task_id: taskId })
961
+ body: JSON.stringify({
962
+ task_id: taskId,
963
+ timezone: getUserTimezone()
964
+ })
965
});
966
967
if (!response.ok) {
@@ -637,19 +971,44 @@ document.addEventListener('alpine:init', () => {
971
972
showToast('Task deleted successfully', 'success');
973
640
- // Refresh task list
641
- this.fetchTasks();
642
-
643
- // Close expanded view if this task was expanded
644
- if (this.expandedTaskId === taskId) {
645
- this.expandedTaskId = null;
974
+ // If we were viewing the detail of the deleted task, close the detail view
975
+ if (this.selectedTaskForDetail && this.selectedTaskForDetail.uuid === taskId) {
976
+ this.closeTaskDetail();
977
}
978
+
979
+ // Immediately update UI without waiting for polling
980
+ this.tasks = this.tasks.filter(t => t.uuid !== taskId);
981
+
982
+ // Update UI using the shared function
983
+ this.updateTasksUI();
984
} catch (error) {
985
console.error('Error deleting task:', error);
986
showToast('Failed to delete task: ' + error.message, 'error');
987
}
988
},
989
990
+ // Initialize datetime input with default value (30 minutes from now)
991
+ initDateTimeInput(event) {
992
+ if (!event.target.value) {
993
+ const now = new Date();
994
+ now.setMinutes(now.getMinutes() + 30);
995
+
996
+ // Format as YYYY-MM-DDThh:mm
997
+ const year = now.getFullYear();
998
+ const month = String(now.getMonth() + 1).padStart(2, '0');
999
+ const day = String(now.getDate()).padStart(2, '0');
1000
+ const hours = String(now.getHours()).padStart(2, '0');
1001
+ const minutes = String(now.getMinutes()).padStart(2, '0');
1002
+
1003
+ event.target.value = `${year}-${month}-${day}T${hours}:${minutes}`;
1004
+
1005
+ // If using Flatpickr, update it as well
1006
+ if (event.target._flatpickr) {
1007
+ event.target._flatpickr.setDate(event.target.value);
1008
+ }
1009
+ }
1010
+ },
1011
+
1012
// Generate a random token for ad-hoc tasks
1013
generateRandomToken() {
1014
const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
@@ -660,6 +1019,72 @@ document.addEventListener('alpine:init', () => {
1019
return token;
1020
},
1021
1022
+ // Getter for filtered tasks
1023
+ get filteredTasks() {
1024
+ // Make sure we have tasks to filter
1025
+ if (!Array.isArray(this.tasks)) {
1026
+ console.warn('Tasks is not an array:', this.tasks);
1027
+ return [];
1028
+ }
1029
+
1030
+ let filtered = [...this.tasks];
1031
+
1032
+ // Apply type filter with case-insensitive comparison
1033
+ if (this.filterType && this.filterType !== 'all') {
1034
+ filtered = filtered.filter(task => {
1035
+ if (!task.type) return false;
1036
+ return String(task.type).toLowerCase() === this.filterType.toLowerCase();
1037
+ });
1038
+ }
1039
+
1040
+ // Apply state filter with case-insensitive comparison
1041
+ if (this.filterState && this.filterState !== 'all') {
1042
+ filtered = filtered.filter(task => {
1043
+ if (!task.state) return false;
1044
+ return String(task.state).toLowerCase() === this.filterState.toLowerCase();
1045
+ });
1046
+ }
1047
+
1048
+ // Sort the filtered tasks
1049
+ return this.sortTasks(filtered);
1050
+ },
1051
+
1052
+ // Sort the tasks based on sort field and direction
1053
+ sortTasks(tasks) {
1054
+ if (!Array.isArray(tasks) || tasks.length === 0) {
1055
+ return tasks;
1056
+ }
1057
+
1058
+ return [...tasks].sort((a, b) => {
1059
+ if (!this.sortField) return 0;
1060
+
1061
+ const fieldA = a[this.sortField];
1062
+ const fieldB = b[this.sortField];
1063
+
1064
+ // Handle cases where fields might be undefined
1065
+ if (fieldA === undefined && fieldB === undefined) return 0;
1066
+ if (fieldA === undefined) return 1;
1067
+ if (fieldB === undefined) return -1;
1068
+
1069
+ // For dates, convert to timestamps
1070
+ if (this.sortField === 'createdAt' || this.sortField === 'updatedAt') {
1071
+ const dateA = new Date(fieldA).getTime();
1072
+ const dateB = new Date(fieldB).getTime();
1073
+ return this.sortDirection === 'asc' ? dateA - dateB : dateB - dateA;
1074
+ }
1075
+
1076
+ // For string comparisons
1077
+ if (typeof fieldA === 'string' && typeof fieldB === 'string') {
1078
+ return this.sortDirection === 'asc'
1079
+ ? fieldA.localeCompare(fieldB)
1080
+ : fieldB.localeCompare(fieldA);
1081
+ }
1082
+
1083
+ // For numerical comparisons
1084
+ return this.sortDirection === 'asc' ? fieldA - fieldB : fieldB - fieldA;
1085
+ });
1086
+ },
1087
+
1088
// Computed property for attachments text representation
1089
get attachmentsText() {
1090
// Ensure we always have an array to work with
@@ -667,16 +1092,12 @@ document.addEventListener('alpine:init', () => {
1092
? this.editingTask.attachments
1093
: [];
1094
670
- console.log('attachmentsText getter called, source:', this.editingTask.attachments);
671
-
1095
// Join array items with newlines
1096
return attachments.join('\n');
1097
},
1098
1099
// Setter for attachments text - preserves empty lines during editing
1100
set attachmentsText(value) {
678
- console.log('attachmentsText setter called with:', value);
679
-
1101
if (typeof value === 'string') {
1102
// Just split by newlines without filtering to preserve editing experience
1103
this.editingTask.attachments = value.split('\n');
@@ -684,8 +1105,572 @@ document.addEventListener('alpine:init', () => {
1105
// Fallback to empty array if not a string
1106
this.editingTask.attachments = [];
1107
}
1108
+ },
1109
+
1110
+ // Debug method to test filtering logic
1111
+ testFiltering() {
1112
+ console.group('SchedulerSettings Debug: Filter Test');
1113
+ console.log('Current Filter Settings:');
1114
+ console.log('- Filter Type:', this.filterType);
1115
+ console.log('- Filter State:', this.filterState);
1116
+ console.log('- Sort Field:', this.sortField);
1117
+ console.log('- Sort Direction:', this.sortDirection);
1118
+
1119
+ // Check if tasks is an array
1120
+ if (!Array.isArray(this.tasks)) {
1121
+ console.error('ERROR: this.tasks is not an array!', this.tasks);
1122
+ console.groupEnd();
1123
+ return;
1124
+ }
1125
+
1126
+ console.log(`Raw Tasks (${this.tasks.length}):`, this.tasks);
1127
+
1128
+ // Test filtering by type
1129
+ console.group('Filter by Type Test');
1130
+ ['all', 'adhoc', 'scheduled', 'recurring'].forEach(type => {
1131
+ const filtered = this.tasks.filter(task =>
1132
+ type === 'all' ||
1133
+ (task.type && String(task.type).toLowerCase() === type)
1134
+ );
1135
+ console.log(`Type "${type}": ${filtered.length} tasks`, filtered);
1136
+ });
1137
+ console.groupEnd();
1138
+
1139
+ // Test filtering by state
1140
+ console.group('Filter by State Test');
1141
+ ['all', 'idle', 'running', 'completed', 'failed'].forEach(state => {
1142
+ const filtered = this.tasks.filter(task =>
1143
+ state === 'all' ||
1144
+ (task.state && String(task.state).toLowerCase() === state)
1145
+ );
1146
+ console.log(`State "${state}": ${filtered.length} tasks`, filtered);
1147
+ });
1148
+ console.groupEnd();
1149
+
1150
+ // Show current filtered tasks
1151
+ console.log('Current Filtered Tasks:', this.filteredTasks);
1152
+
1153
+ console.groupEnd();
1154
+ },
1155
+
1156
+ // New comprehensive debug method
1157
+ debugTasks() {
1158
+ console.group('SchedulerSettings Comprehensive Debug');
1159
+
1160
+ // Component state
1161
+ console.log('Component State:');
1162
+ console.log({
1163
+ filterType: this.filterType,
1164
+ filterState: this.filterState,
1165
+ sortField: this.sortField,
1166
+ sortDirection: this.sortDirection,
1167
+ isLoading: this.isLoading,
1168
+ isEditing: this.isEditing,
1169
+ isCreating: this.isCreating,
1170
+ viewMode: this.viewMode
1171
+ });
1172
+
1173
+ // Tasks validation
1174
+ if (!this.tasks) {
1175
+ console.error('ERROR: this.tasks is undefined or null!');
1176
+ console.groupEnd();
1177
+ return;
1178
+ }
1179
+
1180
+ if (!Array.isArray(this.tasks)) {
1181
+ console.error('ERROR: this.tasks is not an array!', typeof this.tasks, this.tasks);
1182
+ console.groupEnd();
1183
+ return;
1184
+ }
1185
+
1186
+ // Raw tasks
1187
+ console.group('Raw Tasks');
1188
+ console.log(`Count: ${this.tasks.length}`);
1189
+ if (this.tasks.length > 0) {
1190
+ console.table(this.tasks.map(t => ({
1191
+ uuid: t.uuid,
1192
+ name: t.name,
1193
+ type: t.type,
1194
+ state: t.state
1195
+ })));
1196
+
1197
+ // Inspect first task in detail
1198
+ console.log('First Task Structure:', JSON.stringify(this.tasks[0], null, 2));
1199
+ } else {
1200
+ console.log('No tasks available');
1201
+ }
1202
+ console.groupEnd();
1203
+
1204
+ // Filtered tasks
1205
+ console.group('Filtered Tasks');
1206
+ const filteredTasks = this.filteredTasks;
1207
+ console.log(`Count: ${filteredTasks.length}`);
1208
+ if (filteredTasks.length > 0) {
1209
+ console.table(filteredTasks.map(t => ({
1210
+ uuid: t.uuid,
1211
+ name: t.name,
1212
+ type: t.type,
1213
+ state: t.state
1214
+ })));
1215
+ } else {
1216
+ console.log('No filtered tasks');
1217
+ }
1218
+ console.groupEnd();
1219
+
1220
+ // Check for potential issues
1221
+ console.group('Potential Issues');
1222
+
1223
+ // Check for case mismatches
1224
+ if (this.tasks.length > 0 && filteredTasks.length === 0) {
1225
+ console.warn('Filter seems to exclude all tasks. Checking why:');
1226
+
1227
+ // Check type values
1228
+ const uniqueTypes = [...new Set(this.tasks.map(t => t.type))];
1229
+ console.log('Unique task types in data:', uniqueTypes);
1230
+
1231
+ // Check state values
1232
+ const uniqueStates = [...new Set(this.tasks.map(t => t.state))];
1233
+ console.log('Unique task states in data:', uniqueStates);
1234
+
1235
+ // Check for exact mismatches
1236
+ if (this.filterType !== 'all') {
1237
+ const typeMatch = this.tasks.some(t =>
1238
+ t.type && String(t.type).toLowerCase() === this.filterType.toLowerCase()
1239
+ );
1240
+ console.log(`Type "${this.filterType}" matches found:`, typeMatch);
1241
+ }
1242
+
1243
+ if (this.filterState !== 'all') {
1244
+ const stateMatch = this.tasks.some(t =>
1245
+ t.state && String(t.state).toLowerCase() === this.filterState.toLowerCase()
1246
+ );
1247
+ console.log(`State "${this.filterState}" matches found:`, stateMatch);
1248
+ }
1249
+ }
1250
+
1251
+ // Check for undefined or null values
1252
+ const hasUndefinedType = this.tasks.some(t => t.type === undefined || t.type === null);
1253
+ const hasUndefinedState = this.tasks.some(t => t.state === undefined || t.state === null);
1254
+
1255
+ if (hasUndefinedType) {
1256
+ console.warn('Some tasks have undefined or null type values!');
1257
+ }
1258
+
1259
+ if (hasUndefinedState) {
1260
+ console.warn('Some tasks have undefined or null state values!');
1261
+ }
1262
+
1263
+ console.groupEnd();
1264
+
1265
+ console.groupEnd();
1266
+ },
1267
+
1268
+ // Initialize Flatpickr datetime pickers for both create and edit forms
1269
+ /**
1270
+ * Initialize Flatpickr date/time pickers for scheduler forms
1271
+ *
1272
+ * @param {string} mode - Which pickers to initialize: 'all', 'create', or 'edit'
1273
+ * @returns {void}
1274
+ */
1275
+ initFlatpickr(mode = 'all') {
1276
+ const initPicker = (inputId, refName, wrapperClass, options = {}) => {
1277
+ // Try to get input using Alpine.js x-ref first (more reliable)
1278
+ let input = this.$refs[refName];
1279
+
1280
+ // Fall back to getElementById if x-ref is not available
1281
+ if (!input) {
1282
+ input = document.getElementById(inputId);
1283
+ console.log(`Using getElementById fallback for ${inputId}`);
1284
+ }
1285
+
1286
+ if (!input) {
1287
+ console.warn(`Input element ${inputId} not found by ID or ref`);
1288
+ return null;
1289
+ }
1290
+
1291
+ // Create a wrapper around the input
1292
+ const wrapper = document.createElement('div');
1293
+ wrapper.className = wrapperClass || 'scheduler-flatpickr-wrapper';
1294
+ wrapper.style.overflow = 'visible'; // Ensure dropdown can escape container
1295
+
1296
+ // Replace the input with our wrapped version
1297
+ input.parentNode.insertBefore(wrapper, input);
1298
+ wrapper.appendChild(input);
1299
+ input.classList.add('scheduler-flatpickr-input');
1300
+
1301
+ // Default options
1302
+ const defaultOptions = {
1303
+ dateFormat: "Y-m-d H:i",
1304
+ enableTime: true,
1305
+ time_24hr: true,
1306
+ static: false, // Not static so it will float
1307
+ appendTo: document.body, // Append to body to avoid overflow issues
1308
+ theme: "scheduler-theme",
1309
+ allowInput: true,
1310
+ positionElement: wrapper, // Position relative to wrapper
1311
+ onOpen: function(selectedDates, dateStr, instance) {
1312
+ // Ensure calendar is properly positioned and visible
1313
+ instance.calendarContainer.style.zIndex = '9999';
1314
+ instance.calendarContainer.style.position = 'absolute';
1315
+ instance.calendarContainer.style.visibility = 'visible';
1316
+ instance.calendarContainer.style.opacity = '1';
1317
+
1318
+ // Add class to calendar container for our custom styling
1319
+ instance.calendarContainer.classList.add('scheduler-theme');
1320
+ },
1321
+ // Set default date to 30 minutes from now if no date selected
1322
+ onReady: function(selectedDates, dateStr, instance) {
1323
+ if (!dateStr) {
1324
+ const now = new Date();
1325
+ now.setMinutes(now.getMinutes() + 30);
1326
+ instance.setDate(now, true);
1327
+ }
1328
+ }
1329
+ };
1330
+
1331
+ // Merge options
1332
+ const mergedOptions = {...defaultOptions, ...options};
1333
+
1334
+ // Initialize flatpickr
1335
+ const fp = flatpickr(input, mergedOptions);
1336
+
1337
+ // Add a clear button
1338
+ const clearButton = document.createElement('button');
1339
+ clearButton.className = 'scheduler-flatpickr-clear';
1340
+ clearButton.innerHTML = '×';
1341
+ clearButton.type = 'button';
1342
+ clearButton.addEventListener('click', (e) => {
1343
+ e.preventDefault();
1344
+ e.stopPropagation();
1345
+ if (fp) {
1346
+ fp.clear();
1347
+ }
1348
+ });
1349
+ wrapper.appendChild(clearButton);
1350
+
1351
+ return fp;
1352
+ };
1353
+
1354
+ // Clear any existing Flatpickr instances to prevent duplication
1355
+ if (mode === 'all' || mode === 'create') {
1356
+ const createInput = document.getElementById('newPlannedTime-create');
1357
+ if (createInput && createInput._flatpickr) {
1358
+ createInput._flatpickr.destroy();
1359
+ }
1360
+ }
1361
+
1362
+ if (mode === 'all' || mode === 'edit') {
1363
+ const editInput = document.getElementById('newPlannedTime-edit');
1364
+ if (editInput && editInput._flatpickr) {
1365
+ editInput._flatpickr.destroy();
1366
+ }
1367
+ }
1368
+
1369
+ // Initialize new instances
1370
+ if (mode === 'all' || mode === 'create') {
1371
+ initPicker('newPlannedTime-create', 'plannedTimeCreate', 'scheduler-flatpickr-wrapper', {
1372
+ minuteIncrement: 5,
1373
+ defaultHour: new Date().getHours(),
1374
+ defaultMinute: Math.ceil(new Date().getMinutes() / 5) * 5
1375
+ });
1376
+ }
1377
688
- console.log('editingTask.attachments is now:', this.editingTask.attachments);
1378
+ if (mode === 'all' || mode === 'edit') {
1379
+ initPicker('newPlannedTime-edit', 'plannedTimeEdit', 'scheduler-flatpickr-wrapper', {
1380
+ minuteIncrement: 5,
1381
+ defaultHour: new Date().getHours(),
1382
+ defaultMinute: Math.ceil(new Date().getMinutes() / 5) * 5
1383
+ });
1384
+ }
1385
+ },
1386
+
1387
+ // Update tasks UI
1388
+ updateTasksUI() {
1389
+ // First update filteredTasks if that method exists
1390
+ if (typeof this.updateFilteredTasks === 'function') {
1391
+ this.updateFilteredTasks();
1392
+ }
1393
+
1394
+ // Wait for UI to update
1395
+ this.$nextTick(() => {
1396
+ // Get empty state and task list elements
1397
+ const emptyElement = document.querySelector('.scheduler-empty');
1398
+ const tableElement = document.querySelector('.scheduler-task-list');
1399
+
1400
+ // Calculate visibility state based on filtered tasks
1401
+ const hasFilteredTasks = Array.isArray(this.filteredTasks) && this.filteredTasks.length > 0;
1402
+
1403
+ // Update visibility directly
1404
+ if (emptyElement) {
1405
+ emptyElement.style.display = !hasFilteredTasks ? '' : 'none';
1406
+ }
1407
+
1408
+ if (tableElement) {
1409
+ tableElement.style.display = hasFilteredTasks ? '' : 'none';
1410
+ }
1411
+ });
1412
+ }
1413
+ };
1414
+};
1415
+
1416
+// Only define the component if it doesn't already exist or extend the existing one
1417
+if (!window.schedulerSettings) {
1418
+ console.log('Defining schedulerSettings component from scratch');
1419
+ window.schedulerSettings = fullComponentImplementation;
1420
+} else {
1421
+ console.log('Extending existing schedulerSettings component');
1422
+ // Store the original function
1423
+ const originalSchedulerSettings = window.schedulerSettings;
1424
+
1425
+ // Replace with enhanced version that merges the pre-initialized stub with the full implementation
1426
+ window.schedulerSettings = function() {
1427
+ // Get the base pre-initialized component
1428
+ const baseComponent = originalSchedulerSettings();
1429
+
1430
+ // Create a backup of the original init function
1431
+ const originalInit = baseComponent.init || function() {};
1432
+
1433
+ // Create our enhanced init function that adds the missing functionality
1434
+ baseComponent.init = function() {
1435
+ // Call the original init if it exists
1436
+ originalInit.call(this);
1437
+
1438
+ console.log('Enhanced init running: adding missing methods to component');
1439
+
1440
+ // Get the full implementation
1441
+ const fullImpl = fullComponentImplementation();
1442
+
1443
+ // Add essential methods directly
1444
+ const essentialMethods = [
1445
+ 'fetchTasks', 'startPolling', 'stopPolling',
1446
+ 'startCreateTask', 'startEditTask', 'cancelEdit',
1447
+ 'saveTask', 'runTask', 'resetTaskState', 'deleteTask',
1448
+ 'toggleTaskExpand', 'showTaskDetail', 'closeTaskDetail',
1449
+ 'changeSort', 'formatDate', 'formatPlan', 'formatSchedule',
1450
+ 'getStateBadgeClass', 'generateRandomToken', 'testFiltering',
1451
+ 'debugTasks', 'sortTasks', 'initFlatpickr', 'initDateTimeInput',
1452
+ 'updateTasksUI'
1453
+ ];
1454
+
1455
+ essentialMethods.forEach(method => {
1456
+ if (typeof this[method] !== 'function' && typeof fullImpl[method] === 'function') {
1457
+ console.log(`Adding missing method: ${method}`);
1458
+ this[method] = fullImpl[method];
1459
+ }
1460
+ });
1461
+
1462
+ // Make sure we have a filteredTasks array initialized
1463
+ this.filteredTasks = [];
1464
+
1465
+ // Initialize essential properties if missing
1466
+ if (!Array.isArray(this.tasks)) {
1467
+ this.tasks = [];
1468
+ }
1469
+
1470
+ // Make sure attachmentsText getter/setter are defined
1471
+ if (!Object.getOwnPropertyDescriptor(this, 'attachmentsText')?.get) {
1472
+ Object.defineProperty(this, 'attachmentsText', {
1473
+ get: function() {
1474
+ // Ensure we always have an array to work with
1475
+ const attachments = Array.isArray(this.editingTask?.attachments)
1476
+ ? this.editingTask.attachments
1477
+ : [];
1478
+
1479
+ // Join array items with newlines
1480
+ return attachments.join('\n');
1481
+ },
1482
+ set: function(value) {
1483
+ if (!this.editingTask) {
1484
+ this.editingTask = { attachments: [] };
1485
+ }
1486
+
1487
+ if (typeof value === 'string') {
1488
+ // Just split by newlines without filtering to preserve editing experience
1489
+ this.editingTask.attachments = value.split('\n');
1490
+ } else {
1491
+ // Fallback to empty array if not a string
1492
+ this.editingTask.attachments = [];
1493
+ }
1494
+ }
1495
+ });
1496
+ }
1497
+
1498
+ // Add methods for updating filteredTasks directly
1499
+ if (typeof this.updateFilteredTasks !== 'function') {
1500
+ this.updateFilteredTasks = function() {
1501
+ // Make sure we have tasks to filter
1502
+ if (!Array.isArray(this.tasks)) {
1503
+ this.filteredTasks = [];
1504
+ return;
1505
+ }
1506
+
1507
+ let filtered = [...this.tasks];
1508
+
1509
+ // Apply type filter with case-insensitive comparison
1510
+ if (this.filterType && this.filterType !== 'all') {
1511
+ filtered = filtered.filter(task => {
1512
+ if (!task.type) return false;
1513
+ return String(task.type).toLowerCase() === this.filterType.toLowerCase();
1514
+ });
1515
+ }
1516
+
1517
+ // Apply state filter with case-insensitive comparison
1518
+ if (this.filterState && this.filterState !== 'all') {
1519
+ filtered = filtered.filter(task => {
1520
+ if (!task.state) return false;
1521
+ return String(task.state).toLowerCase() === this.filterState.toLowerCase();
1522
+ });
1523
+ }
1524
+
1525
+ // Sort the filtered tasks
1526
+ if (typeof this.sortTasks === 'function') {
1527
+ filtered = this.sortTasks(filtered);
1528
+ }
1529
+
1530
+ // Directly update the filteredTasks property
1531
+ this.filteredTasks = filtered;
1532
+ };
1533
+ }
1534
+
1535
+ // Set up watchers to update filtered tasks when dependencies change
1536
+ this.$nextTick(() => {
1537
+ // Update filtered tasks when raw tasks change
1538
+ this.$watch('tasks', () => {
1539
+ this.updateFilteredTasks();
1540
+ });
1541
+
1542
+ // Update filtered tasks when filter type changes
1543
+ this.$watch('filterType', () => {
1544
+ this.updateFilteredTasks();
1545
+ });
1546
+
1547
+ // Update filtered tasks when filter state changes
1548
+ this.$watch('filterState', () => {
1549
+ this.updateFilteredTasks();
1550
+ });
1551
+
1552
+ // Update filtered tasks when sort field or direction changes
1553
+ this.$watch('sortField', () => {
1554
+ this.updateFilteredTasks();
1555
+ });
1556
+
1557
+ this.$watch('sortDirection', () => {
1558
+ this.updateFilteredTasks();
1559
+ });
1560
+
1561
+ // Initial update
1562
+ this.updateFilteredTasks();
1563
+
1564
+ // Set up watcher for task type changes to initialize Flatpickr for planned tasks
1565
+ this.$watch('editingTask.type', (newType) => {
1566
+ if (newType === 'planned') {
1567
+ this.$nextTick(() => {
1568
+ // Reinitialize Flatpickr when switching to planned task type
1569
+ if (this.isCreating) {
1570
+ this.initFlatpickr('create');
1571
+ } else if (this.isEditing) {
1572
+ this.initFlatpickr('edit');
1573
+ }
1574
+ });
1575
+ }
1576
+ });
1577
+
1578
+ // Initialize Flatpickr
1579
+ this.$nextTick(() => {
1580
+ if (typeof this.initFlatpickr === 'function') {
1581
+ this.initFlatpickr();
1582
+ } else {
1583
+ console.error('initFlatpickr is not available');
1584
+ }
1585
+ });
1586
+ });
1587
+
1588
+ // Try fetching tasks after a short delay
1589
+ setTimeout(() => {
1590
+ if (typeof this.fetchTasks === 'function') {
1591
+ this.fetchTasks();
1592
+ } else {
1593
+ console.error('fetchTasks still not available after enhancement');
1594
+ }
1595
+ }, 100);
1596
+
1597
+ console.log('Enhanced init complete');
1598
+ };
1599
+
1600
+ return baseComponent;
1601
+ };
1602
+}
1603
+
1604
+// Force Alpine.js to register the component immediately
1605
+if (window.Alpine) {
1606
+ // Alpine is already loaded, register now
1607
+ console.log('Alpine already loaded, registering schedulerSettings component now');
1608
+ window.Alpine.data('schedulerSettings', window.schedulerSettings);
1609
+} else {
1610
+ // Wait for Alpine to load
1611
+ document.addEventListener('alpine:init', () => {
1612
+ console.log('Alpine:init - immediately registering schedulerSettings component');
1613
+ Alpine.data('schedulerSettings', window.schedulerSettings);
1614
+ });
1615
+}
1616
+
1617
+// Add a document ready event handler to ensure the scheduler tab can be clicked on first load
1618
+document.addEventListener('DOMContentLoaded', function() {
1619
+ console.log('DOMContentLoaded - setting up scheduler tab click handler');
1620
+ // Setup scheduler tab click handling
1621
+ const setupSchedulerTab = () => {
1622
+ const settingsModal = document.getElementById('settingsModal');
1623
+ if (!settingsModal) {
1624
+ setTimeout(setupSchedulerTab, 100);
1625
+ return;
1626
}
690
- }));
1627
+
1628
+ // Create a global event listener for clicks on the scheduler tab
1629
+ document.addEventListener('click', function(e) {
1630
+ // Find if the click was on the scheduler tab or its children
1631
+ const schedulerTab = e.target.closest('.settings-tab[title="Task Scheduler"]');
1632
+ if (!schedulerTab) return;
1633
+
1634
+ e.preventDefault();
1635
+ e.stopPropagation();
1636
+
1637
+ // Get the settings modal data
1638
+ try {
1639
+ const modalData = Alpine.$data(settingsModal);
1640
+ if (modalData.activeTab !== 'scheduler') {
1641
+ // Directly call the modal's switchTab method
1642
+ modalData.switchTab('scheduler');
1643
+ }
1644
+
1645
+ // Force start polling and fetch tasks immediately when tab is selected
1646
+ setTimeout(() => {
1647
+ // Get the scheduler component data
1648
+ const schedulerElement = document.querySelector('[x-data="schedulerSettings"]');
1649
+ if (schedulerElement) {
1650
+ const schedulerData = Alpine.$data(schedulerElement);
1651
+
1652
+ // Force fetch tasks and start polling
1653
+ if (typeof schedulerData.fetchTasks === 'function') {
1654
+ schedulerData.fetchTasks();
1655
+ } else {
1656
+ console.error('fetchTasks is not a function on scheduler component');
1657
+ }
1658
+
1659
+ if (typeof schedulerData.startPolling === 'function') {
1660
+ schedulerData.startPolling();
1661
+ } else {
1662
+ console.error('startPolling is not a function on scheduler component');
1663
+ }
1664
+ } else {
1665
+ console.error('Could not find scheduler component element');
1666
+ }
1667
+ }, 100);
1668
+ } catch (err) {
1669
+ console.error('Error handling scheduler tab click:', err);
1670
+ }
1671
+ }, true); // Use capture phase to intercept before Alpine.js handlers
1672
+ };
1673
+
1674
+ // Initialize the tab handling
1675
+ setupSchedulerTab();
1676
});
webui/js/settings.js
+76
-1
@@ -36,6 +36,36 @@ const settingsModalProxy = {
36
if (activeTab) {
37
activeTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
38
}
39
+
40
+ // When switching to the scheduler tab, initialize Flatpickr components
41
+ if (tabName === 'scheduler') {
42
+ console.log('Switching to scheduler tab, initializing Flatpickr');
43
+ const schedulerElement = document.querySelector('[x-data="schedulerSettings"]');
44
+ if (schedulerElement) {
45
+ const schedulerData = Alpine.$data(schedulerElement);
46
+ if (schedulerData) {
47
+ // Start polling
48
+ if (typeof schedulerData.startPolling === 'function') {
49
+ schedulerData.startPolling();
50
+ }
51
+
52
+ // Initialize Flatpickr if editing or creating
53
+ if (typeof schedulerData.initFlatpickr === 'function') {
54
+ // Check if we're creating or editing and initialize accordingly
55
+ if (schedulerData.isCreating) {
56
+ schedulerData.initFlatpickr('create');
57
+ } else if (schedulerData.isEditing) {
58
+ schedulerData.initFlatpickr('edit');
59
+ }
60
+ }
61
+
62
+ // Force an immediate fetch
63
+ if (typeof schedulerData.fetchTasks === 'function') {
64
+ schedulerData.fetchTasks();
65
+ }
66
+ }
67
+ }
68
+ }
69
}, 10);
70
},
71
@@ -103,9 +133,25 @@ const settingsModalProxy = {
133
}
134
// Debug log
135
const schedulerTab = document.querySelector('.settings-tab[title="Task Scheduler"]');
106
- console.log(`Current active tab after direct set: ${modalAD.activeTab}`);
136
+ console.log(`Current active tab after direct set: ${modalAD.activeTab}`);
137
console.log('Scheduler tab active after direct initialization?',
138
schedulerTab && schedulerTab.classList.contains('active'));
139
+
140
+ // Explicitly start polling if we're on the scheduler tab
141
+ if (modalAD.activeTab === 'scheduler') {
142
+ console.log('Settings opened directly to scheduler tab, initializing polling');
143
+ const schedulerElement = document.querySelector('[x-data="schedulerSettings"]');
144
+ if (schedulerElement) {
145
+ const schedulerData = Alpine.$data(schedulerElement);
146
+ if (schedulerData && typeof schedulerData.startPolling === 'function') {
147
+ schedulerData.startPolling();
148
+ // Also force an immediate fetch
149
+ if (typeof schedulerData.fetchTasks === 'function') {
150
+ schedulerData.fetchTasks();
151
+ }
152
+ }
153
+ }
154
+ }
155
}, 10); // Small delay just for scrolling
156
157
}, 5); // Keep a minimal delay for modal opening reactivity
@@ -170,6 +216,9 @@ const settingsModalProxy = {
216
this.handleCancel();
217
}
218
219
+ // Stop scheduler polling if it's running
220
+ this.stopSchedulerPolling();
221
+
222
// First update our component state
223
this.isOpen = false;
224
@@ -189,6 +238,9 @@ const settingsModalProxy = {
238
data: null
239
});
240
241
+ // Stop scheduler polling if it's running
242
+ this.stopSchedulerPolling();
243
+
244
// First update our component state
245
this.isOpen = false;
246
@@ -202,6 +254,19 @@ const settingsModalProxy = {
254
}
255
},
256
257
+ // Add a helper method to stop scheduler polling
258
+ stopSchedulerPolling() {
259
+ // Find the scheduler component and stop polling if it exists
260
+ const schedulerElement = document.querySelector('[x-data="schedulerSettings"]');
261
+ if (schedulerElement) {
262
+ const schedulerData = Alpine.$data(schedulerElement);
263
+ if (schedulerData && typeof schedulerData.stopPolling === 'function') {
264
+ console.log('Stopping scheduler polling on modal close');
265
+ schedulerData.stopPolling();
266
+ }
267
+ }
268
+ },
269
+
270
handleFieldButton(field) {
271
console.log(`Button clicked: ${field.action}`);
272
}
@@ -463,6 +528,16 @@ document.addEventListener('alpine:init', function () {
528
},
529
530
closeModal() {
531
+ // Stop scheduler polling before closing the modal
532
+ const schedulerElement = document.querySelector('[x-data="schedulerSettings"]');
533
+ if (schedulerElement) {
534
+ const schedulerData = Alpine.$data(schedulerElement);
535
+ if (schedulerData && typeof schedulerData.stopPolling === 'function') {
536
+ console.log('Stopping scheduler polling on modal close');
537
+ schedulerData.stopPolling();
538
+ }
539
+ }
540
+
541
this.$store.root.isOpen = false;
542
}
543
};
webui/js/time-utils.js
new
+71
@@ -0,0 +1,71 @@
1
+/**
2
+ * Time utilities for handling UTC to local time conversion
3
+ */
4
+
5
+/**
6
+ * Convert a UTC ISO string to a local time string
7
+ * @param {string} utcIsoString - UTC time in ISO format
8
+ * @param {Object} options - Formatting options for Intl.DateTimeFormat
9
+ * @returns {string} Formatted local time string
10
+ */
11
+export function toLocalTime(utcIsoString, options = {}) {
12
+ if (!utcIsoString) return '';
13
+
14
+ const date = new Date(utcIsoString);
15
+ const defaultOptions = {
16
+ dateStyle: 'medium',
17
+ timeStyle: 'medium'
18
+ };
19
+
20
+ return new Intl.DateTimeFormat(
21
+ undefined, // Use browser's locale
22
+ { ...defaultOptions, ...options }
23
+ ).format(date);
24
+}
25
+
26
+/**
27
+ * Convert a local Date object to UTC ISO string
28
+ * @param {Date} date - Date object in local time
29
+ * @returns {string} UTC ISO string
30
+ */
31
+export function toUTCISOString(date) {
32
+ if (!date) return '';
33
+ return date.toISOString();
34
+}
35
+
36
+/**
37
+ * Get current time as UTC ISO string
38
+ * @returns {string} Current UTC time in ISO format
39
+ */
40
+export function getCurrentUTCISOString() {
41
+ return new Date().toISOString();
42
+}
43
+
44
+/**
45
+ * Format a UTC ISO string for display in local time with configurable format
46
+ * @param {string} utcIsoString - UTC time in ISO format
47
+ * @param {string} format - Format type ('full', 'date', 'time', 'short')
48
+ * @returns {string} Formatted local time string
49
+ */
50
+export function formatDateTime(utcIsoString, format = 'full') {
51
+ if (!utcIsoString) return '';
52
+
53
+ const date = new Date(utcIsoString);
54
+
55
+ const formatOptions = {
56
+ full: { dateStyle: 'medium', timeStyle: 'medium' },
57
+ date: { dateStyle: 'medium' },
58
+ time: { timeStyle: 'medium' },
59
+ short: { dateStyle: 'short', timeStyle: 'short' }
60
+ };
61
+
62
+ return toLocalTime(utcIsoString, formatOptions[format] || formatOptions.full);
63
+}
64
+
65
+/**
66
+ * Get the user's local timezone name
67
+ * @returns {string} Timezone name (e.g., 'America/New_York')
68
+ */
69
+export function getUserTimezone() {
70
+ return Intl.DateTimeFormat().resolvedOptions().timeZone;
71
+}