Rafael Uzarowski commited on
Commit
6ff9f06
·
unverified ·
1 Parent(s): ad2b861

(WIP) feat: Task Scheduler Management UI/UX and tools - Part 3

Browse files
agent.py CHANGED
@@ -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
 
31
 
32
  class AgentContext:
@@ -80,6 +81,21 @@ class AgentContext:
80
  context.task.kill()
81
  return context
82
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
  def get_created_at(self):
84
  return self.created_at
85
 
@@ -654,8 +670,13 @@ class Agent:
654
 
655
  if tool_request is not None:
656
  tool_name = tool_request.get("tool_name", "")
 
657
  tool_args = tool_request.get("tool_args", {})
658
- tool = self.get_tool(tool_name, tool_args, msg)
 
 
 
 
659
 
660
  await self.handle_intervention() # wait if paused and handle intervention message if needed
661
  await tool.before_execution(**tool_args)
@@ -685,7 +706,7 @@ class Agent:
685
  except Exception as e:
686
  pass
687
 
688
- def get_tool(self, name: str, args: dict, message: str, **kwargs):
689
  from python.tools.unknown import Unknown
690
  from python.helpers.tool import Tool
691
 
@@ -693,7 +714,7 @@ class Agent:
693
  "python/tools", name + ".py", Tool
694
  )
695
  tool_class = classes[0] if classes else Unknown
696
- return tool_class(agent=self, name=name, args=args, message=message, **kwargs)
697
 
698
  async def call_extensions(self, folder: str, **kwargs) -> Any:
699
  from python.helpers.extension import Extension
 
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:
 
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
 
 
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", {})
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)
 
706
  except Exception as e:
707
  pass
708
 
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
 
 
714
  "python/tools", name + ".py", Tool
715
  )
716
  tool_class = classes[0] if classes else Unknown
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 CHANGED
@@ -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
  # Copy contents of the project to /a0
10
  COPY ./fs/ /
11
 
 
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 CHANGED
@@ -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
  # Copy contents of the project to /a0
10
  COPY ./fs/ /
11
 
 
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 ADDED
@@ -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 CHANGED
@@ -1,4 +1,3 @@
1
  ## Environment
2
  live in debian linux docker container
3
  agent zero framework is python project in /a0 folder
4
-
 
1
  ## Environment
2
  live in debian linux docker container
3
  agent zero framework is python project in /a0 folder
 
prompts/default/agent.system.main.md CHANGED
@@ -8,4 +8,4 @@
8
 
9
  {{ include "./agent.system.main.solving.md" }}
10
 
11
- {{ include "./agent.system.main.tips.md" }}
 
8
 
9
  {{ include "./agent.system.main.solving.md" }}
10
 
11
+ {{ include "./agent.system.main.tips.md" }}
prompts/default/agent.system.tool.scheduler.md ADDED
@@ -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 CHANGED
@@ -15,3 +15,5 @@
15
  {{ include './agent.system.tool.input.md' }}
16
 
17
  {{ include './agent.system.tool.browser.md' }}
 
 
 
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 CHANGED
@@ -1,7 +1,7 @@
1
  ```json
2
  {
3
- "user_intervention": {{message}},
4
  "system_message": {{system_message}},
 
5
  "attachments": {{attachments}}
6
  }
7
  ```
 
1
  ```json
2
  {
 
3
  "system_message": {{system_message}},
4
+ "user_intervention": {{message}},
5
  "attachments": {{attachments}}
6
  }
7
  ```
prompts/default/fw.user_message.md CHANGED
@@ -1,7 +1,7 @@
1
  ```json
2
  {
3
- "user_message": {{message}},
4
  "system_message": {{system_message}},
 
5
  "attachments": {{attachments}}
6
  }
7
  ```
 
1
  ```json
2
  {
 
3
  "system_message": {{system_message}},
4
+ "user_message": {{message}},
5
  "attachments": {{attachments}}
6
  }
7
  ```
python/api/chat_remove.py CHANGED
@@ -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
 
5
 
6
  class RemoveChat(ApiHandler):
7
  async def process(self, input: Input, request: Request) -> Output:
8
  ctxid = input.get("context", "")
9
 
10
- # context instance - get or create
 
 
 
 
11
  AgentContext.remove(ctxid)
12
  persist_chat.remove_chat(ctxid)
13
 
 
 
 
 
 
 
 
14
  return {
15
  "message": "Context removed.",
16
  }
 
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
 
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 CHANGED
@@ -1,5 +1,5 @@
1
  import time
2
-
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
 
11
 
12
  class Poll(ApiHandler):
@@ -15,6 +17,10 @@ class Poll(ApiHandler):
15
  ctxid = input.get("context", None)
16
  from_no = input.get("log_from", 0)
17
 
 
 
 
 
18
  # context instance - get or create
19
  context = self.get_context(ctxid)
20
 
@@ -28,7 +34,7 @@ class Poll(ApiHandler):
28
  # Always reload the scheduler on each poll to ensure we have the latest task state
29
  await scheduler.reload()
30
 
31
- # loop AgentContext._contexts and number unnamed chats
32
 
33
  ctxs = []
34
  tasks = []
@@ -42,21 +48,15 @@ class Poll(ApiHandler):
42
  continue
43
 
44
  # 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:
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
  "uuid": task_details.get("uuid"),
69
  "state": task_details.get("state"),
70
  "type": task_details.get("type"),
@@ -72,12 +73,15 @@ class Poll(ApiHandler):
72
  "prompt": task_details.get("prompt"),
73
  "last_run": task_details.get("last_run"),
74
  "last_result": task_details.get("last_result"),
75
- "attachments": task_details.get("attachments", [])
 
76
  })
77
 
78
  # Add type-specific fields
79
  if task_details.get("type") == "scheduled":
80
  context_data["schedule"] = task_details.get("schedule")
 
 
81
  else:
82
  context_data["token"] = task_details.get("token")
83
 
 
1
  import time
2
+ from datetime import datetime
3
  from python.helpers.api import ApiHandler
4
  from flask import Request, Response
5
 
 
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):
 
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
 
 
34
  # Always reload the scheduler on each poll to ensure we have the latest task state
35
  await scheduler.reload()
36
 
37
+ # loop AgentContext._contexts and divide into contexts and tasks
38
 
39
  ctxs = []
40
  tasks = []
 
48
  continue
49
 
50
  # Create the base context data that will be returned
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
  # 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"),
 
73
  "prompt": task_details.get("prompt"),
74
  "last_run": task_details.get("last_run"),
75
  "last_result": task_details.get("last_result"),
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
@@ -1,5 +0,0 @@
1
- ## JSON Body
2
- # api_key
3
- # task_id
4
- # params
5
- # callback
 
 
 
 
 
 
python/api/scheduler_task_create.py CHANGED
@@ -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
5
  )
 
 
 
6
 
7
 
8
  class SchedulerTaskCreate(ApiHandler):
@@ -10,6 +13,12 @@ class SchedulerTaskCreate(ApiHandler):
10
  """
11
  Create a new task in the scheduler
12
  """
 
 
 
 
 
 
13
  scheduler = TaskScheduler.get()
14
  await scheduler.reload()
15
 
@@ -18,11 +27,22 @@ class SchedulerTaskCreate(ApiHandler):
18
  system_prompt = input.get("system_prompt")
19
  prompt = input.get("prompt")
20
  attachments = input.get("attachments", [])
 
21
 
22
  # Check if schedule is provided (for ScheduledTask)
23
  schedule = input.get("schedule", {})
24
  token: str = input.get("token", "")
25
 
 
 
 
 
 
 
 
 
 
 
26
  # Validate required fields
27
  if not name or not system_prompt or not prompt:
28
  return {"error": "Missing required fields: name, system_prompt, prompt"}
@@ -55,24 +75,61 @@ class SchedulerTaskCreate(ApiHandler):
55
  system_prompt=system_prompt,
56
  prompt=prompt,
57
  schedule=task_schedule,
58
- attachments=attachments
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  )
60
  else:
61
  # Create an ad-hoc task
 
62
  task = AdHocTask.create(
63
  name=name,
64
  system_prompt=system_prompt,
65
  prompt=prompt,
66
  token=token,
67
- attachments=attachments
 
68
  )
 
 
 
69
 
70
  # Add the task to the scheduler
71
  await scheduler.add_task(task)
72
 
 
 
 
 
 
 
 
 
 
 
73
  # Return the created task using our standardized serialization function
74
  task_dict = serialize_task(task)
75
 
 
 
 
 
76
  return {
77
  "task": task_dict
78
  }
 
1
  from python.helpers.api import ApiHandler, Input, Output, Request
2
  from python.helpers.task_scheduler import (
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):
 
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
 
 
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"}
 
75
  system_prompt=system_prompt,
76
  prompt=prompt,
77
  schedule=task_schedule,
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,
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 CHANGED
@@ -1,5 +1,8 @@
1
  from python.helpers.api import ApiHandler, Input, Output, Request
2
- from python.helpers.task_scheduler import TaskScheduler
 
 
 
3
 
4
 
5
  class SchedulerTaskDelete(ApiHandler):
@@ -7,6 +10,10 @@ class SchedulerTaskDelete(ApiHandler):
7
  """
8
  Delete a task from the scheduler by ID
9
  """
 
 
 
 
10
  scheduler = TaskScheduler.get()
11
  await scheduler.reload()
12
 
@@ -21,6 +28,24 @@ class SchedulerTaskDelete(ApiHandler):
21
  if not task:
22
  return {"error": f"Task with ID {task_id} not found"}
23
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  # Remove the task
25
  await scheduler.remove_task_by_uuid(task_id)
26
 
 
1
  from python.helpers.api import ApiHandler, Input, Output, Request
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):
 
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
 
 
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 CHANGED
@@ -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
 
5
 
6
  class SchedulerTaskRun(ApiHandler):
@@ -11,6 +12,9 @@ class SchedulerTaskRun(ApiHandler):
11
  """
12
  Manually run a task from the scheduler by ID
13
  """
 
 
 
14
 
15
  # Get task ID from input
16
  task_id: str = input.get("task_id", "")
 
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):
 
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 CHANGED
@@ -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
5
  )
 
6
 
7
 
8
  class SchedulerTaskUpdate(ApiHandler):
@@ -10,6 +11,10 @@ class SchedulerTaskUpdate(ApiHandler):
10
  """
11
  Update an existing task in the scheduler
12
  """
 
 
 
 
13
  scheduler = TaskScheduler.get()
14
  await scheduler.reload()
15
 
@@ -47,11 +52,28 @@ class SchedulerTaskUpdate(ApiHandler):
47
  if isinstance(task, ScheduledTask) and "schedule" in input:
48
  schedule_data = input.get("schedule", {})
49
  try:
50
- update_params["schedule"] = parse_task_schedule(schedule_data)
 
 
 
 
 
 
 
51
  except ValueError as e:
52
  return {"error": f"Invalid schedule format: {str(e)}"}
53
  elif isinstance(task, AdHocTask) and "token" in input:
54
- update_params["token"] = input.get("token", "")
 
 
 
 
 
 
 
 
 
 
55
 
56
  # Use atomic update method to apply changes
57
  updated_task = await scheduler.update_task(task_id, **update_params)
 
1
  from python.helpers.api import ApiHandler, Input, Output, Request
2
  from python.helpers.task_scheduler import (
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):
 
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
 
 
52
  if isinstance(task, ScheduledTask) and "schedule" in input:
53
  schedule_data = input.get("schedule", {})
54
  try:
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:
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 CHANGED
@@ -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
 
6
 
7
  class SchedulerTasksList(ApiHandler):
@@ -10,6 +11,10 @@ class SchedulerTasksList(ApiHandler):
10
  List all tasks in the scheduler with their types
11
  """
12
  try:
 
 
 
 
13
  # Get task scheduler
14
  scheduler = TaskScheduler.get()
15
  await scheduler.reload()
 
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):
 
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 CHANGED
@@ -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
 
7
 
8
  class SchedulerTick(ApiHandler):
9
  @classmethod
10
- def requires_loopback(cls):
11
  return True
12
 
13
  async def process(self, input: Input, request: Request) -> Output:
 
 
 
 
14
  timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
15
  printer = PrintStyle(font_color="green", padding=False)
16
  printer.print(f"Scheduler tick - API: {timestamp}")
 
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
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 ADDED
@@ -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 CHANGED
@@ -1,6 +1,7 @@
1
- from datetime import datetime
2
  from python.helpers.extension import Extension
3
  from agent import Agent, LoopData
 
4
 
5
 
6
  class SystemPrompt(Extension):
@@ -28,8 +29,12 @@ def get_prompt(file: str, agent: Agent):
28
  # variables for system prompts
29
  # TODO: move variables to the end of chain
30
  # variables in system prompt would break prompt caching, better to add them to the last message in conversation
 
 
 
 
31
  vars = {
32
- "date_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
33
  "agent_name": agent.agent_name,
34
  }
35
  return agent.read_prompt(file, **vars)
 
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):
 
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 = {
37
+ "date_time": current_datetime,
38
  "agent_name": agent.agent_name,
39
  }
40
  return agent.read_prompt(file, **vars)
python/helpers/api.py CHANGED
@@ -12,7 +12,7 @@ from werkzeug.serving import make_server
12
 
13
 
14
  Input = dict
15
- Output = Union[Dict[str, Any], Response, TypedDict]
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):
25
  return False
26
 
27
  @classmethod
28
- def requires_api_key(cls):
29
  return False
30
 
31
  @classmethod
32
- def requires_auth(cls):
33
  return True
34
 
35
  @abstractmethod
 
12
 
13
 
14
  Input = dict
15
+ Output = Union[Dict[str, Any], Response, TypedDict] # type: ignore
16
 
17
 
18
  class ApiHandler:
 
21
  self.thread_lock = thread_lock
22
 
23
  @classmethod
24
+ def requires_loopback(cls) -> bool:
25
  return False
26
 
27
  @classmethod
28
+ def requires_api_key(cls) -> bool:
29
  return False
30
 
31
  @classmethod
32
+ def requires_auth(cls) -> bool:
33
  return True
34
 
35
  @abstractmethod
python/helpers/chat_names.py DELETED
@@ -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 ADDED
@@ -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 CHANGED
@@ -1,4 +1,5 @@
1
  from collections import OrderedDict
 
2
  from typing import Any
3
  import uuid
4
  from agent import Agent, AgentConfig, AgentContext
@@ -104,6 +105,10 @@ def _serialize_context(context: AgentContext):
104
  return {
105
  "id": context.id,
106
  "name": context.name,
 
 
 
 
107
  "agents": agents,
108
  "streaming_agent": (
109
  context.streaming_agent.number if context.streaming_agent else 0
@@ -143,6 +148,12 @@ def _deserialize_context(data):
143
  config=config,
144
  id=data.get("id", None), # get new id
145
  name=data.get("name", None),
 
 
 
 
 
 
146
  log=log,
147
  paused=False,
148
  # agent0=agent0,
 
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
 
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
 
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 CHANGED
@@ -1,15 +1,13 @@
1
  import asyncio
2
  from datetime import datetime, timezone, timedelta
3
- import json
4
  import os
5
  import random
6
  import threading
7
  from urllib.parse import urlparse
8
  import uuid
9
- from dataclasses import dataclass
10
  from enum import Enum
11
  from os.path import exists
12
- from typing import Any, Callable, Coroutine, Dict, Literal, Optional, Type, TypeVar, Union, cast, ClassVar
13
 
14
  import nest_asyncio
15
  nest_asyncio.apply()
@@ -17,22 +15,23 @@ nest_asyncio.apply()
17
  from crontab import CronTab
18
  from pydantic import BaseModel, Field, PrivateAttr
19
 
20
- from agent import Agent, AgentConfig, AgentContext, UserMessage
21
  from initialize import initialize
22
- from python.helpers.persist_chat import load_tmp_chats, save_tmp_chat
23
  from python.helpers.print_style import PrintStyle
24
  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"
30
 
 
31
 
32
  # ----------------------
33
  # Task Models
34
  # ----------------------
35
 
 
36
  class TaskState(str, Enum):
37
  IDLE = "idle"
38
  RUNNING = "running"
@@ -40,49 +39,100 @@ class TaskState(str, Enum):
40
  ERROR = "error"
41
 
42
 
 
 
 
 
 
 
43
  class TaskSchedule(BaseModel):
44
  minute: str
45
  hour: str
46
  day: str
47
  month: str
48
  weekday: str
 
49
 
50
  def to_crontab(self) -> str:
51
  return f"{self.minute} {self.hour} {self.day} {self.month} {self.weekday}"
52
 
53
 
54
- class AdHocTask(BaseModel):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  uuid: str = Field(default_factory=lambda: str(uuid.uuid4()))
 
56
  state: TaskState = Field(default=TaskState.IDLE)
57
  name: str = Field()
58
  system_prompt: str
59
  prompt: str
60
  attachments: list[str] = Field(default_factory=list)
61
- token: str = Field(default_factory=lambda: str(random.randint(1000000000000000000, 9999999999999999999)))
62
  created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
63
  updated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
64
  last_run: datetime | None = None
65
  last_result: str | None = None
66
 
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
-
84
  def __init__(self, *args, **kwargs):
85
  super().__init__(*args, **kwargs)
 
 
86
  self._lock = threading.RLock()
87
 
88
  def update(self,
@@ -93,7 +143,8 @@ class AdHocTask(BaseModel):
93
  attachments: list[str] | None = None,
94
  last_run: datetime | None = None,
95
  last_result: str | None = None,
96
- token: str | None = None):
 
97
  with self._lock:
98
  if name is not None:
99
  self.name = name
@@ -116,29 +167,73 @@ class AdHocTask(BaseModel):
116
  if last_result is not None:
117
  self.last_result = last_result
118
  self.updated_at = datetime.now(timezone.utc)
119
- if token is not None:
120
- self.token = token
121
  self.updated_at = datetime.now(timezone.utc)
 
 
 
 
122
 
123
  def check_schedule(self, frequency_seconds: float = 60.0) -> bool:
124
- with self._lock:
125
- return False
126
 
 
 
127
 
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
 
141
- # lock: Optional[threading.Lock] = Field(exclude=True, default=threading.Lock())
 
 
 
142
 
143
  @classmethod
144
  def create(
@@ -146,18 +241,67 @@ class ScheduledTask(BaseModel):
146
  name: str,
147
  system_prompt: str,
148
  prompt: str,
149
- schedule: TaskSchedule,
150
- attachments: list[str] = []
 
151
  ):
152
  return cls(name=name,
153
  system_prompt=system_prompt,
154
  prompt=prompt,
155
  attachments=attachments,
156
- schedule=schedule)
 
157
 
158
- def __init__(self, *args, **kwargs):
159
- super().__init__(*args, **kwargs)
160
- self._lock = threading.RLock()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
 
162
  def update(self,
163
  name: str | None = None,
@@ -165,55 +309,140 @@ class ScheduledTask(BaseModel):
165
  system_prompt: str | None = None,
166
  prompt: str | None = None,
167
  attachments: list[str] | None = None,
168
- schedule: TaskSchedule | None = None,
169
  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)
196
 
197
  def check_schedule(self, frequency_seconds: float = 60.0) -> bool:
198
  with self._lock:
199
- crontab = CronTab(crontab=self.schedule.to_crontab())
 
 
 
 
 
 
 
 
200
  # 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),
204
  return_datetime=False
205
  ) # type: ignore
 
206
  if next_run_seconds is None:
207
  return False
 
208
  return next_run_seconds < frequency_seconds
209
 
210
- def run(self):
211
- pass
 
 
212
 
213
 
214
- class SchedulerTaskList(BaseModel):
215
- tasks: list[Union[ScheduledTask, AdHocTask]]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
216
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  # Singleton instance
218
  __instance: ClassVar[Optional["SchedulerTaskList"]] = PrivateAttr(default=None)
219
 
@@ -245,7 +474,7 @@ class SchedulerTaskList(BaseModel):
245
  self.tasks.extend(data.tasks)
246
  return self
247
 
248
- async def add_task(self, task: Union[ScheduledTask, AdHocTask]) -> "SchedulerTaskList":
249
  with self._lock:
250
  self.tasks.append(task)
251
  await self.save()
@@ -253,13 +482,50 @@ class SchedulerTaskList(BaseModel):
253
 
254
  async def save(self) -> "SchedulerTaskList":
255
  with self._lock:
 
 
 
 
 
 
 
 
 
 
 
 
 
256
  path = get_abs_path(SCHEDULER_FOLDER, "tasks.json")
257
  if not exists(path):
258
  make_dirs(path)
259
- write_file(path, self.model_dump_json())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  return self
261
 
262
- async def update_task_by_uuid(self, task_uuid: str, updater_func) -> Union[ScheduledTask, AdHocTask] | None:
 
 
 
 
 
263
  """
264
  Atomically update a task by UUID using the provided updater function.
265
 
@@ -273,7 +539,7 @@ class SchedulerTaskList(BaseModel):
273
  await self.reload()
274
 
275
  # Find the task
276
- task = next((task for task in self.tasks if task.uuid == task_uuid), None)
277
  if task is None:
278
  return None
279
 
@@ -281,27 +547,35 @@ class SchedulerTaskList(BaseModel):
281
  updater_func(task)
282
 
283
  # 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())
288
 
289
  return task
290
 
291
- def get_tasks(self) -> list[Union[ScheduledTask, AdHocTask]]:
292
  with self._lock:
293
  return self.tasks
294
 
295
- async def get_due_tasks(self) -> list[Union[ScheduledTask, AdHocTask]]:
 
 
 
 
 
 
 
 
296
  with self._lock:
297
  await self.reload()
298
- return [task for task in self.tasks if task.check_schedule() and task.state == TaskState.IDLE]
 
 
 
299
 
300
- def get_task_by_uuid(self, task_uuid: str) -> Union[ScheduledTask, AdHocTask] | None:
301
  with self._lock:
302
  return next((task for task in self.tasks if task.uuid == task_uuid), None)
303
 
304
- def get_task_by_name(self, name: str) -> Union[ScheduledTask, AdHocTask] | None:
305
  with self._lock:
306
  return next((task for task in self.tasks if task.name == name), None)
307
 
@@ -340,10 +614,13 @@ class TaskScheduler:
340
  async def reload(self):
341
  await self._tasks.reload()
342
 
343
- def get_tasks(self) -> list[Union[ScheduledTask, AdHocTask]]:
344
  return self._tasks.get_tasks()
345
 
346
- async def add_task(self, task: Union[ScheduledTask, AdHocTask]) -> "TaskScheduler":
 
 
 
347
  await self._tasks.add_task(task)
348
  return self
349
 
@@ -355,10 +632,10 @@ class TaskScheduler:
355
  await self._tasks.remove_task_by_name(name)
356
  return self
357
 
358
- def get_task_by_uuid(self, task_uuid: str) -> Union[ScheduledTask, AdHocTask] | None:
359
  return self._tasks.get_task_by_uuid(task_uuid)
360
 
361
- def get_task_by_name(self, name: str) -> Union[ScheduledTask, AdHocTask] | None:
362
  return self._tasks.get_task_by_name(name)
363
 
364
  async def tick(self):
@@ -366,11 +643,33 @@ class TaskScheduler:
366
  await self._run_task(task)
367
 
368
  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")
372
 
373
- # The actual state check and running will be handled in the _run_task method
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  await self._run_task(task)
375
 
376
  async def run_task_by_name(self, name: str):
@@ -382,7 +681,12 @@ class TaskScheduler:
382
  async def save(self):
383
  await self._tasks.save()
384
 
385
- async def update_task(self, task_uuid: str, **update_params) -> Union[ScheduledTask, AdHocTask] | None:
 
 
 
 
 
386
  """
387
  Atomically update a task by UUID with the provided parameters.
388
  This prevents race conditions when multiple processes update tasks concurrently.
@@ -392,49 +696,52 @@ class TaskScheduler:
392
  def _update_task(task):
393
  task.update(**update_params)
394
 
395
- return await self._tasks.update_task_by_uuid(task_uuid, _update_task)
 
 
 
 
 
 
 
396
 
397
- async def __new_context(self, task: Union[ScheduledTask, AdHocTask]) -> AgentContext:
398
  config = initialize()
399
  context: AgentContext = AgentContext(config)
400
- context.id = task.uuid
 
 
 
401
  # Save the context
402
  save_tmp_chat(context)
403
  return context
404
 
405
- async def _get_chat_context(self, task: Union[ScheduledTask, AdHocTask]) -> AgentContext:
406
- ctxids = load_tmp_chats()
407
 
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)
422
  else:
423
  self._printer.print(
424
  f"Scheduler Task {task.name} loaded from task {task.uuid} but context not found"
425
  )
426
  return await self.__new_context(task)
427
 
428
- async def _persist_chat(self, task: Union[ScheduledTask, AdHocTask], context: AgentContext):
429
- context.id = task.uuid
 
430
  save_tmp_chat(context)
431
 
432
- async def _run_task(self, task: Union[ScheduledTask, AdHocTask]):
433
 
434
  async def _run_task_wrapper(task_uuid: str):
435
 
436
  # preflight checks with a snapshot of the task
437
- task_snapshot: Union[ScheduledTask, AdHocTask] | None = self.get_task_by_uuid(task_uuid)
438
  if task_snapshot is None:
439
  self._printer.print(f"Scheduler Task with UUID '{task_uuid}' not found")
440
  return
@@ -443,15 +750,20 @@ class TaskScheduler:
443
  return
444
 
445
  # Atomically fetch and check the task's current state
446
- current_task = await self.update_task(task_uuid, state=TaskState.RUNNING)
447
  if not current_task:
448
- self._printer.print(f"Scheduler Task with UUID '{task_uuid}' not found")
449
  return
450
  if current_task.state != TaskState.RUNNING:
451
  # This means the update failed due to state conflict
452
  self._printer.print(f"Scheduler Task '{current_task.name}' state is '{current_task.state}', skipping")
453
  return
454
 
 
 
 
 
 
455
  try:
456
  self._printer.print(f"Scheduler Task '{current_task.name}' started")
457
 
@@ -459,9 +771,9 @@ class TaskScheduler:
459
 
460
  # Ensure the context is properly registered in the AgentContext._contexts
461
  # This is critical for the polling mechanism to find and stream logs
 
462
  AgentContext._contexts[context.id] = context
463
-
464
- agent = Agent(0, context.config, context)
465
 
466
  # Prepare attachment filenames for logging
467
  attachment_filenames = []
@@ -507,34 +819,46 @@ class TaskScheduler:
507
 
508
  result = await agent.monologue()
509
 
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
-
518
  self._printer.print(f"Scheduler Task '{current_task.name}' completed: {result}")
519
-
520
  await self._persist_chat(current_task, context)
 
 
 
 
 
 
 
 
521
 
522
  except Exception as e:
 
523
  self._printer.print(f"Scheduler Task '{current_task.name}' failed: {e}")
 
524
 
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
- )
531
 
532
  if agent:
533
  agent.handle_critical_exception(e)
 
 
 
 
 
 
534
 
535
  deferred_task = DeferredTask(thread_name=self.__class__.__name__)
536
  deferred_task.start_task(_run_task_wrapper, task.uuid)
537
 
 
 
 
 
538
  def serialize_all_tasks(self) -> list[Dict[str, Any]]:
539
  """
540
  Serialize all tasks in the scheduler to a list of dictionaries.
@@ -558,18 +882,35 @@ class TaskScheduler:
558
  # ----------------------
559
 
560
  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
 
 
 
 
 
 
 
 
563
 
564
 
565
  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."""
 
 
 
 
 
 
 
567
  if not dt_str:
568
  return None
 
569
  try:
570
- return datetime.fromisoformat(dt_str)
571
- except ValueError:
572
- raise ValueError(f"Invalid datetime format: {dt_str}. Expected ISO format.")
 
573
 
574
 
575
  def serialize_task_schedule(schedule: TaskSchedule) -> Dict[str, str]:
@@ -579,7 +920,8 @@ def serialize_task_schedule(schedule: TaskSchedule) -> Dict[str, str]:
579
  'hour': schedule.hour,
580
  'day': schedule.day,
581
  'month': schedule.month,
582
- 'weekday': schedule.weekday
 
583
  }
584
 
585
 
@@ -591,16 +933,84 @@ def parse_task_schedule(schedule_data: Dict[str, str]) -> TaskSchedule:
591
  hour=schedule_data.get('hour', '*'),
592
  day=schedule_data.get('day', '*'),
593
  month=schedule_data.get('month', '*'),
594
- weekday=schedule_data.get('weekday', '*')
 
595
  )
596
  except Exception as e:
597
  raise ValueError(f"Invalid schedule format: {e}") from e
598
 
599
 
600
- T = TypeVar('T', bound=Union[ScheduledTask, AdHocTask])
 
 
 
 
 
 
601
 
602
 
603
- def serialize_task(task: Union[ScheduledTask, AdHocTask]) -> Dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
604
  """
605
  Standardized serialization for task objects with proper handling of all complex types.
606
  """
@@ -615,22 +1025,28 @@ def serialize_task(task: Union[ScheduledTask, AdHocTask]) -> Dict[str, Any]:
615
  "created_at": serialize_datetime(task.created_at),
616
  "updated_at": serialize_datetime(task.updated_at),
617
  "last_run": serialize_datetime(task.last_run),
618
- "last_result": task.last_result
 
 
619
  }
620
 
621
  # Add type-specific fields
622
  if isinstance(task, ScheduledTask):
623
  task_dict['type'] = 'scheduled'
624
- task_dict['schedule'] = serialize_task_schedule(task.schedule)
625
- else:
626
  task_dict['type'] = 'adhoc'
627
  adhoc_task = cast(AdHocTask, task)
628
  task_dict['token'] = adhoc_task.token
 
 
 
 
629
 
630
  return task_dict
631
 
632
 
633
- def serialize_tasks(tasks: list[Union[ScheduledTask, AdHocTask]]) -> list[Dict[str, Any]]:
634
  """
635
  Serialize a list of tasks to a list of dictionaries.
636
  """
@@ -651,10 +1067,18 @@ def deserialize_task(task_data: Dict[str, Any], task_class: Optional[Type[T]] =
651
  determined_class = cast(Type[T], ScheduledTask)
652
  elif task_type_str == 'adhoc':
653
  determined_class = cast(Type[T], AdHocTask)
 
 
 
 
 
654
  else:
655
  raise ValueError(f"Unknown task type: {task_type_str}")
656
  else:
657
  determined_class = task_class
 
 
 
658
 
659
  common_args = {
660
  "uuid": task_data.get("uuid"),
@@ -666,14 +1090,19 @@ def deserialize_task(task_data: Dict[str, Any], task_class: Optional[Type[T]] =
666
  "created_at": parse_datetime(task_data.get("created_at")),
667
  "updated_at": parse_datetime(task_data.get("updated_at")),
668
  "last_run": parse_datetime(task_data.get("last_run")),
669
- "last_result": task_data.get("last_result")
 
670
  }
671
 
672
  # Add type-specific fields
673
- if determined_class == ScheduledTask:
674
  schedule_data = task_data.get("schedule", {})
675
  common_args["schedule"] = parse_task_schedule(schedule_data)
676
  return ScheduledTask(**common_args) # type: ignore
677
- else:
678
  common_args["token"] = task_data.get("token", "")
679
  return AdHocTask(**common_args) # type: ignore
 
 
 
 
 
1
  import asyncio
2
  from datetime import datetime, timezone, timedelta
 
3
  import os
4
  import random
5
  import threading
6
  from urllib.parse import urlparse
7
  import uuid
 
8
  from enum import Enum
9
  from os.path import exists
10
+ from typing import Any, Callable, Dict, Literal, Optional, Type, TypeVar, Union, cast, ClassVar
11
 
12
  import nest_asyncio
13
  nest_asyncio.apply()
 
15
  from crontab import CronTab
16
  from pydantic import BaseModel, Field, PrivateAttr
17
 
18
+ from agent import Agent, AgentContext, UserMessage
19
  from initialize import initialize
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
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"
 
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
 
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)
 
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,
 
143
  attachments: list[str] | None = None,
144
  last_run: datetime | None = None,
145
  last_result: 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
 
167
  if last_result is not None:
168
  self.last_result = last_result
169
  self.updated_at = datetime.now(timezone.utc)
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:
179
+ return False
 
180
 
181
+ def get_next_run(self) -> datetime | None:
182
+ return None
183
 
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
 
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(
 
241
  name: str,
242
  system_prompt: str,
243
  prompt: 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,
252
+ token=token,
253
+ context_id=context_id)
254
 
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,
 
309
  system_prompt: str | None = None,
310
  prompt: str | None = None,
311
  attachments: list[str] | None = None,
 
312
  last_run: datetime | None = None,
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:
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
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
 
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
 
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
 
 
474
  self.tasks.extend(data.tasks)
475
  return self
476
 
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()
 
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)
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
 
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
 
 
539
  await self.reload()
540
 
541
  # Find the task
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
 
 
547
  updater_func(task)
548
 
549
  # Save the changes
550
+ await self.save()
 
 
 
551
 
552
  return task
553
 
554
+ def get_tasks(self) -> list[Union[ScheduledTask, AdHocTask, PlannedTask]]:
555
  with self._lock:
556
  return self.tasks
557
 
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()
569
+ return [
570
+ task for task in self.tasks
571
+ if task.check_schedule() and task.state == TaskState.IDLE
572
+ ]
573
 
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
 
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
 
 
614
  async def reload(self):
615
  await self._tasks.reload()
616
 
617
+ def get_tasks(self) -> list[Union[ScheduledTask, AdHocTask, PlannedTask]]:
618
  return self._tasks.get_tasks()
619
 
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
 
 
632
  await self._tasks.remove_task_by_name(name)
633
  return self
634
 
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
 
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):
 
643
  await self._run_task(task)
644
 
645
  async def run_task_by_uuid(self, task_uuid: str):
646
+ # First reload tasks to ensure we have the latest state
647
+ await self._tasks.reload()
 
648
 
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):
 
681
  async def save(self):
682
  await self._tasks.save()
683
 
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.
 
696
  def _update_task(task):
697
  task.update(**update_params)
698
 
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
 
 
708
  config = initialize()
709
  context: AgentContext = AgentContext(config)
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
 
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
 
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
 
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
 
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
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
 
750
  return
751
 
752
  # Atomically fetch and check the task's current state
753
+ current_task = await self.update_task_checked(task_uuid, lambda task: task.state != TaskState.RUNNING, state=TaskState.RUNNING)
754
  if not current_task:
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
 
 
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
776
+ agent = context.streaming_agent or context.agent0
 
777
 
778
  # Prepare attachment filenames for logging
779
  attachment_filenames = []
 
819
 
820
  result = await agent.monologue()
821
 
822
+ # Success
 
 
 
 
 
 
 
823
  self._printer.print(f"Scheduler Task '{current_task.name}' completed: {result}")
 
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
 
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.
 
882
  # ----------------------
883
 
884
  def serialize_datetime(dt: Optional[datetime]) -> Optional[str]:
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]:
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:
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]:
 
920
  'hour': schedule.hour,
921
  'day': schedule.day,
922
  'month': schedule.month,
923
+ 'weekday': schedule.weekday,
924
+ 'timezone': schedule.timezone
925
  }
926
 
927
 
 
933
  hour=schedule_data.get('hour', '*'),
934
  day=schedule_data.get('day', '*'),
935
  month=schedule_data.get('month', '*'),
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
 
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
 
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
  """
 
1025
  "created_at": serialize_datetime(task.created_at),
1026
  "updated_at": serialize_datetime(task.updated_at),
1027
  "last_run": serialize_datetime(task.last_run),
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'
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
 
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
  """
 
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"),
 
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")),
1093
+ "last_result": task_data.get("last_result"),
1094
+ "context_id": task_data.get("context_id")
1095
  }
1096
 
1097
  # Add type-specific fields
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
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 CHANGED
@@ -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:
16
  self.agent = agent
17
  self.name = name
 
18
  self.args = args
19
  self.message = message
20
 
@@ -39,7 +40,11 @@ class Tool:
39
  self.log.update(content=response.message)
40
 
41
  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
 
44
  def nice_key(self, key:str):
45
  words = key.split('_')
 
12
 
13
  class Tool:
14
 
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
 
 
40
  self.log.update(content=response.message)
41
 
42
  def get_log_object(self):
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 ADDED
@@ -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 CHANGED
@@ -1,3 +1,9 @@
 
 
 
 
 
 
1
  from functools import wraps
2
  import threading
3
  import signal
@@ -10,10 +16,12 @@ from python.helpers.cloudflare_tunnel import CloudflareTunnel
10
  from python.helpers.extract_tools import load_classes_from_folder
11
  from python.helpers.api import ApiHandler
12
  from python.helpers.print_style import PrintStyle
13
- import sys
14
- import socket
15
- import struct
16
 
 
 
 
 
17
 
18
  # initialize the internal Flask server
19
  app = Flask("app", static_folder=get_abs_path("./webui"), static_url_path="/")
@@ -166,6 +174,9 @@ def run():
166
 
167
  # initialize contexts from persisted chats
168
  persist_chat.load_tmp_chats()
 
 
 
169
 
170
  except Exception as e:
171
  PrintStyle().error(errors.format_error(e))
 
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
 
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
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="/")
 
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 ADDED
@@ -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 CHANGED
@@ -145,7 +145,7 @@ select {
145
  }
146
 
147
  select:disabled {
148
- background-color: #f5f5f5;
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);
328
  }
329
 
330
  .settings-tab.active {
331
- color: var(--color-border);
332
  border-color: var(--color-border);
333
  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;
337
  font-weight: bold;
338
  background-color: var(--color-panel);
339
  }
@@ -342,9 +341,9 @@ nav ul li a img {
342
  .light-mode .settings-tab.active {
343
  color: var(--color-border);
344
  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;
348
  }
349
 
350
  .light-mode .settings-tab:not(.active) {
@@ -798,3 +797,155 @@ nav ul li a img {
798
  .light-mode .scheduler-state-explanation span {
799
  background-color: rgba(255, 255, 255, 0.3);
800
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
145
  }
146
 
147
  select:disabled {
148
+ background-color: var(--color-background);
149
  cursor: not-allowed;
150
  }
151
 
 
324
  .settings-tab:not(.active) {
325
  opacity: 0.8;
326
  border-bottom: 3px solid var(--color-border);
327
+ background-color: var(--color-background);
328
  }
329
 
330
  .settings-tab.active {
 
331
  border-color: var(--color-border);
332
  box-shadow:
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
  }
 
341
  .light-mode .settings-tab.active {
342
  color: var(--color-border);
343
  box-shadow:
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) {
 
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 CHANGED
@@ -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
-
2277
  .task-name:hover {
2278
  background-color: rgba(255, 255, 255, 0.1);
2279
  text-decoration: none;
@@ -2330,7 +2299,6 @@ a:active {
2330
  }
2331
 
2332
  .tab.active {
2333
- color: var(--color-primary);
2334
  border-color: var(--color-border);
2335
  box-shadow:
2336
  0 -4px 8px -2px var(--color-border),
@@ -2341,7 +2309,6 @@ a:active {
2341
  }
2342
 
2343
  .light-mode .tab.active {
2344
- color: var(--color-primary);
2345
  box-shadow:
2346
  0 -4px 8px -2px var(--color-border),
2347
  4px 0 8px -2px var(--color-border),
@@ -2509,40 +2476,6 @@ a:active {
2509
  overflow: auto;
2510
  }
2511
 
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
-
2546
  /* Settings Sections */
2547
  nav ul {
2548
  display: grid;
 
2243
  margin-left: 5px;
2244
  }
2245
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2246
  .task-name:hover {
2247
  background-color: rgba(255, 255, 255, 0.1);
2248
  text-decoration: none;
 
2299
  }
2300
 
2301
  .tab.active {
 
2302
  border-color: var(--color-border);
2303
  box-shadow:
2304
  0 -4px 8px -2px var(--color-border),
 
2309
  }
2310
 
2311
  .light-mode .tab.active {
 
2312
  box-shadow:
2313
  0 -4px 8px -2px var(--color-border),
2314
  4px 0 8px -2px var(--color-border),
 
2476
  overflow: auto;
2477
  }
2478
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2479
  /* Settings Sections */
2480
  nav ul {
2481
  display: grid;
webui/index.html CHANGED
@@ -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
 
17
  <script>
18
  window.safeCall = function (name, ...args) {
@@ -20,6 +25,80 @@
20
  }
21
  </script>
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  <script defer src="https://cdn.jsdelivr.net/npm/@alpinejs/collapse@3.14.3/dist/cdn.min.js"></script>
24
  <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.3/dist/cdn.min.js"></script>
25
 
@@ -33,15 +112,10 @@
33
  <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/contrib/auto-render.min.js"
34
  crossorigin="anonymous"></script>
35
 
36
-
37
- <script type="module" src="index.js"></script>
38
  <script type="text/javascript" src="js/settings.js"></script>
39
  <script type="text/javascript" src="js/file_browser.js"></script>
40
  <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
-
45
  </head>
46
 
47
  <body>
@@ -106,7 +180,8 @@
106
  <li>
107
  <div :class="{'chat-list-button': true, 'font-bold': context.id === selected}"
108
  @click="selected = context.id; selectChat(context.id)">
109
- <span class="chat-name" x-text="context.name ? context.name : 'Chat #' + context.no"></span>
 
110
  </div>
111
  <button class="edit-button" @click="killChat(context.id)">X</button>
112
  </li>
@@ -136,8 +211,8 @@
136
  <!-- Task container with a vertical layout -->
137
  <div class="task-container task-container-vertical">
138
  <!-- 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)}`"
141
  :data-task-id="task.id"></span>
142
  <!-- Second line with status badge and action button -->
143
  <div class="task-info-line">
@@ -145,15 +220,22 @@
145
  <span class="scheduler-status-badge scheduler-status-badge-small"
146
  :class="task.state ? `scheduler-status-${task.state}` : 'scheduler-status-idle'"
147
  x-text="task.state || 'idle'"></span>
148
- <!-- Action button -->
149
- <button class="task-detail-button"
150
  @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>
 
 
 
 
 
 
155
  </svg>
156
  </button>
 
157
  </div>
158
  </div>
159
  </div>
@@ -653,15 +735,16 @@
653
  <input type="text" x-model="editingTask.name" placeholder="Enter task name">
654
  </div>
655
 
656
- <!-- Task Type (only editable when creating) -->
657
  <div class="scheduler-form-field">
658
  <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>
661
  </div>
662
- <select x-model="editingTask.type" :disabled="!isCreating">
663
- <option value="scheduled">Scheduled Task</option>
664
- <option value="adhoc">Ad-hoc Task</option>
 
665
  </select>
666
  </div>
667
 
@@ -695,11 +778,11 @@
695
  </div>
696
  </div>
697
 
698
- <!-- Schedule (for scheduled tasks) -->
699
- <div class="scheduler-form-field full-width" x-show="editingTask && editingTask.type === 'scheduled'">
700
  <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>
703
  </div>
704
  <div class="scheduler-schedule-builder">
705
  <div class="scheduler-schedule-field">
@@ -725,6 +808,94 @@
725
  </div>
726
  </div>
727
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
728
  <!-- Token (for ad-hoc tasks) -->
729
  <div class="scheduler-form-field full-width" x-show="editingTask.type === 'adhoc'">
730
  <div class="label-help-wrapper">
@@ -801,6 +972,7 @@
801
  <select x-model="editingTask.type" disabled>
802
  <option value="scheduled">Scheduled Task</option>
803
  <option value="adhoc">Ad-hoc Task</option>
 
804
  </select>
805
  </div>
806
 
@@ -864,6 +1036,94 @@
864
  </div>
865
  </div>
866
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
867
  <!-- Token (for ad-hoc tasks) -->
868
  <div class="scheduler-form-field full-width" x-show="editingTask.type === 'adhoc'">
869
  <div class="label-help-wrapper">
@@ -927,6 +1187,7 @@
927
  <option value="all">All Types</option>
928
  <option value="scheduled">Scheduled</option>
929
  <option value="adhoc">Ad-hoc</option>
 
930
  </select>
931
  </div>
932
 
@@ -943,19 +1204,23 @@
943
  </div>
944
 
945
  <!-- Loading State -->
946
- <div class="scheduler-loading" x-show="isLoading">
947
  Loading tasks...
948
- </div>
949
 
950
  <!-- Empty State -->
951
- <div class="scheduler-empty" x-show="!isLoading && filteredTasks.length === 0">
 
 
952
  <div class="scheduler-empty-icon">📋</div>
953
  <div class="scheduler-empty-text">No tasks found</div>
954
  <button class="btn btn-ok" @click="startCreateTask()">Create your first task</button>
955
  </div>
956
 
957
  <!-- Task List Table -->
958
- <table class="scheduler-task-list" x-show="!isLoading && filteredTasks.length > 0">
 
 
959
  <thead>
960
  <tr>
961
  <th @click="changeSort('name')">
@@ -991,6 +1256,7 @@
991
  <td>
992
  <span x-show="task.type === 'scheduled'" x-text="formatSchedule(task)"></span>
993
  <span x-show="task.type === 'adhoc'" class="scheduler-no-schedule">—</span>
 
994
  </td>
995
  <td x-text="formatDate(task.last_run)"></td>
996
  <td @click.stop>
@@ -1054,6 +1320,38 @@
1054
  <div class="scheduler-details-label" x-show="selectedTaskForDetail && selectedTaskForDetail.type === 'adhoc'">Token:</div>
1055
  <div class="scheduler-details-value" x-show="selectedTaskForDetail && selectedTaskForDetail.type === 'adhoc'" x-text="selectedTaskForDetail ? selectedTaskForDetail.token : ''"></div>
1056
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1057
  <div class="scheduler-details-label">Last Result:</div>
1058
  <div class="scheduler-details-value" x-text="selectedTaskForDetail && selectedTaskForDetail.last_result ? selectedTaskForDetail.last_result : 'No results yet'"></div>
1059
 
 
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) {
 
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
 
 
112
  <script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.8/dist/contrib/auto-render.min.js"
113
  crossorigin="anonymous"></script>
114
 
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>
 
 
 
 
119
  </head>
120
 
121
  <body>
 
180
  <li>
181
  <div :class="{'chat-list-button': true, 'font-bold': context.id === selected}"
182
  @click="selected = context.id; selectChat(context.id)">
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>
 
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 -->
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">
 
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>
223
+ <!-- Action buttons -->
224
+ <button class="edit-button"
225
  @click.stop="openTaskDetail(task.id)"
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>
 
735
  <input type="text" x-model="editingTask.name" placeholder="Enter task name">
736
  </div>
737
 
738
+ <!-- Task Type Selection -->
739
  <div class="scheduler-form-field">
740
  <div class="label-help-wrapper">
741
+ <label class="scheduler-form-label">Type</label>
742
+ <div class="scheduler-form-help">Task execution method</div>
743
  </div>
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
 
 
778
  </div>
779
  </div>
780
 
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">
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">
 
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">
 
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
 
 
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">
 
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
 
 
1204
  </div>
1205
 
1206
  <!-- Loading State -->
1207
+ <!-- <div class="scheduler-loading" x-show="isLoading">
1208
  Loading tasks...
1209
+ </div> -->
1210
 
1211
  <!-- Empty State -->
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 -->
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')">
 
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>
 
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 CHANGED
@@ -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 });
 
 
 
 
 
 
 
 
 
 
346
 
347
  // Check if the response is valid
348
  if (!response) {
@@ -421,7 +431,13 @@ async function poll() {
421
 
422
  // If it doesn't exist in the chats list but we're in chats tab, try to select the first chat
423
  if (!contextExists && contexts.length > 0) {
 
 
 
424
  const firstChatId = contexts[0].id;
 
 
 
425
  setContext(firstChatId);
426
  chatsAD.selected = firstChatId;
427
  localStorage.setItem('lastSelectedChat', firstChatId);
@@ -454,11 +470,18 @@ async function poll() {
454
  } else if (contexts.length > 0 && localStorage.getItem('activeTab') === 'chats') {
455
  // If we're in chats tab with no selection but have chats, select the first one
456
  const firstChatId = contexts[0].id;
457
- setContext(firstChatId);
458
- chatsAD.selected = firstChatId;
459
- localStorage.setItem('lastSelectedChat', firstChatId);
 
 
 
 
460
  }
461
 
 
 
 
462
  } catch (error) {
463
  console.error('Error:', error);
464
  setConnectionStatus(false)
@@ -509,12 +532,12 @@ window.pauseAgent = async function (paused) {
509
  }
510
  }
511
 
512
- window.resetChat = async function () {
513
  try {
514
- const resp = await sendJsonData("/chat_reset", { context });
515
- updateAfterScroll()
516
  } catch (e) {
517
- window.toastFetchError("Error resetting chat", e)
518
  }
519
  }
520
 
@@ -1193,10 +1216,20 @@ function activateTab(tabName) {
1193
  chatsTab.classList.add('active');
1194
  chatsSection.style.display = '';
1195
 
 
 
 
 
1196
  // Restore previous chat selection
1197
  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
 
 
 
 
 
 
1200
  setContext(lastSelectedChat);
1201
  }
1202
  } else if (tabName === 'tasks') {
@@ -1204,10 +1237,20 @@ function activateTab(tabName) {
1204
  tasksSection.style.display = 'flex';
1205
  tasksSection.style.flexDirection = 'column';
1206
 
 
 
 
 
1207
  // Restore previous task selection
1208
  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
 
 
 
 
 
 
1211
  setContext(lastSelectedTask);
1212
  }
1213
  }
 
342
  async function poll() {
343
  let updated = false
344
  try {
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) {
 
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);
 
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;
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)
 
532
  }
533
  }
534
 
535
+ window.resetChat = async function (ctxid=null) {
536
  try {
537
+ const resp = await sendJsonData("/chat_reset", { "context": ctxid === null ? context : ctxid });
538
+ if (ctxid === null) updateAfterScroll();
539
  } catch (e) {
540
+ window.toastFetchError("Error resetting chat", e);
541
  }
542
  }
543
 
 
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');
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') {
 
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');
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 CHANGED
@@ -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', () => ({
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
45
  tasks: [],
46
  isLoading: true,
47
  selectedTask: null,
48
  expandedTaskId: null,
49
  sortField: 'name',
50
  sortDirection: 'asc',
51
- filterType: 'all', // all, scheduled, adhoc
52
  filterState: 'all', // all, idle, running, disabled, error
53
  pollingInterval: null,
54
  pollingActive: false, // Track if polling is currently active
55
- editingTask: null,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  isCreating: false,
57
  isEditing: false,
58
  showLoadingState: false,
59
  viewMode: 'list', // Controls whether to show list or detail view
60
  selectedTaskForDetail: null, // Task object for detail view
 
 
 
61
 
62
  // Initialize the component
63
  init() {
64
- // Initialize editingTask with default values but ensure we're not in editing mode
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  this.editingTask = {
66
  name: '',
67
  type: 'scheduled',
68
- state: 'idle', // Initialize with idle state
69
  schedule: {
70
  minute: '*',
71
  hour: '*',
72
  day: '*',
73
  month: '*',
74
- weekday: '*'
 
 
 
 
 
 
 
75
  },
76
- token: '',
77
  system_prompt: '',
78
  prompt: '',
79
  attachments: []
80
  };
81
 
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
- }
101
  });
102
 
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();
108
 
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();
113
  }
114
- }, 100);
115
 
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
- });
132
  },
133
 
134
  // Start polling for task updates
135
  startPolling() {
136
  // Don't start if already polling
137
  if (this.pollingInterval) {
 
138
  return;
139
  }
140
 
 
141
  this.pollingActive = true;
142
 
143
  // Fetch immediately, then set up interval for every 2 seconds
@@ -151,6 +232,7 @@ document.addEventListener('alpine:init', () => {
151
 
152
  // Stop polling when tab is inactive
153
  stopPolling() {
 
154
  this.pollingActive = false;
155
 
156
  if (this.pollingInterval) {
@@ -177,7 +259,10 @@ document.addEventListener('alpine:init', () => {
177
  method: 'POST',
178
  headers: {
179
  'Content-Type': 'application/json'
180
- }
 
 
 
181
  });
182
 
183
  if (!response.ok) {
@@ -185,68 +270,58 @@ document.addEventListener('alpine:init', () => {
185
  }
186
 
187
  const data = await response.json();
188
- console.log('Tasks fetched from backend:', data.tasks);
189
- this.tasks = data.tasks || [];
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
190
  } catch (error) {
191
  console.error('Error fetching tasks:', error);
192
  // Only show toast for errors on manual refresh, not during polling
193
  if (!this.pollingInterval) {
194
  showToast('Failed to fetch tasks: ' + error.message, 'error');
195
  }
 
 
196
  } finally {
197
  this.isLoading = false;
198
  }
199
  },
200
 
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
-
250
  // Change sort field/direction
251
  changeSort(field) {
252
  if (this.sortField === field) {
@@ -296,9 +371,38 @@ document.addEventListener('alpine:init', () => {
296
  // Format date for display
297
  formatDate(dateString) {
298
  if (!dateString) return 'Never';
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
299
 
300
- const date = new Date(dateString);
301
- return date.toLocaleString();
302
  },
303
 
304
  // Format schedule for display
@@ -309,6 +413,7 @@ document.addEventListener('alpine:init', () => {
309
  if (typeof task.schedule === 'string') {
310
  schedule = task.schedule;
311
  } else if (typeof task.schedule === 'object') {
 
312
  schedule = `${task.schedule.minute || '*'} ${task.schedule.hour || '*'} ${task.schedule.day || '*'} ${task.schedule.month || '*'} ${task.schedule.weekday || '*'}`;
313
  }
314
 
@@ -333,20 +438,31 @@ document.addEventListener('alpine:init', () => {
333
  document.querySelector('[x-data="schedulerSettings"]')?.setAttribute('data-editing-state', 'creating');
334
  this.editingTask = {
335
  name: '',
336
- type: 'scheduled',
337
  state: 'idle', // Initialize with idle state
338
  schedule: {
339
  minute: '*',
340
  hour: '*',
341
  day: '*',
342
  month: '*',
343
- weekday: '*'
 
344
  },
345
  token: this.generateRandomToken(), // Generate token even for scheduled tasks to prevent undefined errors
 
 
 
 
 
346
  system_prompt: '',
347
  prompt: '',
348
  attachments: [], // Always initialize as an empty array
349
  };
 
 
 
 
 
350
  },
351
 
352
  // Edit an existing task
@@ -371,6 +487,38 @@ document.addEventListener('alpine:init', () => {
371
  // Ensure state is set with a default if missing
372
  if (!this.editingTask.state) this.editingTask.state = 'idle';
373
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
374
  // Ensure attachments is always an array
375
  if (!this.editingTask.attachments) {
376
  this.editingTask.attachments = [];
@@ -387,50 +535,92 @@ document.addEventListener('alpine:init', () => {
387
 
388
  // Ensure appropriate properties are initialized based on task type
389
  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
- }
410
  // Initialize token for scheduled tasks to prevent undefined errors if UI accesses it
411
  if (!this.editingTask.token) {
412
  this.editingTask.token = '';
413
  }
 
 
 
 
 
 
 
 
 
414
  } else if (this.editingTask.type === 'adhoc') {
415
  // Initialize token if it doesn't exist
416
  if (!this.editingTask.token) {
417
  this.editingTask.token = this.generateRandomToken();
 
418
  }
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: '*'
 
 
 
 
 
 
 
 
 
 
427
  };
428
  }
 
 
 
 
 
 
 
 
 
 
429
  }
 
 
 
 
 
430
  },
431
 
432
  // Cancel editing
433
  cancelEdit() {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
434
  // Reset to initial state but keep default values to prevent errors
435
  this.editingTask = {
436
  name: '',
@@ -441,9 +631,15 @@ document.addEventListener('alpine:init', () => {
441
  hour: '*',
442
  day: '*',
443
  month: '*',
444
- weekday: '*'
 
445
  },
446
  token: '',
 
 
 
 
 
447
  system_prompt: '',
448
  prompt: '',
449
  attachments: [], // Always initialize as an empty array
@@ -469,7 +665,8 @@ document.addEventListener('alpine:init', () => {
469
  name: this.editingTask.name,
470
  system_prompt: this.editingTask.system_prompt || '',
471
  prompt: this.editingTask.prompt || '',
472
- state: this.editingTask.state || 'idle' // Include state in task data
 
473
  };
474
 
475
  // Process attachments - now always stored as array
@@ -479,7 +676,7 @@ document.addEventListener('alpine:init', () => {
479
  .filter(line => line && line.trim().length > 0)
480
  : [];
481
 
482
- // Handle schedule based on task type
483
  if (this.editingTask.type === 'scheduled') {
484
  // Ensure schedule is properly formatted as an object
485
  if (typeof this.editingTask.schedule === 'string') {
@@ -490,19 +687,87 @@ document.addEventListener('alpine:init', () => {
490
  hour: parts[1] || '*',
491
  day: parts[2] || '*',
492
  month: parts[3] || '*',
493
- weekday: parts[4] || '*'
 
494
  };
495
  } else {
496
- // Use object schedule directly
497
- taskData.schedule = this.editingTask.schedule;
 
 
 
498
  }
499
- // Don't send token for scheduled tasks
500
  delete taskData.token;
501
- } else {
 
502
  // Ad-hoc task with token
 
 
 
 
 
 
 
503
  taskData.token = this.editingTask.token;
504
- // Don't send schedule for adhoc tasks
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
505
  delete taskData.schedule;
 
506
  }
507
 
508
  // Determine if creating or updating
@@ -513,6 +778,9 @@ document.addEventListener('alpine:init', () => {
513
  taskData.task_id = this.editingTask.uuid;
514
  }
515
 
 
 
 
516
  // Make API request
517
  const response = await fetch(apiEndpoint, {
518
  method: 'POST',
@@ -527,14 +795,73 @@ document.addEventListener('alpine:init', () => {
527
  throw new Error(errorData.error || 'Failed to save task');
528
  }
529
 
 
 
 
530
  // Show success message
531
  showToast(this.isCreating ? 'Task created successfully' : 'Task updated successfully', 'success');
532
 
533
- // Refresh task list
534
- this.fetchTasks();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
535
 
536
- // Reset form to default state without setting to null
537
- this.cancelEdit();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
538
  document.querySelector('[x-data="schedulerSettings"]')?.removeAttribute('data-editing-state');
539
  } catch (error) {
540
  console.error('Error saving task:', error);
@@ -550,7 +877,10 @@ document.addEventListener('alpine:init', () => {
550
  headers: {
551
  'Content-Type': 'application/json'
552
  },
553
- body: JSON.stringify({ task_id: taskId })
 
 
 
554
  });
555
 
556
  if (!response.ok) {
@@ -593,7 +923,8 @@ document.addEventListener('alpine:init', () => {
593
  },
594
  body: JSON.stringify({
595
  task_id: taskId,
596
- state: 'idle' // Always reset to idle state
 
597
  })
598
  });
599
 
@@ -627,7 +958,10 @@ document.addEventListener('alpine:init', () => {
627
  headers: {
628
  'Content-Type': 'application/json'
629
  },
630
- body: JSON.stringify({ task_id: taskId })
 
 
 
631
  });
632
 
633
  if (!response.ok) {
@@ -637,19 +971,44 @@ document.addEventListener('alpine:init', () => {
637
 
638
  showToast('Task deleted successfully', 'success');
639
 
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;
646
  }
 
 
 
 
 
 
647
  } catch (error) {
648
  console.error('Error deleting task:', error);
649
  showToast('Failed to delete task: ' + error.message, 'error');
650
  }
651
  },
652
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
653
  // Generate a random token for ad-hoc tasks
654
  generateRandomToken() {
655
  const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
@@ -660,6 +1019,72 @@ document.addEventListener('alpine:init', () => {
660
  return token;
661
  },
662
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
663
  // Computed property for attachments text representation
664
  get attachmentsText() {
665
  // Ensure we always have an array to work with
@@ -667,16 +1092,12 @@ document.addEventListener('alpine:init', () => {
667
  ? this.editingTask.attachments
668
  : [];
669
 
670
- console.log('attachmentsText getter called, source:', this.editingTask.attachments);
671
-
672
  // Join array items with newlines
673
  return attachments.join('\n');
674
  },
675
 
676
  // Setter for attachments text - preserves empty lines during editing
677
  set attachmentsText(value) {
678
- console.log('attachmentsText setter called with:', value);
679
-
680
  if (typeof value === 'string') {
681
  // Just split by newlines without filtering to preserve editing experience
682
  this.editingTask.attachments = value.split('\n');
@@ -684,8 +1105,572 @@ document.addEventListener('alpine:init', () => {
684
  // Fallback to empty array if not a string
685
  this.editingTask.attachments = [];
686
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
687
 
688
- console.log('editingTask.attachments is now:', this.editingTask.attachments);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
689
  }
690
- }));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
691
  });
 
3
  * Manages scheduled and ad-hoc tasks through a dedicated settings tab
4
  */
5
 
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',
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
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() {
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',
163
+ state: 'idle',
164
  schedule: {
165
  minute: '*',
166
  hour: '*',
167
  day: '*',
168
  month: '*',
169
+ weekday: '*',
170
+ timezone: getUserTimezone()
171
+ },
172
+ token: this.generateRandomToken ? this.generateRandomToken() : '',
173
+ plan: {
174
+ todo: [],
175
+ in_progress: null,
176
+ done: []
177
  },
 
178
  system_prompt: '',
179
  prompt: '',
180
  attachments: []
181
  };
182
 
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
 
195
+ // Cleanup on component destruction
196
+ this.$cleanup = () => {
197
+ console.log('Cleaning up schedulerSettings component');
198
+ this.stopPolling();
 
199
 
200
+ // Clean up any Flatpickr instances
201
+ const createInput = document.getElementById('newPlannedTime-create');
202
+ if (createInput && createInput._flatpickr) {
203
+ createInput._flatpickr.destroy();
204
  }
 
205
 
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
 
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) {
 
259
  method: 'POST',
260
  headers: {
261
  'Content-Type': 'application/json'
262
+ },
263
+ body: JSON.stringify({
264
+ timezone: getUserTimezone()
265
+ })
266
  });
267
 
268
  if (!response.ok) {
 
270
  }
271
 
272
  const data = await response.json();
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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
325
  // Change sort field/direction
326
  changeSort(field) {
327
  if (this.sortField === field) {
 
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
 
405
+ return `Next: ${nextRun}\nTodo: ${todoCount}\nIn Progress: ${inProgress}\nDone: ${doneCount}`;
 
406
  },
407
 
408
  // Format schedule for display
 
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
 
 
438
  document.querySelector('[x-data="schedulerSettings"]')?.setAttribute('data-editing-state', 'creating');
439
  this.editingTask = {
440
  name: '',
441
+ type: 'scheduled', // Default to scheduled
442
  state: 'idle', // Initialize with idle state
443
  schedule: {
444
  minute: '*',
445
  hour: '*',
446
  day: '*',
447
  month: '*',
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
 
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 = [];
 
535
 
536
  // Ensure appropriate properties are initialized based on task type
537
  if (this.editingTask.type === 'scheduled') {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
  }
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: '',
 
631
  hour: '*',
632
  day: '*',
633
  month: '*',
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
 
665
  name: this.editingTask.name,
666
  system_prompt: this.editingTask.system_prompt || '',
667
  prompt: this.editingTask.prompt || '',
668
+ state: this.editingTask.state || 'idle', // Include state in task data
669
+ timezone: getUserTimezone()
670
  };
671
 
672
  // Process attachments - now always stored as array
 
676
  .filter(line => line && line.trim().length > 0)
677
  : [];
678
 
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') {
 
687
  hour: parts[1] || '*',
688
  day: parts[2] || '*',
689
  month: parts[3] || '*',
690
+ weekday: parts[4] || '*',
691
+ timezone: getUserTimezone() // Add timezone to schedule object
692
  };
693
  } else {
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
  }
700
+ // Don't send token or plan for scheduled tasks
701
  delete taskData.token;
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;
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
 
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',
 
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
 
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
 
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);
 
877
  headers: {
878
  'Content-Type': 'application/json'
879
  },
880
+ body: JSON.stringify({
881
+ task_id: taskId,
882
+ timezone: getUserTimezone()
883
+ })
884
  });
885
 
886
  if (!response.ok) {
 
923
  },
924
  body: JSON.stringify({
925
  task_id: taskId,
926
+ state: 'idle', // Always reset to idle state
927
+ timezone: getUserTimezone()
928
  })
929
  });
930
 
 
958
  headers: {
959
  'Content-Type': 'application/json'
960
  },
961
+ body: JSON.stringify({
962
+ task_id: taskId,
963
+ timezone: getUserTimezone()
964
+ })
965
  });
966
 
967
  if (!response.ok) {
 
971
 
972
  showToast('Task deleted successfully', 'success');
973
 
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';
 
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
 
1092
  ? this.editingTask.attachments
1093
  : [];
1094
 
 
 
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) {
 
 
1101
  if (typeof value === 'string') {
1102
  // Just split by newlines without filtering to preserve editing experience
1103
  this.editingTask.attachments = value.split('\n');
 
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
 
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
  }
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 CHANGED
@@ -36,6 +36,36 @@ const settingsModalProxy = {
36
  if (activeTab) {
37
  activeTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
38
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  }, 10);
40
  },
41
 
@@ -103,9 +133,25 @@ const settingsModalProxy = {
103
  }
104
  // Debug log
105
  const schedulerTab = document.querySelector('.settings-tab[title="Task Scheduler"]');
106
- console.log(`Current active tab after direct set: ${modalAD.activeTab}`);
107
  console.log('Scheduler tab active after direct initialization?',
108
  schedulerTab && schedulerTab.classList.contains('active'));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
109
  }, 10); // Small delay just for scrolling
110
 
111
  }, 5); // Keep a minimal delay for modal opening reactivity
@@ -170,6 +216,9 @@ const settingsModalProxy = {
170
  this.handleCancel();
171
  }
172
 
 
 
 
173
  // First update our component state
174
  this.isOpen = false;
175
 
@@ -189,6 +238,9 @@ const settingsModalProxy = {
189
  data: null
190
  });
191
 
 
 
 
192
  // First update our component state
193
  this.isOpen = false;
194
 
@@ -202,6 +254,19 @@ const settingsModalProxy = {
202
  }
203
  },
204
 
 
 
 
 
 
 
 
 
 
 
 
 
 
205
  handleFieldButton(field) {
206
  console.log(`Button clicked: ${field.action}`);
207
  }
@@ -463,6 +528,16 @@ document.addEventListener('alpine:init', function () {
463
  },
464
 
465
  closeModal() {
 
 
 
 
 
 
 
 
 
 
466
  this.$store.root.isOpen = false;
467
  }
468
  };
 
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
 
 
133
  }
134
  // Debug log
135
  const schedulerTab = document.querySelector('.settings-tab[title="Task Scheduler"]');
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
 
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
 
 
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
 
 
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
  }
 
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 ADDED
@@ -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
+ }