(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