diff --git a/agent.py b/agent.py index de9ba65dc..3bfd885f8 100644 --- a/agent.py +++ b/agent.py @@ -1,6 +1,7 @@ import asyncio from collections import OrderedDict from dataclasses import dataclass, field +from datetime import datetime import time, importlib, inspect, os, json import token from typing import Any, Awaitable, Coroutine, Optional, Dict, TypedDict @@ -42,6 +43,7 @@ class AgentContext: log: Log.Log | None = None, paused: bool = False, streaming_agent: "Agent|None" = None, + created_at: datetime | None = None, ): # build context self.id = id or str(uuid.uuid4()) @@ -52,6 +54,7 @@ class AgentContext: self.paused = paused self.streaming_agent = streaming_agent self.task: DeferredTask | None = None + self.created_at = created_at or datetime.now() AgentContext._counter += 1 self.no = AgentContext._counter @@ -77,6 +80,9 @@ class AgentContext: context.task.kill() return context + def get_created_at(self): + return self.created_at + def kill_process(self): if self.task: self.task.kill() @@ -195,6 +201,7 @@ class AgentConfig: class UserMessage: message: str attachments: list[str] = field(default_factory=list[str]) + system_message: list[str] = field(default_factory=list[str]) class LoopData: @@ -466,12 +473,14 @@ class Agent: "fw.intervention.md", message=message.message, attachments=message.attachments, + system_message=message.system_message ) else: content = self.parse_prompt( "fw.user_message.md", message=message.message, attachments=message.attachments, + system_message=message.system_message ) # remove empty attachments from template diff --git a/prompts/default/fw.intervention.md b/prompts/default/fw.intervention.md index 78488abf0..7f03e2521 100644 --- a/prompts/default/fw.intervention.md +++ b/prompts/default/fw.intervention.md @@ -1,6 +1,7 @@ ```json { "user_intervention": {{message}}, + "system_message": {{system_message}}, "attachments": {{attachments}} } -``` \ No newline at end of file +``` diff --git a/prompts/default/fw.user_message.md b/prompts/default/fw.user_message.md index 5c7ec0d17..bf73ed899 100644 --- a/prompts/default/fw.user_message.md +++ b/prompts/default/fw.user_message.md @@ -1,6 +1,7 @@ ```json { "user_message": {{message}}, + "system_message": {{system_message}}, "attachments": {{attachments}} } ``` diff --git a/python/api/poll.py b/python/api/poll.py index d51ec2d53..7aefa0f37 100644 --- a/python/api/poll.py +++ b/python/api/poll.py @@ -1,12 +1,16 @@ +import time + from python.helpers.api import ApiHandler from flask import Request, Response from agent import AgentContext from python.helpers import persist_chat +from python.helpers.task_scheduler import TaskScheduler class Poll(ApiHandler): + async def process(self, input: dict, request: Request) -> dict | Response: ctxid = input.get("context", None) from_no = input.get("log_from", 0) @@ -17,19 +21,31 @@ class Poll(ApiHandler): logs = context.log.output(start=from_no) # loop AgentContext._contexts + + # Get a task scheduler instance + scheduler = TaskScheduler.get() + + # Always reload the scheduler on each poll to ensure we have the latest task state + await scheduler.reload() + + # loop AgentContext._contexts and number unnamed chats + ctxs = [] tasks = [] processed_contexts = set() # Track processed context IDs + all_ctxs = list(AgentContext._contexts.values()) # First, identify all tasks - for ctx in AgentContext._contexts.values(): + for ctx in all_ctxs: # Skip if already processed if ctx.id in processed_contexts: continue + # Create the base context data that will be returned context_data = { "id": ctx.id, "name": ctx.name, + "created_at": ctx.created_at, "no": ctx.no, "log_guid": ctx.log.guid, "log_version": len(ctx.log.updates), @@ -37,19 +53,43 @@ class Poll(ApiHandler): "paused": ctx.paused, } - # Determine if this is a task using multiple methods - ctx_path = persist_chat.get_chat_folder_path(ctx.id) - is_task = (ctx_path and persist_chat.TASKS_FOLDER in ctx_path) + # Determine if this is a task by checking if a task with this UUID exists + is_task = scheduler.get_task_by_uuid(ctx.id) is not None - # Add to the appropriate list - if is_task: - tasks.append(context_data) - else: + if not is_task: ctxs.append(context_data) + else: + # If this is a task, get task details from the scheduler + task_details = scheduler.serialize_task(ctx.id) + if task_details: + # Add task details to context_data with the same field names + # as used in scheduler endpoints to maintain UI compatibility + context_data.update({ + "uuid": task_details.get("uuid"), + "state": task_details.get("state"), + "type": task_details.get("type"), + "system_prompt": task_details.get("system_prompt"), + "prompt": task_details.get("prompt"), + "last_run": task_details.get("last_run"), + "last_result": task_details.get("last_result"), + "attachments": task_details.get("attachments", []) + }) + + # Add type-specific fields + if task_details.get("type") == "scheduled": + context_data["schedule"] = task_details.get("schedule") + else: + context_data["token"] = task_details.get("token") + + tasks.append(context_data) # Mark as processed processed_contexts.add(ctx.id) + # Sort tasks and chats by their creation date, descending + ctxs.sort(key=lambda x: x["created_at"], reverse=True) + tasks.sort(key=lambda x: x["created_at"], reverse=True) + # data from this server return { "context": context.id, diff --git a/python/api/scheduler_task_create.py b/python/api/scheduler_task_create.py new file mode 100644 index 000000000..5f657989f --- /dev/null +++ b/python/api/scheduler_task_create.py @@ -0,0 +1,78 @@ +from python.helpers.api import ApiHandler, Input, Output, Request +from python.helpers.task_scheduler import ( + TaskScheduler, ScheduledTask, AdHocTask, TaskSchedule, + serialize_task, parse_task_schedule +) + + +class SchedulerTaskCreate(ApiHandler): + async def process(self, input: Input, request: Request) -> Output: + """ + Create a new task in the scheduler + """ + scheduler = TaskScheduler.get() + await scheduler.reload() + + # Get common fields from input + name = input.get("name") + system_prompt = input.get("system_prompt") + prompt = input.get("prompt") + attachments = input.get("attachments", []) + + # Check if schedule is provided (for ScheduledTask) + schedule = input.get("schedule", {}) + token: str = input.get("token", "") + + # Validate required fields + if not name or not system_prompt or not prompt: + return {"error": "Missing required fields: name, system_prompt, prompt"} + + task = None + if schedule: + # Create a scheduled task + # Handle different schedule formats (string or object) + if isinstance(schedule, str): + # Parse the string schedule + parts = schedule.split(' ') + task_schedule = TaskSchedule( + minute=parts[0] if len(parts) > 0 else "*", + hour=parts[1] if len(parts) > 1 else "*", + day=parts[2] if len(parts) > 2 else "*", + month=parts[3] if len(parts) > 3 else "*", + weekday=parts[4] if len(parts) > 4 else "*" + ) + elif isinstance(schedule, dict): + # Use our standardized parsing function + try: + task_schedule = parse_task_schedule(schedule) + except ValueError as e: + return {"error": str(e)} + else: + return {"error": "Invalid schedule format. Must be string or object."} + + task = ScheduledTask.create( + name=name, + system_prompt=system_prompt, + prompt=prompt, + schedule=task_schedule, + attachments=attachments + ) + else: + # Create an ad-hoc task + task = AdHocTask.create( + name=name, + system_prompt=system_prompt, + prompt=prompt, + token=token, + attachments=attachments + ) + + # Add the task to the scheduler + await scheduler.add_task(task) + + # Return the created task using our standardized serialization function + task_dict = serialize_task(task) + + return { + "task": task_dict + } diff --git a/python/api/scheduler_task_delete.py b/python/api/scheduler_task_delete.py new file mode 100644 index 000000000..e526285aa --- /dev/null +++ b/python/api/scheduler_task_delete.py @@ -0,0 +1,27 @@ +from python.helpers.api import ApiHandler, Input, Output, Request +from python.helpers.task_scheduler import TaskScheduler + + +class SchedulerTaskDelete(ApiHandler): + async def process(self, input: Input, request: Request) -> Output: + """ + Delete a task from the scheduler by ID + """ + scheduler = TaskScheduler.get() + await scheduler.reload() + + # Get task ID from input + task_id: str = input.get("task_id", "") + + if not task_id: + return {"error": "Missing required field: task_id"} + + # Check if the task exists first + task = scheduler.get_task_by_uuid(task_id) + if not task: + return {"error": f"Task with ID {task_id} not found"} + + # Remove the task + await scheduler.remove_task_by_uuid(task_id) + + return {"success": True, "message": f"Task {task_id} deleted successfully"} diff --git a/python/api/scheduler_task_run.py b/python/api/scheduler_task_run.py new file mode 100644 index 000000000..dd1dbbd4c --- /dev/null +++ b/python/api/scheduler_task_run.py @@ -0,0 +1,49 @@ +from python.helpers.api import ApiHandler, Input, Output, Request +from python.helpers.task_scheduler import TaskScheduler, TaskState + + +class SchedulerTaskRun(ApiHandler): + async def process(self, input: Input, request: Request) -> Output: + """ + Manually run a task from the scheduler by ID + """ + scheduler = TaskScheduler.get() + await scheduler.reload() + + # Get task ID from input + task_id: str = input.get("task_id", "") + + if not task_id: + return {"error": "Missing required field: task_id"} + + # Check if the task exists first + task = scheduler.get_task_by_uuid(task_id) + if not task: + return {"error": f"Task with ID '{task_id}' not found"} + + # Check if task is already running + if task.state != TaskState.IDLE: + # Return task details along with error for better frontend handling + serialized_task = scheduler.serialize_task(task_id) + return { + "error": f"Task '{task_id}' is in state '{task.state}' and cannot be run", + "task": serialized_task + } + + # Run the task, which now includes atomic state checks and updates + try: + await scheduler.run_task_by_uuid(task_id) + # Get updated task after run starts + serialized_task = scheduler.serialize_task(task_id) + if serialized_task: + return { + "success": True, + "message": f"Task '{task_id}' started successfully", + "task": serialized_task + } + else: + return {"success": True, "message": f"Task '{task_id}' started successfully"} + except ValueError as e: + return {"error": str(e)} + except Exception as e: + return {"error": f"Failed to run task '{task_id}': {str(e)}"} diff --git a/python/api/scheduler_task_update.py b/python/api/scheduler_task_update.py new file mode 100644 index 000000000..ca73bd39f --- /dev/null +++ b/python/api/scheduler_task_update.py @@ -0,0 +1,67 @@ +from python.helpers.api import ApiHandler, Input, Output, Request +from python.helpers.task_scheduler import ( + TaskScheduler, ScheduledTask, AdHocTask, TaskState, + serialize_task, parse_task_schedule +) + + +class SchedulerTaskUpdate(ApiHandler): + async def process(self, input: Input, request: Request) -> Output: + """ + Update an existing task in the scheduler + """ + scheduler = TaskScheduler.get() + await scheduler.reload() + + # Get task ID from input + task_id: str = input.get("task_id", "") + + if not task_id: + return {"error": "Missing required field: task_id"} + + # Get the task to update + task = scheduler.get_task_by_uuid(task_id) + + if not task: + return {"error": f"Task with ID {task_id} not found"} + + # Update fields if provided using the task's update method + update_params = {} + + if "name" in input: + update_params["name"] = input.get("name", "") + + if "state" in input: + update_params["state"] = TaskState(input.get("state", TaskState.IDLE)) + + if "system_prompt" in input: + update_params["system_prompt"] = input.get("system_prompt", "") + + if "prompt" in input: + update_params["prompt"] = input.get("prompt", "") + + if "attachments" in input: + update_params["attachments"] = input.get("attachments", []) + + # Update schedule if this is a scheduled task and schedule is provided + if isinstance(task, ScheduledTask) and "schedule" in input: + schedule_data = input.get("schedule", {}) + try: + update_params["schedule"] = parse_task_schedule(schedule_data) + except ValueError as e: + return {"error": f"Invalid schedule format: {str(e)}"} + elif isinstance(task, AdHocTask) and "token" in input: + update_params["token"] = input.get("token", "") + + # Use atomic update method to apply changes + updated_task = await scheduler.update_task(task_id, **update_params) + + if not updated_task: + return {"error": f"Task with ID {task_id} not found or could not be updated"} + + # Return the updated task using our standardized serialization function + task_dict = serialize_task(updated_task) + + return { + "task": task_dict + } diff --git a/python/api/scheduler_tasks_list.py b/python/api/scheduler_tasks_list.py new file mode 100644 index 000000000..29fc154b2 --- /dev/null +++ b/python/api/scheduler_tasks_list.py @@ -0,0 +1,24 @@ +from python.helpers.api import ApiHandler, Input, Output, Request +from python.helpers.task_scheduler import TaskScheduler +import traceback +from python.helpers.print_style import PrintStyle + + +class SchedulerTasksList(ApiHandler): + async def process(self, input: Input, request: Request) -> Output: + """ + List all tasks in the scheduler with their types + """ + try: + # Get task scheduler + scheduler = TaskScheduler.get() + await scheduler.reload() + + # Use the scheduler's convenience method for task serialization + tasks_list = scheduler.serialize_all_tasks() + + return {"tasks": tasks_list} + + except Exception as e: + PrintStyle.error(f"Failed to list tasks: {str(e)} {traceback.format_exc()}") + return {"error": f"Failed to list tasks: {str(e)} {traceback.format_exc()}", "tasks": []} diff --git a/python/api/scheduler_tick.py b/python/api/scheduler_tick.py index f5d3dd9ea..87bd9c568 100644 --- a/python/api/scheduler_tick.py +++ b/python/api/scheduler_tick.py @@ -11,7 +11,32 @@ class SchedulerTick(ApiHandler): return True async def process(self, input: Input, request: Request) -> Output: - # timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - # PrintStyle().print(f"Scheduler tick - API: {timestamp}") - await TaskScheduler.get().tick() - return {"scheduler": "tick"} + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + printer = PrintStyle(font_color="green", padding=False) + printer.print(f"Scheduler tick - API: {timestamp}") + + # Get the task scheduler instance and print detailed debug info + scheduler = TaskScheduler.get() + await scheduler.reload() + + tasks = scheduler.get_tasks() + tasks_count = len(tasks) + + # Log information about the tasks + printer.print(f"Scheduler has {tasks_count} task(s)") + if tasks_count > 0: + for task in tasks: + printer.print(f"Task: {task.name} (UUID: {task.uuid}, State: {task.state})") + + # Run the scheduler tick + await scheduler.tick() + + # Get updated tasks after tick + serialized_tasks = scheduler.serialize_all_tasks() + + return { + "scheduler": "tick", + "timestamp": timestamp, + "tasks_count": tasks_count, + "tasks": serialized_tasks + } diff --git a/python/helpers/api.py b/python/helpers/api.py index 8c7e7a59f..2d1cbfaa5 100644 --- a/python/helpers/api.py +++ b/python/helpers/api.py @@ -39,13 +39,21 @@ class ApiHandler: async def handle_request(self, request: Request) -> Response: try: # input data from request based on type + input_data: Input = {} if request.is_json: - input = request.get_json() + try: + if request.data: # Check if there's any data + input_data = request.get_json() + # If empty or not valid JSON, use empty dict + except Exception as e: + # Just log the error and continue with empty input + PrintStyle().print(f"Error parsing JSON: {str(e)}") + input_data = {} else: - input = {"data": request.get_data(as_text=True)} + input_data = {"data": request.get_data(as_text=True)} # process via handler - output = await self.process(input, request) + output = await self.process(input_data, request) # return output based on type if isinstance(output, Response): @@ -59,7 +67,7 @@ class ApiHandler: # return exceptions with 500 except Exception as e: error = format_error(e) - PrintStyle.error(error) + PrintStyle.error(f"API error: {error}") return Response(response=error, status=500, mimetype="text/plain") # get context to run agent zero in diff --git a/python/helpers/persist_chat.py b/python/helpers/persist_chat.py index ce81a1293..4a9eed093 100644 --- a/python/helpers/persist_chat.py +++ b/python/helpers/persist_chat.py @@ -9,30 +9,39 @@ from initialize import initialize from python.helpers.log import Log, LogItem CHATS_FOLDER = "tmp/chats" -TASKS_FOLDER = "tmp/task_chats" LOG_SIZE = 1000 CHAT_FILE_NAME = "chat.json" -def get_chat_folder_path(ctxid: str, folder: str = CHATS_FOLDER): - return files.get_abs_path(folder, ctxid) +def get_chat_folder_path(ctxid: str): + """ + Get the folder path for any context (chat or task). + + Args: + ctxid: The context ID + + Returns: + The absolute path to the context folder + """ + return files.get_abs_path(CHATS_FOLDER, ctxid) -def save_tmp_chat(context: AgentContext, folder: str = CHATS_FOLDER): - path = _get_chat_file_path(context.id, folder) +def save_tmp_chat(context: AgentContext): + """Save context to the chats folder""" + path = _get_chat_file_path(context.id) files.make_dirs(path) data = _serialize_context(context) js = _safe_json_serialize(data, ensure_ascii=False) files.write_file(path, js) -def load_tmp_chats(folder: str = CHATS_FOLDER): - if folder == CHATS_FOLDER: - _convert_v080_chats() - folders = files.list_files(folder, "*") +def load_tmp_chats(): + """Load all contexts from the chats folder""" + _convert_v080_chats() + folders = files.list_files(CHATS_FOLDER, "*") json_files = [] for folder_name in folders: - json_files.append(_get_chat_file_path(folder_name, folder)) + json_files.append(_get_chat_file_path(folder_name)) ctxids = [] for file in json_files: @@ -46,8 +55,8 @@ def load_tmp_chats(folder: str = CHATS_FOLDER): return ctxids -def _get_chat_file_path(ctxid: str, folder: str = CHATS_FOLDER): - return files.get_abs_path(folder, ctxid, CHAT_FILE_NAME) +def _get_chat_file_path(ctxid: str): + return files.get_abs_path(CHATS_FOLDER, ctxid, CHAT_FILE_NAME) def _convert_v080_chats(): @@ -59,7 +68,8 @@ def _convert_v080_chats(): files.move_file(path, new) -def load_json_chats(jsons: list[str], folder: str = CHATS_FOLDER): +def load_json_chats(jsons: list[str]): + """Load contexts from JSON strings""" ctxids = [] for js in jsons: data = json.loads(js) @@ -71,30 +81,16 @@ def load_json_chats(jsons: list[str], folder: str = CHATS_FOLDER): def export_json_chat(context: AgentContext): + """Export context as JSON string""" data = _serialize_context(context) js = _safe_json_serialize(data, ensure_ascii=False) return js -def remove_chat(ctxid, folder: str = CHATS_FOLDER): - files.delete_dir(get_chat_folder_path(ctxid, folder)) - - -# Task-specific functions for convenience -def save_tmp_task(context: AgentContext): - save_tmp_chat(context, TASKS_FOLDER) - - -def load_tmp_tasks(): - return load_tmp_chats(TASKS_FOLDER) - - -def load_json_tasks(jsons: list[str]): - return load_json_chats(jsons, TASKS_FOLDER) - - -def remove_task(ctxid): - remove_chat(ctxid, TASKS_FOLDER) +def remove_chat(ctxid): + """Remove a chat or task context""" + path = get_chat_folder_path(ctxid) + files.delete_dir(path) def _serialize_context(context: AgentContext): diff --git a/python/helpers/print_style.py b/python/helpers/print_style.py index 083326662..ef2de0ea8 100644 --- a/python/helpers/print_style.py +++ b/python/helpers/print_style.py @@ -34,7 +34,7 @@ class PrintStyle: else: rgb_color = webcolors.name_to_rgb(color) r, g, b = rgb_color.red, rgb_color.green, rgb_color.blue - + if is_background: return f"\033[48;2;{r};{g};{b}m", f"background-color: rgb({r}, {g}, {b});" else: @@ -88,15 +88,15 @@ class PrintStyle: def _close_html_log(): if PrintStyle.log_file_path: with open(PrintStyle.log_file_path, "a") as f: - f.write("") + f.write("") def get(self, *args, sep=' ', **kwargs): text = sep.join(map(str, args)) return text, self._get_styled_text(text), self._get_html_styled_text(text) - + def print(self, *args, sep=' ', **kwargs): self._add_padding_if_needed() - if not PrintStyle.last_endline: + if not PrintStyle.last_endline: print() self._log_html("
") plain_text, styled_text, html_text = self.get(*args, sep=sep, **kwargs) @@ -118,15 +118,31 @@ class PrintStyle: return bool(lines) and not lines[-1].strip() @staticmethod - def standard(text:str): + def standard(text: str): PrintStyle().print(text) @staticmethod - def hint(text:str): + def hint(text: str): PrintStyle(font_color="#6C3483", padding=True).print("Hint: "+text) @staticmethod - def error(text:str): + def info(text: str): + PrintStyle(font_color="#0000FF", padding=True).print("Info: "+text) + + @staticmethod + def success(text: str): + PrintStyle(font_color="#008000", padding=True).print("Success: "+text) + + @staticmethod + def warning(text: str): + PrintStyle(font_color="#FFA500", padding=True).print("Warning: "+text) + + @staticmethod + def debug(text: str): + PrintStyle(font_color="#808080", padding=True).print("Debug: "+text) + + @staticmethod + def error(text: str): PrintStyle(font_color="red", padding=True).print("Error: "+text) # Ensure HTML file is closed properly when the program exits diff --git a/python/helpers/task_scheduler.py b/python/helpers/task_scheduler.py index bce416cbe..56e122e95 100644 --- a/python/helpers/task_scheduler.py +++ b/python/helpers/task_scheduler.py @@ -1,28 +1,40 @@ -import uuid -import random -import os -from datetime import datetime, timezone -import threading import asyncio +import os +import random +import threading +import uuid +from datetime import datetime, timezone, timedelta +from enum import Enum +from os.path import exists +from typing import ClassVar, Literal, Optional, Union, Dict, Any, Type, TypeVar, cast + import nest_asyncio nest_asyncio.apply() -from typing import Union, Literal, Optional - from crontab import CronTab from pydantic import BaseModel, Field, PrivateAttr -from python.helpers.files import get_abs_path, exists, write_file, read_file, make_dirs + from agent import Agent, AgentContext, UserMessage from initialize import initialize -from python.helpers.persist_chat import export_json_chat, load_json_chats, load_tmp_chats, save_tmp_chat +from python.helpers.persist_chat import load_tmp_chats, save_tmp_chat from python.helpers.print_style import PrintStyle from python.helpers.defer import DeferredTask -from python.helpers.persist_chat import CHATS_FOLDER, TASKS_FOLDER -from python.helpers import errors +from python.helpers.files import make_dirs, write_file, get_abs_path, read_file SCHEDULER_FOLDER = "memory/scheduler" +# ---------------------- +# Task Models +# ---------------------- + +class TaskState(str, Enum): + IDLE = "idle" + RUNNING = "running" + DISABLED = "disabled" + ERROR = "error" + + class TaskSchedule(BaseModel): minute: str hour: str @@ -36,7 +48,7 @@ class TaskSchedule(BaseModel): class AdHocTask(BaseModel): uuid: str = Field(default_factory=lambda: str(uuid.uuid4())) - state: Literal["idle", "running", "disabled"] = Field(default="idle") + state: TaskState = Field(default=TaskState.IDLE) name: str = Field() system_prompt: str prompt: str @@ -66,16 +78,17 @@ class AdHocTask(BaseModel): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._lock = threading.Lock() + self._lock = threading.RLock() def update(self, name: str | None = None, - state: Literal["idle", "running", "disabled"] | None = None, + state: TaskState | None = None, system_prompt: str | None = None, prompt: str | None = None, attachments: list[str] | None = None, last_run: datetime | None = None, - last_result: str | None = None): + last_result: str | None = None, + token: str | None = None): with self._lock: if name is not None: self.name = name @@ -98,6 +111,9 @@ class AdHocTask(BaseModel): if last_result is not None: self.last_result = last_result self.updated_at = datetime.now(timezone.utc) + if token is not None: + self.token = token + self.updated_at = datetime.now(timezone.utc) def check_schedule(self) -> bool: with self._lock: @@ -106,7 +122,7 @@ class AdHocTask(BaseModel): class ScheduledTask(BaseModel): uuid: str = Field(default_factory=lambda: str(uuid.uuid4())) - state: Literal["idle", "running", "disabled"] = Field(default="idle") + state: TaskState = Field(default=TaskState.IDLE) name: str schedule: TaskSchedule system_prompt: str @@ -136,11 +152,11 @@ class ScheduledTask(BaseModel): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._lock = threading.Lock() + self._lock = threading.RLock() def update(self, name: str | None = None, - state: Literal["idle", "running", "disabled"] | None = None, + state: TaskState | None = None, system_prompt: str | None = None, prompt: str | None = None, attachments: list[str] | None = None, @@ -176,10 +192,15 @@ class ScheduledTask(BaseModel): def check_schedule(self, frequency_seconds: float = 60.0) -> bool: with self._lock: crontab = CronTab(crontab=self.schedule.to_crontab()) - next_run: float | None = crontab.next(now=datetime.now(timezone.utc), return_datetime=False) - if next_run is None: + # Get next run time as seconds until next execution + # Set reference time to now - 1 minute to avoid off-by-one + next_run_seconds: Optional[float] = crontab.next( + now=datetime.now(timezone.utc) - timedelta(seconds=frequency_seconds), + return_datetime=False + ) # type: ignore + if next_run_seconds is None: return False - return next_run < frequency_seconds + return next_run_seconds < frequency_seconds def run(self): pass @@ -188,21 +209,36 @@ class ScheduledTask(BaseModel): class SchedulerTaskList(BaseModel): tasks: list[Union[ScheduledTask, AdHocTask]] + # Singleton instance + __instance: ClassVar[Optional["SchedulerTaskList"]] = PrivateAttr(default=None) + # lock: threading.Lock = Field(exclude=True, default=threading.Lock()) @classmethod def get(cls) -> "SchedulerTaskList": path = get_abs_path(SCHEDULER_FOLDER, "tasks.json") - if not exists(path): - make_dirs(path) - instance = asyncio.run(cls(tasks=[]).save()) + if cls.__instance is None: + if not exists(path): + make_dirs(path) + cls.__instance = asyncio.run(cls(tasks=[]).save()) + else: + cls.__instance = cls.model_validate_json(read_file(path)) else: - instance = cls.model_validate_json(read_file(path)) - return instance + asyncio.run(cls.__instance.reload()) + return cls.__instance def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._lock = threading.Lock() + self._lock = threading.RLock() + + async def reload(self) -> "SchedulerTaskList": + path = get_abs_path(SCHEDULER_FOLDER, "tasks.json") + if exists(path): + with self._lock: + data = self.__class__.model_validate_json(read_file(path)) + self.tasks.clear() + self.tasks.extend(data.tasks) + return self async def add_task(self, task: Union[ScheduledTask, AdHocTask]) -> "SchedulerTaskList": with self._lock: @@ -218,6 +254,39 @@ class SchedulerTaskList(BaseModel): write_file(path, self.model_dump_json()) return self + async def update_task_by_uuid(self, task_uuid: str, updater_func) -> Union[ScheduledTask, AdHocTask] | None: + """ + Atomically update a task by UUID using the provided updater function. + + The updater_func should take the task as an argument and perform any necessary updates. + This method ensures that the task is updated and saved atomically, preventing race conditions. + + Returns the updated task or None if not found. + """ + with self._lock: + # Reload to ensure we have the latest state + await self.reload() + + # Find the task + task = next((task for task in self.tasks if task.uuid == task_uuid), None) + if task is None: + return None + + # Apply the updates via the provided function + updater_func(task) + + # Save the changes + path = get_abs_path(SCHEDULER_FOLDER, "tasks.json") + if not exists(path): + make_dirs(path) + write_file(path, self.model_dump_json()) + + return task + + def get_tasks(self) -> list[Union[ScheduledTask, AdHocTask]]: + with self._lock: + return self.tasks + def get_due_tasks(self) -> list[Union[ScheduledTask, AdHocTask]]: with self._lock: return [task for task in self.tasks if task.check_schedule()] @@ -247,14 +316,44 @@ class TaskScheduler: _tasks: SchedulerTaskList _printer: PrintStyle + _instance = None @classmethod def get(cls) -> "TaskScheduler": - return cls() + if cls._instance is None: + cls._instance = cls() + return cls._instance def __init__(self): - self._tasks = SchedulerTaskList.get() - self._printer = PrintStyle(italic=True, font_color="green", padding=False) + # Only initialize if this is a new instance + if not hasattr(self, '_initialized'): + self._tasks = SchedulerTaskList.get() + self._printer = PrintStyle(italic=True, font_color="green", padding=False) + self._initialized = True + + async def reload(self): + await self._tasks.reload() + + def get_tasks(self) -> list[Union[ScheduledTask, AdHocTask]]: + return self._tasks.get_tasks() + + async def add_task(self, task: Union[ScheduledTask, AdHocTask]) -> "TaskScheduler": + await self._tasks.add_task(task) + return self + + async def remove_task_by_uuid(self, task_uuid: str) -> "TaskScheduler": + await self._tasks.remove_task_by_uuid(task_uuid) + return self + + async def remove_task_by_name(self, name: str) -> "TaskScheduler": + await self._tasks.remove_task_by_name(name) + return self + + def get_task_by_uuid(self, task_uuid: str) -> Union[ScheduledTask, AdHocTask] | None: + return self._tasks.get_task_by_uuid(task_uuid) + + def get_task_by_name(self, name: str) -> Union[ScheduledTask, AdHocTask] | None: + return self._tasks.get_task_by_name(name) async def tick(self): for task in self._tasks.get_due_tasks(): @@ -264,6 +363,8 @@ class TaskScheduler: task = self._tasks.get_task_by_uuid(task_uuid) if task is None: raise ValueError(f"Task with UUID {task_uuid} not found") + + # The actual state check and running will be handled in the _run_task method await self._run_task(task) async def run_task_by_name(self, name: str): @@ -272,30 +373,40 @@ class TaskScheduler: raise ValueError(f"Task with name {name} not found") await self._run_task(task) + async def save(self): + await self._tasks.save() + + async def update_task(self, task_uuid: str, **update_params) -> Union[ScheduledTask, AdHocTask] | None: + """ + Atomically update a task by UUID with the provided parameters. + This prevents race conditions when multiple processes update tasks concurrently. + + Returns the updated task or None if not found. + """ + def _update_task(task): + task.update(**update_params) + + return await self._tasks.update_task_by_uuid(task_uuid, _update_task) + async def __new_context(self, task: Union[ScheduledTask, AdHocTask]) -> AgentContext: config = initialize() context: AgentContext = AgentContext(config) context.id = task.uuid - # chat_json = export_json_chat(context) - # chat_file = get_abs_path(TASKS_FOLDER, task.uuid, "chat.json") - # make_dirs(chat_file) - # write_file(chat_file, chat_json) - save_tmp_chat(context, TASKS_FOLDER) + # Save the context + save_tmp_chat(context) return context async def _get_chat_context(self, task: Union[ScheduledTask, AdHocTask]) -> AgentContext: - ctxids = load_tmp_chats(TASKS_FOLDER) - # chat_file = get_abs_path(TASKS_FOLDER, task.uuid, "chat.json") - # if exists(chat_file): + ctxids = load_tmp_chats() + if task.uuid in ctxids: - # chat = read_file(chat_file) context = AgentContext.get(task.uuid) if isinstance(context, AgentContext): self._printer.print( f"Scheduler Task {task.name} loaded from task {task.uuid}, context ok" ) context.id = task.uuid - save_tmp_chat(context, TASKS_FOLDER) + save_tmp_chat(context) return context else: self._printer.print( @@ -309,42 +420,53 @@ class TaskScheduler: return await self.__new_context(task) async def _persist_chat(self, task: Union[ScheduledTask, AdHocTask], context: AgentContext): - # chat_json = export_json_chat(context) - # chat_file = get_abs_path(TASKS_FOLDER, task.uuid, "chat.json") - # make_dirs(chat_file) - # write_file(chat_file, chat_json) context.id = task.uuid - save_tmp_chat(context, TASKS_FOLDER) + save_tmp_chat(context) async def _run_task(self, task: Union[ScheduledTask, AdHocTask]): - async def _run_task_wrapper(task: Union[ScheduledTask, AdHocTask]): - if task.state != "idle": - self._printer.print(f"Scheduler Task '{task.name}' state is '{task.state}', skipping") + async def _run_task_wrapper(task_uuid: str): + + # preflight checks with a snapshot of the task + task_snapshot: Union[ScheduledTask, AdHocTask] | None = self.get_task_by_uuid(task_uuid) + if task_snapshot is None: + self._printer.print(f"Scheduler Task with UUID '{task_uuid}' not found") + return + if not isinstance(task_snapshot, ScheduledTask): + self._printer.error(f"Scheduler Task '{task_snapshot.name}' is not an ScheduledTask, this should not happen, skipping") + return + if task_snapshot.state == TaskState.RUNNING: + self._printer.print(f"Scheduler Task '{task_snapshot.name}' already running, skipping") + return + if task_snapshot.state != TaskState.IDLE: + self._printer.print(f"Scheduler Task '{task_snapshot.name}' state is '{task_snapshot.state}', skipping") return - if task.state == "running": - self._printer.print(f"Scheduler Task '{task.name}' already running, skipping") + # Atomically fetch and check the task's current state + current_task = await self.update_task(task_uuid, state=TaskState.RUNNING) + if not current_task: + self._printer.print(f"Scheduler Task with UUID '{task_uuid}' not found") + return + if current_task.state != TaskState.RUNNING: + # This means the update failed due to state conflict + self._printer.print(f"Scheduler Task '{current_task.name}' state is '{current_task.state}', skipping") return try: - self._printer.print(f"Scheduler Task '{task.name}' started") + self._printer.print(f"Scheduler Task '{current_task.name}' started") - task.update(state="running") - await self._tasks.save() - - context = await self._get_chat_context(task) + context = await self._get_chat_context(current_task) agent = Agent(0, context.config, context) # Prepare attachment filenames for logging attachment_filenames = [] - if task.attachments: - for attachment in task.attachments: + if current_task.attachments: + for attachment in current_task.attachments: if os.path.exists(attachment): attachment_filenames.append(os.path.basename(attachment)) self._printer.print("User message:") - self._printer.print(f"> {task.prompt}") + self._printer.print(f"> {current_task.prompt}") if attachment_filenames: self._printer.print("Attachments:") for filename in attachment_filenames: @@ -354,40 +476,188 @@ class TaskScheduler: context.log.log( type="user", heading="User message", - content=task.prompt, + content=current_task.prompt, kvps={"attachments": attachment_filenames}, id=str(uuid.uuid4()), ) agent.hist_add_user_message( UserMessage( - message=task.prompt, + message=current_task.prompt, + system_message=[current_task.system_prompt], attachments=[])) - await self._persist_chat(task, context) + await self._persist_chat(current_task, context) result = await agent.monologue() - task.update(last_result="SUCCESS: " + result) - self._printer.print(f"Scheduler Task '{task.name}' completed: {result}") + # Atomically update task state after completion + await self.update_task( + task_uuid, + state=TaskState.IDLE, + last_run=datetime.now(timezone.utc), + last_result="SUCCESS: " + result + ) - await self._persist_chat(task, context) + self._printer.print(f"Scheduler Task '{current_task.name}' completed: {result}") + + await self._persist_chat(current_task, context) except Exception as e: - self._printer.print(f"Scheduler Task '{task.name}' failed: {e}") - task.update(last_result=f"ERROR: {str(e)}") + self._printer.print(f"Scheduler Task '{current_task.name}' failed: {e}") + + # Atomically update task state on error + await self.update_task( + task_uuid, + state=TaskState.ERROR, + last_result=f"ERROR: {str(e)}" + ) + if agent: agent.handle_critical_exception(e) - finally: - try: - task.update( - state="idle", - last_run=datetime.now(timezone.utc) - ) - await self._tasks.save() - except Exception as e: - self._printer.print(f"Scheduler Task '{task.name}' failed to save: {e}") - deferred_task = DeferredTask(thread_name=self.__class__.__name__) - deferred_task.start_task(_run_task_wrapper, task) + deferred_task.start_task(_run_task_wrapper, task.uuid) + + def serialize_all_tasks(self) -> list[Dict[str, Any]]: + """ + Serialize all tasks in the scheduler to a list of dictionaries. + """ + return serialize_tasks(self.get_tasks()) + + def serialize_task(self, task_id: str) -> Optional[Dict[str, Any]]: + """ + Serialize a specific task in the scheduler by UUID. + Returns None if task is not found. + """ + # Get task without locking, as get_task_by_uuid() is already thread-safe + task = self.get_task_by_uuid(task_id) + if task: + return serialize_task(task) + return None + + +# ---------------------- +# Task Serialization Helpers +# ---------------------- + +def serialize_datetime(dt: Optional[datetime]) -> Optional[str]: + """Convert datetime to ISO format string or None if dt is None.""" + return dt.isoformat() if dt is not None else None + + +def parse_datetime(dt_str: Optional[str]) -> Optional[datetime]: + """Parse ISO format datetime string with validation or return None if dt_str is None.""" + if not dt_str: + return None + try: + return datetime.fromisoformat(dt_str) + except ValueError: + raise ValueError(f"Invalid datetime format: {dt_str}. Expected ISO format.") + + +def serialize_task_schedule(schedule: TaskSchedule) -> Dict[str, str]: + """Convert TaskSchedule to a standardized dictionary format.""" + return { + 'minute': schedule.minute, + 'hour': schedule.hour, + 'day': schedule.day, + 'month': schedule.month, + 'weekday': schedule.weekday + } + + +def parse_task_schedule(schedule_data: Dict[str, str]) -> TaskSchedule: + """Parse dictionary into TaskSchedule with validation.""" + try: + return TaskSchedule( + minute=schedule_data.get('minute', '*'), + hour=schedule_data.get('hour', '*'), + day=schedule_data.get('day', '*'), + month=schedule_data.get('month', '*'), + weekday=schedule_data.get('weekday', '*') + ) + except Exception as e: + raise ValueError(f"Invalid schedule format: {e}") + + +T = TypeVar('T', bound=Union[ScheduledTask, AdHocTask]) + + +def serialize_task(task: Union[ScheduledTask, AdHocTask]) -> Dict[str, Any]: + """ + Standardized serialization for task objects with proper handling of all complex types. + """ + # Start with a basic dictionary + task_dict = { + "uuid": task.uuid, + "name": task.name, + "state": task.state, + "system_prompt": task.system_prompt, + "prompt": task.prompt, + "attachments": task.attachments, + "created_at": serialize_datetime(task.created_at), + "updated_at": serialize_datetime(task.updated_at), + "last_run": serialize_datetime(task.last_run), + "last_result": task.last_result + } + + # Add type-specific fields + if isinstance(task, ScheduledTask): + task_dict['type'] = 'scheduled' + task_dict['schedule'] = serialize_task_schedule(task.schedule) + else: + task_dict['type'] = 'adhoc' + adhoc_task = cast(AdHocTask, task) + task_dict['token'] = adhoc_task.token + + return task_dict + + +def serialize_tasks(tasks: list[Union[ScheduledTask, AdHocTask]]) -> list[Dict[str, Any]]: + """ + Serialize a list of tasks to a list of dictionaries. + """ + return [serialize_task(task) for task in tasks] + + +def deserialize_task(task_data: Dict[str, Any], task_class: Optional[Type[T]] = None) -> T: + """ + Deserialize dictionary into appropriate task object with validation. + If task_class is provided, uses that type. Otherwise determines type from data. + """ + task_type_str = task_data.get('type', '') + determined_class = None + + if not task_class: + # Determine task class from data + if task_type_str == 'scheduled': + determined_class = cast(Type[T], ScheduledTask) + elif task_type_str == 'adhoc': + determined_class = cast(Type[T], AdHocTask) + else: + raise ValueError(f"Unknown task type: {task_type_str}") + else: + determined_class = task_class + + common_args = { + "uuid": task_data.get("uuid"), + "name": task_data.get("name"), + "state": TaskState(task_data.get("state", TaskState.IDLE)), + "system_prompt": task_data.get("system_prompt", ""), + "prompt": task_data.get("prompt", ""), + "attachments": task_data.get("attachments", []), + "created_at": parse_datetime(task_data.get("created_at")), + "updated_at": parse_datetime(task_data.get("updated_at")), + "last_run": parse_datetime(task_data.get("last_run")), + "last_result": task_data.get("last_result") + } + + # Add type-specific fields + if determined_class == ScheduledTask: + schedule_data = task_data.get("schedule", {}) + common_args["schedule"] = parse_task_schedule(schedule_data) + return ScheduledTask(**common_args) # type: ignore + else: + common_args["token"] = task_data.get("token", "") + return AdHocTask(**common_args) # type: ignore diff --git a/run_ui.py b/run_ui.py index da5e46c84..0aecbeec6 100644 --- a/run_ui.py +++ b/run_ui.py @@ -167,9 +167,6 @@ def run(): # initialize contexts from persisted chats persist_chat.load_tmp_chats() - # initialize contexts from persisted tasks - persist_chat.load_tmp_tasks() - except Exception as e: PrintStyle().error(errors.format_error(e)) diff --git a/test_scheduler.py b/test_scheduler.py new file mode 100644 index 000000000..e0cae14ad --- /dev/null +++ b/test_scheduler.py @@ -0,0 +1,16 @@ +from python.helpers.task_scheduler import ScheduledTask, TaskSchedule, SchedulerTaskList, TaskState +import asyncio + +slist = SchedulerTaskList.get() + +print(slist.model_dump_json(indent=4)) + +for task in slist.tasks: + t = slist.get_task_by_uuid(task.uuid) + t.update(state=TaskState.DISABLED) + print("-" * 100) + print(t.model_dump_json(indent=4)) + +print("-" * 100) + +print(slist.model_dump_json(indent=4)) diff --git a/webui/css/modals.css b/webui/css/modals.css index c90c6e110..86a193c55 100644 --- a/webui/css/modals.css +++ b/webui/css/modals.css @@ -18,20 +18,33 @@ .modal-container { background-color: var(--color-panel); border-radius: 12px; - width: 90%; - max-width: 800px; + width: 1100px; /* Reduced from 1200px to 1100px */ max-height: 90vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 4px 23px rgba(0, 0, 0, 0.2); - transition: all 0.3s ease; + box-sizing: border-box; } .light-mode .modal-container { background-color: var(--color-panel-light); } +/* Mobile Viewport Behavior */ +@media (max-width: 1280px) { + .modal-container { + width: 95%; /* Take up most of the screen on mobile */ + min-width: unset; /* Remove min-width constraints */ + max-width: 95%; /* Ensure consistent width */ + } + + /* Ensure section content can scroll horizontally */ + .section { + overflow-x: auto; + } +} + /* Modal Header */ .modal-header { display: grid; @@ -77,21 +90,20 @@ .modal-description { padding: 0.8rem 1rem 0 1rem; flex-grow: 1; - transition: all 0.3s ease; } /* Modal Content */ .modal-content { padding: 0.5rem 1.5rem 0 1.5rem; overflow-y: auto; + overflow-x: hidden; height: calc(90vh); flex-grow: 1; background-clip: border-box; border: 6px solid transparent; - transition: all 0.3s ease; margin-bottom: 0; - padding-bottom: 0; - + padding-bottom: 10px; + box-sizing: border-box; } .modal-content::-webkit-scrollbar { diff --git a/webui/css/settings.css b/webui/css/settings.css index e5c1c97be..f2aab3182 100644 --- a/webui/css/settings.css +++ b/webui/css/settings.css @@ -74,6 +74,14 @@ textarea:focus { background-color: #151515; } +/* Button Disabled State */ +.btn-disabled, +.btn-ok.btn-disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + /* Toggle Switch Styles */ .toggle { position: relative; @@ -376,3 +384,417 @@ nav ul li a img { max-width: 80px; } } + +/* Scheduler Task List - updated with guaranteed width handling */ +.scheduler-task-list { + width: 100%; + min-width: 100%; + margin: 0; + border-collapse: separate; + border-spacing: 0; + white-space: nowrap; + padding-bottom: 8px; + table-layout: auto; +} + +.scheduler-task-list th, +.scheduler-task-list td { + padding: 8px 12px; + text-align: left; + vertical-align: middle; + border-bottom: 1px solid var(--color-border); +} + +/* Ensure columns have proper min-width */ +.scheduler-task-list th:nth-child(1), +.scheduler-task-list td:nth-child(1) { + min-width: 150px; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; +} + +.scheduler-task-list th:nth-child(2), +.scheduler-task-list td:nth-child(2) { + min-width: 100px; +} + +.scheduler-task-list th:nth-child(3), +.scheduler-task-list td:nth-child(3) { + min-width: 100px; +} + +.scheduler-task-list th:nth-child(4), +.scheduler-task-list td:nth-child(4) { + min-width: 150px; +} + +.scheduler-task-list th:nth-child(5), +.scheduler-task-list td:nth-child(5) { + min-width: 180px; +} + +.scheduler-task-list th:nth-child(6), +.scheduler-task-list td:nth-child(6) { + min-width: 160px; + white-space: nowrap; +} + +/* Task actions container */ +.scheduler-task-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + flex-wrap: nowrap; +} + +/* Scheduler form styles */ +.scheduler-form { + display: flex; + flex-direction: column; + gap: 1.5rem; + padding: 1rem 0; +} + +.scheduler-form-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.scheduler-form-title { + font-size: 1.25rem; + font-weight: bold; + color: var(--color-primary); + margin: 0; +} + +.scheduler-form-actions { + display: flex; + gap: 0.8rem; + justify-content: flex-end; + align-items: center; +} + +.scheduler-form-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1.5rem; + overflow-x: auto; +} + +.scheduler-form-field { + display: grid; + grid-template-columns: 1fr 2fr; + gap: 1rem; + align-items: flex-start; +} + +@media (max-width: 768px) { + .scheduler-form-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .scheduler-form-actions { + align-self: flex-end; + } + + .scheduler-form-field { + grid-template-columns: 1fr; + gap: 0.5rem; + } +} + +/* Section Styles */ +.section { + margin-bottom: 2rem; + padding: 1rem; + padding-bottom: 0; + border: 1px solid var(--color-border); + border-radius: 0.5rem; + overflow-x: visible; /* Desktop: No horizontal scroll */ + width: 100%; /* Fill available width */ + min-width: min-content; + display: block; + box-sizing: border-box; +} + +.section-title { + font-size: 1.25rem; + font-weight: bold; + color: var(--color-primary); + margin-bottom: 0.5rem; +} + +.section-description { + color: var(--color-text); + margin-bottom: 1rem; +} + +/* Scheduler container - updated with guaranteed width handling */ +.scheduler-container { + width: 100%; + box-sizing: border-box; + display: block; + padding: 0.5rem 0; +} + +/* Scheduler task actions and buttons */ +.scheduler-task-action { + display: inline-flex; + align-items: center; + justify-content: center; + background-color: transparent; + border: 1px solid var(--color-border); + color: var(--color-text); + padding: 4px; + border-radius: 4px; + cursor: pointer; + transition: all 0.2s ease; + width: 28px; + height: 28px; + flex-shrink: 0; +} + +.scheduler-task-action:hover { + background-color: var(--color-secondary); +} + +/* Adjust media queries to handle small screens */ +@media (max-width: 768px) { + .scheduler-task-list { + min-width: 700px; + } + + .scheduler-detail-view { + min-width: 650px; + } +} + +/* Scrollbar styling for better visibility */ +.section::-webkit-scrollbar { + height: 10px; /* Taller scrollbar for better usability */ + background-color: rgba(0,0,0,0.1); +} + +.section::-webkit-scrollbar-thumb { + background-color: rgba(155, 155, 155, 0.7); + border-radius: 5px; +} + +.section::-webkit-scrollbar-thumb:hover { + background-color: rgba(155, 155, 155, 0.9); +} + +/* Mobile styles for scheduler sections */ +@media (max-width: 1280px) { + .scheduler-container { + min-width: max-content; /* Allow expansion based on content */ + } + + .scheduler-task-list { + min-width: max-content; /* Expand to fit content if needed */ + } + + /* Scrollbar styling for mobile view */ + .section::-webkit-scrollbar { + height: 10px; + background-color: rgba(0,0,0,0.1); + } + + .section::-webkit-scrollbar-thumb { + background-color: rgba(155, 155, 155, 0.7); + border-radius: 5px; + } + + .section::-webkit-scrollbar-thumb:hover { + background-color: rgba(155, 155, 155, 0.9); + } +} + +/* Scheduler form field styling to match standard field styling */ +.scheduler-form-label { + font-weight: bold; + color: var(--color-primary); + margin-bottom: 0.25rem; /* Add consistent spacing between label and help text */ +} + +.scheduler-form-help { + color: var(--color-text); + font-size: 0.875rem; + opacity: 0.8; + margin: 0.25rem 0 0.5rem 0; /* Match the spacing of field-description */ +} + +/* Label and help text wrapper for tighter grouping */ +.label-help-wrapper { + margin-bottom: 0.5rem; +} + +.label-help-wrapper .scheduler-form-label { + margin-bottom: 2px; +} + +.label-help-wrapper .scheduler-form-help { + margin-top: 0; + margin-bottom: 0; +} + +/* Scheduler detail header styling */ +.scheduler-detail-header { + display: flex; + justify-content: flex-start; + align-items: center; + flex-wrap: wrap; + gap: 10px; + width: 100%; +} + +.scheduler-detail-header .scheduler-detail-title { + margin-right: auto; +} + +/* Responsive adjustments for headers */ +@media (max-width: 768px) { + .scheduler-form-header { + flex-direction: column; + align-items: flex-start; + gap: 1rem; + } + + .scheduler-form-actions { + align-self: flex-end; + } + + .scheduler-detail-header { + flex-direction: row; /* Keep in row even on mobile */ + align-items: center; + flex-wrap: wrap; + gap: 0.5rem; + } + + .scheduler-detail-header .btn { + margin-left: auto; /* Push to right edge */ + } + + .scheduler-form-field { + grid-template-columns: 1fr; + gap: 0.5rem; + } +} + +/* Input group for token field with generate button */ +.input-group { + display: flex; + gap: 8px; + width: 100%; +} + +.input-group input[type="text"] { + flex: 1; + min-width: 0; /* Allows the input to shrink below its content size */ +} + +/* Specific styling for the Generate button in token field */ +.input-group .scheduler-task-action { + white-space: nowrap; + padding: 4px 10px; + width: auto; + height: auto; + background-color: var(--color-secondary); + font-size: 0.9rem; +} + +.input-group .scheduler-task-action:hover { + background-color: var(--color-accent); + color: var(--color-bg); +} + +/* Ensure parent container allows proper flow */ +.scheduler-form-field .input-group { + max-width: 100%; + overflow: hidden; +} + +/* Adjustments for mobile */ +@media (max-width: 768px) { + .input-group .scheduler-task-action { + padding: 4px 8px; + font-size: 0.8rem; + } +} + +@media (max-width: 480px) { + .input-group { + flex-direction: column; + } + + .input-group .scheduler-task-action { + align-self: flex-start; + } +} + +/* Task state selector styling */ +.scheduler-state-selector { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.scheduler-state-selector .scheduler-status-badge { + cursor: pointer; + transition: all 0.2s ease; + opacity: 0.7; + border: 1px solid transparent; +} + +.scheduler-state-selector .scheduler-status-badge:hover { + opacity: 0.9; + transform: scale(1.05); +} + +.scheduler-status-selected { + opacity: 1 !important; + transform: scale(1.05); + box-shadow: 0 0 0 2px var(--color-bg), 0 0 0 4px var(--color-border); + border: 2px solid var(--color-border) !important; + outline: none; +} + +/* Make status badges in selector more prominent */ +.scheduler-state-selector .scheduler-status-idle, +.scheduler-state-selector .scheduler-status-running, +.scheduler-state-selector .scheduler-status-disabled, +.scheduler-state-selector .scheduler-status-error { + font-weight: 600; + padding: 6px 12px; +} + +.light-mode .scheduler-status-selected { + box-shadow: 0 0 0 2px var(--color-bg-light), 0 0 0 4px var(--color-accent); +} + +/* State explanation styling */ +.scheduler-state-explanation { + margin-top: 10px; + font-size: 0.85rem; + color: var(--color-text-secondary); + line-height: 1.4; + min-height: 1.4em; /* Ensure consistent height even when changing descriptions */ + transition: all 0.2s ease; +} + +.scheduler-state-explanation span { + display: block; + padding: 4px 8px; + background-color: rgba(0, 0, 0, 0.05); + border-radius: 4px; + margin-top: 8px; +} + +.light-mode .scheduler-state-explanation span { + background-color: rgba(255, 255, 255, 0.3); +} diff --git a/webui/index.css b/webui/index.css index aa36d293b..7719fc601 100644 --- a/webui/index.css +++ b/webui/index.css @@ -217,15 +217,22 @@ body, gap: 0; } +/* Update the chat-list-button padding to accommodate the vertical layout */ .chat-list-button { display: block; width: 100%; - padding: 1px 5px; + padding: 8px 5px; cursor: pointer; overflow: hidden; position: relative; - transition: background-color 0.2s; border-radius: 4px; + transition: background-color 0.2s ease-in-out; +} + +/* Add some more padding to the list items to accommodate the vertical layout */ +.chat-list-button.has-task-container { + padding-top: 6px; + padding-bottom: 6px; } /* Subtle background on hover for the entire row */ @@ -2160,11 +2167,6 @@ a:active { display: none !important; } -/* Add new styles for reasoning and deepsearchbutton */ -.ml-auto { - margin-left: auto; -} - /* Remove unnecessary specific media query that was causing issues */ @media (max-width: 480px) { .text-button svg { @@ -2186,6 +2188,7 @@ a:active { justify-content: flex-end; } + /* Tasks list container - similar to chats list */ .tasks-list-container { max-height: 300px; @@ -2217,17 +2220,58 @@ a:active { } .task-name { - display: inline-block; - max-width: 160px; + display: block; + width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + padding: 3px 0; + margin-left: 10px; cursor: pointer; - padding: 3px 5px; border-radius: 4px; transition: background-color 0.2s; - margin-right: 60px; /* Make space for future buttons */ - font-size: var(--font-size-small); /* Match config button font size */ + font-size: var(--font-size-small); + margin-bottom: 2px; +} + +.task-info-line { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + margin-top: 2px; + margin-left: 5px; +} + +.task-detail-button { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.2); + color: #999; + cursor: pointer; + padding: 5px; + margin-left: auto; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.task-detail-button:hover { + color: #fff; + background-color: rgba(255, 255, 255, 0.15); + border-color: rgba(255, 255, 255, 0.3); +} + +.light-mode .task-detail-button { + color: #666; + border: 1px solid rgba(0, 0, 0, 0.1); +} + +.light-mode .task-detail-button:hover { + color: #222; + background-color: rgba(0, 0, 0, 0.08); + border-color: rgba(0, 0, 0, 0.2); } .task-name:hover { @@ -2407,16 +2451,585 @@ a:active { background-color: var(--color-border); } -/* Add some padding to the list items to accommodate the stripe */ -.chat-list-button { - padding-left: 5px; - border-radius: 4px; - transition: background-color 0.2s ease-in-out; -} - /* Make sure the chat container has proper spacing */ .chat-container, .task-container { display: flex; align-items: center; width: 100%; + justify-content: space-between; +} + +/* Settings Modal Styles */ +.settings-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: 1000; + background-color: rgba(0, 0, 0, 0.75); + display: flex; + justify-content: center; + align-items: center; + overflow: auto; + padding: 24px; +} + +.settings-modal-close { + position: absolute; + top: 8px; + right: 16px; + border: none; + background: transparent; + font-size: 24px; + cursor: pointer; + color: var(--color-text-secondary); +} + +.settings-modal-title { + margin-top: 0; + margin-bottom: 24px; + font-size: 1.8rem; + text-align: center; + border-bottom: 1px solid var(--color-border); + padding-bottom: 12px; +} + +.settings-modal-content { + background-color: var(--color-panel); + color: var(--color-text); + width: 100%; + max-width: 800px; + height: auto; + max-height: 90vh; + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); + padding: 24px; + position: relative; + overflow: auto; +} + +/* Settings Tabs */ +.settings-tabs { + display: flex; + border-bottom: 1px solid var(--color-border); + margin-bottom: 24px; + gap: 8px; + overflow-x: auto; + scrollbar-width: none; + -ms-overflow-style: none; +} + +.settings-tabs::-webkit-scrollbar { + display: none; +} + +.settings-tab { + padding: 8px 16px; + cursor: pointer; + border-bottom: 3px solid transparent; + color: var(--color-text-secondary); + transition: all 0.2s ease; + white-space: nowrap; +} + +.settings-tab:hover { + color: var(--color-text); +} + +.settings-tab.active { + border-bottom-color: var(--color-primary); + color: var(--color-primary); + font-weight: 500; +} + +/* Settings Sections */ +nav ul { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 1rem; + padding: 0; + margin: 0 0 1rem 0; + list-style: none; +} + +/* Add specific styling for the nav element itself to ensure spacing */ +nav { +margin-bottom: 1rem; +} + +nav ul li a { + display: flex; + flex-direction: column; + align-items: center; + padding: 1rem; + border-radius: 0.5rem; + background-color: var(--color-bg-secondary); + text-decoration: none; + color: var(--color-text); + transition: background-color 0.2s ease; +} + +nav ul li a:hover { + background-color: var(--color-bg-tertiary); +} + +nav ul li a img { + width: 50px; + height: 50px; + margin-bottom: 0.5rem; + filter: var(--svg-filter); +} + +.section { + margin-bottom: 3rem; + animation: fadeIn 0.3s ease; + width: 100%; +} + +.section-title { + margin-top: 0; + margin-bottom: 0.5rem; + font-size: 1.5rem; + font-weight: 500; + color: var(--color-primary); + border-bottom: 1px solid var(--color-border); + padding-bottom: 0.5rem; +} + +.section-description { + margin-bottom: 1.5rem; + color: var(--color-text-secondary); +} + +.scheduler-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; +} + +.scheduler-header h2 { + margin: 0; + font-size: 1.2rem; + font-weight: 500; +} + +.scheduler-filters { + display: flex; + gap: 20px; + margin-bottom: 20px; + flex-wrap: wrap; +} + +.scheduler-filter-group { + display: flex; + align-items: center; + gap: 8px; +} + +.scheduler-filter-label { + font-weight: 500; + color: var(--color-text-secondary); +} + +.scheduler-filter-select { + padding: 6px 8px; + border-radius: 4px; + border: 1px solid var(--color-border); + background-color: var(--color-bg-secondary); + color: var(--color-text); +} + +.scheduler-task-list { + width: 100%; + border-collapse: collapse; + margin: 1rem 0; + font-size: 0.9rem; + overflow-x: auto; + table-layout: fixed; +} + +.scheduler-task-list th, +.scheduler-task-list td { + padding: 0.75rem; + text-align: left; + border-bottom: 1px solid var(--color-border); + vertical-align: middle; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.scheduler-task-list th:nth-child(1), /* Name */ +.scheduler-task-list td:nth-child(1) { + width: 25%; +} + +.scheduler-task-list th:nth-child(2), /* State */ +.scheduler-task-list td:nth-child(2) { + width: 10%; +} + +.scheduler-task-list th:nth-child(3), /* Type */ +.scheduler-task-list td:nth-child(3) { + width: 10%; +} + +.scheduler-task-list th:nth-child(4), /* Schedule */ +.scheduler-task-list td:nth-child(4) { + width: 20%; +} + +.scheduler-task-list th:nth-child(5), /* Last Run */ +.scheduler-task-list td:nth-child(5) { + width: 20%; +} + +.scheduler-task-list th:nth-child(6), /* Actions */ +.scheduler-task-list td:nth-child(6) { + width: 15%; + text-align: right; +} + +.scheduler-task-list th { + background-color: var(--color-bg-secondary); + font-weight: 500; + cursor: pointer; + user-select: none; +} + +.scheduler-task-list th:hover { + background-color: var(--color-bg-tertiary); +} + +.scheduler-task-actions { + display: flex; + gap: 8px; +} + +.scheduler-task-action { + padding: 4px; + background: none; + border: none; + color: var(--color-text-secondary); + cursor: pointer; + border-radius: 4px; +} + +.scheduler-task-action:hover { + background-color: var(--color-bg-tertiary); + color: var(--color-text); +} + +.scheduler-status-badge { + display: inline-block; + padding: 4px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + text-transform: capitalize; + white-space: nowrap; +} + +.scheduler-status-idle { + background-color: rgba(0, 100, 0, 0.2); + color: #2a9d8f; /* Dark green that works with both light and dark themes */ + border: 1px solid rgba(42, 157, 143, 0.3); +} + +.scheduler-status-running { + background-color: rgba(0, 60, 120, 0.2); + color: #4361ee; /* Dark blue that works with both light and dark themes */ + border: 1px solid rgba(67, 97, 238, 0.3); +} + +.scheduler-status-disabled { + background-color: rgba(70, 70, 70, 0.2); + color: #6c757d; /* Dark grey that works with both light and dark themes */ + border: 1px solid rgba(108, 117, 125, 0.3); +} + +.scheduler-status-error { + background-color: rgba(120, 0, 0, 0.2); + color: #e63946; /* Dark red that works with both light and dark themes */ + border: 1px solid rgba(230, 57, 70, 0.3); +} + +/* Light mode adjustments */ +.light-mode .scheduler-status-idle { + background-color: rgba(42, 157, 143, 0.1); + color: #1a6f65; /* Darker green for light mode */ +} + +.light-mode .scheduler-status-running { + background-color: rgba(67, 97, 238, 0.1); + color: #2540b3; /* Darker blue for light mode */ +} + +.light-mode .scheduler-status-disabled { + background-color: rgba(108, 117, 125, 0.1); + color: #495057; /* Darker grey for light mode */ +} + +.light-mode .scheduler-status-error { + background-color: rgba(230, 57, 70, 0.1); + color: #c5283d; /* Darker red for light mode */ +} + +.scheduler-empty { + text-align: center; + padding: 40px 0; + color: var(--color-text-secondary); +} + +.scheduler-empty-icon { + font-size: 32px; + margin-bottom: 10px; +} + +.scheduler-empty-text { + margin-bottom: 20px; +} + +.scheduler-loading { + text-align: center; + padding: 40px 0; + color: var(--color-text-secondary); +} + +.scheduler-task-details { + padding: 16px; + background-color: var(--color-bg-secondary); + border-radius: 4px; +} + +.scheduler-details-grid { + display: grid; + grid-template-columns: 120px 1fr; + gap: 8px 16px; + margin-bottom: 16px; +} + +.scheduler-details-label { + font-weight: 500; + color: var(--color-text-secondary); + display: flex; + align-items: center; +} + +.scheduler-details-value { + color: var(--color-text); + word-break: break-word; +} + +.scheduler-details-actions { + display: flex; + justify-content: flex-end; +} + +.scheduler-form { + background-color: var(--color-bg-secondary); + border-radius: 4px; + padding: 20px; + margin-bottom: 20px; +} + +.scheduler-form-title { + font-size: 1.2rem; + font-weight: 500; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 1px solid var(--color-border); +} + +.scheduler-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + margin-bottom: 20px; +} + +.scheduler-form-field { + display: flex; + flex-direction: column; + gap: 6px; +} + +.full-width { + grid-column: 1 / -1; +} + +.scheduler-form-label { + font-weight: 500; +} + +.scheduler-form-help { + font-size: 12px; + color: var(--color-text-secondary); +} + +.scheduler-form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; +} + +.scheduler-schedule-builder { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 12px; +} + +.scheduler-schedule-field { + display: flex; + flex-direction: column; + gap: 4px; + max-width: 70px; /* Limit width of schedule fields */ +} + +.scheduler-schedule-field input { + width: 100%; + min-width: 0; /* Allow shrinking below content size */ + font-size: 0.9rem; /* Slightly reduce font size for better fit */ +} + +.scheduler-schedule-label { + font-size: 12px; + color: var(--color-text-secondary); +} + +.input-group { + display: flex; + gap: 8px; +} + +/* Sort indicators */ +.scheduler-sort-indicator { + display: inline-block; + margin-left: 4px; + transition: transform 0.2s ease; +} + +.scheduler-sort-desc { + transform: rotate(180deg); +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .scheduler-form-grid { + grid-template-columns: 1fr; + } + + .scheduler-schedule-builder { + grid-template-columns: 1fr 1fr; + } + + .scheduler-filters { + flex-direction: column; + gap: 12px; + } + + .scheduler-task-actions { + flex-wrap: wrap; + } +} + +@media (max-width: 480px) { + nav ul li a { + flex-direction: row; + justify-content: flex-start; + gap: 1rem; + padding: 0.75rem 1rem; + } + + nav ul li a img { + margin-bottom: 0; + width: 30px; + height: 30px; + } +} + +/* Add row hover effect for task list rows matching left panel hover */ +.scheduler-task-list tbody tr { + cursor: pointer; + transition: background-color 0.2s ease; +} + +.scheduler-task-list tbody tr:hover { + background-color: rgba(255, 255, 255, 0.03); +} + +.light-mode .scheduler-task-list tbody tr:hover { + background-color: rgba(0, 0, 0, 0.02); +} + +.scheduler-task-list th { + background-color: var(--color-bg-secondary); + font-weight: 500; + cursor: pointer; + user-select: none; +} + +.scheduler-task-list th:hover { + background-color: var(--color-bg-tertiary); +} + +/* Task detail view styling */ +.scheduler-detail-view { + background-color: var(--color-bg-secondary); + border-radius: 4px; + padding: 20px; + margin-bottom: 20px; + animation: fadeIn 0.3s ease; +} + +.scheduler-detail-header { + display: flex; + justify-content: flex-start; + align-items: center; + margin-bottom: 20px; + padding-bottom: 10px; + border-bottom: 1px solid var(--color-border); + flex-wrap: wrap; + gap: 10px; +} + +.scheduler-detail-header .scheduler-detail-title { + font-size: 1.4rem; + font-weight: 500; + margin: 0; + margin-right: auto; +} + +.scheduler-detail-header .scheduler-status-badge { + margin-right: 10px; +} + +.scheduler-detail-content { + margin-bottom: 20px; +} + +/* Task Scheduler Styles */ + +.scheduler-no-schedule { + color: var(--color-text-secondary); + opacity: 0.7; + font-style: italic; +} + +.task-container-vertical { + display: flex; + flex-direction: column; + width: 100%; + gap: 6px; +} + +/* Smaller status badge for task list */ +.scheduler-status-badge-small { + font-size: 10px; + padding: 2px 6px; + margin-right: 5px; + min-width: 40px; + text-align: center; } diff --git a/webui/index.html b/webui/index.html index 99acf9526..24a1a8b4d 100644 --- a/webui/index.html +++ b/webui/index.html @@ -38,6 +38,7 @@ + @@ -118,18 +119,42 @@ -