(WIP) feat: Task Scheduler Management UI/UX
Rafael Uzarowski committed
Apr 5, 2025 at 17:24 UTC
82eebf730e9bac3d935ce2a2290f88a7a7d4da76
25 files changed
+3466
-265
agent.py
+9
@@ -1,6 +1,7 @@
1
import asyncio
2
from collections import OrderedDict
3
from dataclasses import dataclass, field
4
+from datetime import datetime
5
import time, importlib, inspect, os, json
6
import token
7
from typing import Any, Awaitable, Coroutine, Optional, Dict, TypedDict
@@ -42,6 +43,7 @@ class AgentContext:
43
log: Log.Log | None = None,
44
paused: bool = False,
45
streaming_agent: "Agent|None" = None,
46
+ created_at: datetime | None = None,
47
):
48
# build context
49
self.id = id or str(uuid.uuid4())
@@ -52,6 +54,7 @@ class AgentContext:
54
self.paused = paused
55
self.streaming_agent = streaming_agent
56
self.task: DeferredTask | None = None
57
+ self.created_at = created_at or datetime.now()
58
AgentContext._counter += 1
59
self.no = AgentContext._counter
60
@@ -77,6 +80,9 @@ class AgentContext:
80
context.task.kill()
81
return context
82
83
+ def get_created_at(self):
84
+ return self.created_at
85
+
86
def kill_process(self):
87
if self.task:
88
self.task.kill()
@@ -195,6 +201,7 @@ class AgentConfig:
201
class UserMessage:
202
message: str
203
attachments: list[str] = field(default_factory=list[str])
204
+ system_message: list[str] = field(default_factory=list[str])
205
206
207
class LoopData:
@@ -466,12 +473,14 @@ class Agent:
473
"fw.intervention.md",
474
message=message.message,
475
attachments=message.attachments,
476
+ system_message=message.system_message
477
)
478
else:
479
content = self.parse_prompt(
480
"fw.user_message.md",
481
message=message.message,
482
attachments=message.attachments,
483
+ system_message=message.system_message
484
)
485
486
# remove empty attachments from template
prompts/default/fw.intervention.md
+2
-1
@@ -1,6 +1,7 @@
1
```json
2
{
3
"user_intervention": {{message}},
4
+ "system_message": {{system_message}},
5
"attachments": {{attachments}}
6
}
6
-```
\ No newline at end of file
7
+```
prompts/default/fw.user_message.md
+1
@@ -1,6 +1,7 @@
1
```json
2
{
3
"user_message": {{message}},
4
+ "system_message": {{system_message}},
5
"attachments": {{attachments}}
6
}
7
```
python/api/poll.py
+48
-8
@@ -1,12 +1,16 @@
1
+import time
2
+
3
from python.helpers.api import ApiHandler
4
from flask import Request, Response
5
6
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):
13
+
14
async def process(self, input: dict, request: Request) -> dict | Response:
15
ctxid = input.get("context", None)
16
from_no = input.get("log_from", 0)
@@ -17,19 +21,31 @@ class Poll(ApiHandler):
21
logs = context.log.output(start=from_no)
22
23
# loop AgentContext._contexts
24
+
25
+ # Get a task scheduler instance
26
+ scheduler = TaskScheduler.get()
27
+
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 = []
35
processed_contexts = set() # Track processed context IDs
36
37
+ all_ctxs = list(AgentContext._contexts.values())
38
# First, identify all tasks
25
- for ctx in AgentContext._contexts.values():
39
+ for ctx in all_ctxs:
40
# Skip if already processed
41
if ctx.id in processed_contexts:
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),
@@ -37,19 +53,43 @@ class Poll(ApiHandler):
53
"paused": ctx.paused,
54
}
55
40
- # Determine if this is a task using multiple methods
41
- ctx_path = persist_chat.get_chat_folder_path(ctx.id)
42
- is_task = (ctx_path and persist_chat.TASKS_FOLDER in ctx_path)
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
44
- # Add to the appropriate list
45
- if is_task:
46
- tasks.append(context_data)
47
- else:
59
+ if not is_task:
60
ctxs.append(context_data)
61
+ else:
62
+ # If this is a task, get task details from the scheduler
63
+ task_details = scheduler.serialize_task(ctx.id)
64
+ if task_details:
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"),
71
+ "system_prompt": task_details.get("system_prompt"),
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
+
84
+ tasks.append(context_data)
85
86
# Mark as processed
87
processed_contexts.add(ctx.id)
88
89
+ # Sort tasks and chats by their creation date, descending
90
+ ctxs.sort(key=lambda x: x["created_at"], reverse=True)
91
+ tasks.sort(key=lambda x: x["created_at"], reverse=True)
92
+
93
# data from this server
94
return {
95
"context": context.id,
python/api/scheduler_task_create.py
new
+78
@@ -0,0 +1,78 @@
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):
9
+ async def process(self, input: Input, request: Request) -> Output:
10
+ """
11
+ Create a new task in the scheduler
12
+ """
13
+ scheduler = TaskScheduler.get()
14
+ await scheduler.reload()
15
+
16
+ # Get common fields from input
17
+ name = input.get("name")
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"}
29
+
30
+ task = None
31
+ if schedule:
32
+ # Create a scheduled task
33
+ # Handle different schedule formats (string or object)
34
+ if isinstance(schedule, str):
35
+ # Parse the string schedule
36
+ parts = schedule.split(' ')
37
+ task_schedule = TaskSchedule(
38
+ minute=parts[0] if len(parts) > 0 else "*",
39
+ hour=parts[1] if len(parts) > 1 else "*",
40
+ day=parts[2] if len(parts) > 2 else "*",
41
+ month=parts[3] if len(parts) > 3 else "*",
42
+ weekday=parts[4] if len(parts) > 4 else "*"
43
+ )
44
+ elif isinstance(schedule, dict):
45
+ # Use our standardized parsing function
46
+ try:
47
+ task_schedule = parse_task_schedule(schedule)
48
+ except ValueError as e:
49
+ return {"error": str(e)}
50
+ else:
51
+ return {"error": "Invalid schedule format. Must be string or object."}
52
+
53
+ task = ScheduledTask.create(
54
+ name=name,
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
+ }
python/api/scheduler_task_delete.py
new
+27
@@ -0,0 +1,27 @@
1
+from python.helpers.api import ApiHandler, Input, Output, Request
2
+from python.helpers.task_scheduler import TaskScheduler
3
+
4
+
5
+class SchedulerTaskDelete(ApiHandler):
6
+ async def process(self, input: Input, request: Request) -> Output:
7
+ """
8
+ Delete a task from the scheduler by ID
9
+ """
10
+ scheduler = TaskScheduler.get()
11
+ await scheduler.reload()
12
+
13
+ # Get task ID from input
14
+ task_id: str = input.get("task_id", "")
15
+
16
+ if not task_id:
17
+ return {"error": "Missing required field: task_id"}
18
+
19
+ # Check if the task exists first
20
+ task = scheduler.get_task_by_uuid(task_id)
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
+
27
+ return {"success": True, "message": f"Task {task_id} deleted successfully"}
python/api/scheduler_task_run.py
new
+49
@@ -0,0 +1,49 @@
1
+from python.helpers.api import ApiHandler, Input, Output, Request
2
+from python.helpers.task_scheduler import TaskScheduler, TaskState
3
+
4
+
5
+class SchedulerTaskRun(ApiHandler):
6
+ async def process(self, input: Input, request: Request) -> Output:
7
+ """
8
+ Manually run a task from the scheduler by ID
9
+ """
10
+ scheduler = TaskScheduler.get()
11
+ await scheduler.reload()
12
+
13
+ # Get task ID from input
14
+ task_id: str = input.get("task_id", "")
15
+
16
+ if not task_id:
17
+ return {"error": "Missing required field: task_id"}
18
+
19
+ # Check if the task exists first
20
+ task = scheduler.get_task_by_uuid(task_id)
21
+ if not task:
22
+ return {"error": f"Task with ID '{task_id}' not found"}
23
+
24
+ # Check if task is already running
25
+ if task.state != TaskState.IDLE:
26
+ # Return task details along with error for better frontend handling
27
+ serialized_task = scheduler.serialize_task(task_id)
28
+ return {
29
+ "error": f"Task '{task_id}' is in state '{task.state}' and cannot be run",
30
+ "task": serialized_task
31
+ }
32
+
33
+ # Run the task, which now includes atomic state checks and updates
34
+ try:
35
+ await scheduler.run_task_by_uuid(task_id)
36
+ # Get updated task after run starts
37
+ serialized_task = scheduler.serialize_task(task_id)
38
+ if serialized_task:
39
+ return {
40
+ "success": True,
41
+ "message": f"Task '{task_id}' started successfully",
42
+ "task": serialized_task
43
+ }
44
+ else:
45
+ return {"success": True, "message": f"Task '{task_id}' started successfully"}
46
+ except ValueError as e:
47
+ return {"error": str(e)}
48
+ except Exception as e:
49
+ return {"error": f"Failed to run task '{task_id}': {str(e)}"}
python/api/scheduler_task_update.py
new
+67
@@ -0,0 +1,67 @@
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):
9
+ async def process(self, input: Input, request: Request) -> Output:
10
+ """
11
+ Update an existing task in the scheduler
12
+ """
13
+ scheduler = TaskScheduler.get()
14
+ await scheduler.reload()
15
+
16
+ # Get task ID from input
17
+ task_id: str = input.get("task_id", "")
18
+
19
+ if not task_id:
20
+ return {"error": "Missing required field: task_id"}
21
+
22
+ # Get the task to update
23
+ task = scheduler.get_task_by_uuid(task_id)
24
+
25
+ if not task:
26
+ return {"error": f"Task with ID {task_id} not found"}
27
+
28
+ # Update fields if provided using the task's update method
29
+ update_params = {}
30
+
31
+ if "name" in input:
32
+ update_params["name"] = input.get("name", "")
33
+
34
+ if "state" in input:
35
+ update_params["state"] = TaskState(input.get("state", TaskState.IDLE))
36
+
37
+ if "system_prompt" in input:
38
+ update_params["system_prompt"] = input.get("system_prompt", "")
39
+
40
+ if "prompt" in input:
41
+ update_params["prompt"] = input.get("prompt", "")
42
+
43
+ if "attachments" in input:
44
+ update_params["attachments"] = input.get("attachments", [])
45
+
46
+ # Update schedule if this is a scheduled task and schedule is provided
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)
58
+
59
+ if not updated_task:
60
+ return {"error": f"Task with ID {task_id} not found or could not be updated"}
61
+
62
+ # Return the updated task using our standardized serialization function
63
+ task_dict = serialize_task(updated_task)
64
+
65
+ return {
66
+ "task": task_dict
67
+ }
python/api/scheduler_tasks_list.py
new
+24
@@ -0,0 +1,24 @@
1
+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):
8
+ async def process(self, input: Input, request: Request) -> Output:
9
+ """
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()
16
+
17
+ # Use the scheduler's convenience method for task serialization
18
+ tasks_list = scheduler.serialize_all_tasks()
19
+
20
+ return {"tasks": tasks_list}
21
+
22
+ except Exception as e:
23
+ PrintStyle.error(f"Failed to list tasks: {str(e)} {traceback.format_exc()}")
24
+ return {"error": f"Failed to list tasks: {str(e)} {traceback.format_exc()}", "tasks": []}
python/api/scheduler_tick.py
+29
-4
@@ -11,7 +11,32 @@ class SchedulerTick(ApiHandler):
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
- # PrintStyle().print(f"Scheduler tick - API: {timestamp}")
16
- await TaskScheduler.get().tick()
17
- return {"scheduler": "tick"}
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}")
17
+
18
+ # Get the task scheduler instance and print detailed debug info
19
+ scheduler = TaskScheduler.get()
20
+ await scheduler.reload()
21
+
22
+ tasks = scheduler.get_tasks()
23
+ tasks_count = len(tasks)
24
+
25
+ # Log information about the tasks
26
+ printer.print(f"Scheduler has {tasks_count} task(s)")
27
+ if tasks_count > 0:
28
+ for task in tasks:
29
+ printer.print(f"Task: {task.name} (UUID: {task.uuid}, State: {task.state})")
30
+
31
+ # Run the scheduler tick
32
+ await scheduler.tick()
33
+
34
+ # Get updated tasks after tick
35
+ serialized_tasks = scheduler.serialize_all_tasks()
36
+
37
+ return {
38
+ "scheduler": "tick",
39
+ "timestamp": timestamp,
40
+ "tasks_count": tasks_count,
41
+ "tasks": serialized_tasks
42
+ }
python/helpers/api.py
+12
-4
@@ -39,13 +39,21 @@ class ApiHandler:
39
async def handle_request(self, request: Request) -> Response:
40
try:
41
# input data from request based on type
42
+ input_data: Input = {}
43
if request.is_json:
43
- input = request.get_json()
44
+ try:
45
+ if request.data: # Check if there's any data
46
+ input_data = request.get_json()
47
+ # If empty or not valid JSON, use empty dict
48
+ except Exception as e:
49
+ # Just log the error and continue with empty input
50
+ PrintStyle().print(f"Error parsing JSON: {str(e)}")
51
+ input_data = {}
52
else:
45
- input = {"data": request.get_data(as_text=True)}
53
+ input_data = {"data": request.get_data(as_text=True)}
54
55
# process via handler
48
- output = await self.process(input, request)
56
+ output = await self.process(input_data, request)
57
58
# return output based on type
59
if isinstance(output, Response):
@@ -59,7 +67,7 @@ class ApiHandler:
67
# return exceptions with 500
68
except Exception as e:
69
error = format_error(e)
62
- PrintStyle.error(error)
70
+ PrintStyle.error(f"API error: {error}")
71
return Response(response=error, status=500, mimetype="text/plain")
72
73
# get context to run agent zero in
python/helpers/persist_chat.py
+28
-32
@@ -9,30 +9,39 @@ from initialize import initialize
9
from python.helpers.log import Log, LogItem
10
11
CHATS_FOLDER = "tmp/chats"
12
-TASKS_FOLDER = "tmp/task_chats"
12
LOG_SIZE = 1000
13
CHAT_FILE_NAME = "chat.json"
14
15
17
-def get_chat_folder_path(ctxid: str, folder: str = CHATS_FOLDER):
18
- return files.get_abs_path(folder, ctxid)
16
+def get_chat_folder_path(ctxid: str):
17
+ """
18
+ Get the folder path for any context (chat or task).
19
20
+ Args:
21
+ ctxid: The context ID
22
21
-def save_tmp_chat(context: AgentContext, folder: str = CHATS_FOLDER):
22
- path = _get_chat_file_path(context.id, folder)
23
+ Returns:
24
+ The absolute path to the context folder
25
+ """
26
+ return files.get_abs_path(CHATS_FOLDER, ctxid)
27
+
28
+
29
+def save_tmp_chat(context: AgentContext):
30
+ """Save context to the chats folder"""
31
+ path = _get_chat_file_path(context.id)
32
files.make_dirs(path)
33
data = _serialize_context(context)
34
js = _safe_json_serialize(data, ensure_ascii=False)
35
files.write_file(path, js)
36
37
29
-def load_tmp_chats(folder: str = CHATS_FOLDER):
30
- if folder == CHATS_FOLDER:
31
- _convert_v080_chats()
32
- folders = files.list_files(folder, "*")
38
+def load_tmp_chats():
39
+ """Load all contexts from the chats folder"""
40
+ _convert_v080_chats()
41
+ folders = files.list_files(CHATS_FOLDER, "*")
42
json_files = []
43
for folder_name in folders:
35
- json_files.append(_get_chat_file_path(folder_name, folder))
44
+ json_files.append(_get_chat_file_path(folder_name))
45
46
ctxids = []
47
for file in json_files:
@@ -46,8 +55,8 @@ def load_tmp_chats(folder: str = CHATS_FOLDER):
55
return ctxids
56
57
49
-def _get_chat_file_path(ctxid: str, folder: str = CHATS_FOLDER):
50
- return files.get_abs_path(folder, ctxid, CHAT_FILE_NAME)
58
+def _get_chat_file_path(ctxid: str):
59
+ return files.get_abs_path(CHATS_FOLDER, ctxid, CHAT_FILE_NAME)
60
61
62
def _convert_v080_chats():
@@ -59,7 +68,8 @@ def _convert_v080_chats():
68
files.move_file(path, new)
69
70
62
-def load_json_chats(jsons: list[str], folder: str = CHATS_FOLDER):
71
+def load_json_chats(jsons: list[str]):
72
+ """Load contexts from JSON strings"""
73
ctxids = []
74
for js in jsons:
75
data = json.loads(js)
@@ -71,30 +81,16 @@ def load_json_chats(jsons: list[str], folder: str = CHATS_FOLDER):
81
82
83
def export_json_chat(context: AgentContext):
84
+ """Export context as JSON string"""
85
data = _serialize_context(context)
86
js = _safe_json_serialize(data, ensure_ascii=False)
87
return js
88
89
79
-def remove_chat(ctxid, folder: str = CHATS_FOLDER):
80
- files.delete_dir(get_chat_folder_path(ctxid, folder))
81
-
82
-
83
-# Task-specific functions for convenience
84
-def save_tmp_task(context: AgentContext):
85
- save_tmp_chat(context, TASKS_FOLDER)
86
-
87
-
88
-def load_tmp_tasks():
89
- return load_tmp_chats(TASKS_FOLDER)
90
-
91
-
92
-def load_json_tasks(jsons: list[str]):
93
- return load_json_chats(jsons, TASKS_FOLDER)
94
-
95
-
96
-def remove_task(ctxid):
97
- remove_chat(ctxid, TASKS_FOLDER)
90
+def remove_chat(ctxid):
91
+ """Remove a chat or task context"""
92
+ path = get_chat_folder_path(ctxid)
93
+ files.delete_dir(path)
94
95
96
def _serialize_context(context: AgentContext):
python/helpers/print_style.py
+23
-7
@@ -34,7 +34,7 @@ class PrintStyle:
34
else:
35
rgb_color = webcolors.name_to_rgb(color)
36
r, g, b = rgb_color.red, rgb_color.green, rgb_color.blue
37
-
37
+
38
if is_background:
39
return f"\033[48;2;{r};{g};{b}m", f"background-color: rgb({r}, {g}, {b});"
40
else:
@@ -88,15 +88,15 @@ class PrintStyle:
88
def _close_html_log():
89
if PrintStyle.log_file_path:
90
with open(PrintStyle.log_file_path, "a") as f:
91
- f.write("</pre></body></html>")
91
+ f.write("</pre></body></html>")
92
93
def get(self, *args, sep=' ', **kwargs):
94
text = sep.join(map(str, args))
95
return text, self._get_styled_text(text), self._get_html_styled_text(text)
96
-
96
+
97
def print(self, *args, sep=' ', **kwargs):
98
self._add_padding_if_needed()
99
- if not PrintStyle.last_endline:
99
+ if not PrintStyle.last_endline:
100
print()
101
self._log_html("<br>")
102
plain_text, styled_text, html_text = self.get(*args, sep=sep, **kwargs)
@@ -118,15 +118,31 @@ class PrintStyle:
118
return bool(lines) and not lines[-1].strip()
119
120
@staticmethod
121
- def standard(text:str):
121
+ def standard(text: str):
122
PrintStyle().print(text)
123
124
@staticmethod
125
- def hint(text:str):
125
+ def hint(text: str):
126
PrintStyle(font_color="#6C3483", padding=True).print("Hint: "+text)
127
128
@staticmethod
129
- def error(text:str):
129
+ def info(text: str):
130
+ PrintStyle(font_color="#0000FF", padding=True).print("Info: "+text)
131
+
132
+ @staticmethod
133
+ def success(text: str):
134
+ PrintStyle(font_color="#008000", padding=True).print("Success: "+text)
135
+
136
+ @staticmethod
137
+ def warning(text: str):
138
+ PrintStyle(font_color="#FFA500", padding=True).print("Warning: "+text)
139
+
140
+ @staticmethod
141
+ def debug(text: str):
142
+ PrintStyle(font_color="#808080", padding=True).print("Debug: "+text)
143
+
144
+ @staticmethod
145
+ def error(text: str):
146
PrintStyle(font_color="red", padding=True).print("Error: "+text)
147
148
# Ensure HTML file is closed properly when the program exits
python/helpers/task_scheduler.py
+346
-76
@@ -1,28 +1,40 @@
1
-import uuid
2
-import random
1
+import asyncio
2
import os
4
-from datetime import datetime, timezone
3
+import random
4
import threading
6
-import asyncio
5
+import uuid
6
+from datetime import datetime, timezone, timedelta
7
+from enum import Enum
8
+from os.path import exists
9
+from typing import ClassVar, Literal, Optional, Union, Dict, Any, Type, TypeVar, cast
10
+
11
import nest_asyncio
12
nest_asyncio.apply()
13
10
-from typing import Union, Literal, Optional
11
-
14
from crontab import CronTab
15
from pydantic import BaseModel, Field, PrivateAttr
14
-from python.helpers.files import get_abs_path, exists, write_file, read_file, make_dirs
16
+
17
from agent import Agent, AgentContext, UserMessage
18
from initialize import initialize
17
-from python.helpers.persist_chat import export_json_chat, load_json_chats, load_tmp_chats, save_tmp_chat
19
+from python.helpers.persist_chat import load_tmp_chats, save_tmp_chat
20
from python.helpers.print_style import PrintStyle
21
from python.helpers.defer import DeferredTask
20
-from python.helpers.persist_chat import CHATS_FOLDER, TASKS_FOLDER
21
-from python.helpers import errors
22
+from python.helpers.files import make_dirs, write_file, get_abs_path, read_file
23
24
SCHEDULER_FOLDER = "memory/scheduler"
25
26
27
+# ----------------------
28
+# Task Models
29
+# ----------------------
30
+
31
+class TaskState(str, Enum):
32
+ IDLE = "idle"
33
+ RUNNING = "running"
34
+ DISABLED = "disabled"
35
+ ERROR = "error"
36
+
37
+
38
class TaskSchedule(BaseModel):
39
minute: str
40
hour: str
@@ -36,7 +48,7 @@ class TaskSchedule(BaseModel):
48
49
class AdHocTask(BaseModel):
50
uuid: str = Field(default_factory=lambda: str(uuid.uuid4()))
39
- state: Literal["idle", "running", "disabled"] = Field(default="idle")
51
+ state: TaskState = Field(default=TaskState.IDLE)
52
name: str = Field()
53
system_prompt: str
54
prompt: str
@@ -66,16 +78,17 @@ class AdHocTask(BaseModel):
78
79
def __init__(self, *args, **kwargs):
80
super().__init__(*args, **kwargs)
69
- self._lock = threading.Lock()
81
+ self._lock = threading.RLock()
82
83
def update(self,
84
name: str | None = None,
73
- state: Literal["idle", "running", "disabled"] | None = None,
85
+ state: TaskState | None = None,
86
system_prompt: str | None = None,
87
prompt: str | None = None,
88
attachments: list[str] | None = None,
89
last_run: datetime | None = None,
78
- last_result: str | None = None):
90
+ last_result: str | None = None,
91
+ token: str | None = None):
92
with self._lock:
93
if name is not None:
94
self.name = name
@@ -98,6 +111,9 @@ class AdHocTask(BaseModel):
111
if last_result is not None:
112
self.last_result = last_result
113
self.updated_at = datetime.now(timezone.utc)
114
+ if token is not None:
115
+ self.token = token
116
+ self.updated_at = datetime.now(timezone.utc)
117
118
def check_schedule(self) -> bool:
119
with self._lock:
@@ -106,7 +122,7 @@ class AdHocTask(BaseModel):
122
123
class ScheduledTask(BaseModel):
124
uuid: str = Field(default_factory=lambda: str(uuid.uuid4()))
109
- state: Literal["idle", "running", "disabled"] = Field(default="idle")
125
+ state: TaskState = Field(default=TaskState.IDLE)
126
name: str
127
schedule: TaskSchedule
128
system_prompt: str
@@ -136,11 +152,11 @@ class ScheduledTask(BaseModel):
152
153
def __init__(self, *args, **kwargs):
154
super().__init__(*args, **kwargs)
139
- self._lock = threading.Lock()
155
+ self._lock = threading.RLock()
156
157
def update(self,
158
name: str | None = None,
143
- state: Literal["idle", "running", "disabled"] | None = None,
159
+ state: TaskState | None = None,
160
system_prompt: str | None = None,
161
prompt: str | None = None,
162
attachments: list[str] | None = None,
@@ -176,10 +192,15 @@ class ScheduledTask(BaseModel):
192
def check_schedule(self, frequency_seconds: float = 60.0) -> bool:
193
with self._lock:
194
crontab = CronTab(crontab=self.schedule.to_crontab())
179
- next_run: float | None = crontab.next(now=datetime.now(timezone.utc), return_datetime=False)
180
- if next_run is None:
195
+ # Get next run time as seconds until next execution
196
+ # Set reference time to now - 1 minute to avoid off-by-one
197
+ next_run_seconds: Optional[float] = crontab.next(
198
+ now=datetime.now(timezone.utc) - timedelta(seconds=frequency_seconds),
199
+ return_datetime=False
200
+ ) # type: ignore
201
+ if next_run_seconds is None:
202
return False
182
- return next_run < frequency_seconds
203
+ return next_run_seconds < frequency_seconds
204
205
def run(self):
206
pass
@@ -188,21 +209,36 @@ class ScheduledTask(BaseModel):
209
class SchedulerTaskList(BaseModel):
210
tasks: list[Union[ScheduledTask, AdHocTask]]
211
212
+ # Singleton instance
213
+ __instance: ClassVar[Optional["SchedulerTaskList"]] = PrivateAttr(default=None)
214
+
215
# lock: threading.Lock = Field(exclude=True, default=threading.Lock())
216
217
@classmethod
218
def get(cls) -> "SchedulerTaskList":
219
path = get_abs_path(SCHEDULER_FOLDER, "tasks.json")
196
- if not exists(path):
197
- make_dirs(path)
198
- instance = asyncio.run(cls(tasks=[]).save())
220
+ if cls.__instance is None:
221
+ if not exists(path):
222
+ make_dirs(path)
223
+ cls.__instance = asyncio.run(cls(tasks=[]).save())
224
+ else:
225
+ cls.__instance = cls.model_validate_json(read_file(path))
226
else:
200
- instance = cls.model_validate_json(read_file(path))
201
- return instance
227
+ asyncio.run(cls.__instance.reload())
228
+ return cls.__instance
229
230
def __init__(self, *args, **kwargs):
231
super().__init__(*args, **kwargs)
205
- self._lock = threading.Lock()
232
+ self._lock = threading.RLock()
233
+
234
+ async def reload(self) -> "SchedulerTaskList":
235
+ path = get_abs_path(SCHEDULER_FOLDER, "tasks.json")
236
+ if exists(path):
237
+ with self._lock:
238
+ data = self.__class__.model_validate_json(read_file(path))
239
+ self.tasks.clear()
240
+ self.tasks.extend(data.tasks)
241
+ return self
242
243
async def add_task(self, task: Union[ScheduledTask, AdHocTask]) -> "SchedulerTaskList":
244
with self._lock:
@@ -218,6 +254,39 @@ class SchedulerTaskList(BaseModel):
254
write_file(path, self.model_dump_json())
255
return self
256
257
+ async def update_task_by_uuid(self, task_uuid: str, updater_func) -> Union[ScheduledTask, AdHocTask] | None:
258
+ """
259
+ Atomically update a task by UUID using the provided updater function.
260
+
261
+ The updater_func should take the task as an argument and perform any necessary updates.
262
+ This method ensures that the task is updated and saved atomically, preventing race conditions.
263
+
264
+ Returns the updated task or None if not found.
265
+ """
266
+ with self._lock:
267
+ # Reload to ensure we have the latest state
268
+ await self.reload()
269
+
270
+ # Find the task
271
+ task = next((task for task in self.tasks if task.uuid == task_uuid), None)
272
+ if task is None:
273
+ return None
274
+
275
+ # Apply the updates via the provided function
276
+ updater_func(task)
277
+
278
+ # Save the changes
279
+ path = get_abs_path(SCHEDULER_FOLDER, "tasks.json")
280
+ if not exists(path):
281
+ make_dirs(path)
282
+ write_file(path, self.model_dump_json())
283
+
284
+ return task
285
+
286
+ def get_tasks(self) -> list[Union[ScheduledTask, AdHocTask]]:
287
+ with self._lock:
288
+ return self.tasks
289
+
290
def get_due_tasks(self) -> list[Union[ScheduledTask, AdHocTask]]:
291
with self._lock:
292
return [task for task in self.tasks if task.check_schedule()]
@@ -247,14 +316,44 @@ class TaskScheduler:
316
317
_tasks: SchedulerTaskList
318
_printer: PrintStyle
319
+ _instance = None
320
321
@classmethod
322
def get(cls) -> "TaskScheduler":
253
- return cls()
323
+ if cls._instance is None:
324
+ cls._instance = cls()
325
+ return cls._instance
326
327
def __init__(self):
256
- self._tasks = SchedulerTaskList.get()
257
- self._printer = PrintStyle(italic=True, font_color="green", padding=False)
328
+ # Only initialize if this is a new instance
329
+ if not hasattr(self, '_initialized'):
330
+ self._tasks = SchedulerTaskList.get()
331
+ self._printer = PrintStyle(italic=True, font_color="green", padding=False)
332
+ self._initialized = True
333
+
334
+ async def reload(self):
335
+ await self._tasks.reload()
336
+
337
+ def get_tasks(self) -> list[Union[ScheduledTask, AdHocTask]]:
338
+ return self._tasks.get_tasks()
339
+
340
+ async def add_task(self, task: Union[ScheduledTask, AdHocTask]) -> "TaskScheduler":
341
+ await self._tasks.add_task(task)
342
+ return self
343
+
344
+ async def remove_task_by_uuid(self, task_uuid: str) -> "TaskScheduler":
345
+ await self._tasks.remove_task_by_uuid(task_uuid)
346
+ return self
347
+
348
+ async def remove_task_by_name(self, name: str) -> "TaskScheduler":
349
+ await self._tasks.remove_task_by_name(name)
350
+ return self
351
+
352
+ def get_task_by_uuid(self, task_uuid: str) -> Union[ScheduledTask, AdHocTask] | None:
353
+ return self._tasks.get_task_by_uuid(task_uuid)
354
+
355
+ def get_task_by_name(self, name: str) -> Union[ScheduledTask, AdHocTask] | None:
356
+ return self._tasks.get_task_by_name(name)
357
358
async def tick(self):
359
for task in self._tasks.get_due_tasks():
@@ -264,6 +363,8 @@ class TaskScheduler:
363
task = self._tasks.get_task_by_uuid(task_uuid)
364
if task is None:
365
raise ValueError(f"Task with UUID {task_uuid} not found")
366
+
367
+ # The actual state check and running will be handled in the _run_task method
368
await self._run_task(task)
369
370
async def run_task_by_name(self, name: str):
@@ -272,30 +373,40 @@ class TaskScheduler:
373
raise ValueError(f"Task with name {name} not found")
374
await self._run_task(task)
375
376
+ async def save(self):
377
+ await self._tasks.save()
378
+
379
+ async def update_task(self, task_uuid: str, **update_params) -> Union[ScheduledTask, AdHocTask] | None:
380
+ """
381
+ Atomically update a task by UUID with the provided parameters.
382
+ This prevents race conditions when multiple processes update tasks concurrently.
383
+
384
+ Returns the updated task or None if not found.
385
+ """
386
+ def _update_task(task):
387
+ task.update(**update_params)
388
+
389
+ return await self._tasks.update_task_by_uuid(task_uuid, _update_task)
390
+
391
async def __new_context(self, task: Union[ScheduledTask, AdHocTask]) -> AgentContext:
392
config = initialize()
393
context: AgentContext = AgentContext(config)
394
context.id = task.uuid
279
- # chat_json = export_json_chat(context)
280
- # chat_file = get_abs_path(TASKS_FOLDER, task.uuid, "chat.json")
281
- # make_dirs(chat_file)
282
- # write_file(chat_file, chat_json)
283
- save_tmp_chat(context, TASKS_FOLDER)
395
+ # Save the context
396
+ save_tmp_chat(context)
397
return context
398
399
async def _get_chat_context(self, task: Union[ScheduledTask, AdHocTask]) -> AgentContext:
287
- ctxids = load_tmp_chats(TASKS_FOLDER)
288
- # chat_file = get_abs_path(TASKS_FOLDER, task.uuid, "chat.json")
289
- # if exists(chat_file):
400
+ ctxids = load_tmp_chats()
401
+
402
if task.uuid in ctxids:
291
- # chat = read_file(chat_file)
403
context = AgentContext.get(task.uuid)
404
if isinstance(context, AgentContext):
405
self._printer.print(
406
f"Scheduler Task {task.name} loaded from task {task.uuid}, context ok"
407
)
408
context.id = task.uuid
298
- save_tmp_chat(context, TASKS_FOLDER)
409
+ save_tmp_chat(context)
410
return context
411
else:
412
self._printer.print(
@@ -309,42 +420,53 @@ class TaskScheduler:
420
return await self.__new_context(task)
421
422
async def _persist_chat(self, task: Union[ScheduledTask, AdHocTask], context: AgentContext):
312
- # chat_json = export_json_chat(context)
313
- # chat_file = get_abs_path(TASKS_FOLDER, task.uuid, "chat.json")
314
- # make_dirs(chat_file)
315
- # write_file(chat_file, chat_json)
423
context.id = task.uuid
317
- save_tmp_chat(context, TASKS_FOLDER)
424
+ save_tmp_chat(context)
425
426
async def _run_task(self, task: Union[ScheduledTask, AdHocTask]):
427
321
- async def _run_task_wrapper(task: Union[ScheduledTask, AdHocTask]):
322
- if task.state != "idle":
323
- self._printer.print(f"Scheduler Task '{task.name}' state is '{task.state}', skipping")
428
+ async def _run_task_wrapper(task_uuid: str):
429
+
430
+ # preflight checks with a snapshot of the task
431
+ task_snapshot: Union[ScheduledTask, AdHocTask] | None = self.get_task_by_uuid(task_uuid)
432
+ if task_snapshot is None:
433
+ self._printer.print(f"Scheduler Task with UUID '{task_uuid}' not found")
434
+ return
435
+ if not isinstance(task_snapshot, ScheduledTask):
436
+ self._printer.error(f"Scheduler Task '{task_snapshot.name}' is not an ScheduledTask, this should not happen, skipping")
437
+ return
438
+ if task_snapshot.state == TaskState.RUNNING:
439
+ self._printer.print(f"Scheduler Task '{task_snapshot.name}' already running, skipping")
440
+ return
441
+ if task_snapshot.state != TaskState.IDLE:
442
+ self._printer.print(f"Scheduler Task '{task_snapshot.name}' state is '{task_snapshot.state}', skipping")
443
return
444
326
- if task.state == "running":
327
- self._printer.print(f"Scheduler Task '{task.name}' already running, skipping")
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:
331
- self._printer.print(f"Scheduler Task '{task.name}' started")
456
+ self._printer.print(f"Scheduler Task '{current_task.name}' started")
457
333
- task.update(state="running")
334
- await self._tasks.save()
335
-
336
- context = await self._get_chat_context(task)
458
+ context = await self._get_chat_context(current_task)
459
agent = Agent(0, context.config, context)
460
461
# Prepare attachment filenames for logging
462
attachment_filenames = []
341
- if task.attachments:
342
- for attachment in task.attachments:
463
+ if current_task.attachments:
464
+ for attachment in current_task.attachments:
465
if os.path.exists(attachment):
466
attachment_filenames.append(os.path.basename(attachment))
467
468
self._printer.print("User message:")
347
- self._printer.print(f"> {task.prompt}")
469
+ self._printer.print(f"> {current_task.prompt}")
470
if attachment_filenames:
471
self._printer.print("Attachments:")
472
for filename in attachment_filenames:
@@ -354,40 +476,188 @@ class TaskScheduler:
476
context.log.log(
477
type="user",
478
heading="User message",
357
- content=task.prompt,
479
+ content=current_task.prompt,
480
kvps={"attachments": attachment_filenames},
481
id=str(uuid.uuid4()),
482
)
483
484
agent.hist_add_user_message(
485
UserMessage(
364
- message=task.prompt,
486
+ message=current_task.prompt,
487
+ system_message=[current_task.system_prompt],
488
attachments=[]))
489
367
- await self._persist_chat(task, context)
490
+ await self._persist_chat(current_task, context)
491
492
result = await agent.monologue()
370
- task.update(last_result="SUCCESS: " + result)
493
372
- self._printer.print(f"Scheduler Task '{task.name}' completed: {result}")
494
+ # Atomically update task state after completion
495
+ await self.update_task(
496
+ task_uuid,
497
+ state=TaskState.IDLE,
498
+ last_run=datetime.now(timezone.utc),
499
+ last_result="SUCCESS: " + result
500
+ )
501
+
502
+ self._printer.print(f"Scheduler Task '{current_task.name}' completed: {result}")
503
374
- await self._persist_chat(task, context)
504
+ await self._persist_chat(current_task, context)
505
506
except Exception as e:
377
- self._printer.print(f"Scheduler Task '{task.name}' failed: {e}")
378
- task.update(last_result=f"ERROR: {str(e)}")
507
+ self._printer.print(f"Scheduler Task '{current_task.name}' failed: {e}")
508
+
509
+ # Atomically update task state on error
510
+ await self.update_task(
511
+ task_uuid,
512
+ state=TaskState.ERROR,
513
+ last_result=f"ERROR: {str(e)}"
514
+ )
515
+
516
if agent:
517
agent.handle_critical_exception(e)
518
382
- finally:
383
- try:
384
- task.update(
385
- state="idle",
386
- last_run=datetime.now(timezone.utc)
387
- )
388
- await self._tasks.save()
389
- except Exception as e:
390
- self._printer.print(f"Scheduler Task '{task.name}' failed to save: {e}")
391
-
519
deferred_task = DeferredTask(thread_name=self.__class__.__name__)
393
- deferred_task.start_task(_run_task_wrapper, task)
520
+ deferred_task.start_task(_run_task_wrapper, task.uuid)
521
+
522
+ def serialize_all_tasks(self) -> list[Dict[str, Any]]:
523
+ """
524
+ Serialize all tasks in the scheduler to a list of dictionaries.
525
+ """
526
+ return serialize_tasks(self.get_tasks())
527
+
528
+ def serialize_task(self, task_id: str) -> Optional[Dict[str, Any]]:
529
+ """
530
+ Serialize a specific task in the scheduler by UUID.
531
+ Returns None if task is not found.
532
+ """
533
+ # Get task without locking, as get_task_by_uuid() is already thread-safe
534
+ task = self.get_task_by_uuid(task_id)
535
+ if task:
536
+ return serialize_task(task)
537
+ return None
538
+
539
+
540
+# ----------------------
541
+# Task Serialization Helpers
542
+# ----------------------
543
+
544
+def serialize_datetime(dt: Optional[datetime]) -> Optional[str]:
545
+ """Convert datetime to ISO format string or None if dt is None."""
546
+ return dt.isoformat() if dt is not None else None
547
+
548
+
549
+def parse_datetime(dt_str: Optional[str]) -> Optional[datetime]:
550
+ """Parse ISO format datetime string with validation or return None if dt_str is None."""
551
+ if not dt_str:
552
+ return None
553
+ try:
554
+ return datetime.fromisoformat(dt_str)
555
+ except ValueError:
556
+ raise ValueError(f"Invalid datetime format: {dt_str}. Expected ISO format.")
557
+
558
+
559
+def serialize_task_schedule(schedule: TaskSchedule) -> Dict[str, str]:
560
+ """Convert TaskSchedule to a standardized dictionary format."""
561
+ return {
562
+ 'minute': schedule.minute,
563
+ 'hour': schedule.hour,
564
+ 'day': schedule.day,
565
+ 'month': schedule.month,
566
+ 'weekday': schedule.weekday
567
+ }
568
+
569
+
570
+def parse_task_schedule(schedule_data: Dict[str, str]) -> TaskSchedule:
571
+ """Parse dictionary into TaskSchedule with validation."""
572
+ try:
573
+ return TaskSchedule(
574
+ minute=schedule_data.get('minute', '*'),
575
+ hour=schedule_data.get('hour', '*'),
576
+ day=schedule_data.get('day', '*'),
577
+ month=schedule_data.get('month', '*'),
578
+ weekday=schedule_data.get('weekday', '*')
579
+ )
580
+ except Exception as e:
581
+ raise ValueError(f"Invalid schedule format: {e}")
582
+
583
+
584
+T = TypeVar('T', bound=Union[ScheduledTask, AdHocTask])
585
+
586
+
587
+def serialize_task(task: Union[ScheduledTask, AdHocTask]) -> Dict[str, Any]:
588
+ """
589
+ Standardized serialization for task objects with proper handling of all complex types.
590
+ """
591
+ # Start with a basic dictionary
592
+ task_dict = {
593
+ "uuid": task.uuid,
594
+ "name": task.name,
595
+ "state": task.state,
596
+ "system_prompt": task.system_prompt,
597
+ "prompt": task.prompt,
598
+ "attachments": task.attachments,
599
+ "created_at": serialize_datetime(task.created_at),
600
+ "updated_at": serialize_datetime(task.updated_at),
601
+ "last_run": serialize_datetime(task.last_run),
602
+ "last_result": task.last_result
603
+ }
604
+
605
+ # Add type-specific fields
606
+ if isinstance(task, ScheduledTask):
607
+ task_dict['type'] = 'scheduled'
608
+ task_dict['schedule'] = serialize_task_schedule(task.schedule)
609
+ else:
610
+ task_dict['type'] = 'adhoc'
611
+ adhoc_task = cast(AdHocTask, task)
612
+ task_dict['token'] = adhoc_task.token
613
+
614
+ return task_dict
615
+
616
+
617
+def serialize_tasks(tasks: list[Union[ScheduledTask, AdHocTask]]) -> list[Dict[str, Any]]:
618
+ """
619
+ Serialize a list of tasks to a list of dictionaries.
620
+ """
621
+ return [serialize_task(task) for task in tasks]
622
+
623
+
624
+def deserialize_task(task_data: Dict[str, Any], task_class: Optional[Type[T]] = None) -> T:
625
+ """
626
+ Deserialize dictionary into appropriate task object with validation.
627
+ If task_class is provided, uses that type. Otherwise determines type from data.
628
+ """
629
+ task_type_str = task_data.get('type', '')
630
+ determined_class = None
631
+
632
+ if not task_class:
633
+ # Determine task class from data
634
+ if task_type_str == 'scheduled':
635
+ determined_class = cast(Type[T], ScheduledTask)
636
+ elif task_type_str == 'adhoc':
637
+ determined_class = cast(Type[T], AdHocTask)
638
+ else:
639
+ raise ValueError(f"Unknown task type: {task_type_str}")
640
+ else:
641
+ determined_class = task_class
642
+
643
+ common_args = {
644
+ "uuid": task_data.get("uuid"),
645
+ "name": task_data.get("name"),
646
+ "state": TaskState(task_data.get("state", TaskState.IDLE)),
647
+ "system_prompt": task_data.get("system_prompt", ""),
648
+ "prompt": task_data.get("prompt", ""),
649
+ "attachments": task_data.get("attachments", []),
650
+ "created_at": parse_datetime(task_data.get("created_at")),
651
+ "updated_at": parse_datetime(task_data.get("updated_at")),
652
+ "last_run": parse_datetime(task_data.get("last_run")),
653
+ "last_result": task_data.get("last_result")
654
+ }
655
+
656
+ # Add type-specific fields
657
+ if determined_class == ScheduledTask:
658
+ schedule_data = task_data.get("schedule", {})
659
+ common_args["schedule"] = parse_task_schedule(schedule_data)
660
+ return ScheduledTask(**common_args) # type: ignore
661
+ else:
662
+ common_args["token"] = task_data.get("token", "")
663
+ return AdHocTask(**common_args) # type: ignore
run_ui.py
-3
@@ -167,9 +167,6 @@ def run():
167
# initialize contexts from persisted chats
168
persist_chat.load_tmp_chats()
169
170
- # initialize contexts from persisted tasks
171
- persist_chat.load_tmp_tasks()
172
-
170
except Exception as e:
171
PrintStyle().error(errors.format_error(e))
172
test_scheduler.py
new
+16
@@ -0,0 +1,16 @@
1
+from python.helpers.task_scheduler import ScheduledTask, TaskSchedule, SchedulerTaskList, TaskState
2
+import asyncio
3
+
4
+slist = SchedulerTaskList.get()
5
+
6
+print(slist.model_dump_json(indent=4))
7
+
8
+for task in slist.tasks:
9
+ t = slist.get_task_by_uuid(task.uuid)
10
+ t.update(state=TaskState.DISABLED)
11
+ print("-" * 100)
12
+ print(t.model_dump_json(indent=4))
13
+
14
+print("-" * 100)
15
+
16
+print(slist.model_dump_json(indent=4))
webui/css/modals.css
+19
-7
@@ -18,20 +18,33 @@
18
.modal-container {
19
background-color: var(--color-panel);
20
border-radius: 12px;
21
- width: 90%;
22
- max-width: 800px;
21
+ width: 1100px; /* Reduced from 1200px to 1100px */
22
max-height: 90vh;
23
display: flex;
24
flex-direction: column;
25
overflow: hidden;
26
box-shadow: 0 4px 23px rgba(0, 0, 0, 0.2);
28
- transition: all 0.3s ease;
27
+ box-sizing: border-box;
28
}
29
30
.light-mode .modal-container {
31
background-color: var(--color-panel-light);
32
}
33
34
+/* Mobile Viewport Behavior */
35
+@media (max-width: 1280px) {
36
+ .modal-container {
37
+ width: 95%; /* Take up most of the screen on mobile */
38
+ min-width: unset; /* Remove min-width constraints */
39
+ max-width: 95%; /* Ensure consistent width */
40
+ }
41
+
42
+ /* Ensure section content can scroll horizontally */
43
+ .section {
44
+ overflow-x: auto;
45
+ }
46
+}
47
+
48
/* Modal Header */
49
.modal-header {
50
display: grid;
@@ -77,21 +90,20 @@
90
.modal-description {
91
padding: 0.8rem 1rem 0 1rem;
92
flex-grow: 1;
80
- transition: all 0.3s ease;
93
}
94
95
/* Modal Content */
96
.modal-content {
97
padding: 0.5rem 1.5rem 0 1.5rem;
98
overflow-y: auto;
99
+ overflow-x: hidden;
100
height: calc(90vh);
101
flex-grow: 1;
102
background-clip: border-box;
103
border: 6px solid transparent;
91
- transition: all 0.3s ease;
104
margin-bottom: 0;
93
- padding-bottom: 0;
94
-
105
+ padding-bottom: 10px;
106
+ box-sizing: border-box;
107
}
108
109
.modal-content::-webkit-scrollbar {
webui/css/settings.css
+422
@@ -74,6 +74,14 @@ textarea:focus {
74
background-color: #151515;
75
}
76
77
+/* Button Disabled State */
78
+.btn-disabled,
79
+.btn-ok.btn-disabled {
80
+ opacity: 0.5;
81
+ cursor: not-allowed;
82
+ pointer-events: none;
83
+}
84
+
85
/* Toggle Switch Styles */
86
.toggle {
87
position: relative;
@@ -376,3 +384,417 @@ nav ul li a img {
384
max-width: 80px;
385
}
386
}
387
+
388
+/* Scheduler Task List - updated with guaranteed width handling */
389
+.scheduler-task-list {
390
+ width: 100%;
391
+ min-width: 100%;
392
+ margin: 0;
393
+ border-collapse: separate;
394
+ border-spacing: 0;
395
+ white-space: nowrap;
396
+ padding-bottom: 8px;
397
+ table-layout: auto;
398
+}
399
+
400
+.scheduler-task-list th,
401
+.scheduler-task-list td {
402
+ padding: 8px 12px;
403
+ text-align: left;
404
+ vertical-align: middle;
405
+ border-bottom: 1px solid var(--color-border);
406
+}
407
+
408
+/* Ensure columns have proper min-width */
409
+.scheduler-task-list th:nth-child(1),
410
+.scheduler-task-list td:nth-child(1) {
411
+ min-width: 150px;
412
+ max-width: 200px;
413
+ overflow: hidden;
414
+ text-overflow: ellipsis;
415
+}
416
+
417
+.scheduler-task-list th:nth-child(2),
418
+.scheduler-task-list td:nth-child(2) {
419
+ min-width: 100px;
420
+}
421
+
422
+.scheduler-task-list th:nth-child(3),
423
+.scheduler-task-list td:nth-child(3) {
424
+ min-width: 100px;
425
+}
426
+
427
+.scheduler-task-list th:nth-child(4),
428
+.scheduler-task-list td:nth-child(4) {
429
+ min-width: 150px;
430
+}
431
+
432
+.scheduler-task-list th:nth-child(5),
433
+.scheduler-task-list td:nth-child(5) {
434
+ min-width: 180px;
435
+}
436
+
437
+.scheduler-task-list th:nth-child(6),
438
+.scheduler-task-list td:nth-child(6) {
439
+ min-width: 160px;
440
+ white-space: nowrap;
441
+}
442
+
443
+/* Task actions container */
444
+.scheduler-task-actions {
445
+ display: flex;
446
+ justify-content: flex-end;
447
+ gap: 10px;
448
+ flex-wrap: nowrap;
449
+}
450
+
451
+/* Scheduler form styles */
452
+.scheduler-form {
453
+ display: flex;
454
+ flex-direction: column;
455
+ gap: 1.5rem;
456
+ padding: 1rem 0;
457
+}
458
+
459
+.scheduler-form-header {
460
+ display: flex;
461
+ justify-content: space-between;
462
+ align-items: center;
463
+ margin-bottom: 1.5rem;
464
+}
465
+
466
+.scheduler-form-title {
467
+ font-size: 1.25rem;
468
+ font-weight: bold;
469
+ color: var(--color-primary);
470
+ margin: 0;
471
+}
472
+
473
+.scheduler-form-actions {
474
+ display: flex;
475
+ gap: 0.8rem;
476
+ justify-content: flex-end;
477
+ align-items: center;
478
+}
479
+
480
+.scheduler-form-grid {
481
+ display: grid;
482
+ grid-template-columns: 1fr;
483
+ gap: 1.5rem;
484
+ overflow-x: auto;
485
+}
486
+
487
+.scheduler-form-field {
488
+ display: grid;
489
+ grid-template-columns: 1fr 2fr;
490
+ gap: 1rem;
491
+ align-items: flex-start;
492
+}
493
+
494
+@media (max-width: 768px) {
495
+ .scheduler-form-header {
496
+ flex-direction: column;
497
+ align-items: flex-start;
498
+ gap: 1rem;
499
+ }
500
+
501
+ .scheduler-form-actions {
502
+ align-self: flex-end;
503
+ }
504
+
505
+ .scheduler-form-field {
506
+ grid-template-columns: 1fr;
507
+ gap: 0.5rem;
508
+ }
509
+}
510
+
511
+/* Section Styles */
512
+.section {
513
+ margin-bottom: 2rem;
514
+ padding: 1rem;
515
+ padding-bottom: 0;
516
+ border: 1px solid var(--color-border);
517
+ border-radius: 0.5rem;
518
+ overflow-x: visible; /* Desktop: No horizontal scroll */
519
+ width: 100%; /* Fill available width */
520
+ min-width: min-content;
521
+ display: block;
522
+ box-sizing: border-box;
523
+}
524
+
525
+.section-title {
526
+ font-size: 1.25rem;
527
+ font-weight: bold;
528
+ color: var(--color-primary);
529
+ margin-bottom: 0.5rem;
530
+}
531
+
532
+.section-description {
533
+ color: var(--color-text);
534
+ margin-bottom: 1rem;
535
+}
536
+
537
+/* Scheduler container - updated with guaranteed width handling */
538
+.scheduler-container {
539
+ width: 100%;
540
+ box-sizing: border-box;
541
+ display: block;
542
+ padding: 0.5rem 0;
543
+}
544
+
545
+/* Scheduler task actions and buttons */
546
+.scheduler-task-action {
547
+ display: inline-flex;
548
+ align-items: center;
549
+ justify-content: center;
550
+ background-color: transparent;
551
+ border: 1px solid var(--color-border);
552
+ color: var(--color-text);
553
+ padding: 4px;
554
+ border-radius: 4px;
555
+ cursor: pointer;
556
+ transition: all 0.2s ease;
557
+ width: 28px;
558
+ height: 28px;
559
+ flex-shrink: 0;
560
+}
561
+
562
+.scheduler-task-action:hover {
563
+ background-color: var(--color-secondary);
564
+}
565
+
566
+/* Adjust media queries to handle small screens */
567
+@media (max-width: 768px) {
568
+ .scheduler-task-list {
569
+ min-width: 700px;
570
+ }
571
+
572
+ .scheduler-detail-view {
573
+ min-width: 650px;
574
+ }
575
+}
576
+
577
+/* Scrollbar styling for better visibility */
578
+.section::-webkit-scrollbar {
579
+ height: 10px; /* Taller scrollbar for better usability */
580
+ background-color: rgba(0,0,0,0.1);
581
+}
582
+
583
+.section::-webkit-scrollbar-thumb {
584
+ background-color: rgba(155, 155, 155, 0.7);
585
+ border-radius: 5px;
586
+}
587
+
588
+.section::-webkit-scrollbar-thumb:hover {
589
+ background-color: rgba(155, 155, 155, 0.9);
590
+}
591
+
592
+/* Mobile styles for scheduler sections */
593
+@media (max-width: 1280px) {
594
+ .scheduler-container {
595
+ min-width: max-content; /* Allow expansion based on content */
596
+ }
597
+
598
+ .scheduler-task-list {
599
+ min-width: max-content; /* Expand to fit content if needed */
600
+ }
601
+
602
+ /* Scrollbar styling for mobile view */
603
+ .section::-webkit-scrollbar {
604
+ height: 10px;
605
+ background-color: rgba(0,0,0,0.1);
606
+ }
607
+
608
+ .section::-webkit-scrollbar-thumb {
609
+ background-color: rgba(155, 155, 155, 0.7);
610
+ border-radius: 5px;
611
+ }
612
+
613
+ .section::-webkit-scrollbar-thumb:hover {
614
+ background-color: rgba(155, 155, 155, 0.9);
615
+ }
616
+}
617
+
618
+/* Scheduler form field styling to match standard field styling */
619
+.scheduler-form-label {
620
+ font-weight: bold;
621
+ color: var(--color-primary);
622
+ margin-bottom: 0.25rem; /* Add consistent spacing between label and help text */
623
+}
624
+
625
+.scheduler-form-help {
626
+ color: var(--color-text);
627
+ font-size: 0.875rem;
628
+ opacity: 0.8;
629
+ margin: 0.25rem 0 0.5rem 0; /* Match the spacing of field-description */
630
+}
631
+
632
+/* Label and help text wrapper for tighter grouping */
633
+.label-help-wrapper {
634
+ margin-bottom: 0.5rem;
635
+}
636
+
637
+.label-help-wrapper .scheduler-form-label {
638
+ margin-bottom: 2px;
639
+}
640
+
641
+.label-help-wrapper .scheduler-form-help {
642
+ margin-top: 0;
643
+ margin-bottom: 0;
644
+}
645
+
646
+/* Scheduler detail header styling */
647
+.scheduler-detail-header {
648
+ display: flex;
649
+ justify-content: flex-start;
650
+ align-items: center;
651
+ flex-wrap: wrap;
652
+ gap: 10px;
653
+ width: 100%;
654
+}
655
+
656
+.scheduler-detail-header .scheduler-detail-title {
657
+ margin-right: auto;
658
+}
659
+
660
+/* Responsive adjustments for headers */
661
+@media (max-width: 768px) {
662
+ .scheduler-form-header {
663
+ flex-direction: column;
664
+ align-items: flex-start;
665
+ gap: 1rem;
666
+ }
667
+
668
+ .scheduler-form-actions {
669
+ align-self: flex-end;
670
+ }
671
+
672
+ .scheduler-detail-header {
673
+ flex-direction: row; /* Keep in row even on mobile */
674
+ align-items: center;
675
+ flex-wrap: wrap;
676
+ gap: 0.5rem;
677
+ }
678
+
679
+ .scheduler-detail-header .btn {
680
+ margin-left: auto; /* Push to right edge */
681
+ }
682
+
683
+ .scheduler-form-field {
684
+ grid-template-columns: 1fr;
685
+ gap: 0.5rem;
686
+ }
687
+}
688
+
689
+/* Input group for token field with generate button */
690
+.input-group {
691
+ display: flex;
692
+ gap: 8px;
693
+ width: 100%;
694
+}
695
+
696
+.input-group input[type="text"] {
697
+ flex: 1;
698
+ min-width: 0; /* Allows the input to shrink below its content size */
699
+}
700
+
701
+/* Specific styling for the Generate button in token field */
702
+.input-group .scheduler-task-action {
703
+ white-space: nowrap;
704
+ padding: 4px 10px;
705
+ width: auto;
706
+ height: auto;
707
+ background-color: var(--color-secondary);
708
+ font-size: 0.9rem;
709
+}
710
+
711
+.input-group .scheduler-task-action:hover {
712
+ background-color: var(--color-accent);
713
+ color: var(--color-bg);
714
+}
715
+
716
+/* Ensure parent container allows proper flow */
717
+.scheduler-form-field .input-group {
718
+ max-width: 100%;
719
+ overflow: hidden;
720
+}
721
+
722
+/* Adjustments for mobile */
723
+@media (max-width: 768px) {
724
+ .input-group .scheduler-task-action {
725
+ padding: 4px 8px;
726
+ font-size: 0.8rem;
727
+ }
728
+}
729
+
730
+@media (max-width: 480px) {
731
+ .input-group {
732
+ flex-direction: column;
733
+ }
734
+
735
+ .input-group .scheduler-task-action {
736
+ align-self: flex-start;
737
+ }
738
+}
739
+
740
+/* Task state selector styling */
741
+.scheduler-state-selector {
742
+ display: flex;
743
+ gap: 10px;
744
+ flex-wrap: wrap;
745
+}
746
+
747
+.scheduler-state-selector .scheduler-status-badge {
748
+ cursor: pointer;
749
+ transition: all 0.2s ease;
750
+ opacity: 0.7;
751
+ border: 1px solid transparent;
752
+}
753
+
754
+.scheduler-state-selector .scheduler-status-badge:hover {
755
+ opacity: 0.9;
756
+ transform: scale(1.05);
757
+}
758
+
759
+.scheduler-status-selected {
760
+ opacity: 1 !important;
761
+ transform: scale(1.05);
762
+ box-shadow: 0 0 0 2px var(--color-bg), 0 0 0 4px var(--color-border);
763
+ border: 2px solid var(--color-border) !important;
764
+ outline: none;
765
+}
766
+
767
+/* Make status badges in selector more prominent */
768
+.scheduler-state-selector .scheduler-status-idle,
769
+.scheduler-state-selector .scheduler-status-running,
770
+.scheduler-state-selector .scheduler-status-disabled,
771
+.scheduler-state-selector .scheduler-status-error {
772
+ font-weight: 600;
773
+ padding: 6px 12px;
774
+}
775
+
776
+.light-mode .scheduler-status-selected {
777
+ box-shadow: 0 0 0 2px var(--color-bg-light), 0 0 0 4px var(--color-accent);
778
+}
779
+
780
+/* State explanation styling */
781
+.scheduler-state-explanation {
782
+ margin-top: 10px;
783
+ font-size: 0.85rem;
784
+ color: var(--color-text-secondary);
785
+ line-height: 1.4;
786
+ min-height: 1.4em; /* Ensure consistent height even when changing descriptions */
787
+ transition: all 0.2s ease;
788
+}
789
+
790
+.scheduler-state-explanation span {
791
+ display: block;
792
+ padding: 4px 8px;
793
+ background-color: rgba(0, 0, 0, 0.05);
794
+ border-radius: 4px;
795
+ margin-top: 8px;
796
+}
797
+
798
+.light-mode .scheduler-state-explanation span {
799
+ background-color: rgba(255, 255, 255, 0.3);
800
+}
webui/index.css
+631
-18
@@ -217,15 +217,22 @@ body,
217
gap: 0;
218
}
219
220
+/* Update the chat-list-button padding to accommodate the vertical layout */
221
.chat-list-button {
222
display: block;
223
width: 100%;
223
- padding: 1px 5px;
224
+ padding: 8px 5px;
225
cursor: pointer;
226
overflow: hidden;
227
position: relative;
227
- transition: background-color 0.2s;
228
border-radius: 4px;
229
+ transition: background-color 0.2s ease-in-out;
230
+}
231
+
232
+/* Add some more padding to the list items to accommodate the vertical layout */
233
+.chat-list-button.has-task-container {
234
+ padding-top: 6px;
235
+ padding-bottom: 6px;
236
}
237
238
/* Subtle background on hover for the entire row */
@@ -2160,11 +2167,6 @@ a:active {
2167
display: none !important;
2168
}
2169
2163
-/* Add new styles for reasoning and deepsearchbutton */
2164
-.ml-auto {
2165
- margin-left: auto;
2166
-}
2167
-
2170
/* Remove unnecessary specific media query that was causing issues */
2171
@media (max-width: 480px) {
2172
.text-button svg {
@@ -2186,6 +2188,7 @@ a:active {
2188
justify-content: flex-end;
2189
}
2190
2191
+
2192
/* Tasks list container - similar to chats list */
2193
.tasks-list-container {
2194
max-height: 300px;
@@ -2217,17 +2220,58 @@ a:active {
2220
}
2221
2222
.task-name {
2220
- display: inline-block;
2221
- max-width: 160px;
2223
+ display: block;
2224
+ width: 100%;
2225
overflow: hidden;
2226
text-overflow: ellipsis;
2227
white-space: nowrap;
2228
+ padding: 3px 0;
2229
+ margin-left: 10px;
2230
cursor: pointer;
2226
- padding: 3px 5px;
2231
border-radius: 4px;
2232
transition: background-color 0.2s;
2229
- margin-right: 60px; /* Make space for future buttons */
2230
- font-size: var(--font-size-small); /* Match config button font size */
2233
+ font-size: var(--font-size-small);
2234
+ margin-bottom: 2px;
2235
+}
2236
+
2237
+.task-info-line {
2238
+ display: flex;
2239
+ justify-content: space-between;
2240
+ align-items: center;
2241
+ width: 100%;
2242
+ margin-top: 2px;
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 {
@@ -2407,16 +2451,585 @@ a:active {
2451
background-color: var(--color-border);
2452
}
2453
2410
-/* Add some padding to the list items to accommodate the stripe */
2411
-.chat-list-button {
2412
- padding-left: 5px;
2454
+/* Make sure the chat container has proper spacing */
2455
+.chat-container, .task-container {
2456
+ display: flex;
2457
+ align-items: center;
2458
+ width: 100%;
2459
+ justify-content: space-between;
2460
+}
2461
+
2462
+/* Settings Modal Styles */
2463
+.settings-modal {
2464
+ position: fixed;
2465
+ top: 0;
2466
+ left: 0;
2467
+ right: 0;
2468
+ bottom: 0;
2469
+ z-index: 1000;
2470
+ background-color: rgba(0, 0, 0, 0.75);
2471
+ display: flex;
2472
+ justify-content: center;
2473
+ align-items: center;
2474
+ overflow: auto;
2475
+ padding: 24px;
2476
+}
2477
+
2478
+.settings-modal-close {
2479
+ position: absolute;
2480
+ top: 8px;
2481
+ right: 16px;
2482
+ border: none;
2483
+ background: transparent;
2484
+ font-size: 24px;
2485
+ cursor: pointer;
2486
+ color: var(--color-text-secondary);
2487
+}
2488
+
2489
+.settings-modal-title {
2490
+ margin-top: 0;
2491
+ margin-bottom: 24px;
2492
+ font-size: 1.8rem;
2493
+ text-align: center;
2494
+ border-bottom: 1px solid var(--color-border);
2495
+ padding-bottom: 12px;
2496
+}
2497
+
2498
+.settings-modal-content {
2499
+ background-color: var(--color-panel);
2500
+ color: var(--color-text);
2501
+ width: 100%;
2502
+ max-width: 800px;
2503
+ height: auto;
2504
+ max-height: 90vh;
2505
+ border-radius: 8px;
2506
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
2507
+ padding: 24px;
2508
+ position: relative;
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;
2549
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
2550
+ gap: 1rem;
2551
+ padding: 0;
2552
+ margin: 0 0 1rem 0;
2553
+ list-style: none;
2554
+}
2555
+
2556
+/* Add specific styling for the nav element itself to ensure spacing */
2557
+nav {
2558
+margin-bottom: 1rem;
2559
+}
2560
+
2561
+nav ul li a {
2562
+ display: flex;
2563
+ flex-direction: column;
2564
+ align-items: center;
2565
+ padding: 1rem;
2566
+ border-radius: 0.5rem;
2567
+ background-color: var(--color-bg-secondary);
2568
+ text-decoration: none;
2569
+ color: var(--color-text);
2570
+ transition: background-color 0.2s ease;
2571
+}
2572
+
2573
+nav ul li a:hover {
2574
+ background-color: var(--color-bg-tertiary);
2575
+}
2576
+
2577
+nav ul li a img {
2578
+ width: 50px;
2579
+ height: 50px;
2580
+ margin-bottom: 0.5rem;
2581
+ filter: var(--svg-filter);
2582
+}
2583
+
2584
+.section {
2585
+ margin-bottom: 3rem;
2586
+ animation: fadeIn 0.3s ease;
2587
+ width: 100%;
2588
+}
2589
+
2590
+.section-title {
2591
+ margin-top: 0;
2592
+ margin-bottom: 0.5rem;
2593
+ font-size: 1.5rem;
2594
+ font-weight: 500;
2595
+ color: var(--color-primary);
2596
+ border-bottom: 1px solid var(--color-border);
2597
+ padding-bottom: 0.5rem;
2598
+}
2599
+
2600
+.section-description {
2601
+ margin-bottom: 1.5rem;
2602
+ color: var(--color-text-secondary);
2603
+}
2604
+
2605
+.scheduler-header {
2606
+ display: flex;
2607
+ justify-content: space-between;
2608
+ align-items: center;
2609
+ margin-bottom: 20px;
2610
+}
2611
+
2612
+.scheduler-header h2 {
2613
+ margin: 0;
2614
+ font-size: 1.2rem;
2615
+ font-weight: 500;
2616
+}
2617
+
2618
+.scheduler-filters {
2619
+ display: flex;
2620
+ gap: 20px;
2621
+ margin-bottom: 20px;
2622
+ flex-wrap: wrap;
2623
+}
2624
+
2625
+.scheduler-filter-group {
2626
+ display: flex;
2627
+ align-items: center;
2628
+ gap: 8px;
2629
+}
2630
+
2631
+.scheduler-filter-label {
2632
+ font-weight: 500;
2633
+ color: var(--color-text-secondary);
2634
+}
2635
+
2636
+.scheduler-filter-select {
2637
+ padding: 6px 8px;
2638
border-radius: 4px;
2414
- transition: background-color 0.2s ease-in-out;
2639
+ border: 1px solid var(--color-border);
2640
+ background-color: var(--color-bg-secondary);
2641
+ color: var(--color-text);
2642
}
2643
2417
-/* Make sure the chat container has proper spacing */
2418
-.chat-container, .task-container {
2644
+.scheduler-task-list {
2645
+ width: 100%;
2646
+ border-collapse: collapse;
2647
+ margin: 1rem 0;
2648
+ font-size: 0.9rem;
2649
+ overflow-x: auto;
2650
+ table-layout: fixed;
2651
+}
2652
+
2653
+.scheduler-task-list th,
2654
+.scheduler-task-list td {
2655
+ padding: 0.75rem;
2656
+ text-align: left;
2657
+ border-bottom: 1px solid var(--color-border);
2658
+ vertical-align: middle;
2659
+ overflow: hidden;
2660
+ text-overflow: ellipsis;
2661
+ white-space: nowrap;
2662
+}
2663
+
2664
+.scheduler-task-list th:nth-child(1), /* Name */
2665
+.scheduler-task-list td:nth-child(1) {
2666
+ width: 25%;
2667
+}
2668
+
2669
+.scheduler-task-list th:nth-child(2), /* State */
2670
+.scheduler-task-list td:nth-child(2) {
2671
+ width: 10%;
2672
+}
2673
+
2674
+.scheduler-task-list th:nth-child(3), /* Type */
2675
+.scheduler-task-list td:nth-child(3) {
2676
+ width: 10%;
2677
+}
2678
+
2679
+.scheduler-task-list th:nth-child(4), /* Schedule */
2680
+.scheduler-task-list td:nth-child(4) {
2681
+ width: 20%;
2682
+}
2683
+
2684
+.scheduler-task-list th:nth-child(5), /* Last Run */
2685
+.scheduler-task-list td:nth-child(5) {
2686
+ width: 20%;
2687
+}
2688
+
2689
+.scheduler-task-list th:nth-child(6), /* Actions */
2690
+.scheduler-task-list td:nth-child(6) {
2691
+ width: 15%;
2692
+ text-align: right;
2693
+}
2694
+
2695
+.scheduler-task-list th {
2696
+ background-color: var(--color-bg-secondary);
2697
+ font-weight: 500;
2698
+ cursor: pointer;
2699
+ user-select: none;
2700
+}
2701
+
2702
+.scheduler-task-list th:hover {
2703
+ background-color: var(--color-bg-tertiary);
2704
+}
2705
+
2706
+.scheduler-task-actions {
2707
+ display: flex;
2708
+ gap: 8px;
2709
+}
2710
+
2711
+.scheduler-task-action {
2712
+ padding: 4px;
2713
+ background: none;
2714
+ border: none;
2715
+ color: var(--color-text-secondary);
2716
+ cursor: pointer;
2717
+ border-radius: 4px;
2718
+}
2719
+
2720
+.scheduler-task-action:hover {
2721
+ background-color: var(--color-bg-tertiary);
2722
+ color: var(--color-text);
2723
+}
2724
+
2725
+.scheduler-status-badge {
2726
+ display: inline-block;
2727
+ padding: 4px 8px;
2728
+ border-radius: 4px;
2729
+ font-size: 12px;
2730
+ font-weight: 500;
2731
+ text-transform: capitalize;
2732
+ white-space: nowrap;
2733
+}
2734
+
2735
+.scheduler-status-idle {
2736
+ background-color: rgba(0, 100, 0, 0.2);
2737
+ color: #2a9d8f; /* Dark green that works with both light and dark themes */
2738
+ border: 1px solid rgba(42, 157, 143, 0.3);
2739
+}
2740
+
2741
+.scheduler-status-running {
2742
+ background-color: rgba(0, 60, 120, 0.2);
2743
+ color: #4361ee; /* Dark blue that works with both light and dark themes */
2744
+ border: 1px solid rgba(67, 97, 238, 0.3);
2745
+}
2746
+
2747
+.scheduler-status-disabled {
2748
+ background-color: rgba(70, 70, 70, 0.2);
2749
+ color: #6c757d; /* Dark grey that works with both light and dark themes */
2750
+ border: 1px solid rgba(108, 117, 125, 0.3);
2751
+}
2752
+
2753
+.scheduler-status-error {
2754
+ background-color: rgba(120, 0, 0, 0.2);
2755
+ color: #e63946; /* Dark red that works with both light and dark themes */
2756
+ border: 1px solid rgba(230, 57, 70, 0.3);
2757
+}
2758
+
2759
+/* Light mode adjustments */
2760
+.light-mode .scheduler-status-idle {
2761
+ background-color: rgba(42, 157, 143, 0.1);
2762
+ color: #1a6f65; /* Darker green for light mode */
2763
+}
2764
+
2765
+.light-mode .scheduler-status-running {
2766
+ background-color: rgba(67, 97, 238, 0.1);
2767
+ color: #2540b3; /* Darker blue for light mode */
2768
+}
2769
+
2770
+.light-mode .scheduler-status-disabled {
2771
+ background-color: rgba(108, 117, 125, 0.1);
2772
+ color: #495057; /* Darker grey for light mode */
2773
+}
2774
+
2775
+.light-mode .scheduler-status-error {
2776
+ background-color: rgba(230, 57, 70, 0.1);
2777
+ color: #c5283d; /* Darker red for light mode */
2778
+}
2779
+
2780
+.scheduler-empty {
2781
+ text-align: center;
2782
+ padding: 40px 0;
2783
+ color: var(--color-text-secondary);
2784
+}
2785
+
2786
+.scheduler-empty-icon {
2787
+ font-size: 32px;
2788
+ margin-bottom: 10px;
2789
+}
2790
+
2791
+.scheduler-empty-text {
2792
+ margin-bottom: 20px;
2793
+}
2794
+
2795
+.scheduler-loading {
2796
+ text-align: center;
2797
+ padding: 40px 0;
2798
+ color: var(--color-text-secondary);
2799
+}
2800
+
2801
+.scheduler-task-details {
2802
+ padding: 16px;
2803
+ background-color: var(--color-bg-secondary);
2804
+ border-radius: 4px;
2805
+}
2806
+
2807
+.scheduler-details-grid {
2808
+ display: grid;
2809
+ grid-template-columns: 120px 1fr;
2810
+ gap: 8px 16px;
2811
+ margin-bottom: 16px;
2812
+}
2813
+
2814
+.scheduler-details-label {
2815
+ font-weight: 500;
2816
+ color: var(--color-text-secondary);
2817
+ display: flex;
2818
+ align-items: center;
2819
+}
2820
+
2821
+.scheduler-details-value {
2822
+ color: var(--color-text);
2823
+ word-break: break-word;
2824
+}
2825
+
2826
+.scheduler-details-actions {
2827
+ display: flex;
2828
+ justify-content: flex-end;
2829
+}
2830
+
2831
+.scheduler-form {
2832
+ background-color: var(--color-bg-secondary);
2833
+ border-radius: 4px;
2834
+ padding: 20px;
2835
+ margin-bottom: 20px;
2836
+}
2837
+
2838
+.scheduler-form-title {
2839
+ font-size: 1.2rem;
2840
+ font-weight: 500;
2841
+ margin-bottom: 20px;
2842
+ padding-bottom: 10px;
2843
+ border-bottom: 1px solid var(--color-border);
2844
+}
2845
+
2846
+.scheduler-form-grid {
2847
+ display: grid;
2848
+ grid-template-columns: 1fr 1fr;
2849
+ gap: 16px;
2850
+ margin-bottom: 20px;
2851
+}
2852
+
2853
+.scheduler-form-field {
2854
+ display: flex;
2855
+ flex-direction: column;
2856
+ gap: 6px;
2857
+}
2858
+
2859
+.full-width {
2860
+ grid-column: 1 / -1;
2861
+}
2862
+
2863
+.scheduler-form-label {
2864
+ font-weight: 500;
2865
+}
2866
+
2867
+.scheduler-form-help {
2868
+ font-size: 12px;
2869
+ color: var(--color-text-secondary);
2870
+}
2871
+
2872
+.scheduler-form-actions {
2873
+ display: flex;
2874
+ justify-content: flex-end;
2875
+ gap: 12px;
2876
+}
2877
+
2878
+.scheduler-schedule-builder {
2879
+ display: grid;
2880
+ grid-template-columns: repeat(5, 1fr);
2881
+ gap: 12px;
2882
+}
2883
+
2884
+.scheduler-schedule-field {
2885
+ display: flex;
2886
+ flex-direction: column;
2887
+ gap: 4px;
2888
+ max-width: 70px; /* Limit width of schedule fields */
2889
+}
2890
+
2891
+.scheduler-schedule-field input {
2892
+ width: 100%;
2893
+ min-width: 0; /* Allow shrinking below content size */
2894
+ font-size: 0.9rem; /* Slightly reduce font size for better fit */
2895
+}
2896
+
2897
+.scheduler-schedule-label {
2898
+ font-size: 12px;
2899
+ color: var(--color-text-secondary);
2900
+}
2901
+
2902
+.input-group {
2903
+ display: flex;
2904
+ gap: 8px;
2905
+}
2906
+
2907
+/* Sort indicators */
2908
+.scheduler-sort-indicator {
2909
+ display: inline-block;
2910
+ margin-left: 4px;
2911
+ transition: transform 0.2s ease;
2912
+}
2913
+
2914
+.scheduler-sort-desc {
2915
+ transform: rotate(180deg);
2916
+}
2917
+
2918
+/* Responsive adjustments */
2919
+@media (max-width: 768px) {
2920
+ .scheduler-form-grid {
2921
+ grid-template-columns: 1fr;
2922
+ }
2923
+
2924
+ .scheduler-schedule-builder {
2925
+ grid-template-columns: 1fr 1fr;
2926
+ }
2927
+
2928
+ .scheduler-filters {
2929
+ flex-direction: column;
2930
+ gap: 12px;
2931
+ }
2932
+
2933
+ .scheduler-task-actions {
2934
+ flex-wrap: wrap;
2935
+ }
2936
+}
2937
+
2938
+@media (max-width: 480px) {
2939
+ nav ul li a {
2940
+ flex-direction: row;
2941
+ justify-content: flex-start;
2942
+ gap: 1rem;
2943
+ padding: 0.75rem 1rem;
2944
+ }
2945
+
2946
+ nav ul li a img {
2947
+ margin-bottom: 0;
2948
+ width: 30px;
2949
+ height: 30px;
2950
+ }
2951
+}
2952
+
2953
+/* Add row hover effect for task list rows matching left panel hover */
2954
+.scheduler-task-list tbody tr {
2955
+ cursor: pointer;
2956
+ transition: background-color 0.2s ease;
2957
+}
2958
+
2959
+.scheduler-task-list tbody tr:hover {
2960
+ background-color: rgba(255, 255, 255, 0.03);
2961
+}
2962
+
2963
+.light-mode .scheduler-task-list tbody tr:hover {
2964
+ background-color: rgba(0, 0, 0, 0.02);
2965
+}
2966
+
2967
+.scheduler-task-list th {
2968
+ background-color: var(--color-bg-secondary);
2969
+ font-weight: 500;
2970
+ cursor: pointer;
2971
+ user-select: none;
2972
+}
2973
+
2974
+.scheduler-task-list th:hover {
2975
+ background-color: var(--color-bg-tertiary);
2976
+}
2977
+
2978
+/* Task detail view styling */
2979
+.scheduler-detail-view {
2980
+ background-color: var(--color-bg-secondary);
2981
+ border-radius: 4px;
2982
+ padding: 20px;
2983
+ margin-bottom: 20px;
2984
+ animation: fadeIn 0.3s ease;
2985
+}
2986
+
2987
+.scheduler-detail-header {
2988
display: flex;
2989
+ justify-content: flex-start;
2990
align-items: center;
2991
+ margin-bottom: 20px;
2992
+ padding-bottom: 10px;
2993
+ border-bottom: 1px solid var(--color-border);
2994
+ flex-wrap: wrap;
2995
+ gap: 10px;
2996
+}
2997
+
2998
+.scheduler-detail-header .scheduler-detail-title {
2999
+ font-size: 1.4rem;
3000
+ font-weight: 500;
3001
+ margin: 0;
3002
+ margin-right: auto;
3003
+}
3004
+
3005
+.scheduler-detail-header .scheduler-status-badge {
3006
+ margin-right: 10px;
3007
+}
3008
+
3009
+.scheduler-detail-content {
3010
+ margin-bottom: 20px;
3011
+}
3012
+
3013
+/* Task Scheduler Styles */
3014
+
3015
+.scheduler-no-schedule {
3016
+ color: var(--color-text-secondary);
3017
+ opacity: 0.7;
3018
+ font-style: italic;
3019
+}
3020
+
3021
+.task-container-vertical {
3022
+ display: flex;
3023
+ flex-direction: column;
3024
width: 100%;
3025
+ gap: 6px;
3026
+}
3027
+
3028
+/* Smaller status badge for task list */
3029
+.scheduler-status-badge-small {
3030
+ font-size: 10px;
3031
+ padding: 2px 6px;
3032
+ margin-right: 5px;
3033
+ min-width: 40px;
3034
+ text-align: center;
3035
}
webui/index.html
+580
-76
@@ -38,6 +38,7 @@
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
@@ -118,18 +119,42 @@
119
</div>
120
121
<!-- Tasks List -->
121
- <div class="config-section" id="tasks-section" x-data="{ tasks: [], selected: '' }"
122
+ <div class="config-section" id="tasks-section" x-data="{
123
+ tasks: [],
124
+ selected: '',
125
+ openTaskDetail(taskId) {
126
+ window.openTaskDetail(taskId);
127
+ }
128
+ }"
129
style="display: none;">
130
<div class="tasks-list-container">
131
<ul class="config-list" x-show="tasks.length > 0">
132
<template x-for="task in tasks">
133
<li>
127
- <div :class="{'chat-list-button': true, 'font-bold': task.id === selected}"
134
+ <div :class="{'chat-list-button': true, 'font-bold': task.id === selected, 'has-task-container': true}"
135
@click="selected = task.id; selectChat(task.id)">
129
- <div class="chat-container">
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">
144
+ <!-- Status badge (reusing scheduler styling) -->
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>
160
</li>
@@ -473,10 +498,15 @@
498
:class="{'active': activeTab === 'developer'}"
499
@click="switchTab('developer')"
500
title="Developer">Developer</div>
501
+ <div class="settings-tab"
502
+ :class="{'active': activeTab === 'scheduler'}"
503
+ @click="switchTab('scheduler')"
504
+ title="Task Scheduler">Task Scheduler</div>
505
</div>
506
</div>
507
479
- <div id="settings-sections">
508
+ <!-- Display settings sections for agent, external, developer tabs -->
509
+ <div id="settings-sections" x-show="activeTab !== 'scheduler'">
510
<nav>
511
<ul>
512
<template x-for="(section, index) in filteredSections" :key="section.title">
@@ -490,94 +520,568 @@
520
</template>
521
</ul>
522
</nav>
523
+
524
+ <template x-for="(section, sectionIndex) in filteredSections" :key="sectionIndex">
525
+ <div :id="'section' + (sectionIndex + 1)" class="section">
526
+ <div class="section-title" x-text="section.title"></div>
527
+ <div class="section-description" x-html="section.description"></div>
528
+
529
+ <template x-for="(field, fieldIndex) in section.fields" :key="fieldIndex">
530
+ <div :class="{'field': true, 'field-full': field.type === 'textarea'}">
531
+ <div class="field-label">
532
+ <div class="field-title" x-text="field.title"></div>
533
+ <div class="field-description" x-html="field.description || ''"></div>
534
+ </div>
535
+
536
+ <div class="field-control">
537
+ <!-- Input field -->
538
+ <template x-if="field.type === 'text'">
539
+ <input type="text" :class="field.classes" :value="field.value"
540
+ :readonly="field.readonly === true"
541
+ @input="field.value = $event.target.value">
542
+ </template>
543
+
544
+ <!-- Number field -->
545
+ <template x-if="field.type === 'number'">
546
+ <input type="number" :class="field.classes" :value="field.value"
547
+ :readonly="field.readonly === true"
548
+ @input="field.value = $event.target.value"
549
+ :min="field.min" :max="field.max" :step="field.step">
550
+ </template>
551
+
552
+
553
+ <!-- Password field -->
554
+ <template x-if="field.type === 'password'">
555
+ <input type="password" :class="field.classes" :value="field.value"
556
+ :readonly="field.readonly === true"
557
+ @input="field.value = $event.target.value">
558
+ </template>
559
+
560
+ <!-- Textarea field -->
561
+ <template x-if="field.type === 'textarea'">
562
+ <textarea :class="field.classes" :value="field.value"
563
+ :readonly="field.readonly === true"
564
+ @input="field.value = $event.target.value"></textarea>
565
+ </template>
566
+
567
+ <!-- Switch field -->
568
+ <template x-if="field.type === 'switch'">
569
+ <label class="toggle">
570
+ <input type="checkbox" :checked="field.value"
571
+ :disabled="field.readonly === true"
572
+ @change="field.value = $event.target.checked">
573
+ <span class="toggler"></span>
574
+ </label>
575
+ </template>
576
+
577
+ <!-- Range field -->
578
+ <template x-if="field.type === 'range'">
579
+ <div class="field-control">
580
+ <input type="range" :min="field.min" :max="field.max"
581
+ :step="field.step" :value="field.value"
582
+ :disabled="field.readonly === true"
583
+ @input="field.value = $event.target.value"
584
+ :class="field.classes">
585
+ <span class="range-value" x-text="field.value"></span>
586
+ </div>
587
+ </template>
588
+
589
+ <!-- Button field -->
590
+ <template x-if="field.type === 'button'">
591
+ <button class="btn btn-field" :class="field.classes"
592
+ :disabled="field.readonly === true"
593
+ @click="handleFieldButton(field)" x-text="field.value"></button>
594
+ </template>
595
+
596
+ <!-- Select field -->
597
+ <template x-if="field.type === 'select'">
598
+ <select :class="field.classes" x-model="field.value"
599
+ :disabled="field.readonly === true">
600
+ <template x-for="option in field.options" :key="option.value">
601
+ <option :value="option.value" x-text="option.label"
602
+ :selected="option.value === field.value"></option>
603
+ </template>
604
+ </select>
605
+ </template>
606
+ </div>
607
+ </div>
608
+ </template>
609
+ </div>
610
+ </template>
611
</div>
494
- <template x-for="(section, sectionIndex) in filteredSections" :key="sectionIndex">
495
- <div :id="'section' + (sectionIndex + 1)" class="section">
496
- <div class="section-title" x-text="section.title"></div>
497
- <div class="section-description" x-html="section.description"></div>
498
-
499
- <template x-for="(field, fieldIndex) in section.fields" :key="fieldIndex">
500
- <div :class="{'field': true, 'field-full': field.type === 'textarea'}">
501
- <div class="field-label">
502
- <div class="field-title" x-text="field.title"></div>
503
- <div class="field-description" x-html="field.description || ''"></div>
612
+
613
+ <!-- Task Scheduler Tab Content -->
614
+ <div id="scheduler-tab-content" x-show="activeTab === 'scheduler'" x-cloak>
615
+ <!-- Settings section structure for task scheduler -->
616
+ <nav>
617
+ <ul>
618
+ <li>
619
+ <a href="#section-task-scheduler">
620
+ <img src="/public/task_scheduler.svg" alt="Task Scheduler">
621
+ <span>Task Scheduler</span>
622
+ </a>
623
+ </li>
624
+ </ul>
625
+ </nav>
626
+
627
+ <div id="section-task-scheduler" class="section"
628
+ x-data="schedulerSettings"
629
+ x-init="$watch('activeTab', (val) => { if(val === 'scheduler') { fetchTasks(); } })">
630
+ <div class="section-title">Task Scheduler</div>
631
+ <div class="section-description">Manage scheduled tasks and automated processes for Agent Zero.</div>
632
+
633
+ <!-- Create Task Form -->
634
+ <div class="scheduler-form" x-show="isCreating">
635
+ <div class="scheduler-form-header">
636
+ <div class="scheduler-form-title">Create New Task</div>
637
+ <div class="scheduler-form-actions">
638
+ <button class="btn btn-ok btn-field" @click="saveTask()">
639
+ Save
640
+ </button>
641
+ <button class="btn btn-cancel" @click="cancelEdit()">
642
+ Cancel
643
+ </button>
644
</div>
645
+ </div>
646
506
- <div class="field-control">
507
- <!-- Input field -->
508
- <template x-if="field.type === 'text'">
509
- <input type="text" :class="field.classes" :value="field.value"
510
- :readonly="field.readonly === true"
511
- @input="field.value = $event.target.value">
512
- </template>
647
+ <div class="scheduler-form-grid">
648
+ <!-- Task Name -->
649
+ <div class="scheduler-form-field">
650
+ <div class="label-help-wrapper">
651
+ <label class="scheduler-form-label">Task Name</label>
652
+ <div class="scheduler-form-help">A unique name to identify this task</div>
653
+ </div>
654
+ <input type="text" x-model="editingTask.name" placeholder="Enter task name">
655
+ </div>
656
514
- <!-- Number field -->
515
- <template x-if="field.type === 'number'">
516
- <input type="number" :class="field.classes" :value="field.value"
517
- :readonly="field.readonly === true"
518
- @input="field.value = $event.target.value"
519
- :min="field.min" :max="field.max" :step="field.step">
520
- </template>
657
+ <!-- Task Type (only editable when creating) -->
658
+ <div class="scheduler-form-field">
659
+ <div class="label-help-wrapper">
660
+ <label class="scheduler-form-label">Task Type</label>
661
+ <div class="scheduler-form-help">Task type cannot be changed after creation</div>
662
+ </div>
663
+ <select x-model="editingTask.type" :disabled="!isCreating">
664
+ <option value="scheduled">Scheduled Task</option>
665
+ <option value="adhoc">Ad-hoc Task</option>
666
+ </select>
667
+ </div>
668
+
669
+ <!-- Task State in Create Form - Add after Task Type -->
670
+ <div class="scheduler-form-field" x-show="isCreating">
671
+ <div class="label-help-wrapper">
672
+ <label class="scheduler-form-label">State</label>
673
+ <div class="scheduler-form-help">Select the initial state of the task</div>
674
+ </div>
675
+ <div>
676
+ <div class="scheduler-state-selector">
677
+ <span class="scheduler-status-badge scheduler-status-idle"
678
+ :class="{'scheduler-status-selected': editingTask.state === 'idle'}"
679
+ @click="editingTask.state = 'idle'">idle</span>
680
+ <span class="scheduler-status-badge scheduler-status-running"
681
+ :class="{'scheduler-status-selected': editingTask.state === 'running'}"
682
+ @click="editingTask.state = 'running'">running</span>
683
+ <span class="scheduler-status-badge scheduler-status-disabled"
684
+ :class="{'scheduler-status-selected': editingTask.state === 'disabled'}"
685
+ @click="editingTask.state = 'disabled'">disabled</span>
686
+ <span class="scheduler-status-badge scheduler-status-error"
687
+ :class="{'scheduler-status-selected': editingTask.state === 'error'}"
688
+ @click="editingTask.state = 'error'">error</span>
689
+ </div>
690
+ <div class="scheduler-state-explanation">
691
+ <span x-show="editingTask.state === 'idle'"><strong>idle</strong>: ready to run</span>
692
+ <span x-show="editingTask.state === 'running'"><strong>running</strong>: currently executing</span>
693
+ <span x-show="editingTask.state === 'disabled'"><strong>disabled</strong>: won't execute automatically</span>
694
+ <span x-show="editingTask.state === 'error'"><strong>error</strong>: task encountered an error</span>
695
+ </div>
696
+ </div>
697
+ </div>
698
699
+ <!-- Schedule (for scheduled tasks) -->
700
+ <div class="scheduler-form-field full-width" x-show="editingTask && editingTask.type === 'scheduled'">
701
+ <div class="label-help-wrapper">
702
+ <label class="scheduler-form-label">Schedule (Cron Expression)</label>
703
+ <div class="scheduler-form-help">Format: minute hour day month weekday (e.g., "* * * * *" for every minute)</div>
704
+ </div>
705
+ <div class="scheduler-schedule-builder">
706
+ <div class="scheduler-schedule-field">
707
+ <span class="scheduler-schedule-label">Minute</span>
708
+ <input type="text" x-model="editingTask.schedule.minute" placeholder="*" maxlength="9">
709
+ </div>
710
+ <div class="scheduler-schedule-field">
711
+ <span class="scheduler-schedule-label">Hour</span>
712
+ <input type="text" x-model="editingTask.schedule.hour" placeholder="*" maxlength="9">
713
+ </div>
714
+ <div class="scheduler-schedule-field">
715
+ <span class="scheduler-schedule-label">Day</span>
716
+ <input type="text" x-model="editingTask.schedule.day" placeholder="*" maxlength="9">
717
+ </div>
718
+ <div class="scheduler-schedule-field">
719
+ <span class="scheduler-schedule-label">Month</span>
720
+ <input type="text" x-model="editingTask.schedule.month" placeholder="*" maxlength="9">
721
+ </div>
722
+ <div class="scheduler-schedule-field">
723
+ <span class="scheduler-schedule-label">Weekday</span>
724
+ <input type="text" x-model="editingTask.schedule.weekday" placeholder="*" maxlength="9">
725
+ </div>
726
+ </div>
727
+ </div>
728
523
- <!-- Password field -->
524
- <template x-if="field.type === 'password'">
525
- <input type="password" :class="field.classes" :value="field.value"
526
- :readonly="field.readonly === true"
527
- @input="field.value = $event.target.value">
528
- </template>
729
+ <!-- Token (for ad-hoc tasks) -->
730
+ <div class="scheduler-form-field full-width" x-show="editingTask.type === 'adhoc'">
731
+ <div class="label-help-wrapper">
732
+ <label class="scheduler-form-label">Token</label>
733
+ <div class="scheduler-form-help">Token used to trigger this task externally</div>
734
+ </div>
735
+ <div class="input-group">
736
+ <input type="text" x-model="editingTask.token" placeholder="Token for ad-hoc task">
737
+ <button class="scheduler-task-action" @click="editingTask.token = generateRandomToken()">
738
+ Generate
739
+ </button>
740
+ </div>
741
+ </div>
742
530
- <!-- Textarea field -->
531
- <template x-if="field.type === 'textarea'">
532
- <textarea :class="field.classes" :value="field.value"
533
- :readonly="field.readonly === true"
534
- @input="field.value = $event.target.value"></textarea>
535
- </template>
743
+ <!-- System Prompt -->
744
+ <div class="scheduler-form-field full-width">
745
+ <div class="label-help-wrapper">
746
+ <label class="scheduler-form-label">System Prompt</label>
747
+ <div class="scheduler-form-help">System-level instructions for the assistant</div>
748
+ </div>
749
+ <textarea x-model="editingTask.system_prompt" placeholder="System instructions for the AI"></textarea>
750
+ </div>
751
537
- <!-- Switch field -->
538
- <template x-if="field.type === 'switch'">
539
- <label class="toggle">
540
- <input type="checkbox" :checked="field.value"
541
- :disabled="field.readonly === true"
542
- @change="field.value = $event.target.checked">
543
- <span class="toggler"></span>
544
- </label>
545
- </template>
752
+ <!-- User Prompt -->
753
+ <div class="scheduler-form-field full-width">
754
+ <div class="label-help-wrapper">
755
+ <label class="scheduler-form-label">User Prompt</label>
756
+ <div class="scheduler-form-help">The main task prompt that will be executed</div>
757
+ </div>
758
+ <textarea x-model="editingTask.prompt" placeholder="User message for the AI"></textarea>
759
+ </div>
760
547
- <!-- Range field -->
548
- <template x-if="field.type === 'range'">
549
- <div class="field-control">
550
- <input type="range" :min="field.min" :max="field.max"
551
- :step="field.step" :value="field.value"
552
- :disabled="field.readonly === true"
553
- @input="field.value = $event.target.value"
554
- :class="field.classes">
555
- <span class="range-value" x-text="field.value"></span>
761
+ <!-- Attachments Field -->
762
+ <div class="scheduler-form-field full-width">
763
+ <div class="label-help-wrapper">
764
+ <label class="scheduler-form-label">Attachments</label>
765
+ <div class="scheduler-form-help">Container file paths or URLs, one per line</div>
766
+ </div>
767
+ <textarea x-model="attachmentsText" placeholder="Enter file paths or URLs, one per line"></textarea>
768
+ </div>
769
+ </div>
770
+ </div>
771
+
772
+ <!-- Edit Task Form -->
773
+ <div class="scheduler-form" x-show="isEditing">
774
+ <div class="scheduler-form-header">
775
+ <div class="scheduler-form-title">Edit Task</div>
776
+ <div class="scheduler-form-actions">
777
+ <button class="btn btn-ok btn-field" @click="saveTask()">
778
+ Save
779
+ </button>
780
+ <button class="btn btn-cancel" @click="cancelEdit()">
781
+ Cancel
782
+ </button>
783
+ </div>
784
+ </div>
785
+
786
+ <div class="scheduler-form-grid">
787
+ <!-- Task Name -->
788
+ <div class="scheduler-form-field">
789
+ <div class="label-help-wrapper">
790
+ <label class="scheduler-form-label">Task Name</label>
791
+ <div class="scheduler-form-help">A unique name to identify this task</div>
792
+ </div>
793
+ <input type="text" x-model="editingTask.name" placeholder="Enter task name">
794
+ </div>
795
+
796
+ <!-- Task Type (disabled when editing) -->
797
+ <div class="scheduler-form-field">
798
+ <div class="label-help-wrapper">
799
+ <label class="scheduler-form-label">Task Type</label>
800
+ <div class="scheduler-form-help">Task type cannot be changed after creation</div>
801
+ </div>
802
+ <select x-model="editingTask.type" disabled>
803
+ <option value="scheduled">Scheduled Task</option>
804
+ <option value="adhoc">Ad-hoc Task</option>
805
+ </select>
806
+ </div>
807
+
808
+ <!-- Task State in Edit Form - Add after Task Type -->
809
+ <div class="scheduler-form-field" x-show="isEditing">
810
+ <div class="label-help-wrapper">
811
+ <label class="scheduler-form-label">State</label>
812
+ <div class="scheduler-form-help">Change the task's state</div>
813
+ </div>
814
+ <div>
815
+ <div class="scheduler-state-selector">
816
+ <span class="scheduler-status-badge scheduler-status-idle"
817
+ :class="{'scheduler-status-selected': editingTask.state === 'idle'}"
818
+ @click="editingTask.state = 'idle'">idle</span>
819
+ <span class="scheduler-status-badge scheduler-status-running"
820
+ :class="{'scheduler-status-selected': editingTask.state === 'running'}"
821
+ @click="editingTask.state = 'running'">running</span>
822
+ <span class="scheduler-status-badge scheduler-status-disabled"
823
+ :class="{'scheduler-status-selected': editingTask.state === 'disabled'}"
824
+ @click="editingTask.state = 'disabled'">disabled</span>
825
+ <span class="scheduler-status-badge scheduler-status-error"
826
+ :class="{'scheduler-status-selected': editingTask.state === 'error'}"
827
+ @click="editingTask.state = 'error'">error</span>
828
</div>
557
- </template>
829
+ <div class="scheduler-state-explanation">
830
+ <span x-show="editingTask.state === 'idle'"><strong>idle</strong>: ready to run</span>
831
+ <span x-show="editingTask.state === 'running'"><strong>running</strong>: currently executing</span>
832
+ <span x-show="editingTask.state === 'disabled'"><strong>disabled</strong>: won't execute automatically</span>
833
+ <span x-show="editingTask.state === 'error'"><strong>error</strong>: task encountered an error</span>
834
+ </div>
835
+ </div>
836
+ </div>
837
559
- <!-- Button field -->
560
- <template x-if="field.type === 'button'">
561
- <button class="btn btn-field" :class="field.classes"
562
- :disabled="field.readonly === true"
563
- @click="handleFieldButton(field)" x-text="field.value"></button>
564
- </template>
838
+ <!-- Schedule (for scheduled tasks) -->
839
+ <div class="scheduler-form-field full-width" x-show="editingTask && editingTask.type === 'scheduled'">
840
+ <div class="label-help-wrapper">
841
+ <label class="scheduler-form-label">Schedule (Cron Expression)</label>
842
+ <div class="scheduler-form-help">Format: minute hour day month weekday (e.g., "* * * * *" for every minute)</div>
843
+ </div>
844
+ <div class="scheduler-schedule-builder">
845
+ <div class="scheduler-schedule-field">
846
+ <span class="scheduler-schedule-label">Minute</span>
847
+ <input type="text" x-model="editingTask.schedule.minute" placeholder="*" maxlength="9">
848
+ </div>
849
+ <div class="scheduler-schedule-field">
850
+ <span class="scheduler-schedule-label">Hour</span>
851
+ <input type="text" x-model="editingTask.schedule.hour" placeholder="*" maxlength="9">
852
+ </div>
853
+ <div class="scheduler-schedule-field">
854
+ <span class="scheduler-schedule-label">Day</span>
855
+ <input type="text" x-model="editingTask.schedule.day" placeholder="*" maxlength="9">
856
+ </div>
857
+ <div class="scheduler-schedule-field">
858
+ <span class="scheduler-schedule-label">Month</span>
859
+ <input type="text" x-model="editingTask.schedule.month" placeholder="*" maxlength="9">
860
+ </div>
861
+ <div class="scheduler-schedule-field">
862
+ <span class="scheduler-schedule-label">Weekday</span>
863
+ <input type="text" x-model="editingTask.schedule.weekday" placeholder="*" maxlength="9">
864
+ </div>
865
+ </div>
866
+ </div>
867
+
868
+ <!-- Token (for ad-hoc tasks) -->
869
+ <div class="scheduler-form-field full-width" x-show="editingTask.type === 'adhoc'">
870
+ <div class="label-help-wrapper">
871
+ <label class="scheduler-form-label">Token</label>
872
+ <div class="scheduler-form-help">Token used to trigger this task externally</div>
873
+ </div>
874
+ <div class="input-group">
875
+ <input type="text" x-model="editingTask.token" placeholder="Token for ad-hoc task">
876
+ <button class="scheduler-task-action" @click="editingTask.token = generateRandomToken()">
877
+ Generate
878
+ </button>
879
+ </div>
880
+ </div>
881
+
882
+ <!-- System Prompt -->
883
+ <div class="scheduler-form-field full-width">
884
+ <div class="label-help-wrapper">
885
+ <label class="scheduler-form-label">System Prompt</label>
886
+ <div class="scheduler-form-help">System-level instructions for the assistant</div>
887
+ </div>
888
+ <textarea x-model="editingTask.system_prompt" placeholder="System instructions for the AI"></textarea>
889
+ </div>
890
+
891
+ <!-- User Prompt -->
892
+ <div class="scheduler-form-field full-width">
893
+ <div class="label-help-wrapper">
894
+ <label class="scheduler-form-label">User Prompt</label>
895
+ <div class="scheduler-form-help">The main task prompt that will be executed</div>
896
+ </div>
897
+ <textarea x-model="editingTask.prompt" placeholder="User message for the AI"></textarea>
898
+ </div>
899
566
- <!-- Select field -->
567
- <template x-if="field.type === 'select'">
568
- <select :class="field.classes" x-model="field.value"
569
- :disabled="field.readonly === true">
570
- <template x-for="option in field.options" :key="option.value">
571
- <option :value="option.value" x-text="option.label"
572
- :selected="option.value === field.value"></option>
573
- </template>
574
- </select>
900
+ <!-- Attachments Field -->
901
+ <div class="scheduler-form-field full-width">
902
+ <div class="label-help-wrapper">
903
+ <label class="scheduler-form-label">Attachments</label>
904
+ <div class="scheduler-form-help">Container file paths or URLs, one per line</div>
905
+ </div>
906
+ <textarea x-model="attachmentsText" placeholder="Enter file paths or URLs, one per line"></textarea>
907
+ </div>
908
+ </div>
909
+ </div>
910
+
911
+ <!-- Task List View -->
912
+ <div class="scheduler-container" x-show="!isCreating && !isEditing && viewMode === 'list'">
913
+ <!-- Header with Actions -->
914
+ <div class="scheduler-header">
915
+ <h2>Task Management</h2>
916
+ <div class="scheduler-actions">
917
+ <button class="btn btn-ok" @click="startCreateTask()">
918
+ New Task
919
+ </button>
920
+ </div>
921
+ </div>
922
+
923
+ <!-- Filters -->
924
+ <div class="scheduler-filters">
925
+ <div class="scheduler-filter-group">
926
+ <span class="scheduler-filter-label">Type:</span>
927
+ <select class="scheduler-filter-select" x-model="filterType">
928
+ <option value="all">All Types</option>
929
+ <option value="scheduled">Scheduled</option>
930
+ <option value="adhoc">Ad-hoc</option>
931
+ </select>
932
+ </div>
933
+
934
+ <div class="scheduler-filter-group">
935
+ <span class="scheduler-filter-label">State:</span>
936
+ <select class="scheduler-filter-select" x-model="filterState">
937
+ <option value="all">All States</option>
938
+ <option value="idle">Idle</option>
939
+ <option value="running">Running</option>
940
+ <option value="disabled">Disabled</option>
941
+ <option value="error">Error</option>
942
+ </select>
943
+ </div>
944
+ </div>
945
+
946
+ <!-- Loading State -->
947
+ <div class="scheduler-loading" x-show="isLoading">
948
+ Loading tasks...
949
+ </div>
950
+
951
+ <!-- Empty State -->
952
+ <div class="scheduler-empty" x-show="!isLoading && filteredTasks.length === 0">
953
+ <div class="scheduler-empty-icon">📋</div>
954
+ <div class="scheduler-empty-text">No tasks found</div>
955
+ <button class="btn btn-ok" @click="startCreateTask()">Create your first task</button>
956
+ </div>
957
+
958
+ <!-- Task List Table -->
959
+ <table class="scheduler-task-list" x-show="!isLoading && filteredTasks.length > 0">
960
+ <thead>
961
+ <tr>
962
+ <th @click="changeSort('name')">
963
+ Name
964
+ <span class="scheduler-sort-indicator" x-show="sortField === 'name'"
965
+ :class="{'scheduler-sort-desc': sortDirection === 'desc'}">↑</span>
966
+ </th>
967
+ <th @click="changeSort('state')">
968
+ State
969
+ <span class="scheduler-sort-indicator" x-show="sortField === 'state'"
970
+ :class="{'scheduler-sort-desc': sortDirection === 'desc'}">↑</span>
971
+ </th>
972
+ <th>Type</th>
973
+ <th>Schedule</th>
974
+ <th @click="changeSort('last_run')">
975
+ Last Run
976
+ <span class="scheduler-sort-indicator" x-show="sortField === 'last_run'"
977
+ :class="{'scheduler-sort-desc': sortDirection === 'desc'}">↑</span>
978
+ </th>
979
+ <th>Actions</th>
980
+ </tr>
981
+ </thead>
982
+ <tbody>
983
+ <template x-for="task in filteredTasks" :key="task.uuid">
984
+ <tr @click="showTaskDetail(task.uuid)">
985
+ <td>
986
+ <span x-text="task.name"></span>
987
+ </td>
988
+ <td>
989
+ <span class="scheduler-status-badge" :class="getStateBadgeClass(task.state)" x-text="task.state"></span>
990
+ </td>
991
+ <td x-text="task.type"></td>
992
+ <td>
993
+ <span x-show="task.type === 'scheduled'" x-text="formatSchedule(task)"></span>
994
+ <span x-show="task.type === 'adhoc'" class="scheduler-no-schedule">—</span>
995
+ </td>
996
+ <td x-text="formatDate(task.last_run)"></td>
997
+ <td @click.stop>
998
+ <div class="scheduler-task-actions">
999
+ <button class="scheduler-task-action" @click="runTask(task.uuid)" title="Run Task">
1000
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
1001
+ <path d="M8 5v14l11-7z"/>
1002
+ </svg>
1003
+ </button>
1004
+ <button class="scheduler-task-action" @click="resetTaskState(task.uuid)" title="Reset State">
1005
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
1006
+ <path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/>
1007
+ </svg>
1008
+ </button>
1009
+ <button class="scheduler-task-action" @click="startEditTask(task.uuid)" title="Edit Task">
1010
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
1011
+ <path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
1012
+ </svg>
1013
+ </button>
1014
+ <button class="scheduler-task-action" @click="deleteTask(task.uuid)" title="Delete Task">
1015
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="16" height="16">
1016
+ <path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z"/>
1017
+ </svg>
1018
+ </button>
1019
+ </div>
1020
+ </td>
1021
+ </tr>
1022
</template>
1023
+ </tbody>
1024
+ </table>
1025
+ </div>
1026
+
1027
+ <!-- Task Detail View -->
1028
+ <div class="scheduler-detail-view" x-show="!isCreating && !isEditing && viewMode === 'detail' && selectedTaskForDetail">
1029
+ <div class="scheduler-detail-header">
1030
+ <h2 class="scheduler-detail-title" x-text="selectedTaskForDetail ? selectedTaskForDetail.name : ''"></h2>
1031
+ <div class="scheduler-status-badge"
1032
+ :class="selectedTaskForDetail ? getStateBadgeClass(selectedTaskForDetail.state) : ''"
1033
+ x-text="selectedTaskForDetail ? selectedTaskForDetail.state : ''">
1034
</div>
1035
+ <button class="btn btn-cancel" @click="closeTaskDetail()">Close</button>
1036
</div>
578
- </template>
1037
+
1038
+ <div class="scheduler-detail-content">
1039
+ <div class="scheduler-details-grid">
1040
+ <div class="scheduler-details-label">Type:</div>
1041
+ <div class="scheduler-details-value" x-text="selectedTaskForDetail ? selectedTaskForDetail.type : ''"></div>
1042
+
1043
+ <div class="scheduler-details-label">Created:</div>
1044
+ <div class="scheduler-details-value" x-text="selectedTaskForDetail ? formatDate(selectedTaskForDetail.created_at) : ''"></div>
1045
+
1046
+ <div class="scheduler-details-label">Last Updated:</div>
1047
+ <div class="scheduler-details-value" x-text="selectedTaskForDetail ? formatDate(selectedTaskForDetail.updated_at) : ''"></div>
1048
+
1049
+ <div class="scheduler-details-label">Last Run:</div>
1050
+ <div class="scheduler-details-value" x-text="selectedTaskForDetail ? formatDate(selectedTaskForDetail.last_run) : ''"></div>
1051
+
1052
+ <div class="scheduler-details-label" x-show="selectedTaskForDetail && selectedTaskForDetail.type === 'scheduled'">Schedule:</div>
1053
+ <div class="scheduler-details-value" x-show="selectedTaskForDetail && selectedTaskForDetail.type === 'scheduled'" x-text="selectedTaskForDetail ? formatSchedule(selectedTaskForDetail) : ''"></div>
1054
+
1055
+ <div class="scheduler-details-label" x-show="selectedTaskForDetail && selectedTaskForDetail.type === 'adhoc'">Token:</div>
1056
+ <div class="scheduler-details-value" x-show="selectedTaskForDetail && selectedTaskForDetail.type === 'adhoc'" x-text="selectedTaskForDetail ? selectedTaskForDetail.token : ''"></div>
1057
+
1058
+ <div class="scheduler-details-label">Last Result:</div>
1059
+ <div class="scheduler-details-value" x-text="selectedTaskForDetail && selectedTaskForDetail.last_result ? selectedTaskForDetail.last_result : 'No results yet'"></div>
1060
+
1061
+ <div class="scheduler-details-label">System Prompt:</div>
1062
+ <div class="scheduler-details-value" x-text="selectedTaskForDetail ? selectedTaskForDetail.system_prompt : ''"></div>
1063
+
1064
+ <div class="scheduler-details-label">User Prompt:</div>
1065
+ <div class="scheduler-details-value" x-text="selectedTaskForDetail ? selectedTaskForDetail.prompt : ''"></div>
1066
+
1067
+ <div class="scheduler-details-label">Attachments:</div>
1068
+ <div class="scheduler-details-value">
1069
+ <template x-if="selectedTaskForDetail && selectedTaskForDetail.attachments && selectedTaskForDetail.attachments.length > 0">
1070
+ <div>
1071
+ <template x-for="(attachment, index) in selectedTaskForDetail.attachments" :key="index">
1072
+ <div x-text="attachment"></div>
1073
+ </template>
1074
+ </div>
1075
+ </template>
1076
+ <template x-if="!selectedTaskForDetail || !selectedTaskForDetail.attachments || selectedTaskForDetail.attachments.length === 0">
1077
+ <div>No attachments</div>
1078
+ </template>
1079
+ </div>
1080
+ </div>
1081
+ </div>
1082
+ </div>
1083
</div>
580
- </template>
1084
+ </div>
1085
</div>
1086
1087
<div class="modal-footer">
webui/index.js
+66
-22
@@ -392,28 +392,18 @@ async function poll() {
392
const tasksAD = Alpine.$data(tasksSection);
393
let tasks = response.tasks || [];
394
395
- // Only update the tasks array if it's actually different
396
- // This prevents unnecessary reactivity triggers
397
- const currentTaskIds = new Set((tasksAD.tasks || []).map(t => t.id));
398
- const newTaskIds = new Set(tasks.map(t => t.id));
399
-
400
- // Check if the sets are different sizes or have different contents
401
- const needsUpdate = currentTaskIds.size !== newTaskIds.size ||
402
- tasks.some(task => !currentTaskIds.has(task.id));
403
-
404
- if (needsUpdate) {
405
- if (tasks.length > 0) {
406
- // Sort the tasks by creation time
407
- const sortedTasks = [...tasks].sort((a, b) =>
408
- (b.created_at || 0) - (a.created_at || 0)
409
- );
410
-
411
- // Use a clean array assignment to avoid duplicating elements
412
- tasksAD.tasks = sortedTasks;
413
- } else {
414
- // Make sure to use a new empty array instance
415
- tasksAD.tasks = [];
416
- }
395
+ // Always update tasks to ensure state changes are reflected
396
+ if (tasks.length > 0) {
397
+ // Sort the tasks by creation time
398
+ const sortedTasks = [...tasks].sort((a, b) =>
399
+ (b.created_at || 0) - (a.created_at || 0)
400
+ );
401
+
402
+ // Assign the sorted tasks to the Alpine data
403
+ tasksAD.tasks = sortedTasks;
404
+ } else {
405
+ // Make sure to use a new empty array instance
406
+ tasksAD.tasks = [];
407
}
408
}
409
@@ -1251,3 +1241,57 @@ function initializeActiveTab() {
1241
* - Tasks use the same context system as chats for communication with the backend
1242
* - Future support for renaming and deletion will be implemented later
1243
*/
1244
+
1245
+// Open the scheduler detail view for a specific task
1246
+function openTaskDetail(taskId) {
1247
+ // Wait for Alpine.js to be fully loaded
1248
+ if (window.Alpine) {
1249
+ // Get the settings modal button and click it to ensure all init logic happens
1250
+ const settingsButton = document.getElementById('settings');
1251
+ if (settingsButton) {
1252
+ // Programmatically click the settings button
1253
+ settingsButton.click();
1254
+
1255
+ // Now get a reference to the modal element
1256
+ const modalEl = document.getElementById('settingsModal');
1257
+ if (!modalEl) {
1258
+ console.error('Settings modal element not found after clicking button');
1259
+ return;
1260
+ }
1261
+
1262
+ // Get the Alpine.js data for the modal
1263
+ const modalData = Alpine.$data(modalEl);
1264
+
1265
+ // Use a timeout to ensure the modal is fully rendered
1266
+ setTimeout(() => {
1267
+ // Switch to the scheduler tab first
1268
+ modalData.switchTab('scheduler');
1269
+
1270
+ // Use another timeout to ensure the scheduler component is initialized
1271
+ setTimeout(() => {
1272
+ // Get the scheduler component
1273
+ const schedulerComponent = document.querySelector('[x-data="schedulerSettings"]');
1274
+ if (!schedulerComponent) {
1275
+ console.error('Scheduler component not found');
1276
+ return;
1277
+ }
1278
+
1279
+ // Get the Alpine.js data for the scheduler component
1280
+ const schedulerData = Alpine.$data(schedulerComponent);
1281
+
1282
+ // Show the task detail view for the specific task
1283
+ schedulerData.showTaskDetail(taskId);
1284
+
1285
+ console.log('Task detail view opened for task:', taskId);
1286
+ }, 50); // Give time for the scheduler tab to initialize
1287
+ }, 25); // Give time for the modal to render
1288
+ } else {
1289
+ console.error('Settings button not found');
1290
+ }
1291
+ } else {
1292
+ console.error('Alpine.js not loaded');
1293
+ }
1294
+}
1295
+
1296
+// Make the function available globally
1297
+window.openTaskDetail = openTaskDetail;
webui/js/scheduler.js
new
+659
@@ -0,0 +1,659 @@
1
+/**
2
+ * Task Scheduler Component for Settings Modal
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
+ console.log('DOMContentLoaded: Setting up scheduler tab click handler');
9
+
10
+ // Setup scheduler tab click handling
11
+ const setupSchedulerTab = () => {
12
+ const settingsModal = document.getElementById('settingsModal');
13
+ if (!settingsModal) {
14
+ setTimeout(setupSchedulerTab, 100);
15
+ return;
16
+ }
17
+
18
+ console.log('Setting up click interceptor for scheduler tab');
19
+
20
+ // Create a global event listener for clicks on the scheduler tab
21
+ document.addEventListener('click', function(e) {
22
+ // Find if the click was on the scheduler tab or its children
23
+ const schedulerTab = e.target.closest('.settings-tab[title="Task Scheduler"]');
24
+ if (!schedulerTab) return;
25
+
26
+ console.log('Intercepted click on scheduler tab');
27
+ e.preventDefault();
28
+ e.stopPropagation();
29
+
30
+ // Get the settings modal data
31
+ try {
32
+ const modalData = Alpine.$data(settingsModal);
33
+ if (modalData.activeTab !== 'scheduler') {
34
+ console.log(`Directly switching to scheduler tab via click interceptor.`);
35
+ // Directly call the modal's switchTab method
36
+ modalData.switchTab('scheduler');
37
+ }
38
+ } catch (err) {
39
+ console.error('Error handling scheduler tab click:', err);
40
+ }
41
+ }, true); // Use capture phase to intercept before Alpine.js handlers
42
+ };
43
+
44
+ // Initialize the tab handling
45
+ setupSchedulerTab();
46
+});
47
+
48
+document.addEventListener('alpine:init', () => {
49
+ // Register as an Alpine component
50
+ Alpine.data('schedulerSettings', () => ({
51
+ tasks: [],
52
+ isLoading: true,
53
+ selectedTask: null,
54
+ expandedTaskId: null,
55
+ sortField: 'name',
56
+ sortDirection: 'asc',
57
+ filterType: 'all', // all, scheduled, adhoc
58
+ filterState: 'all', // all, idle, running, disabled, error
59
+ pollingInterval: null,
60
+ editingTask: null,
61
+ isCreating: false,
62
+ isEditing: false,
63
+ showLoadingState: false,
64
+ viewMode: 'list', // Controls whether to show list or detail view
65
+ selectedTaskForDetail: null, // Task object for detail view
66
+
67
+ // Initialize the component
68
+ init() {
69
+ // Initialize editingTask with default values but ensure we're not in editing mode
70
+ this.editingTask = {
71
+ name: '',
72
+ type: 'scheduled',
73
+ state: 'idle', // Initialize with idle state
74
+ schedule: {
75
+ minute: '*',
76
+ hour: '*',
77
+ day: '*',
78
+ month: '*',
79
+ weekday: '*'
80
+ },
81
+ token: '',
82
+ system_prompt: '',
83
+ prompt: '',
84
+ attachments: []
85
+ };
86
+
87
+ // Make sure we're in "list view" mode by default
88
+ this.isCreating = false;
89
+ this.isEditing = false;
90
+
91
+ // Add a watcher for task type changes to initialize the appropriate properties
92
+ this.$watch('editingTask.type', (newType) => {
93
+ if (newType === 'scheduled' && !this.editingTask.schedule) {
94
+ // Initialize schedule if changing to scheduled type
95
+ this.editingTask.schedule = {
96
+ minute: '*',
97
+ hour: '*',
98
+ day: '*',
99
+ month: '*',
100
+ weekday: '*'
101
+ };
102
+ } else if (newType === 'adhoc' && !this.editingTask.token) {
103
+ // Initialize token if changing to adhoc type
104
+ this.editingTask.token = this.generateRandomToken();
105
+ }
106
+ });
107
+
108
+ // Use a small delay to ensure Alpine.js has fully initialized
109
+ // before fetching tasks, which helps prevent layout shift
110
+ setTimeout(() => {
111
+ this.fetchTasks();
112
+ }, 50);
113
+
114
+ // Set up polling when component is active
115
+ this.$watch('$store.root.activeTab', (newTab, oldTab) => {
116
+ if (newTab === 'scheduler') {
117
+ this.startPolling();
118
+ } else if (oldTab === 'scheduler') {
119
+ this.stopPolling();
120
+ }
121
+ });
122
+
123
+ // Initial polling if tab is active on load
124
+ if (this.$store.root.activeTab === 'scheduler') {
125
+ this.startPolling();
126
+ }
127
+ },
128
+
129
+ // Start polling for task updates
130
+ startPolling() {
131
+ this.fetchTasks();
132
+ this.pollingInterval = setInterval(() => this.fetchTasks(), 5000); // Poll every 5 seconds
133
+ },
134
+
135
+ // Stop polling when tab is inactive
136
+ stopPolling() {
137
+ if (this.pollingInterval) {
138
+ clearInterval(this.pollingInterval);
139
+ this.pollingInterval = null;
140
+ }
141
+ },
142
+
143
+ // Fetch tasks from API
144
+ async fetchTasks() {
145
+ this.isLoading = true;
146
+ try {
147
+ const response = await fetch('/scheduler_tasks_list', {
148
+ method: 'POST',
149
+ headers: {
150
+ 'Content-Type': 'application/json'
151
+ }
152
+ });
153
+
154
+ if (!response.ok) {
155
+ throw new Error('Failed to fetch tasks');
156
+ }
157
+
158
+ const data = await response.json();
159
+ console.log('Tasks fetched from backend:', data.tasks);
160
+ this.tasks = data.tasks || [];
161
+ } catch (error) {
162
+ console.error('Error fetching tasks:', error);
163
+ showToast('Failed to fetch tasks: ' + error.message, 'error');
164
+ } finally {
165
+ this.isLoading = false;
166
+ }
167
+ },
168
+
169
+ // Computed property for filtered tasks
170
+ get filteredTasks() {
171
+ return this.tasks
172
+ .filter(task => {
173
+ // Filter by type
174
+ if (this.filterType !== 'all') {
175
+ if (this.filterType === 'scheduled' && !task.schedule) return false;
176
+ if (this.filterType === 'adhoc' && !task.token) return false;
177
+ }
178
+
179
+ // Filter by state
180
+ if (this.filterState !== 'all' && task.state !== this.filterState) {
181
+ return false;
182
+ }
183
+
184
+ return true;
185
+ })
186
+ .sort((a, b) => {
187
+ // Handle sorting
188
+ let valueA, valueB;
189
+
190
+ switch (this.sortField) {
191
+ case 'name':
192
+ valueA = a.name.toLowerCase();
193
+ valueB = b.name.toLowerCase();
194
+ break;
195
+ case 'state':
196
+ valueA = a.state;
197
+ valueB = b.state;
198
+ break;
199
+ case 'last_run':
200
+ valueA = a.last_run ? new Date(a.last_run).getTime() : 0;
201
+ valueB = b.last_run ? new Date(b.last_run).getTime() : 0;
202
+ break;
203
+ default:
204
+ valueA = a.name.toLowerCase();
205
+ valueB = b.name.toLowerCase();
206
+ }
207
+
208
+ // Determine sort direction
209
+ const direction = this.sortDirection === 'asc' ? 1 : -1;
210
+
211
+ // Compare values
212
+ if (valueA < valueB) return -1 * direction;
213
+ if (valueA > valueB) return 1 * direction;
214
+ return 0;
215
+ });
216
+ },
217
+
218
+ // Change sort field/direction
219
+ changeSort(field) {
220
+ if (this.sortField === field) {
221
+ // Toggle direction if already sorting by this field
222
+ this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
223
+ } else {
224
+ // Set new sort field and default to ascending
225
+ this.sortField = field;
226
+ this.sortDirection = 'asc';
227
+ }
228
+ },
229
+
230
+ // Toggle expanded task row
231
+ toggleTaskExpand(taskId) {
232
+ if (this.expandedTaskId === taskId) {
233
+ this.expandedTaskId = null;
234
+ } else {
235
+ this.expandedTaskId = taskId;
236
+ }
237
+ },
238
+
239
+ // Show task detail view
240
+ showTaskDetail(taskId) {
241
+ const task = this.tasks.find(t => t.uuid === taskId);
242
+ if (!task) {
243
+ showToast('Task not found', 'error');
244
+ return;
245
+ }
246
+
247
+ // Create a copy of the task to avoid modifying the original
248
+ this.selectedTaskForDetail = JSON.parse(JSON.stringify(task));
249
+
250
+ // Ensure attachments is always an array
251
+ if (!this.selectedTaskForDetail.attachments) {
252
+ this.selectedTaskForDetail.attachments = [];
253
+ }
254
+
255
+ this.viewMode = 'detail';
256
+ },
257
+
258
+ // Close detail view and return to list
259
+ closeTaskDetail() {
260
+ this.selectedTaskForDetail = null;
261
+ this.viewMode = 'list';
262
+ },
263
+
264
+ // Format date for display
265
+ formatDate(dateString) {
266
+ if (!dateString) return 'Never';
267
+
268
+ const date = new Date(dateString);
269
+ return date.toLocaleString();
270
+ },
271
+
272
+ // Format schedule for display
273
+ formatSchedule(task) {
274
+ if (!task.schedule) return 'None';
275
+
276
+ let schedule = '';
277
+ if (typeof task.schedule === 'string') {
278
+ schedule = task.schedule;
279
+ } else if (typeof task.schedule === 'object') {
280
+ schedule = `${task.schedule.minute || '*'} ${task.schedule.hour || '*'} ${task.schedule.day || '*'} ${task.schedule.month || '*'} ${task.schedule.weekday || '*'}`;
281
+ }
282
+
283
+ return schedule;
284
+ },
285
+
286
+ // Get CSS class for state badge
287
+ getStateBadgeClass(state) {
288
+ switch (state) {
289
+ case 'idle': return 'scheduler-status-idle';
290
+ case 'running': return 'scheduler-status-running';
291
+ case 'disabled': return 'scheduler-status-disabled';
292
+ case 'error': return 'scheduler-status-error';
293
+ default: return '';
294
+ }
295
+ },
296
+
297
+ // Create a new task
298
+ startCreateTask() {
299
+ this.isCreating = true;
300
+ this.isEditing = false;
301
+ document.querySelector('[x-data="schedulerSettings"]')?.setAttribute('data-editing-state', 'creating');
302
+ this.editingTask = {
303
+ name: '',
304
+ type: 'scheduled',
305
+ state: 'idle', // Initialize with idle state
306
+ schedule: {
307
+ minute: '*',
308
+ hour: '*',
309
+ day: '*',
310
+ month: '*',
311
+ weekday: '*'
312
+ },
313
+ token: this.generateRandomToken(), // Generate token even for scheduled tasks to prevent undefined errors
314
+ system_prompt: '',
315
+ prompt: '',
316
+ attachments: [], // Always initialize as an empty array
317
+ };
318
+ },
319
+
320
+ // Edit an existing task
321
+ async startEditTask(taskId) {
322
+ const task = this.tasks.find(t => t.uuid === taskId);
323
+ if (!task) {
324
+ showToast('Task not found', 'error');
325
+ return;
326
+ }
327
+
328
+ this.isCreating = false;
329
+ this.isEditing = true;
330
+ document.querySelector('[x-data="schedulerSettings"]')?.setAttribute('data-editing-state', 'editing');
331
+
332
+ // Create a deep copy to avoid modifying the original
333
+ this.editingTask = JSON.parse(JSON.stringify(task));
334
+
335
+ // Debug log
336
+ console.log('Task data for editing:', task);
337
+ console.log('Attachments from task:', task.attachments);
338
+
339
+ // Ensure state is set with a default if missing
340
+ if (!this.editingTask.state) this.editingTask.state = 'idle';
341
+
342
+ // Ensure attachments is always an array
343
+ if (!this.editingTask.attachments) {
344
+ this.editingTask.attachments = [];
345
+ } else if (typeof this.editingTask.attachments === 'string') {
346
+ // Handle case where attachments might be stored as a string
347
+ this.editingTask.attachments = this.editingTask.attachments
348
+ .split('\n')
349
+ .map(line => line.trim())
350
+ .filter(line => line.length > 0);
351
+ } else if (!Array.isArray(this.editingTask.attachments)) {
352
+ // If not an array or string, set to empty array
353
+ this.editingTask.attachments = [];
354
+ }
355
+
356
+ // Ensure appropriate properties are initialized based on task type
357
+ if (this.editingTask.type === 'scheduled') {
358
+ // Ensure proper structure for schedule
359
+ if (typeof this.editingTask.schedule === 'string') {
360
+ const parts = this.editingTask.schedule.split(' ');
361
+ this.editingTask.schedule = {
362
+ minute: parts[0] || '*',
363
+ hour: parts[1] || '*',
364
+ day: parts[2] || '*',
365
+ month: parts[3] || '*',
366
+ weekday: parts[4] || '*'
367
+ };
368
+ } else if (!this.editingTask.schedule) {
369
+ // Initialize schedule if it doesn't exist
370
+ this.editingTask.schedule = {
371
+ minute: '*',
372
+ hour: '*',
373
+ day: '*',
374
+ month: '*',
375
+ weekday: '*'
376
+ };
377
+ }
378
+ // Initialize token for scheduled tasks to prevent undefined errors if UI accesses it
379
+ if (!this.editingTask.token) {
380
+ this.editingTask.token = '';
381
+ }
382
+ } else if (this.editingTask.type === 'adhoc') {
383
+ // Initialize token if it doesn't exist
384
+ if (!this.editingTask.token) {
385
+ this.editingTask.token = this.generateRandomToken();
386
+ }
387
+ // Initialize schedule for adhoc tasks to prevent undefined errors when UI accesses schedule properties
388
+ if (!this.editingTask.schedule) {
389
+ this.editingTask.schedule = {
390
+ minute: '*',
391
+ hour: '*',
392
+ day: '*',
393
+ month: '*',
394
+ weekday: '*'
395
+ };
396
+ }
397
+ }
398
+ },
399
+
400
+ // Cancel editing
401
+ cancelEdit() {
402
+ // Reset to initial state but keep default values to prevent errors
403
+ this.editingTask = {
404
+ name: '',
405
+ type: 'scheduled',
406
+ state: 'idle', // Initialize with idle state
407
+ schedule: {
408
+ minute: '*',
409
+ hour: '*',
410
+ day: '*',
411
+ month: '*',
412
+ weekday: '*'
413
+ },
414
+ token: '',
415
+ system_prompt: '',
416
+ prompt: '',
417
+ attachments: [], // Always initialize as an empty array
418
+ };
419
+ this.isCreating = false;
420
+ this.isEditing = false;
421
+ document.querySelector('[x-data="schedulerSettings"]')?.removeAttribute('data-editing-state');
422
+ },
423
+
424
+ // Save task (create new or update existing)
425
+ async saveTask() {
426
+ // Validate task data
427
+ if (!this.editingTask.name.trim()) {
428
+ showToast('Task name is required', 'error');
429
+ return;
430
+ }
431
+
432
+ try {
433
+ let apiEndpoint, taskData;
434
+
435
+ // Prepare task data
436
+ taskData = {
437
+ name: this.editingTask.name,
438
+ system_prompt: this.editingTask.system_prompt || '',
439
+ prompt: this.editingTask.prompt || '',
440
+ state: this.editingTask.state || 'idle' // Include state in task data
441
+ };
442
+
443
+ // Process attachments - now always stored as array
444
+ taskData.attachments = Array.isArray(this.editingTask.attachments)
445
+ ? this.editingTask.attachments
446
+ .map(line => typeof line === 'string' ? line.trim() : line)
447
+ .filter(line => line && line.trim().length > 0)
448
+ : [];
449
+
450
+ // Handle schedule based on task type
451
+ if (this.editingTask.type === 'scheduled') {
452
+ // Ensure schedule is properly formatted as an object
453
+ if (typeof this.editingTask.schedule === 'string') {
454
+ // Parse string schedule into object
455
+ const parts = this.editingTask.schedule.split(' ');
456
+ taskData.schedule = {
457
+ minute: parts[0] || '*',
458
+ hour: parts[1] || '*',
459
+ day: parts[2] || '*',
460
+ month: parts[3] || '*',
461
+ weekday: parts[4] || '*'
462
+ };
463
+ } else {
464
+ // Use object schedule directly
465
+ taskData.schedule = this.editingTask.schedule;
466
+ }
467
+ // Don't send token for scheduled tasks
468
+ delete taskData.token;
469
+ } else {
470
+ // Ad-hoc task with token
471
+ taskData.token = this.editingTask.token;
472
+ // Don't send schedule for adhoc tasks
473
+ delete taskData.schedule;
474
+ }
475
+
476
+ // Determine if creating or updating
477
+ if (this.isCreating) {
478
+ apiEndpoint = '/scheduler_task_create';
479
+ } else {
480
+ apiEndpoint = '/scheduler_task_update';
481
+ taskData.task_id = this.editingTask.uuid;
482
+ }
483
+
484
+ // Make API request
485
+ const response = await fetch(apiEndpoint, {
486
+ method: 'POST',
487
+ headers: {
488
+ 'Content-Type': 'application/json'
489
+ },
490
+ body: JSON.stringify(taskData)
491
+ });
492
+
493
+ if (!response.ok) {
494
+ const errorData = await response.json();
495
+ throw new Error(errorData.error || 'Failed to save task');
496
+ }
497
+
498
+ // Show success message
499
+ showToast(this.isCreating ? 'Task created successfully' : 'Task updated successfully', 'success');
500
+
501
+ // Refresh task list
502
+ this.fetchTasks();
503
+
504
+ // Reset form to default state without setting to null
505
+ this.cancelEdit();
506
+ document.querySelector('[x-data="schedulerSettings"]')?.removeAttribute('data-editing-state');
507
+ } catch (error) {
508
+ console.error('Error saving task:', error);
509
+ showToast('Failed to save task: ' + error.message, 'error');
510
+ }
511
+ },
512
+
513
+ // Run a task
514
+ async runTask(taskId) {
515
+ try {
516
+ const response = await fetch('/scheduler_task_run', {
517
+ method: 'POST',
518
+ headers: {
519
+ 'Content-Type': 'application/json'
520
+ },
521
+ body: JSON.stringify({ task_id: taskId })
522
+ });
523
+
524
+ if (!response.ok) {
525
+ const errorData = await response.json();
526
+ throw new Error(errorData.error || 'Failed to run task');
527
+ }
528
+
529
+ showToast('Task started successfully', 'success');
530
+
531
+ // Refresh task list
532
+ this.fetchTasks();
533
+ } catch (error) {
534
+ console.error('Error running task:', error);
535
+ showToast('Failed to run task: ' + error.message, 'error');
536
+ }
537
+ },
538
+
539
+ // Reset a task's state
540
+ async resetTaskState(taskId) {
541
+ try {
542
+ const task = this.tasks.find(t => t.uuid === taskId);
543
+ if (!task) {
544
+ showToast('Task not found', 'error');
545
+ return;
546
+ }
547
+
548
+ // Check if task is already in idle state
549
+ if (task.state === 'idle') {
550
+ showToast('Task is already in idle state', 'info');
551
+ return;
552
+ }
553
+
554
+ this.showLoadingState = true;
555
+
556
+ // Call API to update the task state
557
+ const response = await fetch('/scheduler_task_update', {
558
+ method: 'POST',
559
+ headers: {
560
+ 'Content-Type': 'application/json'
561
+ },
562
+ body: JSON.stringify({
563
+ task_id: taskId,
564
+ state: 'idle' // Always reset to idle state
565
+ })
566
+ });
567
+
568
+ if (!response.ok) {
569
+ const errorData = await response.json();
570
+ throw new Error(errorData.error || 'Failed to reset task state');
571
+ }
572
+
573
+ showToast('Task state reset to idle', 'success');
574
+
575
+ // Refresh task list
576
+ await this.fetchTasks();
577
+ this.showLoadingState = false;
578
+ } catch (error) {
579
+ console.error('Error resetting task state:', error);
580
+ showToast('Failed to reset task state: ' + error.message, 'error');
581
+ this.showLoadingState = false;
582
+ }
583
+ },
584
+
585
+ // Delete a task
586
+ async deleteTask(taskId) {
587
+ // Confirm deletion
588
+ if (!confirm('Are you sure you want to delete this task? This action cannot be undone.')) {
589
+ return;
590
+ }
591
+
592
+ try {
593
+ const response = await fetch('/scheduler_task_delete', {
594
+ method: 'POST',
595
+ headers: {
596
+ 'Content-Type': 'application/json'
597
+ },
598
+ body: JSON.stringify({ task_id: taskId })
599
+ });
600
+
601
+ if (!response.ok) {
602
+ const errorData = await response.json();
603
+ throw new Error(errorData.error || 'Failed to delete task');
604
+ }
605
+
606
+ showToast('Task deleted successfully', 'success');
607
+
608
+ // Refresh task list
609
+ this.fetchTasks();
610
+
611
+ // Close expanded view if this task was expanded
612
+ if (this.expandedTaskId === taskId) {
613
+ this.expandedTaskId = null;
614
+ }
615
+ } catch (error) {
616
+ console.error('Error deleting task:', error);
617
+ showToast('Failed to delete task: ' + error.message, 'error');
618
+ }
619
+ },
620
+
621
+ // Generate a random token for ad-hoc tasks
622
+ generateRandomToken() {
623
+ const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
624
+ let token = '';
625
+ for (let i = 0; i < 16; i++) {
626
+ token += characters.charAt(Math.floor(Math.random() * characters.length));
627
+ }
628
+ return token;
629
+ },
630
+
631
+ // Computed property for attachments text representation
632
+ get attachmentsText() {
633
+ // Ensure we always have an array to work with
634
+ const attachments = Array.isArray(this.editingTask.attachments)
635
+ ? this.editingTask.attachments
636
+ : [];
637
+
638
+ console.log('attachmentsText getter called, source:', this.editingTask.attachments);
639
+
640
+ // Join array items with newlines
641
+ return attachments.join('\n');
642
+ },
643
+
644
+ // Setter for attachments text - preserves empty lines during editing
645
+ set attachmentsText(value) {
646
+ console.log('attachmentsText setter called with:', value);
647
+
648
+ if (typeof value === 'string') {
649
+ // Just split by newlines without filtering to preserve editing experience
650
+ this.editingTask.attachments = value.split('\n');
651
+ } else {
652
+ // Fallback to empty array if not a string
653
+ this.editingTask.attachments = [];
654
+ }
655
+
656
+ console.log('editingTask.attachments is now:', this.editingTask.attachments);
657
+ }
658
+ }));
659
+});
webui/js/settings.js
+326
-7
@@ -19,20 +19,29 @@ const settingsModalProxy = {
19
20
// Switch tab method
21
switchTab(tabName) {
22
+ console.log(`Switching tab from ${this.activeTab} to ${tabName}`);
23
this.activeTab = tabName;
24
localStorage.setItem('settingsActiveTab', tabName);
25
25
- // Auto-scroll active tab into view (added after a small delay to ensure DOM updates)
26
+ // Auto-scroll active tab into view after a short delay to ensure DOM updates
27
setTimeout(() => {
28
const activeTab = document.querySelector('.settings-tab.active');
29
if (activeTab) {
30
+ console.log(`Scrolling active tab into view: ${activeTab.textContent}`);
31
activeTab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
32
+ } else {
33
+ console.warn('No active tab found to scroll into view');
34
}
35
+
36
+ // Debug the scheduler tab specifically
37
+ const schedulerTab = document.querySelector('.settings-tab[title="Task Scheduler"]');
38
+ console.log('Scheduler tab:', schedulerTab);
39
+ console.log('Scheduler tab active?', schedulerTab && schedulerTab.classList.contains('active'));
40
}, 10);
41
},
42
43
async openModal() {
35
-
44
+ console.log('Settings modal opening');
45
const modalEl = document.getElementById('settingsModal');
46
const modalAD = Alpine.$data(modalEl);
47
@@ -40,9 +49,7 @@ const settingsModalProxy = {
49
try {
50
const set = await sendJsonData("/settings_get", null);
51
43
- // Restore active tab from localStorage or use default
44
- this.activeTab = localStorage.getItem('settingsActiveTab') || 'agent';
45
-
52
+ // First load the settings data without setting the active tab
53
const settings = {
54
"title": "Settings",
55
"buttons": [
@@ -61,8 +68,66 @@ const settingsModalProxy = {
68
"sections": set.settings.sections
69
}
70
64
- modalAD.isOpen = true; // Update directly
65
- modalAD.settings = settings; // Update directly
71
+ // Update modal data
72
+ modalAD.isOpen = true;
73
+ modalAD.settings = settings;
74
+
75
+ // Now set the active tab after the modal is open
76
+ // This ensures Alpine reactivity works as expected
77
+ setTimeout(() => {
78
+ // Get stored tab or default to 'agent'
79
+ const savedTab = localStorage.getItem('settingsActiveTab') || 'agent';
80
+ console.log(`Setting initial tab to: ${savedTab}`);
81
+
82
+ // Directly set the active tab
83
+ modalAD.activeTab = savedTab;
84
+ localStorage.setItem('settingsActiveTab', savedTab);
85
+
86
+ // Add a small delay *after* setting the tab to ensure scrolling works
87
+ setTimeout(() => {
88
+ const activeTabElement = document.querySelector('.settings-tab.active');
89
+ if (activeTabElement) {
90
+ activeTabElement.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' });
91
+ }
92
+ // Debug log
93
+ const schedulerTab = document.querySelector('.settings-tab[title="Task Scheduler"]');
94
+ console.log(`Current active tab after direct set: ${modalAD.activeTab}`);
95
+ console.log('Scheduler tab active after direct initialization?',
96
+ schedulerTab && schedulerTab.classList.contains('active'));
97
+ }, 10); // Small delay just for scrolling
98
+
99
+ }, 5); // Keep a minimal delay for modal opening reactivity
100
+
101
+ // Add a watcher to disable the Save button when a task is being created or edited
102
+ const schedulerComponent = document.querySelector('[x-data="schedulerSettings"]');
103
+ if (schedulerComponent) {
104
+ // Watch for changes to the scheduler's editing state
105
+ const checkSchedulerEditingState = () => {
106
+ const schedulerData = Alpine.$data(schedulerComponent);
107
+ if (schedulerData) {
108
+ // If we're on the scheduler tab and creating/editing a task, disable the Save button
109
+ const saveButton = document.querySelector('.modal-footer button.btn-ok');
110
+ if (saveButton && modalAD.activeTab === 'scheduler' &&
111
+ (schedulerData.isCreating || schedulerData.isEditing)) {
112
+ saveButton.disabled = true;
113
+ saveButton.classList.add('btn-disabled');
114
+ } else if (saveButton) {
115
+ saveButton.disabled = false;
116
+ saveButton.classList.remove('btn-disabled');
117
+ }
118
+ }
119
+ };
120
+
121
+ // Add a mutation observer to detect changes in the scheduler component's state
122
+ const observer = new MutationObserver(checkSchedulerEditingState);
123
+ observer.observe(schedulerComponent, { attributes: true, subtree: true, childList: true });
124
+
125
+ // Also watch for tab changes to update button state
126
+ modalAD.$watch('activeTab', checkSchedulerEditingState);
127
+
128
+ // Initial check
129
+ setTimeout(checkSchedulerEditingState, 100);
130
+ }
131
132
return new Promise(resolve => {
133
this.resolvePromise = resolve;
@@ -124,3 +189,257 @@ const settingsModalProxy = {
189
// document.addEventListener('alpine:init', () => {
190
// Alpine.store('settingsModal', initSettingsModal());
191
// });
192
+
193
+document.addEventListener('alpine:init', function () {
194
+ Alpine.store('root', {
195
+ activeTab: localStorage.getItem('settingsActiveTab') || 'agent',
196
+ isOpen: false,
197
+
198
+ toggleSettings() {
199
+ this.isOpen = !this.isOpen;
200
+ }
201
+ });
202
+
203
+ Alpine.data('settingsModal', function () {
204
+ return {
205
+ settingsData: {},
206
+ filteredSections: [],
207
+ activeTab: 'agent',
208
+ isLoading: true,
209
+
210
+ async init() {
211
+ // Watch store tab changes
212
+ this.$watch('$store.root.activeTab', (newTab) => {
213
+ this.activeTab = newTab;
214
+ localStorage.setItem('settingsActiveTab', newTab);
215
+ this.updateFilteredSections();
216
+ });
217
+
218
+ // Load settings
219
+ await this.fetchSettings();
220
+ this.activeTab = this.$store.root.activeTab;
221
+ this.updateFilteredSections();
222
+ },
223
+
224
+ switchTab(tab) {
225
+ this.$store.root.activeTab = tab;
226
+ },
227
+
228
+ async fetchSettings() {
229
+ try {
230
+ this.isLoading = true;
231
+ const response = await fetch('/api/settings_get', {
232
+ method: 'POST',
233
+ headers: {
234
+ 'Content-Type': 'application/json'
235
+ }
236
+ });
237
+
238
+ if (response.ok) {
239
+ const data = await response.json();
240
+ if (data && data.settings) {
241
+ this.settingsData = data.settings;
242
+ } else {
243
+ console.error('Invalid settings data format');
244
+ }
245
+ } else {
246
+ console.error('Failed to fetch settings:', response.statusText);
247
+ }
248
+ } catch (error) {
249
+ console.error('Error fetching settings:', error);
250
+ } finally {
251
+ this.isLoading = false;
252
+ }
253
+ },
254
+
255
+ updateFilteredSections() {
256
+ // Filter sections based on active tab
257
+ if (this.activeTab === 'agent') {
258
+ this.filteredSections = this.settingsData.sections?.filter(section =>
259
+ section.group === 'agent'
260
+ ) || [];
261
+ } else if (this.activeTab === 'external') {
262
+ this.filteredSections = this.settingsData.sections?.filter(section =>
263
+ section.group === 'external'
264
+ ) || [];
265
+ } else if (this.activeTab === 'developer') {
266
+ this.filteredSections = this.settingsData.sections?.filter(section =>
267
+ section.group === 'developer'
268
+ ) || [];
269
+ } else {
270
+ // For any other tab, show nothing since those tabs have custom UI
271
+ this.filteredSections = [];
272
+ }
273
+ },
274
+
275
+ async saveSettings() {
276
+ try {
277
+ // First validate
278
+ for (const section of this.settingsData.sections) {
279
+ for (const field of section.fields) {
280
+ if (field.required && (!field.value || field.value.trim() === '')) {
281
+ showToast(`${field.title} in ${section.title} is required`, 'error');
282
+ return;
283
+ }
284
+ }
285
+ }
286
+
287
+ // Prepare data
288
+ const formData = {};
289
+ for (const section of this.settingsData.sections) {
290
+ for (const field of section.fields) {
291
+ formData[field.id] = field.value;
292
+ }
293
+ }
294
+
295
+ // Send request
296
+ const response = await fetch('/api/settings_save', {
297
+ method: 'POST',
298
+ headers: {
299
+ 'Content-Type': 'application/json'
300
+ },
301
+ body: JSON.stringify(formData)
302
+ });
303
+
304
+ if (response.ok) {
305
+ showToast('Settings saved successfully', 'success');
306
+ // Refresh settings
307
+ await this.fetchSettings();
308
+ } else {
309
+ const errorData = await response.json();
310
+ throw new Error(errorData.error || 'Failed to save settings');
311
+ }
312
+ } catch (error) {
313
+ console.error('Error saving settings:', error);
314
+ showToast('Failed to save settings: ' + error.message, 'error');
315
+ }
316
+ },
317
+
318
+ // Handle special button field actions
319
+ handleFieldButton(field) {
320
+ if (field.action === 'test_connection') {
321
+ this.testConnection(field);
322
+ } else if (field.action === 'reveal_token') {
323
+ this.revealToken(field);
324
+ } else if (field.action === 'generate_token') {
325
+ this.generateToken(field);
326
+ } else {
327
+ console.warn('Unknown button action:', field.action);
328
+ }
329
+ },
330
+
331
+ // Test API connection
332
+ async testConnection(field) {
333
+ try {
334
+ field.testResult = 'Testing...';
335
+ field.testStatus = 'loading';
336
+
337
+ // Find the API key field
338
+ let apiKey = '';
339
+ for (const section of this.settingsData.sections) {
340
+ for (const f of section.fields) {
341
+ if (f.id === field.target) {
342
+ apiKey = f.value;
343
+ break;
344
+ }
345
+ }
346
+ }
347
+
348
+ if (!apiKey) {
349
+ throw new Error('API key is required');
350
+ }
351
+
352
+ // Send test request
353
+ const response = await fetch('/api/test_connection', {
354
+ method: 'POST',
355
+ headers: {
356
+ 'Content-Type': 'application/json'
357
+ },
358
+ body: JSON.stringify({
359
+ service: field.service,
360
+ api_key: apiKey
361
+ })
362
+ });
363
+
364
+ const data = await response.json();
365
+
366
+ if (response.ok && data.success) {
367
+ field.testResult = 'Connection successful!';
368
+ field.testStatus = 'success';
369
+ } else {
370
+ throw new Error(data.error || 'Connection failed');
371
+ }
372
+ } catch (error) {
373
+ console.error('Connection test failed:', error);
374
+ field.testResult = `Failed: ${error.message}`;
375
+ field.testStatus = 'error';
376
+ }
377
+ },
378
+
379
+ // Reveal token temporarily
380
+ revealToken(field) {
381
+ // Find target field
382
+ for (const section of this.settingsData.sections) {
383
+ for (const f of section.fields) {
384
+ if (f.id === field.target) {
385
+ // Toggle field type
386
+ f.type = f.type === 'password' ? 'text' : 'password';
387
+
388
+ // Update button text
389
+ field.value = f.type === 'password' ? 'Show' : 'Hide';
390
+
391
+ break;
392
+ }
393
+ }
394
+ }
395
+ },
396
+
397
+ // Generate random token
398
+ generateToken(field) {
399
+ // Find target field
400
+ for (const section of this.settingsData.sections) {
401
+ for (const f of section.fields) {
402
+ if (f.id === field.target) {
403
+ // Generate random token
404
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
405
+ let token = '';
406
+ for (let i = 0; i < 32; i++) {
407
+ token += chars.charAt(Math.floor(Math.random() * chars.length));
408
+ }
409
+
410
+ // Set field value
411
+ f.value = token;
412
+ break;
413
+ }
414
+ }
415
+ }
416
+ },
417
+
418
+ closeModal() {
419
+ this.$store.root.isOpen = false;
420
+ }
421
+ };
422
+ });
423
+});
424
+
425
+// Show toast notification
426
+function showToast(message, type = 'info') {
427
+ const toast = document.createElement('div');
428
+ toast.className = `toast toast-${type}`;
429
+ toast.textContent = message;
430
+
431
+ document.body.appendChild(toast);
432
+
433
+ // Trigger animation
434
+ setTimeout(() => {
435
+ toast.classList.add('show');
436
+ }, 10);
437
+
438
+ // Remove after delay
439
+ setTimeout(() => {
440
+ toast.classList.remove('show');
441
+ setTimeout(() => {
442
+ document.body.removeChild(toast);
443
+ }, 300);
444
+ }, 3000);
445
+}
webui/public/schedule.svg
new
+2
@@ -0,0 +1,2 @@
1
+<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
2
+<svg fill="#000000" width="800px" height="800px" viewBox="0 0 14 14" role="img" focusable="false" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="m 8.9994537,13.238438 c -0.0393,-0.07725 -0.213095,-0.409163 -0.386333,-0.73767 -0.173254,-0.328507 -0.307642,-0.604662 -0.298641,-0.613662 0.01605,-0.01605 1.642563,0.0489 1.659979,0.0663 0.0045,0.0045 -0.0489,0.0984 -0.119703,0.207904 -0.07065,0.109352 -0.119552,0.208039 -0.108452,0.219125 0.048,0.04815 0.6568033,0.04065 0.8887233,-0.0105 0.277896,-0.0618 0.671129,-0.24986 0.858978,-0.410738 0.147453,-0.126303 0.169323,-0.134553 0.169323,-0.06375 0,0.153783 0.187939,0.329766 0.355072,0.332482 0.06615,0.0011 0.0612,0.012 -0.05385,0.111902 -0.589122,0.51418 -1.481475,0.774016 -2.2191153,0.646153 -0.104552,-0.01815 -0.233,-0.0402 -0.285306,-0.04905 -0.08625,-0.015 -0.103352,-0.0015 -0.184084,0.136203 -0.0489,0.0837 -0.115052,0.186829 -0.147003,0.229159 l -0.05805,0.07695 -0.0714,-0.140403 z m -1.176609,-1.49553 c -0.148053,-0.23828 -0.246275,-0.45739 -0.333127,-0.743266 -0.07455,-0.2453 -0.08505,-0.335046 -0.087,-0.738884 -0.0015,-0.3858833 0.009,-0.4993003 0.06885,-0.7065743 0.178369,-0.6201433 0.520046,-1.1357488 0.984591,-1.485736 0.09885,-0.074402 0.182598,-0.1365028 0.186198,-0.1378528 0.003,-0.00136 -0.0282,-0.1152023 -0.07065,-0.2528151 -0.04245,-0.1377028 -0.07035,-0.2572553 -0.0618,-0.2657005 0.0252,-0.025201 1.6001573,0.3539173 1.6001573,0.3852529 0,0.0159 -0.2377693,0.2806257 -0.5283853,0.588312 -0.290616,0.3077013 -0.545817,0.5787568 -0.567102,0.6023373 -0.0312,0.034651 -0.0525,-0.003 -0.110252,-0.1981391 -0.0393,-0.1326027 -0.07755,-0.2410249 -0.0849,-0.2410249 -0.048,0 -0.254751,0.197044 -0.378428,0.3606674 -0.337462,0.4464695 -0.472105,0.8850038 -0.447444,1.4573551 0.012,0.290466 0.0321,0.399008 0.110102,0.608262 0.0522,0.140103 0.132603,0.314571 0.178849,0.387713 0.103052,0.163218 0.103052,0.165963 0.003,0.165963 -0.137253,0 -0.268371,0.09015 -0.325477,0.223925 l -0.0534,0.124952 -0.08385,-0.134852 z m 4.1891653,0.0771 c 0,-0.0171 -0.0357,-0.370687 -0.0792,-0.785731 -0.04365,-0.415058 -0.0792,-0.779415 -0.0792,-0.809671 0,-0.04755 0.0315,-0.04035 0.22985,0.0516 l 0.229834,0.106652 0.009,-0.141603 C 12.339844,9.9851577 12.245794,9.6008647 12.086532,9.2786274 11.876977,8.8544788 11.547031,8.524592 11.123077,8.3152928 10.957819,8.2336911 10.737014,8.1511594 10.632387,8.131839 10.341051,8.0779879 10.349856,8.083988 10.423958,7.9897861 c 0.078,-0.099152 0.0852,-0.2624603 0.0174,-0.393668 -0.0468,-0.090302 -0.0459,-0.093452 0.02385,-0.093302 0.146703,4.5e-4 0.606552,0.1299026 0.832742,0.2344098 0.303996,0.1404028 0.490315,0.2695405 0.75669,0.5242607 0.570477,0.5455311 0.847562,1.1963501 0.856293,2.0112414 l 0.0045,0.376643 0.243575,0.114752 0.243575,0.114752 -0.664484,0.475525 c -0.690194,0.493915 -0.72615,0.516985 -0.72615,0.465564 z M 2.1445312,0.99804688 A 0.3678473,0.3678473 0 0 0 2.125,1 0.3678473,0.3678473 0 0 0 1.84375,1.375 l 0,11.25 A 0.3678473,0.3678473 0 0 0 2.21875,13 l 4.3359375,0 0,-0.75 -3.9609375,0 0,-10.53125 8.8125,0 0,4.8066406 0.75,0 0,-5.1503906 A 0.3678473,0.3678473 0 0 0 11.78125,1 l -9.5625,0 a 0.3678473,0.3678473 0 0 0 -0.074219,-0.001953 z M 11.125,1.9375 9.1875,1.96875 l 0,0.09375 1.9375,-0.03125 0,-0.09375 z m 0,0.21875 -1.9375,0.03125 0,0.09375 1.9375,-0.03125 0,-0.09375 z m 0,0.1875 -1.9375,0.03125 0,0.09375 1.9375,-0.03125 0,-0.09375 z m 0,0.1875 -1.9375,0.03125 0,0.09375 1.9375,-0.03125 0,-0.09375 z m 0,0.1875 -1.9375,0.03125 0,0.09375 1.9375,-0.03125 0,-0.09375 z M 5.15625,2.875 l -1.9375,0.03125 0,0.09375 1.9375,-0.03125 0,-0.09375 z m 2.875,0 L 6.125,2.90625 6.125,3 8.03125,2.96875 l 0,-0.09375 z m -2.875,0.21875 -1.9375,0.03125 0,0.09375 1.9375,-0.03125 0,-0.09375 z m 2.875,0 L 6.125,3.125 l 0,0.09375 1.90625,-0.03125 0,-0.09375 z m -2.875,0.1875 -1.9375,0.03125 0,0.09375 1.9375,-0.03125 0,-0.09375 z m 2.875,0 -1.90625,0.03125 0,0.09375 1.90625,-0.03125 0,-0.09375 z m -2.875,0.1875 -1.9375,0.03125 0,0.09375 1.9375,-0.03125 0,-0.09375 z m 2.875,0 L 6.125,3.5 l 0,0.09375 1.90625,-0.03125 0,-0.09375 z m -2.875,0.1875 -1.9375,0.03125 0,0.09375 1.9375,-0.03125 0,-0.09375 z m 2.875,0 -1.90625,0.03125 0,0.09375 1.90625,-0.03125 0,-0.09375 z m -4.96875,0.9375 0,0.125 0,5.03125 0,0.09375 0.125,0 3.3671875,0 0,-0.21875 -1.4296875,0 c -4.249e-4,-0.00853 8.041e-4,-0.024839 0,-0.0625 -7.696e-4,-0.036006 -0.00862,-0.1745288 -0.015625,-0.2539062 0.2701075,-0.00208 1.0622819,-0.00278 1.4453125,-0.00586 l 0,-0.1152344 c -0.3845771,-4.38e-5 -1.1803643,-4.158e-4 -1.4511719,0 -0.00371,-0.065008 -0.00913,-0.067093 -0.00977,-0.15625 -1.268e-4,-0.017828 1.122e-4,-0.106098 0,-0.125 0.2718265,-4.227e-4 1.0741495,-4.7e-5 1.4609375,0 l 0,-0.09375 c -0.3867797,-4.7e-5 -1.1890846,-4.227e-4 -1.4609375,0 -2.542e-4,-0.049319 1.733e-4,-0.2559925 0,-0.3125 0.2673701,-4.484e-4 1.0794915,-3.2e-5 1.4609375,0 l 0,-0.09375 c -0.3814388,-3.2e-5 -1.193539,-4.484e-4 -1.4609375,0 -1.359e-4,-0.052881 8.09e-5,-0.2220683 0,-0.28125 0.2673701,-4.484e-4 1.0794915,-3.2e-5 1.4609375,0 l 0,-0.1035156 c -0.381437,0.00311 -1.19354,0.00383 -1.4609375,0.00586 -7.05e-5,-0.068278 1.55e-5,-0.2358458 0,-0.3125 0.2673692,-0.00203 1.0794932,-0.00275 1.4609375,-0.00586 l 0,-0.1152344 c -0.3814388,-3.2e-5 -1.193539,-4.484e-4 -1.4609375,0 3.1e-6,-0.080027 -4.39e-5,-0.2234405 0,-0.3125 0.2695936,-4.355e-4 1.0768252,-3.96e-5 1.4609375,0 l 0,-0.09375 c -0.3841045,-3.96e-5 -1.1913165,-4.355e-4 -1.4609375,0 5.48e-5,-0.09301 -8.28e-5,-0.2135038 0,-0.3164062 0.2673692,-0.00203 1.0794932,-0.00275 1.4609375,-0.00586 l 0,-0.1152344 c -0.3814388,-3.2e-5 -1.193539,-4.484e-4 -1.4609375,0 9.86e-5,-0.1160496 -1.137e-4,-0.2151978 0,-0.34375 0.3979705,-6.423e-4 1.4981572,-2.581e-4 2.15625,0 -3.95e-5,0.04455 3.82e-5,0.076117 0,0.1191406 l 0.09375,0 c 3.81e-5,-0.043028 -3.94e-5,-0.074586 0,-0.1191406 0.4569181,1.902e-4 0.8659677,-2.833e-4 1.46875,0 -3.87e-5,0.044384 3.74e-5,0.076277 0,0.1191406 l 0.1054688,0 c -3.809e-4,-0.042866 3.698e-4,-0.074754 0,-0.1191406 0.7658707,3.498e-4 0.8031403,-2.94e-5 1.8320312,0 l 0,0.1191406 0.21875,0 0,-1.8066406 0,-0.125 -0.125,0 -7.65625,0 -0.125,0 z M 3.28125,4.8125 5,4.8125 c -3.7e-6,0.1416371 3.03e-5,0.1165691 0,0.25 -0.1462573,2.639e-4 -0.6962545,-4.758e-4 -0.8125,0 -0.4739238,0.00194 -0.7250132,-0.00146 -0.84375,0 -0.039579,4.862e-4 -0.047915,1.621e-4 -0.0625,0 l 0,-0.25 z m 1.8125,0 2.15625,0 c -3.7e-6,0.1416371 3.03e-5,0.1165691 0,0.25 -0.6580745,-2.627e-4 -1.7583068,-6.427e-4 -2.15625,0 3.02e-5,-0.133429 -3.7e-6,-0.1083692 0,-0.25 z m 2.25,0 1.46875,0 c -7.1e-6,0.1414487 3.22e-5,0.1168235 0,0.25 -0.6050101,-2.842e-4 -1.0117629,1.933e-4 -1.46875,0 3.02e-5,-0.133429 -3.7e-6,-0.1083692 0,-0.25 z m 1.5625,0 1.84375,0 0,0.25 c -1.0439245,-2.01e-5 -1.0701095,3.527e-4 -1.8417969,0 -2.113e-4,-0.1331757 -0.00191,-0.1085562 -0.00195,-0.25 z m -5.625,0.34375 c 0.014645,1.613e-4 0.023103,4.84e-4 0.0625,0 0.118191,-0.00145 0.3699627,0.00194 0.84375,0 0.116212,-4.758e-4 0.6662595,2.639e-4 0.8125,0 -5.29e-5,0.1772845 8.69e-5,0.177026 0,0.3398438 C 4.8468448,5.4972449 4.2769664,5.4995057 4.15625,5.5 3.6823262,5.50194 3.4312368,5.49854 3.3125,5.5 c -0.019789,2.431e-4 -0.019067,4.89e-5 -0.03125,0 l 0,-0.34375 z m 1.8125,0 c 0.3979028,-6.427e-4 1.4981507,-2.627e-4 2.15625,0 -5.14e-5,0.1721901 8.36e-5,0.1715435 0,0.3300781 -0.6623331,0.00479 -1.7550131,0.00667 -2.15625,0.00977 8.67e-5,-0.1628235 -5.28e-5,-0.1625599 0,-0.3398438 z m 2.25,0 c 0.4570048,1.933e-4 0.8637209,-2.842e-4 1.46875,0 -5.2e-5,0.1677598 8.11e-5,0.1676002 0,0.3222656 -0.608386,0.00261 -1.0088341,0.0026 -1.46875,0.00586 8.28e-5,-0.1576805 -5.09e-5,-0.1569543 0,-0.328125 z m 1.5664062,0 c 0.7711585,3.523e-4 0.7970432,-2e-5 1.8398438,0 l 0,0.3125 c -1.0466803,7.13e-5 -1.0630407,0.0066 -1.8378906,0.00977 C 8.9115346,5.3238465 8.9105029,5.3240081 8.9101562,5.15625 Z M 3.28125,5.59375 c 0.012304,4.54e-5 0.011551,2.42e-4 0.03125,0 0.118191,-0.00145 0.3699627,0.00194 0.84375,0 0.1206817,-4.942e-4 0.6906117,2.665e-4 0.84375,0 -8.66e-5,0.1459986 1.014e-4,0.1780905 0,0.3125 -0.1531523,2.662e-4 -0.7236003,-4.918e-4 -0.84375,0 -0.4739238,0.00194 -0.6937632,-0.00146 -0.8125,0 -0.029684,3.646e-4 -0.04059,1.462e-4 -0.0625,0 l 0,-0.3125 z m 1.8125,0 c 0.4011985,-6.239e-4 1.4938905,-2.699e-4 2.15625,0 -8.66e-5,0.1459987 1.014e-4,0.1780905 0,0.3125 -0.6622889,-2.653e-4 -1.7549089,-6.236e-4 -2.15625,0 1.012e-4,-0.1344194 -8.64e-5,-0.1664948 0,-0.3125 z m 2.25,0 c 0.4599352,1.979e-4 0.8603429,-2.834e-4 1.46875,0 -8.61e-5,0.1455683 9.99e-5,0.1785471 0,0.3125 -0.6060777,-2.826e-4 -1.0089482,1.948e-4 -1.46875,0 1.012e-4,-0.1344194 -8.64e-5,-0.1664948 0,-0.3125 z m 1.5683594,0 c 0.7748709,3.503e-4 0.7912313,-1.01e-5 1.8378906,0 l 0,0.3125 c -1.0359246,-1.98e-5 -1.0647996,3.493e-4 -1.8359375,0 -7.889e-4,-0.1339616 -0.00133,-0.1669262 -0.00195,-0.3125 z M 3.28125,6 c 0.02208,1.419e-4 0.032952,3.63e-4 0.0625,0 C 3.461941,5.99855 3.6824627,6.00194 4.15625,6 4.2763651,5.9995082 4.8468646,6.0002662 5,6 c -1.038e-4,0.1310253 1.052e-4,0.1924473 0,0.3125 -0.1462397,2.635e-4 -0.6968003,-4.736e-4 -0.8125,0 -0.4739238,0.00194 -0.6937632,-0.00146 -0.8125,0 -0.059368,7.293e-4 -0.078397,-4.401e-4 -0.09375,0 l 0,-0.3125 z m 1.8125,0 C 5.4950521,5.9993764 6.5876862,5.9997347 7.25,6 c -1.038e-4,0.1310253 1.052e-4,0.1924473 0,0.3125 -0.6580683,-2.581e-4 -1.7582391,-6.423e-4 -2.15625,0 1.049e-4,-0.1200647 -1.036e-4,-0.1814642 0,-0.3125 z m 2.25,0 c 0.4598196,1.948e-4 0.8626535,-2.826e-4 1.46875,0 -1.021e-4,0.1305665 1.031e-4,0.1928939 0,0.3125 -0.6027633,-2.833e-4 -1.0118495,1.902e-4 -1.46875,0 1.049e-4,-0.1200647 -1.036e-4,-0.1814642 0,-0.3125 z M 8.9160156,6 C 9.686617,6.0003488 9.7151941,5.9999802 10.75,6 l 0,0.3125 c -1.0289065,-2.94e-5 -1.0661833,3.498e-4 -1.8320312,0 C 8.9170253,6.1928833 8.9168459,6.1305756 8.9160156,6 Z M 4.1875,6.40625 C 4.3031664,6.4057764 4.8537771,6.4065135 5,6.40625 4.999886,6.5347888 5.0000988,6.6339632 5,6.75 4.8605412,6.7502608 4.3305245,6.7495425 4.21875,6.75 3.7448262,6.75194 3.4937368,6.74854 3.375,6.75 3.315632,6.7507293 3.296603,6.78081 3.28125,6.78125 l 0,-0.34375 c 0.014263,-4.088e-4 0.034654,7.26e-4 0.09375,0 C 3.493191,6.43605 3.7137127,6.40819 4.1875,6.40625 Z M 5,6.8710938 c -8.3e-5,0.1028911 5.49e-5,0.2234053 0,0.3164062 -0.1462573,2.639e-4 -0.6962545,-4.758e-4 -0.8125,0 -0.4739238,0.00194 -0.6937632,-0.00146 -0.8125,0 -0.044526,5.47e-4 -0.070349,1.09e-4 -0.09375,0 l 0,-0.3125 c 0.014263,-4.088e-4 0.034654,7.26e-4 0.09375,0 0.118191,-0.00145 0.3699627,0.00194 0.84375,0 C 4.3304924,6.8745425 4.8605578,6.8721212 5,6.8710938 Z M 3.375,7.28125 c 0.118191,-0.00145 0.3387126,0.00194 0.8125,0 0.116212,-4.758e-4 0.6662596,2.639e-4 0.8125,0 -4.4e-5,0.089052 3.1e-6,0.2324761 0,0.3125 -0.1394588,2.608e-4 -0.6694755,-4.575e-4 -0.78125,0 -0.4739238,0.00194 -0.7250132,0.029791 -0.84375,0.03125 -0.059368,7.293e-4 -0.078397,-4.401e-4 -0.09375,0 l 0,-0.3203125 C 3.3045614,7.2960111 3.330678,7.2817945 3.375,7.28125 Z M 5,7.7148438 c 1.55e-5,0.076653 -7.07e-5,0.2442168 0,0.3125 -0.139459,0.00103 -0.6694753,0.00345 -0.78125,0.00391 -0.4739239,0.00194 -0.7250132,-0.00146 -0.84375,0 -0.059368,7.293e-4 -0.078397,-4.401e-4 -0.09375,0 l 0,-0.3125 c 0.014263,-4.088e-4 0.034654,7.26e-4 0.09375,0 0.118191,-0.00145 0.3699627,0.00194 0.84375,0 C 4.3304924,7.7182925 4.8605578,7.7158712 5,7.7148438 Z M 3.28125,8.125 c 0.014263,-4.088e-4 0.034654,7.26e-4 0.09375,0 0.118191,-0.00145 0.3699626,0.00194 0.84375,0 0.1117423,-4.575e-4 0.641808,2.608e-4 0.78125,0 8.1e-5,0.059188 -1.363e-4,0.2283563 0,0.28125 -0.1394588,2.608e-4 -0.6694755,-4.575e-4 -0.78125,0 -0.4739239,0.00194 -0.7250132,-0.00146 -0.84375,0 -0.059368,7.293e-4 -0.078397,-4.401e-4 -0.09375,0 l 0,-0.28125 z m 0,0.375 c 0.014263,-4.088e-4 0.034654,7.26e-4 0.09375,0 0.118191,-0.00145 0.3699626,0.00194 0.84375,0 0.1117423,-4.575e-4 0.641808,2.608e-4 0.78125,0 1.737e-4,0.056525 -2.548e-4,0.2631535 0,0.3125 -0.1531552,2.665e-4 -0.7230335,-4.942e-4 -0.84375,0 -0.4739238,0.00194 -0.7250132,-0.00146 -0.84375,0 -0.019789,2.431e-4 -0.019067,4.89e-5 -0.03125,0 l 0,-0.3125 z m 0,0.40625 c 0.012304,4.54e-5 0.011551,2.42e-4 0.03125,0 0.118191,-0.00145 0.3699627,0.00194 0.84375,0 0.1206817,-4.942e-4 0.6906117,2.665e-4 0.84375,0 1.124e-4,0.018914 -1.271e-4,0.1071585 0,0.125 6.353e-4,0.089208 1.048e-4,0.091337 0,0.15625 -0.1531523,2.662e-4 -0.7236003,-4.918e-4 -0.84375,0 -0.4739239,0.00194 -0.6937632,0.029791 -0.8125,0.03125 -0.029684,3.646e-4 -0.04059,1.462e-4 -0.0625,0 l 0,-0.3125 z M 5,9.3085938 C 4.9995442,9.3878171 4.999221,9.5260469 5,9.5625 5.0008143,9.600635 5.030731,9.61458 5.03125,9.625 l -1.75,0 0,-0.3125 c 0.02208,1.419e-4 0.032952,3.63e-4 0.0625,0 0.118191,-0.00145 0.3387126,0.00194 0.8125,0 C 4.2763651,9.3120082 4.8468647,9.3097369 5,9.3085938 Z m 1.25,0.7226562 -3.125,0.03125 0,0.09375 3.125,-0.03125 0,-0.09375 z M 3.125,10.25 l 0,0.125 3.125,-0.03125 0,-0.09375 -3.125,0 z m 3.125,0.1875 -3.125,0.03125 0,0.09375 3.125,-0.03125 0,-0.09375 z m 0,0.1875 -3.125,0.03125 0,0.09375 3.125,-0.03125 0,-0.09375 z m 0,0.1875 -3.125,0.03125 0,0.09375 3.125,-0.03125 0,-0.09375 z M 6.25,11 3.125,11.03125 3.125,11.125 6.25,11.09375 6.25,11 Z m 0,0.21875 -3.125,0.03125 0,0.09375 3.125,-0.03125 0,-0.09375 z m 0,0.1875 -3.125,0.03125 0,0.09375 L 6.25,11.5 l 0,-0.09375 z m 0,0.1875 -3.125,0.03125 0,0.09375 3.125,-0.03125 0,-0.09375 z m 0,0.1875 -3.125,0.03125 0,0.09375 3.125,-0.03125 0,-0.09375 z"/></svg>
\ No newline at end of file
webui/public/task_scheduler.svg
new
+2
@@ -0,0 +1,2 @@
1
+<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
2
+<svg fill="#000000" width="800px" height="800px" viewBox="0 0 14 14" role="img" focusable="false" aria-hidden="true" xmlns="http://www.w3.org/2000/svg"><path d="m 8.9994537,13.238438 c -0.0393,-0.07725 -0.213095,-0.409163 -0.386333,-0.73767 -0.173254,-0.328507 -0.307642,-0.604662 -0.298641,-0.613662 0.01605,-0.01605 1.642563,0.0489 1.659979,0.0663 0.0045,0.0045 -0.0489,0.0984 -0.119703,0.207904 -0.07065,0.109352 -0.119552,0.208039 -0.108452,0.219125 0.048,0.04815 0.6568033,0.04065 0.8887233,-0.0105 0.277896,-0.0618 0.671129,-0.24986 0.858978,-0.410738 0.147453,-0.126303 0.169323,-0.134553 0.169323,-0.06375 0,0.153783 0.187939,0.329766 0.355072,0.332482 0.06615,0.0011 0.0612,0.012 -0.05385,0.111902 -0.589122,0.51418 -1.481475,0.774016 -2.2191153,0.646153 -0.104552,-0.01815 -0.233,-0.0402 -0.285306,-0.04905 -0.08625,-0.015 -0.103352,-0.0015 -0.184084,0.136203 -0.0489,0.0837 -0.115052,0.186829 -0.147003,0.229159 l -0.05805,0.07695 -0.0714,-0.140403 z m -1.176609,-1.49553 c -0.148053,-0.23828 -0.246275,-0.45739 -0.333127,-0.743266 -0.07455,-0.2453 -0.08505,-0.335046 -0.087,-0.738884 -0.0015,-0.3858833 0.009,-0.4993003 0.06885,-0.7065743 0.178369,-0.6201433 0.520046,-1.1357488 0.984591,-1.485736 0.09885,-0.074402 0.182598,-0.1365028 0.186198,-0.1378528 0.003,-0.00136 -0.0282,-0.1152023 -0.07065,-0.2528151 -0.04245,-0.1377028 -0.07035,-0.2572553 -0.0618,-0.2657005 0.0252,-0.025201 1.6001573,0.3539173 1.6001573,0.3852529 0,0.0159 -0.2377693,0.2806257 -0.5283853,0.588312 -0.290616,0.3077013 -0.545817,0.5787568 -0.567102,0.6023373 -0.0312,0.034651 -0.0525,-0.003 -0.110252,-0.1981391 -0.0393,-0.1326027 -0.07755,-0.2410249 -0.0849,-0.2410249 -0.048,0 -0.254751,0.197044 -0.378428,0.3606674 -0.337462,0.4464695 -0.472105,0.8850038 -0.447444,1.4573551 0.012,0.290466 0.0321,0.399008 0.110102,0.608262 0.0522,0.140103 0.132603,0.314571 0.178849,0.387713 0.103052,0.163218 0.103052,0.165963 0.003,0.165963 -0.137253,0 -0.268371,0.09015 -0.325477,0.223925 l -0.0534,0.124952 -0.08385,-0.134852 z m 4.1891653,0.0771 c 0,-0.0171 -0.0357,-0.370687 -0.0792,-0.785731 -0.04365,-0.415058 -0.0792,-0.779415 -0.0792,-0.809671 0,-0.04755 0.0315,-0.04035 0.22985,0.0516 l 0.229834,0.106652 0.009,-0.141603 C 12.339844,9.9851577 12.245794,9.6008647 12.086532,9.2786274 11.876977,8.8544788 11.547031,8.524592 11.123077,8.3152928 10.957819,8.2336911 10.737014,8.1511594 10.632387,8.131839 10.341051,8.0779879 10.349856,8.083988 10.423958,7.9897861 c 0.078,-0.099152 0.0852,-0.2624603 0.0174,-0.393668 -0.0468,-0.090302 -0.0459,-0.093452 0.02385,-0.093302 0.146703,4.5e-4 0.606552,0.1299026 0.832742,0.2344098 0.303996,0.1404028 0.490315,0.2695405 0.75669,0.5242607 0.570477,0.5455311 0.847562,1.1963501 0.856293,2.0112414 l 0.0045,0.376643 0.243575,0.114752 0.243575,0.114752 -0.664484,0.475525 c -0.690194,0.493915 -0.72615,0.516985 -0.72615,0.465564 z M 2.1445312,0.99804688 A 0.3678473,0.3678473 0 0 0 2.125,1 0.3678473,0.3678473 0 0 0 1.84375,1.375 l 0,11.25 A 0.3678473,0.3678473 0 0 0 2.21875,13 l 4.3359375,0 0,-0.75 -3.9609375,0 0,-10.53125 8.8125,0 0,4.8066406 0.75,0 0,-5.1503906 A 0.3678473,0.3678473 0 0 0 11.78125,1 l -9.5625,0 a 0.3678473,0.3678473 0 0 0 -0.074219,-0.001953 z M 11.125,1.9375 9.1875,1.96875 l 0,0.09375 1.9375,-0.03125 0,-0.09375 z m 0,0.21875 -1.9375,0.03125 0,0.09375 1.9375,-0.03125 0,-0.09375 z m 0,0.1875 -1.9375,0.03125 0,0.09375 1.9375,-0.03125 0,-0.09375 z m 0,0.1875 -1.9375,0.03125 0,0.09375 1.9375,-0.03125 0,-0.09375 z m 0,0.1875 -1.9375,0.03125 0,0.09375 1.9375,-0.03125 0,-0.09375 z M 5.15625,2.875 l -1.9375,0.03125 0,0.09375 1.9375,-0.03125 0,-0.09375 z m 2.875,0 L 6.125,2.90625 6.125,3 8.03125,2.96875 l 0,-0.09375 z m -2.875,0.21875 -1.9375,0.03125 0,0.09375 1.9375,-0.03125 0,-0.09375 z m 2.875,0 L 6.125,3.125 l 0,0.09375 1.90625,-0.03125 0,-0.09375 z m -2.875,0.1875 -1.9375,0.03125 0,0.09375 1.9375,-0.03125 0,-0.09375 z m 2.875,0 -1.90625,0.03125 0,0.09375 1.90625,-0.03125 0,-0.09375 z m -2.875,0.1875 -1.9375,0.03125 0,0.09375 1.9375,-0.03125 0,-0.09375 z m 2.875,0 L 6.125,3.5 l 0,0.09375 1.90625,-0.03125 0,-0.09375 z m -2.875,0.1875 -1.9375,0.03125 0,0.09375 1.9375,-0.03125 0,-0.09375 z m 2.875,0 -1.90625,0.03125 0,0.09375 1.90625,-0.03125 0,-0.09375 z m -4.96875,0.9375 0,0.125 0,5.03125 0,0.09375 0.125,0 3.3671875,0 0,-0.21875 -1.4296875,0 c -4.249e-4,-0.00853 8.041e-4,-0.024839 0,-0.0625 -7.696e-4,-0.036006 -0.00862,-0.1745288 -0.015625,-0.2539062 0.2701075,-0.00208 1.0622819,-0.00278 1.4453125,-0.00586 l 0,-0.1152344 c -0.3845771,-4.38e-5 -1.1803643,-4.158e-4 -1.4511719,0 -0.00371,-0.065008 -0.00913,-0.067093 -0.00977,-0.15625 -1.268e-4,-0.017828 1.122e-4,-0.106098 0,-0.125 0.2718265,-4.227e-4 1.0741495,-4.7e-5 1.4609375,0 l 0,-0.09375 c -0.3867797,-4.7e-5 -1.1890846,-4.227e-4 -1.4609375,0 -2.542e-4,-0.049319 1.733e-4,-0.2559925 0,-0.3125 0.2673701,-4.484e-4 1.0794915,-3.2e-5 1.4609375,0 l 0,-0.09375 c -0.3814388,-3.2e-5 -1.193539,-4.484e-4 -1.4609375,0 -1.359e-4,-0.052881 8.09e-5,-0.2220683 0,-0.28125 0.2673701,-4.484e-4 1.0794915,-3.2e-5 1.4609375,0 l 0,-0.1035156 c -0.381437,0.00311 -1.19354,0.00383 -1.4609375,0.00586 -7.05e-5,-0.068278 1.55e-5,-0.2358458 0,-0.3125 0.2673692,-0.00203 1.0794932,-0.00275 1.4609375,-0.00586 l 0,-0.1152344 c -0.3814388,-3.2e-5 -1.193539,-4.484e-4 -1.4609375,0 3.1e-6,-0.080027 -4.39e-5,-0.2234405 0,-0.3125 0.2695936,-4.355e-4 1.0768252,-3.96e-5 1.4609375,0 l 0,-0.09375 c -0.3841045,-3.96e-5 -1.1913165,-4.355e-4 -1.4609375,0 5.48e-5,-0.09301 -8.28e-5,-0.2135038 0,-0.3164062 0.2673692,-0.00203 1.0794932,-0.00275 1.4609375,-0.00586 l 0,-0.1152344 c -0.3814388,-3.2e-5 -1.193539,-4.484e-4 -1.4609375,0 9.86e-5,-0.1160496 -1.137e-4,-0.2151978 0,-0.34375 0.3979705,-6.423e-4 1.4981572,-2.581e-4 2.15625,0 -3.95e-5,0.04455 3.82e-5,0.076117 0,0.1191406 l 0.09375,0 c 3.81e-5,-0.043028 -3.94e-5,-0.074586 0,-0.1191406 0.4569181,1.902e-4 0.8659677,-2.833e-4 1.46875,0 -3.87e-5,0.044384 3.74e-5,0.076277 0,0.1191406 l 0.1054688,0 c -3.809e-4,-0.042866 3.698e-4,-0.074754 0,-0.1191406 0.7658707,3.498e-4 0.8031403,-2.94e-5 1.8320312,0 l 0,0.1191406 0.21875,0 0,-1.8066406 0,-0.125 -0.125,0 -7.65625,0 -0.125,0 z M 3.28125,4.8125 5,4.8125 c -3.7e-6,0.1416371 3.03e-5,0.1165691 0,0.25 -0.1462573,2.639e-4 -0.6962545,-4.758e-4 -0.8125,0 -0.4739238,0.00194 -0.7250132,-0.00146 -0.84375,0 -0.039579,4.862e-4 -0.047915,1.621e-4 -0.0625,0 l 0,-0.25 z m 1.8125,0 2.15625,0 c -3.7e-6,0.1416371 3.03e-5,0.1165691 0,0.25 -0.6580745,-2.627e-4 -1.7583068,-6.427e-4 -2.15625,0 3.02e-5,-0.133429 -3.7e-6,-0.1083692 0,-0.25 z m 2.25,0 1.46875,0 c -7.1e-6,0.1414487 3.22e-5,0.1168235 0,0.25 -0.6050101,-2.842e-4 -1.0117629,1.933e-4 -1.46875,0 3.02e-5,-0.133429 -3.7e-6,-0.1083692 0,-0.25 z m 1.5625,0 1.84375,0 0,0.25 c -1.0439245,-2.01e-5 -1.0701095,3.527e-4 -1.8417969,0 -2.113e-4,-0.1331757 -0.00191,-0.1085562 -0.00195,-0.25 z m -5.625,0.34375 c 0.014645,1.613e-4 0.023103,4.84e-4 0.0625,0 0.118191,-0.00145 0.3699627,0.00194 0.84375,0 0.116212,-4.758e-4 0.6662595,2.639e-4 0.8125,0 -5.29e-5,0.1772845 8.69e-5,0.177026 0,0.3398438 C 4.8468448,5.4972449 4.2769664,5.4995057 4.15625,5.5 3.6823262,5.50194 3.4312368,5.49854 3.3125,5.5 c -0.019789,2.431e-4 -0.019067,4.89e-5 -0.03125,0 l 0,-0.34375 z m 1.8125,0 c 0.3979028,-6.427e-4 1.4981507,-2.627e-4 2.15625,0 -5.14e-5,0.1721901 8.36e-5,0.1715435 0,0.3300781 -0.6623331,0.00479 -1.7550131,0.00667 -2.15625,0.00977 8.67e-5,-0.1628235 -5.28e-5,-0.1625599 0,-0.3398438 z m 2.25,0 c 0.4570048,1.933e-4 0.8637209,-2.842e-4 1.46875,0 -5.2e-5,0.1677598 8.11e-5,0.1676002 0,0.3222656 -0.608386,0.00261 -1.0088341,0.0026 -1.46875,0.00586 8.28e-5,-0.1576805 -5.09e-5,-0.1569543 0,-0.328125 z m 1.5664062,0 c 0.7711585,3.523e-4 0.7970432,-2e-5 1.8398438,0 l 0,0.3125 c -1.0466803,7.13e-5 -1.0630407,0.0066 -1.8378906,0.00977 C 8.9115346,5.3238465 8.9105029,5.3240081 8.9101562,5.15625 Z M 3.28125,5.59375 c 0.012304,4.54e-5 0.011551,2.42e-4 0.03125,0 0.118191,-0.00145 0.3699627,0.00194 0.84375,0 0.1206817,-4.942e-4 0.6906117,2.665e-4 0.84375,0 -8.66e-5,0.1459986 1.014e-4,0.1780905 0,0.3125 -0.1531523,2.662e-4 -0.7236003,-4.918e-4 -0.84375,0 -0.4739238,0.00194 -0.6937632,-0.00146 -0.8125,0 -0.029684,3.646e-4 -0.04059,1.462e-4 -0.0625,0 l 0,-0.3125 z m 1.8125,0 c 0.4011985,-6.239e-4 1.4938905,-2.699e-4 2.15625,0 -8.66e-5,0.1459987 1.014e-4,0.1780905 0,0.3125 -0.6622889,-2.653e-4 -1.7549089,-6.236e-4 -2.15625,0 1.012e-4,-0.1344194 -8.64e-5,-0.1664948 0,-0.3125 z m 2.25,0 c 0.4599352,1.979e-4 0.8603429,-2.834e-4 1.46875,0 -8.61e-5,0.1455683 9.99e-5,0.1785471 0,0.3125 -0.6060777,-2.826e-4 -1.0089482,1.948e-4 -1.46875,0 1.012e-4,-0.1344194 -8.64e-5,-0.1664948 0,-0.3125 z m 1.5683594,0 c 0.7748709,3.503e-4 0.7912313,-1.01e-5 1.8378906,0 l 0,0.3125 c -1.0359246,-1.98e-5 -1.0647996,3.493e-4 -1.8359375,0 -7.889e-4,-0.1339616 -0.00133,-0.1669262 -0.00195,-0.3125 z M 3.28125,6 c 0.02208,1.419e-4 0.032952,3.63e-4 0.0625,0 C 3.461941,5.99855 3.6824627,6.00194 4.15625,6 4.2763651,5.9995082 4.8468646,6.0002662 5,6 c -1.038e-4,0.1310253 1.052e-4,0.1924473 0,0.3125 -0.1462397,2.635e-4 -0.6968003,-4.736e-4 -0.8125,0 -0.4739238,0.00194 -0.6937632,-0.00146 -0.8125,0 -0.059368,7.293e-4 -0.078397,-4.401e-4 -0.09375,0 l 0,-0.3125 z m 1.8125,0 C 5.4950521,5.9993764 6.5876862,5.9997347 7.25,6 c -1.038e-4,0.1310253 1.052e-4,0.1924473 0,0.3125 -0.6580683,-2.581e-4 -1.7582391,-6.423e-4 -2.15625,0 1.049e-4,-0.1200647 -1.036e-4,-0.1814642 0,-0.3125 z m 2.25,0 c 0.4598196,1.948e-4 0.8626535,-2.826e-4 1.46875,0 -1.021e-4,0.1305665 1.031e-4,0.1928939 0,0.3125 -0.6027633,-2.833e-4 -1.0118495,1.902e-4 -1.46875,0 1.049e-4,-0.1200647 -1.036e-4,-0.1814642 0,-0.3125 z M 8.9160156,6 C 9.686617,6.0003488 9.7151941,5.9999802 10.75,6 l 0,0.3125 c -1.0289065,-2.94e-5 -1.0661833,3.498e-4 -1.8320312,0 C 8.9170253,6.1928833 8.9168459,6.1305756 8.9160156,6 Z M 4.1875,6.40625 C 4.3031664,6.4057764 4.8537771,6.4065135 5,6.40625 4.999886,6.5347888 5.0000988,6.6339632 5,6.75 4.8605412,6.7502608 4.3305245,6.7495425 4.21875,6.75 3.7448262,6.75194 3.4937368,6.74854 3.375,6.75 3.315632,6.7507293 3.296603,6.78081 3.28125,6.78125 l 0,-0.34375 c 0.014263,-4.088e-4 0.034654,7.26e-4 0.09375,0 C 3.493191,6.43605 3.7137127,6.40819 4.1875,6.40625 Z M 5,6.8710938 c -8.3e-5,0.1028911 5.49e-5,0.2234053 0,0.3164062 -0.1462573,2.639e-4 -0.6962545,-4.758e-4 -0.8125,0 -0.4739238,0.00194 -0.6937632,-0.00146 -0.8125,0 -0.044526,5.47e-4 -0.070349,1.09e-4 -0.09375,0 l 0,-0.3125 c 0.014263,-4.088e-4 0.034654,7.26e-4 0.09375,0 0.118191,-0.00145 0.3699627,0.00194 0.84375,0 C 4.3304924,6.8745425 4.8605578,6.8721212 5,6.8710938 Z M 3.375,7.28125 c 0.118191,-0.00145 0.3387126,0.00194 0.8125,0 0.116212,-4.758e-4 0.6662596,2.639e-4 0.8125,0 -4.4e-5,0.089052 3.1e-6,0.2324761 0,0.3125 -0.1394588,2.608e-4 -0.6694755,-4.575e-4 -0.78125,0 -0.4739238,0.00194 -0.7250132,0.029791 -0.84375,0.03125 -0.059368,7.293e-4 -0.078397,-4.401e-4 -0.09375,0 l 0,-0.3203125 C 3.3045614,7.2960111 3.330678,7.2817945 3.375,7.28125 Z M 5,7.7148438 c 1.55e-5,0.076653 -7.07e-5,0.2442168 0,0.3125 -0.139459,0.00103 -0.6694753,0.00345 -0.78125,0.00391 -0.4739239,0.00194 -0.7250132,-0.00146 -0.84375,0 -0.059368,7.293e-4 -0.078397,-4.401e-4 -0.09375,0 l 0,-0.3125 c 0.014263,-4.088e-4 0.034654,7.26e-4 0.09375,0 0.118191,-0.00145 0.3699627,0.00194 0.84375,0 C 4.3304924,7.7182925 4.8605578,7.7158712 5,7.7148438 Z M 3.28125,8.125 c 0.014263,-4.088e-4 0.034654,7.26e-4 0.09375,0 0.118191,-0.00145 0.3699626,0.00194 0.84375,0 0.1117423,-4.575e-4 0.641808,2.608e-4 0.78125,0 8.1e-5,0.059188 -1.363e-4,0.2283563 0,0.28125 -0.1394588,2.608e-4 -0.6694755,-4.575e-4 -0.78125,0 -0.4739239,0.00194 -0.7250132,-0.00146 -0.84375,0 -0.059368,7.293e-4 -0.078397,-4.401e-4 -0.09375,0 l 0,-0.28125 z m 0,0.375 c 0.014263,-4.088e-4 0.034654,7.26e-4 0.09375,0 0.118191,-0.00145 0.3699626,0.00194 0.84375,0 0.1117423,-4.575e-4 0.641808,2.608e-4 0.78125,0 1.737e-4,0.056525 -2.548e-4,0.2631535 0,0.3125 -0.1531552,2.665e-4 -0.7230335,-4.942e-4 -0.84375,0 -0.4739238,0.00194 -0.7250132,-0.00146 -0.84375,0 -0.019789,2.431e-4 -0.019067,4.89e-5 -0.03125,0 l 0,-0.3125 z m 0,0.40625 c 0.012304,4.54e-5 0.011551,2.42e-4 0.03125,0 0.118191,-0.00145 0.3699627,0.00194 0.84375,0 0.1206817,-4.942e-4 0.6906117,2.665e-4 0.84375,0 1.124e-4,0.018914 -1.271e-4,0.1071585 0,0.125 6.353e-4,0.089208 1.048e-4,0.091337 0,0.15625 -0.1531523,2.662e-4 -0.7236003,-4.918e-4 -0.84375,0 -0.4739239,0.00194 -0.6937632,0.029791 -0.8125,0.03125 -0.029684,3.646e-4 -0.04059,1.462e-4 -0.0625,0 l 0,-0.3125 z M 5,9.3085938 C 4.9995442,9.3878171 4.999221,9.5260469 5,9.5625 5.0008143,9.600635 5.030731,9.61458 5.03125,9.625 l -1.75,0 0,-0.3125 c 0.02208,1.419e-4 0.032952,3.63e-4 0.0625,0 0.118191,-0.00145 0.3387126,0.00194 0.8125,0 C 4.2763651,9.3120082 4.8468647,9.3097369 5,9.3085938 Z m 1.25,0.7226562 -3.125,0.03125 0,0.09375 3.125,-0.03125 0,-0.09375 z M 3.125,10.25 l 0,0.125 3.125,-0.03125 0,-0.09375 -3.125,0 z m 3.125,0.1875 -3.125,0.03125 0,0.09375 3.125,-0.03125 0,-0.09375 z m 0,0.1875 -3.125,0.03125 0,0.09375 3.125,-0.03125 0,-0.09375 z m 0,0.1875 -3.125,0.03125 0,0.09375 3.125,-0.03125 0,-0.09375 z M 6.25,11 3.125,11.03125 3.125,11.125 6.25,11.09375 6.25,11 Z m 0,0.21875 -3.125,0.03125 0,0.09375 3.125,-0.03125 0,-0.09375 z m 0,0.1875 -3.125,0.03125 0,0.09375 L 6.25,11.5 l 0,-0.09375 z m 0,0.1875 -3.125,0.03125 0,0.09375 3.125,-0.03125 0,-0.09375 z m 0,0.1875 -3.125,0.03125 0,0.09375 3.125,-0.03125 0,-0.09375 z"/></svg>
\ No newline at end of file