From b823fcfb5d743c6f29a00aa86704660f93dc9886 Mon Sep 17 00:00:00 2001 From: Alessandro Date: Thu, 11 Dec 2025 11:17:24 +0100 Subject: [PATCH 01/34] refactor settings and scheduler - Simplified task detail opening logic by integrating it into the `settingsModalStore` - Updated the visibility condition for the task detail view in `scheduler-task-detail.html` to rely solely on the selected task state rm attributes from components simplify task display logic settings components init scheduler componentize - Removed the inline scheduler settings script from `index.html` and replaced it with a new component structure in `scheduler-settings.html`, `scheduler-task-editor.html`, `scheduler-task-list.html`, and `scheduler-task-detail.html`. - Introduced a dedicated `scheduler-store.js` to manage state and logic for the scheduler, enhancing maintainability and separation of concerns. - Updated the `index.js` to remove the now obsolete `openTaskDetail` function, integrating task detail handling within the new store. - Removed the deprecated `scheduler.js` file, consolidating functionality into the new component architecture. settings modal store rename - Replaced all instances of `$store.settingsModalStore` with `$store.settingsStore` across various settings components. scheduler tab content x-if --- python/api/settings_set.py | 8 +- .../settings/a2a/a2a-connection.html | 23 +- .../settings/agent/agent-settings.html | 62 + webui/components/settings/agent/agent.html | 146 ++ .../settings/agent/browser_model.html | 146 ++ .../components/settings/agent/chat_model.html | 146 ++ .../settings/agent/embed_model.html | 146 ++ webui/components/settings/agent/memory.html | 146 ++ webui/components/settings/agent/speech.html | 146 ++ .../components/settings/agent/util_model.html | 146 ++ .../settings/backup/backup-settings.html | 44 + .../settings/backup/backup_restore.html | 146 ++ webui/components/settings/developer/dev.html | 146 ++ .../developer/developer-settings.html | 44 + .../settings/external/api-examples.html | 22 +- .../settings/external/api_keys.html | 146 ++ webui/components/settings/external/auth.html | 146 ++ .../settings/external/external-settings.html | 74 + .../settings/external/external_api.html | 146 ++ .../components/settings/external/litellm.html | 146 ++ .../components/settings/external/secrets.html | 146 ++ .../settings/external/update_checker.html | 146 ++ webui/components/settings/mcp/a2a_server.html | 146 ++ .../settings/mcp/client/mcp-servers-store.js | 13 +- .../components/settings/mcp/mcp-settings.html | 50 + webui/components/settings/mcp/mcp_client.html | 146 ++ webui/components/settings/mcp/mcp_server.html | 146 ++ .../settings/mcp/server/example.html | 20 +- .../scheduler/scheduler-settings.html | 33 + .../settings/scheduler/scheduler-store.js | 1025 +++++++++ .../scheduler/scheduler-task-detail.html | 129 ++ .../scheduler/scheduler-task-editor.html | 439 ++++ .../scheduler/scheduler-task-list.html | 146 ++ webui/components/settings/settings-store.js | 255 +++ webui/components/settings/settings.html | 146 ++ webui/components/sidebar/tasks/tasks-store.js | 11 +- .../sidebar/top-section/quick-actions.html | 5 +- webui/index.html | 1123 ---------- webui/index.js | 58 - webui/js/scheduler.js | 1835 ----------------- webui/js/settings.js | 591 ------ 41 files changed, 5151 insertions(+), 3633 deletions(-) create mode 100644 webui/components/settings/agent/agent-settings.html create mode 100644 webui/components/settings/agent/agent.html create mode 100644 webui/components/settings/agent/browser_model.html create mode 100644 webui/components/settings/agent/chat_model.html create mode 100644 webui/components/settings/agent/embed_model.html create mode 100644 webui/components/settings/agent/memory.html create mode 100644 webui/components/settings/agent/speech.html create mode 100644 webui/components/settings/agent/util_model.html create mode 100644 webui/components/settings/backup/backup-settings.html create mode 100644 webui/components/settings/backup/backup_restore.html create mode 100644 webui/components/settings/developer/dev.html create mode 100644 webui/components/settings/developer/developer-settings.html create mode 100644 webui/components/settings/external/api_keys.html create mode 100644 webui/components/settings/external/auth.html create mode 100644 webui/components/settings/external/external-settings.html create mode 100644 webui/components/settings/external/external_api.html create mode 100644 webui/components/settings/external/litellm.html create mode 100644 webui/components/settings/external/secrets.html create mode 100644 webui/components/settings/external/update_checker.html create mode 100644 webui/components/settings/mcp/a2a_server.html create mode 100644 webui/components/settings/mcp/mcp-settings.html create mode 100644 webui/components/settings/mcp/mcp_client.html create mode 100644 webui/components/settings/mcp/mcp_server.html create mode 100644 webui/components/settings/scheduler/scheduler-settings.html create mode 100644 webui/components/settings/scheduler/scheduler-store.js create mode 100644 webui/components/settings/scheduler/scheduler-task-detail.html create mode 100644 webui/components/settings/scheduler/scheduler-task-editor.html create mode 100644 webui/components/settings/scheduler/scheduler-task-list.html create mode 100644 webui/components/settings/settings-store.js create mode 100644 webui/components/settings/settings.html delete mode 100644 webui/js/scheduler.js delete mode 100644 webui/js/settings.js diff --git a/python/api/settings_set.py b/python/api/settings_set.py index c24a3cc66..354392bf5 100644 --- a/python/api/settings_set.py +++ b/python/api/settings_set.py @@ -7,6 +7,8 @@ from typing import Any class SetSettings(ApiHandler): async def process(self, input: dict[Any, Any], request: Request) -> dict[Any, Any] | Response: - set = settings.convert_in(input) - set = settings.set_settings(set) - return {"settings": set} + # Convert SettingsOutput (sections/fields) into internal flat Settings, + # persist it, then return the updated SettingsOutput back to the UI. + internal = settings.convert_in(input) + settings.set_settings(internal) + return {"settings": settings.convert_out(settings.get_settings())} diff --git a/webui/components/settings/a2a/a2a-connection.html b/webui/components/settings/a2a/a2a-connection.html index fc640a68d..060bbd906 100644 --- a/webui/components/settings/a2a/a2a-connection.html +++ b/webui/components/settings/a2a/a2a-connection.html @@ -34,16 +34,25 @@
- + + +
+ +
+ + diff --git a/webui/components/settings/scheduler/scheduler-store.js b/webui/components/settings/scheduler/scheduler-store.js new file mode 100644 index 000000000..3bd3b0b7d --- /dev/null +++ b/webui/components/settings/scheduler/scheduler-store.js @@ -0,0 +1,1025 @@ +import { createStore } from "/js/AlpineStore.js"; +import { formatDateTime, getUserTimezone } from "/js/time-utils.js"; +import { store as chatsStore } from "/components/sidebar/chats/chats-store.js"; +import { store as projectsStore } from "/components/projects/projects-store.js"; +import { store as notificationsStore } from "/components/notifications/notification-store.js"; + +const API = globalThis.fetchApi || globalThis.fetch; +const VIEW_MODE_STORAGE_KEY = "scheduler_view_mode"; +const NOTIFICATION_DURATION = { + success: 3, + info: 3, + warning: 4, + error: 5, +}; +const DEFAULT_TASK_STATE = "idle"; +const TASK_TYPES = ["scheduled", "adhoc", "planned"]; + +/** + * @typedef {Object} SchedulerPlan + * @property {string[]} todo + * @property {string|null} in_progress + * @property {string[]} done + */ + +/** + * @typedef {Object} SchedulerProject + * @property {string|null} name + * @property {string|null} title + * @property {string} color + */ + +/** + * @typedef {Object} SchedulerTask + * @property {string} uuid + * @property {string} name + * @property {string} type + * @property {string} state + * @property {SchedulerPlan} plan + * @property {Object|string} schedule + * @property {string} token + * @property {SchedulerProject|null} project + * @property {string|null} project_name + * @property {string} [project_color] + * @property {string[]} attachments + * @property {string} [system_prompt] + * @property {string} [prompt] + * @property {string} [created_at] + * @property {string} [updated_at] + * @property {string} [last_run] + * @property {string} [last_result] + */ + +/** + * @typedef {Object} EditingTask + * @property {string} [uuid] + * @property {string} name + * @property {string} type + * @property {string} state + * @property {SchedulerPlan} plan + * @property {ReturnType} schedule + * @property {string} token + * @property {SchedulerProject|null} project + * @property {boolean} dedicated_context + * @property {string[]} attachments + * @property {string} system_prompt + * @property {string} prompt + */ + +/** + * @template T + * @typedef {Object} SchedulerApiResult + * @property {boolean} ok + * @property {string} [error] + * @property {T} [data] + */ + +// ----------------------------------------------------------------------------- +// Pure helpers +// ----------------------------------------------------------------------------- + +const defaultSchedule = () => ({ + minute: "*", + hour: "*", + day: "*", + month: "*", + weekday: "*", + timezone: getUserTimezone(), +}); + +const emptyPlan = () => ({ + todo: [], + in_progress: null, + done: [], +}); + +const defaultEditingTask = (overrides = {}) => ({ + name: "", + type: "scheduled", + state: DEFAULT_TASK_STATE, + schedule: defaultSchedule(), + token: "", + plan: emptyPlan(), + system_prompt: "", + prompt: "", + attachments: [], + project: null, + dedicated_context: true, + ...overrides, +}); + +const readPersistedViewMode = () => { + if (typeof window === "undefined") return "list"; + return window.localStorage?.getItem(VIEW_MODE_STORAGE_KEY) || "list"; +}; + +const sleep = (ms = 0) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +function safeJsonClone(value) { + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return value; + } +} + +function normalizeAttachments(value) { + if (!value) return []; + if (Array.isArray(value)) { + return value.filter((item) => typeof item === "string" && item.trim().length > 0); + } + if (typeof value === "string") { + return value + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + } + return []; +} + +function normalizeSchedule(schedule) { + if (!schedule) return defaultSchedule(); + if (typeof schedule === "string") { + const [minute = "*", hour = "*", day = "*", month = "*", weekday = "*"] = schedule + .split(" ") + .map((segment) => segment || "*"); + return { + minute, + hour, + day, + month, + weekday, + timezone: getUserTimezone(), + }; + } + return { + minute: schedule.minute || "*", + hour: schedule.hour || "*", + day: schedule.day || "*", + month: schedule.month || "*", + weekday: schedule.weekday || "*", + timezone: schedule.timezone || getUserTimezone(), + }; +} + +function normalizePlanStruct(plan) { + if (!plan) return emptyPlan(); + const clone = { + todo: Array.isArray(plan.todo) ? [...plan.todo] : [], + in_progress: plan.in_progress || null, + done: Array.isArray(plan.done) ? [...plan.done] : [], + }; + const sanitized = clone.todo + .map((value) => new Date(value)) + .filter((date) => !Number.isNaN(date.getTime())) + .map((date) => date.toISOString()) + .sort(); + clone.todo = sanitized; + clone.done = clone.done + .map((value) => new Date(value)) + .filter((date) => !Number.isNaN(date.getTime())) + .map((date) => date.toISOString()); + if (clone.in_progress) { + const inProgress = new Date(clone.in_progress); + clone.in_progress = Number.isNaN(inProgress.getTime()) + ? null + : inProgress.toISOString(); + } + return clone; +} + +function ensureTaskValidity(task) { + return Boolean(task && task.uuid && task.name && task.type); +} + +function extractProjectInfo(task) { + if (!task) return null; + const slug = task.project_name || task.project?.name || null; + const title = task.project?.title || task.project?.name || slug; + const color = task.project_color || task.project?.color || ""; + if (!slug && !title) return null; + return { + name: slug, + title: title || slug, + color: color || "", + }; +} + +function composeEditingTask(task = {}) { + const base = task && task.uuid ? { ...task } : { ...defaultEditingTask(), ...task }; + return { + ...base, + schedule: normalizeSchedule(base.schedule), + plan: normalizePlanStruct(base.plan), + attachments: normalizeAttachments(base.attachments), + token: base.token || "", + project: base.project || extractProjectInfo(base) || null, + dedicated_context: + typeof base.dedicated_context === "boolean" ? base.dedicated_context : true, + state: base.state || DEFAULT_TASK_STATE, + }; +} + +function normalizeTaskFromBackend(task) { + if (!ensureTaskValidity(task)) return null; + return composeEditingTask(task); +} + +function buildPayloadFromEditingTask(editingTask, { isCreating = false } = {}) { + const payload = { + name: editingTask.name.trim(), + system_prompt: editingTask.system_prompt || "", + prompt: editingTask.prompt || "", + state: editingTask.state || DEFAULT_TASK_STATE, + timezone: getUserTimezone(), + attachments: normalizeAttachments(editingTask.attachments), + dedicated_context: editingTask.dedicated_context, + }; + + if (editingTask.type === "scheduled") { + payload.schedule = normalizeSchedule(editingTask.schedule); + } + + if (editingTask.type === "planned") { + payload.plan = normalizePlanStruct(editingTask.plan); + } + + if (editingTask.type === "adhoc") { + payload.token = editingTask.token; + } + + if (editingTask.project && editingTask.project.name) { + payload.project_name = editingTask.project.name; + if (editingTask.project.color) { + payload.project_color = editingTask.project.color; + } + } + + if (!isCreating && editingTask.uuid) { + payload.task_id = editingTask.uuid; + } + + return payload; +} + +async function callSchedulerEndpoint(endpoint, payload = {}, defaultError) { + try { + const response = await API(endpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + return { ok: false, error: data?.error || defaultError || "Scheduler request failed" }; + } + return { ok: true, data }; + } catch (error) { + return { ok: false, error: error?.message || defaultError || "Scheduler request failed" }; + } +} + +const schedulerApi = { + async listTasks() { + const result = await callSchedulerEndpoint( + "/scheduler_tasks_list", + { timezone: getUserTimezone() }, + "Failed to fetch tasks" + ); + if (!result.ok) return { ok: false, error: result.error }; + const rawTasks = Array.isArray(result.data?.tasks) ? result.data.tasks : []; + const normalized = rawTasks + .filter(ensureTaskValidity) + .map((task) => normalizeTaskFromBackend(task)) + .filter(Boolean); + return { ok: true, tasks: normalized }; + }, + + async createTask(payload) { + const result = await callSchedulerEndpoint( + "/scheduler_task_create", + payload, + "Failed to create task" + ); + if (!result.ok) return { ok: false, error: result.error }; + const task = result.data?.task ? normalizeTaskFromBackend(result.data.task) : null; + return { ok: true, task }; + }, + + async updateTask(payload) { + const result = await callSchedulerEndpoint( + "/scheduler_task_update", + payload, + "Failed to update task" + ); + if (!result.ok) return { ok: false, error: result.error }; + const task = result.data?.task ? normalizeTaskFromBackend(result.data.task) : null; + return { ok: true, task }; + }, + + async runTask(taskId) { + return callSchedulerEndpoint( + "/scheduler_task_run", + { task_id: taskId, timezone: getUserTimezone() }, + "Failed to run task" + ); + }, + + async deleteTask(taskId) { + return callSchedulerEndpoint( + "/scheduler_task_delete", + { task_id: taskId, timezone: getUserTimezone() }, + "Failed to delete task" + ); + }, +}; + +const notificationChannels = { + success: "frontendSuccess", + info: "frontendInfo", + warning: "frontendWarning", + error: "frontendError", +}; + +function pushNotification(type, message, title = "Scheduler", duration) { + const channel = notificationChannels[type]; + if (!channel || typeof notificationsStore[channel] !== "function") return; + const ttl = duration ?? NOTIFICATION_DURATION[type] ?? 4; + notificationsStore[channel](message, title, ttl); +} + +function destroyPlannerInput(inputId) { + const input = typeof document !== "undefined" ? document.getElementById(inputId) : null; + if (!input || !input._flatpickr) return; + input._flatpickr.destroy(); + const wrapper = input.closest(".scheduler-flatpickr-wrapper"); + if (wrapper && wrapper.parentNode) { + wrapper.parentNode.insertBefore(input, wrapper); + wrapper.parentNode.removeChild(wrapper); + } + input.classList.remove("scheduler-flatpickr-input"); +} + +function setupPlannerInput(inputId) { + if (typeof flatpickr === "undefined") { + return null; + } + const input = document.getElementById(inputId); + if (!input) return null; + + destroyPlannerInput(inputId); + + const wrapper = document.createElement("div"); + wrapper.className = "scheduler-flatpickr-wrapper"; + wrapper.style.overflow = "visible"; + input.parentNode.insertBefore(wrapper, input); + wrapper.appendChild(input); + input.classList.add("scheduler-flatpickr-input"); + + const options = { + dateFormat: "Y-m-d H:i", + enableTime: true, + time_24hr: true, + static: false, + appendTo: document.body, + allowInput: true, + positionElement: wrapper, + theme: "scheduler-theme", + minuteIncrement: 5, + defaultHour: new Date().getHours(), + defaultMinute: Math.ceil(new Date().getMinutes() / 5) * 5, + onOpen(selectedDates, dateStr, instance) { + instance.calendarContainer.style.zIndex = "9999"; + instance.calendarContainer.style.position = "absolute"; + instance.calendarContainer.style.visibility = "visible"; + instance.calendarContainer.style.opacity = "1"; + instance.calendarContainer.classList.add("scheduler-theme"); + }, + onReady(selectedDates, dateStr, instance) { + if (!dateStr) { + const now = new Date(); + now.setMinutes(now.getMinutes() + 30); + instance.setDate(now, true); + } + }, + }; + + const picker = flatpickr(input, options); + const clearButton = document.createElement("button"); + clearButton.className = "scheduler-flatpickr-clear"; + clearButton.innerHTML = "×"; + clearButton.type = "button"; + clearButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + if (picker) picker.clear(); + }); + wrapper.appendChild(clearButton); + + return picker; +} + +function readDateFromPlannerInput(input) { + if (!input) return null; + if (input._flatpickr && input._flatpickr.selectedDates.length > 0) { + return input._flatpickr.selectedDates[0]; + } + if (input.value) { + const date = new Date(input.value); + if (!Number.isNaN(date.getTime())) { + return date; + } + } + return null; +} + +function sortByDate(value) { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? 0 : date.getTime(); +} + +// ----------------------------------------------------------------------------- +// Store definition +// ----------------------------------------------------------------------------- + +const schedulerStoreModel = { + // Core collection state ----------------------------------------------------- + tasks: [], + isLoading: false, + showLoadingState: false, + hasNoTasks: true, + + // Filtering & view --------------------------------------------------------- + filterType: "all", + filterState: "all", + sortField: "name", + sortDirection: "asc", + viewMode: readPersistedViewMode(), + selectedTaskForDetail: null, + + // Editor state ------------------------------------------------------------- + isCreating: false, + isEditing: false, + editingTask: defaultEditingTask(), + selectedProjectSlug: "", + projectOptions: [], + + // Polling ------------------------------------------------------------------ + pollingInterval: null, + pollingActive: false, + + // Computed ----------------------------------------------------------------- + get filteredTasks() { + if (!Array.isArray(this.tasks)) return []; + let filtered = [...this.tasks]; + + if (this.filterType && this.filterType !== "all") { + filtered = filtered.filter((task) => + task.type ? task.type.toLowerCase() === this.filterType.toLowerCase() : false + ); + } + + if (this.filterState && this.filterState !== "all") { + filtered = filtered.filter((task) => + task.state ? task.state.toLowerCase() === this.filterState.toLowerCase() : false + ); + } + + return this.sortTasks(filtered); + }, + + get attachmentsText() { + const attachments = Array.isArray(this.editingTask.attachments) + ? this.editingTask.attachments + : []; + return attachments.join("\n"); + }, + + set attachmentsText(value) { + if (typeof value === "string") { + this.editingTask.attachments = value.split("\n"); + } else { + this.editingTask.attachments = []; + } + }, + + // Lifecycle ---------------------------------------------------------------- + init() { + this.resetEditingTask(); + this.refreshProjectOptions(); + }, + + persistViewMode(mode) { + this.viewMode = mode; + try { + window.localStorage?.setItem(VIEW_MODE_STORAGE_KEY, mode); + } catch { + /* ignore storage failures */ + } + }, + + setViewMode(mode) { + this.persistViewMode(mode); + }, + + onTabActivated() { + this.pollingActive = true; + this.startPolling(); + }, + + onTabDeactivated() { + this.stopPolling(); + }, + + async onModalClosed() { + this.stopPolling(); + this.destroyFlatpickr("all"); + this.isCreating = false; + this.isEditing = false; + this.resetEditingTask(); + this.selectedTaskForDetail = null; + this.persistViewMode("list"); + }, + + startPolling() { + if (this.pollingInterval) return; + this.fetchTasks(); + this.pollingInterval = setInterval(() => { + if (this.pollingActive) { + this.fetchTasks(); + } + }, 2000); + }, + + stopPolling() { + this.pollingActive = false; + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + this.pollingInterval = null; + } + }, + + // Data fetching ------------------------------------------------------------- + async fetchTasks({ manual = false } = {}) { + if (this.isCreating || this.isEditing) return; + this.isLoading = true; + try { + const { ok, error, tasks } = await schedulerApi.listTasks(); + if (!ok) { + if (manual) this.notifyError(`Failed to fetch tasks: ${error}`); + this.tasks = []; + this.hasNoTasks = true; + return; + } + this.tasks = tasks; + this.hasNoTasks = tasks.length === 0; + } catch (error) { + if (manual) this.notifyError(`Failed to fetch tasks: ${error.message}`); + this.tasks = []; + this.hasNoTasks = true; + } finally { + this.isLoading = false; + } + }, + + async saveTask() { + if (!this.editingTask.name?.trim() || !this.editingTask.prompt?.trim()) { + window.alert("Task name and prompt are required"); + return; + } + + if (!TASK_TYPES.includes(this.editingTask.type)) { + window.alert("Invalid task type"); + return; + } + + if (this.editingTask.type === "adhoc" && !this.editingTask.token) { + this.editingTask.token = this.generateRandomToken(); + } + + const payload = buildPayloadFromEditingTask(this.editingTask, { + isCreating: this.isCreating, + }); + + try { + const result = this.isCreating + ? await schedulerApi.createTask(payload) + : await schedulerApi.updateTask(payload); + + if (!result.ok) { + throw new Error(result.error); + } + + const message = this.isCreating + ? "Task created successfully" + : "Task updated successfully"; + this.notifySuccess(message); + + if (result.task) { + if (this.isCreating) { + this.tasks = [...this.tasks, result.task]; + } else { + this.tasks = this.tasks.map((task) => + task.uuid === result.task.uuid ? result.task : task + ); + } + } else { + await this.fetchTasks({ manual: true }); + } + } catch (error) { + this.notifyError(`Failed to save task: ${error.message}`); + return; + } finally { + this.destroyFlatpickr("all"); + this.resetEditingTask(); + this.isCreating = false; + this.isEditing = false; + } + }, + + async runTask(taskId) { + try { + const result = await schedulerApi.runTask(taskId); + if (!result.ok) throw new Error(result.error); + const warning = result.data?.warning; + const message = result.data?.message || "Task started successfully"; + if (warning) { + this.notifyWarning(warning); + } else { + this.notifySuccess(message); + } + this.fetchTasks({ manual: true }); + } catch (error) { + this.notifyError(`Failed to run task: ${error.message}`); + } + }, + + async resetTaskState(taskId) { + const task = this.tasks.find((t) => t.uuid === taskId); + if (!task) { + this.notifyError("Task not found"); + return; + } + if (task.state === "idle") { + this.notifyInfo("Task is already in idle state"); + return; + } + + this.showLoadingState = true; + try { + const result = await schedulerApi.updateTask({ task_id: taskId, state: "idle" }); + if (!result.ok) throw new Error(result.error); + this.notifySuccess("Task state reset to idle"); + await this.fetchTasks({ manual: true }); + } catch (error) { + this.notifyError(`Failed to reset task state: ${error.message}`); + } finally { + this.showLoadingState = false; + } + }, + + async deleteTask(taskId) { + if ( + !window.confirm( + "Are you sure you want to delete this task? This action cannot be undone." + ) + ) { + return; + } + + try { + if (typeof chatsStore.switchFromContext === "function") { + await chatsStore.switchFromContext(taskId); + } + } catch (error) { + console.warn("[scheduler] Failed to switch from context before delete", error); + } + + try { + const result = await schedulerApi.deleteTask(taskId); + if (!result.ok) throw new Error(result.error); + this.notifySuccess("Task deleted successfully"); + this.tasks = this.tasks.filter((task) => task.uuid !== taskId); + this.hasNoTasks = this.tasks.length === 0; + if (this.selectedTaskForDetail?.uuid === taskId) { + this.closeTaskDetail(); + } + } catch (error) { + this.notifyError(`Failed to delete task: ${error.message}`); + } + }, + + async deleteTaskFromSidebar(taskId) { + await this.deleteTask(taskId); + }, + + // Domain helpers ----------------------------------------------------------- + resetEditingTask() { + this.editingTask = defaultEditingTask(); + this.selectedProjectSlug = ""; + }, + + setEditingTask(task) { + const normalized = composeEditingTask(task); + this.editingTask = normalized; + this.selectedProjectSlug = normalized.project?.name || ""; + }, + + async refreshProjectOptions() { + try { + if ( + !Array.isArray(projectsStore.projectList) || + projectsStore.projectList.length === 0 + ) { + if (typeof projectsStore.loadProjectsList === "function") { + await projectsStore.loadProjectsList(); + } + } + } catch (error) { + console.warn("[scheduler] Failed to load project list", error); + } + + const list = Array.isArray(projectsStore.projectList) + ? projectsStore.projectList + : []; + + this.projectOptions = list.map((proj) => ({ + name: proj.name, + title: proj.title || proj.name, + color: proj.color || "", + })); + }, + + deriveActiveProject() { + const selected = chatsStore?.selectedContext || null; + if (!selected || !selected.project) return null; + const project = selected.project; + return { + name: project.name || null, + title: project.title || project.name || null, + color: project.color || "", + }; + }, + + onProjectSelect(slug) { + this.selectedProjectSlug = slug || ""; + if (!slug) { + this.editingTask.project = null; + return; + } + + const option = this.projectOptions.find((item) => item.name === slug); + if (option) { + this.editingTask.project = { ...option }; + } else { + this.editingTask.project = { name: slug, title: slug, color: "" }; + } + }, + + changeSort(field) { + if (this.sortField === field) { + this.sortDirection = this.sortDirection === "asc" ? "desc" : "asc"; + } else { + this.sortField = field; + this.sortDirection = "asc"; + } + }, + + sortTasks(tasks) { + if (!Array.isArray(tasks) || tasks.length === 0) return tasks; + const direction = this.sortDirection === "asc" ? 1 : -1; + const field = this.sortField; + return [...tasks].sort((a, b) => { + const fieldA = a[field]; + const fieldB = b[field]; + if (fieldA === undefined && fieldB === undefined) return 0; + if (fieldA === undefined) return 1; + if (fieldB === undefined) return -1; + if (["createdAt", "updatedAt", "last_run"].includes(field)) { + return (sortByDate(fieldA) - sortByDate(fieldB)) * direction; + } + if (typeof fieldA === "string" && typeof fieldB === "string") { + return fieldA.localeCompare(fieldB) * direction; + } + return (fieldA - fieldB) * direction; + }); + }, + + formatDate(dateString) { + if (!dateString) return "Never"; + return formatDateTime(dateString, "full"); + }, + + formatPlan(task) { + if (!task || !task.plan) return "No plan"; + const todoCount = Array.isArray(task.plan.todo) ? task.plan.todo.length : 0; + const inProgress = task.plan.in_progress ? "Yes" : "No"; + const doneCount = Array.isArray(task.plan.done) ? task.plan.done.length : 0; + let nextRun = ""; + if (Array.isArray(task.plan.todo) && task.plan.todo.length > 0) { + const nextTime = new Date(task.plan.todo[0]); + nextRun = Number.isNaN(nextTime.getTime()) + ? "Invalid date" + : formatDateTime(nextTime, "short"); + } else { + nextRun = "None"; + } + return `Next: ${nextRun}\nTodo: ${todoCount}\nIn Progress: ${inProgress}\nDone: ${doneCount}`; + }, + + formatSchedule(task) { + if (!task.schedule) return "None"; + if (typeof task.schedule === "string") return task.schedule; + return `${task.schedule.minute || "*"} ${task.schedule.hour || "*"} ${ + task.schedule.day || "*" + } ${task.schedule.month || "*"} ${task.schedule.weekday || "*"}`; + }, + + getStateBadgeClass(state) { + switch (state) { + case "idle": + return "scheduler-status-idle"; + case "running": + return "scheduler-status-running"; + case "disabled": + return "scheduler-status-disabled"; + case "error": + return "scheduler-status-error"; + default: + return ""; + } + }, + + extractTaskProject(task) { + return extractProjectInfo(task); + }, + + formatProjectName(project) { + if (!project) return "No Project"; + return project.title || project.name || "No Project"; + }, + + formatProjectLabel(project) { + return `Project: ${this.formatProjectName(project)}`; + }, + + formatTaskProject(task) { + return this.formatProjectName(this.extractTaskProject(task)); + }, + + showTaskDetail(taskId) { + const task = this.tasks.find((t) => t.uuid === taskId); + if (!task) { + this.notifyError("Task not found"); + return; + } + + const snapshot = safeJsonClone(task); + if (!snapshot.attachments) { + snapshot.attachments = []; + } + + this.selectedTaskForDetail = snapshot; + const closePromise = window.openModal("settings/scheduler/scheduler-task-detail.html"); + if (closePromise && typeof closePromise.then === "function") { + closePromise.then(() => { + if (this.selectedTaskForDetail?.uuid === snapshot.uuid) { + this.selectedTaskForDetail = null; + } + }); + } + }, + + closeTaskDetail() { + this.selectedTaskForDetail = null; + window.closeModal(); + }, + + async startCreateTask() { + this.isCreating = true; + this.isEditing = false; + await this.refreshProjectOptions(); + + let initialProject = this.deriveActiveProject(); + if (!initialProject && this.projectOptions.length > 0) { + initialProject = { ...this.projectOptions[0] }; + } + + this.editingTask = defaultEditingTask({ + token: this.generateRandomToken(), + project: initialProject, + }); + this.selectedProjectSlug = initialProject?.name || ""; + setTimeout(() => this.initFlatpickr("create"), 100); + }, + + async startEditTask(taskId) { + const task = this.tasks.find((t) => t.uuid === taskId); + if (!task) { + this.notifyError("Task not found"); + return; + } + + this.isCreating = false; + this.isEditing = true; + this.setEditingTask(safeJsonClone(task)); + setTimeout(() => this.initFlatpickr("edit"), 100); + }, + + cancelEdit() { + this.destroyFlatpickr("all"); + this.resetEditingTask(); + this.selectedProjectSlug = ""; + this.isCreating = false; + this.isEditing = false; + }, + + normalizePlan() { + this.editingTask.plan = normalizePlanStruct(this.editingTask.plan); + }, + + addPlannedTime(mode = "create") { + if (!this.editingTask.plan) { + this.editingTask.plan = emptyPlan(); + } + if (!Array.isArray(this.editingTask.plan.todo)) { + this.editingTask.plan.todo = []; + } + + const inputId = mode === "edit" ? "newPlannedTime-edit" : "newPlannedTime-create"; + const input = document.getElementById(inputId); + if (!input) { + console.warn("[scheduler] Input element not found for planned time", inputId); + return; + } + + const selectedDate = readDateFromPlannerInput(input); + if (!selectedDate) { + window.alert("Please select a valid date and time"); + return; + } + + this.editingTask.plan.todo.push(selectedDate.toISOString()); + this.editingTask.plan.todo.sort(); + + if (input._flatpickr) { + input._flatpickr.clear(); + } else { + input.value = ""; + } + }, + + generateRandomToken() { + const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let token = ""; + for (let i = 0; i < 16; i++) { + token += characters.charAt(Math.floor(Math.random() * characters.length)); + } + return token; + }, + + // UI bridge helpers -------------------------------------------------------- + initFlatpickr(mode = "all") { + if (mode === "all" || mode === "create") { + setupPlannerInput("newPlannedTime-create"); + } + if (mode === "all" || mode === "edit") { + setupPlannerInput("newPlannedTime-edit"); + } + }, + + destroyFlatpickr(mode = "all") { + if (mode === "all" || mode === "create") { + destroyPlannerInput("newPlannedTime-create"); + } + if (mode === "all" || mode === "edit") { + destroyPlannerInput("newPlannedTime-edit"); + } + }, + + // Notifications ------------------------------------------------------------ + notifySuccess(message, options = {}) { + pushNotification("success", message, options.title, options.duration); + }, + + notifyInfo(message, options = {}) { + pushNotification("info", message, options.title, options.duration); + }, + + notifyWarning(message, options = {}) { + pushNotification("warning", message, options.title, options.duration); + }, + + notifyError(message, options = {}) { + pushNotification("error", message, options.title, options.duration); + }, +}; + +const store = createStore("schedulerStore", schedulerStoreModel); + +export { store }; diff --git a/webui/components/settings/scheduler/scheduler-task-detail.html b/webui/components/settings/scheduler/scheduler-task-detail.html new file mode 100644 index 000000000..85dae0187 --- /dev/null +++ b/webui/components/settings/scheduler/scheduler-task-detail.html @@ -0,0 +1,129 @@ + + + Task Details + + + +
+ +
+ + diff --git a/webui/components/settings/scheduler/scheduler-task-editor.html b/webui/components/settings/scheduler/scheduler-task-editor.html new file mode 100644 index 000000000..cc9b2e5f1 --- /dev/null +++ b/webui/components/settings/scheduler/scheduler-task-editor.html @@ -0,0 +1,439 @@ + + + + + +
+ +
+ + diff --git a/webui/components/settings/scheduler/scheduler-task-list.html b/webui/components/settings/scheduler/scheduler-task-list.html new file mode 100644 index 000000000..0d60e45a9 --- /dev/null +++ b/webui/components/settings/scheduler/scheduler-task-list.html @@ -0,0 +1,146 @@ + + + + + +
+ +
+ + diff --git a/webui/components/settings/settings-store.js b/webui/components/settings/settings-store.js new file mode 100644 index 000000000..98d13f41e --- /dev/null +++ b/webui/components/settings/settings-store.js @@ -0,0 +1,255 @@ +import { createStore } from "/js/AlpineStore.js"; +import * as API from "/js/api.js"; +import { store as notificationStore } from "/components/notifications/notification-store.js"; +import { store as schedulerStore } from "/components/settings/scheduler/scheduler-store.js"; + +// Constants +const VIEW_MODE_STORAGE_KEY = "settingsActiveTab"; +const DEFAULT_TAB = "agent"; + +// Helper for toasts +function toast(text, type = "info", timeout = 5000) { + notificationStore.addFrontendToastOnly(type, text, "", timeout / 1000); +} + +// Settings Store +const model = { + // State + isLoading: false, + error: null, + settings: null, + sectionsById: {}, + + // Tab state + _activeTab: DEFAULT_TAB, + get activeTab() { + return this._activeTab; + }, + set activeTab(value) { + const previous = this._activeTab; + this._activeTab = value; + this.applyActiveTab(previous, value); + }, + + // Lifecycle + init() { + // Restore persisted tab + try { + const saved = localStorage.getItem(VIEW_MODE_STORAGE_KEY); + if (saved) this._activeTab = saved; + } catch {} + }, + + async onOpen() { + this.error = null; + this.isLoading = true; + + try { + const response = await API.callJsonApi("settings_get", null); + if (response && response.settings) { + this.settings = response.settings; + this.rebuildSectionsIndex(); + } else { + throw new Error("Invalid settings response"); + } + } catch (e) { + console.error("Failed to load settings:", e); + this.error = e.message || "Failed to load settings"; + toast("Failed to load settings", "error"); + } finally { + this.isLoading = false; + } + + // Trigger tab activation for current tab + this.applyActiveTab(null, this._activeTab); + }, + + cleanup() { + // Notify scheduler if it was active + if (this._activeTab === "scheduler") { + schedulerStore.onTabDeactivated?.(); + } + schedulerStore.onModalClosed?.(); + + this.settings = null; + this.sectionsById = {}; + this.error = null; + this.isLoading = false; + }, + + // Tab management + applyActiveTab(previous, current) { + // Persist + try { + localStorage.setItem(VIEW_MODE_STORAGE_KEY, current); + } catch {} + + // Scheduler lifecycle + if (previous === "scheduler" && current !== "scheduler") { + schedulerStore.onTabDeactivated?.(); + } + if (current === "scheduler" && previous !== "scheduler") { + schedulerStore.onTabActivated?.(); + } + }, + + switchTab(tabName) { + this.activeTab = tabName; + }, + + // Computed: sections for current tab + get filteredSections() { + if (!this.settings || !this.settings.sections) return []; + const sections = this.settings.sections.filter( + (section) => section.tab === this._activeTab + ); + return sections; + }, + + rebuildSectionsIndex() { + const map = {}; + if (this.settings && Array.isArray(this.settings.sections)) { + for (const section of this.settings.sections) { + if (section && section.id) { + map[section.id] = section; + } + } + } + this.sectionsById = map; + }, + + getSectionById(sectionId) { + if (!sectionId) return null; + return this.sectionsById[sectionId] || null; + }, + + // Save settings + async saveSettings() { + if (!this.settings) { + toast("No settings to save", "warning"); + return false; + } + + this.isLoading = true; + try { + const response = await API.callJsonApi("settings_set", this.settings); + if (response && response.settings) { + this.settings = response.settings; + this.rebuildSectionsIndex(); + toast("Settings saved successfully", "success"); + document.dispatchEvent( + new CustomEvent("settings-updated", { detail: response.settings }) + ); + return true; + } else { + throw new Error("Failed to save settings"); + } + } catch (e) { + console.error("Failed to save settings:", e); + toast("Failed to save settings: " + e.message, "error"); + return false; + } finally { + this.isLoading = false; + } + }, + + // Close the modal + closeSettings() { + window.closeModal("settings/settings.html"); + }, + + // Save and close + async saveAndClose() { + const success = await this.saveSettings(); + if (success) { + this.closeSettings(); + } + }, + + // Field helpers for external components + getField(sectionId, fieldId) { + if (!this.settings || !this.settings.sections) return null; + for (const section of this.settings.sections) { + if (section.id === sectionId) { + for (const field of section.fields) { + if (field.id === fieldId) { + return field; + } + } + } + } + return null; + }, + + setFieldValue(sectionId, fieldId, value) { + const field = this.getField(sectionId, fieldId); + if (field) { + field.value = value; + return true; + } + return false; + }, + + findFieldValue(fieldId) { + if (!this.settings || !this.settings.sections) return null; + for (const section of this.settings.sections) { + for (const field of section.fields) { + if (field.id === fieldId) { + return field.value; + } + } + } + return null; + }, + + // Handle button field clicks (opens sub-modals) + async handleFieldButton(field) { + if (field.id === "mcp_servers_config") { + window.openModal("settings/mcp/client/mcp-servers.html"); + } else if (field.id === "backup_create") { + window.openModal("settings/backup/backup.html"); + } else if (field.id === "backup_restore") { + window.openModal("settings/backup/restore.html"); + } else if (field.id === "show_a2a_connection") { + window.openModal("settings/a2a/a2a-connection.html"); + } else if (field.id === "external_api_examples") { + window.openModal("settings/external/api-examples.html"); + } else if (field.id === "memory_dashboard") { + window.openModal("settings/memory/memory-dashboard.html"); + } + }, + + // Open settings modal from external callers + async open(initialTab = null) { + if (initialTab) { + this._activeTab = initialTab; + } + await window.openModal("settings/settings.html"); + }, + + // Scheduler integration: open Settings modal, switch to scheduler tab, show task detail + async openSchedulerTaskDetail(taskId) { + // Set tab to scheduler before opening + this._activeTab = "scheduler"; + + // Open the modal (do NOT await - openModal resolves on close) + window.openModal("settings/settings.html"); + + // Ensure scheduler tasks are loaded, then show the detail modal + setTimeout(async () => { + try { + if (typeof schedulerStore.fetchTasks === "function") { + await schedulerStore.fetchTasks({ manual: true }); + } + schedulerStore.showTaskDetail?.(taskId); + } catch (error) { + console.warn("[settings-store] openSchedulerTaskDetail failed", error); + } + }, 200); + }, +}; + +const store = createStore("settingsStore", model); + +export { store }; + diff --git a/webui/components/settings/settings.html b/webui/components/settings/settings.html new file mode 100644 index 000000000..8e5ac124d --- /dev/null +++ b/webui/components/settings/settings.html @@ -0,0 +1,146 @@ + + + Settings + + + + + +
+ +
+ + +
+ + +
+ + + + + diff --git a/webui/components/sidebar/tasks/tasks-store.js b/webui/components/sidebar/tasks/tasks-store.js index f014207f0..bedacd27f 100644 --- a/webui/components/sidebar/tasks/tasks-store.js +++ b/webui/components/sidebar/tasks/tasks-store.js @@ -1,5 +1,7 @@ import { createStore } from "/js/AlpineStore.js"; import { store as chatsStore } from "/components/sidebar/chats/chats-store.js"; +import { store as schedulerStore } from "/components/settings/scheduler/scheduler-store.js"; +import { store as settingsStore } from "/components/settings/settings-store.js"; // Tasks sidebar store: tasks list and selected task id const model = { @@ -50,8 +52,9 @@ const model = { }, openDetail(taskId) { - if (globalThis.openTaskDetail) { - globalThis.openTaskDetail(taskId); + // Use the new settings modal store to open scheduler task detail + if (settingsStore?.openSchedulerTaskDetail) { + settingsStore.openSchedulerTaskDetail(taskId); } }, @@ -60,8 +63,8 @@ const model = { }, deleteTask(taskId) { - if (globalThis.deleteTaskGlobal) { - globalThis.deleteTaskGlobal(taskId); + if (schedulerStore?.deleteTaskFromSidebar) { + schedulerStore.deleteTaskFromSidebar(taskId); } }, }; diff --git a/webui/components/sidebar/top-section/quick-actions.html b/webui/components/sidebar/top-section/quick-actions.html index a4d76e147..8b807e9ec 100644 --- a/webui/components/sidebar/top-section/quick-actions.html +++ b/webui/components/sidebar/top-section/quick-actions.html @@ -3,6 +3,9 @@ @@ -13,7 +16,7 @@ - + + + - - -
- - -
From 745d4ebd318924904591d58ff0cc08e53b4c1b4a Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Mon, 22 Dec 2025 06:07:48 +0100 Subject: [PATCH 05/34] fix scheduler flickering --- .../settings/scheduler/scheduler-store.js | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/webui/components/settings/scheduler/scheduler-store.js b/webui/components/settings/scheduler/scheduler-store.js index 1ea2be670..a65395e2d 100644 --- a/webui/components/settings/scheduler/scheduler-store.js +++ b/webui/components/settings/scheduler/scheduler-store.js @@ -549,12 +549,11 @@ const schedulerStoreModel = { startPolling() { if (this.pollingInterval) return; this.fetchTasks(); - // Poll every 5 seconds - balances responsiveness with performance this.pollingInterval = setInterval(() => { if (this.pollingActive) { this.fetchTasks(); } - }, 5000); + }, 2000); }, stopPolling() { @@ -568,7 +567,8 @@ const schedulerStoreModel = { // Data fetching ------------------------------------------------------------- async fetchTasks({ manual = false } = {}) { if (this.isCreating || this.isEditing) return; - this.isLoading = true; + if (manual) this.isLoading = true; + try { const { ok, error, tasks } = await schedulerApi.listTasks(); if (!ok) { @@ -577,12 +577,22 @@ const schedulerStoreModel = { this.hasNoTasks = true; return; } - // Only update if data actually changed - prevents DOM thrashing during polling - const newJson = JSON.stringify(tasks); - if (newJson !== JSON.stringify(this.tasks)) { - this.tasks = tasks; - } - this.hasNoTasks = tasks.length === 0; + + // Smart merge: preserve object references to prevent UI flickering + const taskMap = new Map(this.tasks.map((t) => [t.uuid, t])); + this.tasks = tasks.map((newTask) => { + const existing = taskMap.get(newTask.uuid); + if (existing) { + // Update existing object in-place if different + if (JSON.stringify(existing) !== JSON.stringify(newTask)) { + Object.assign(existing, newTask); + } + return existing; // Return the SAME object reference + } + return newTask; // New object + }); + + this.hasNoTasks = this.tasks.length === 0; } catch (error) { if (manual) this.notifyError(`Failed to fetch tasks: ${error.message}`); this.tasks = []; From d3a02482766a3950fc698562a3b5ad539f8c897f Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Mon, 22 Dec 2025 08:46:21 +0100 Subject: [PATCH 06/34] task detail ux/quality of life --- .../settings/scheduler/scheduler-store.js | 13 ++ .../scheduler/scheduler-task-detail.html | 31 ++++- .../scheduler/scheduler-task-list.html | 31 ++--- webui/css/modals.css | 1 - webui/css/settings.css | 129 +++++++++++------- webui/index.css | 32 ----- 6 files changed, 127 insertions(+), 110 deletions(-) diff --git a/webui/components/settings/scheduler/scheduler-store.js b/webui/components/settings/scheduler/scheduler-store.js index a65395e2d..e2b0afe54 100644 --- a/webui/components/settings/scheduler/scheduler-store.js +++ b/webui/components/settings/scheduler/scheduler-store.js @@ -915,6 +915,19 @@ const schedulerStoreModel = { window.closeModal(); }, + editFromDetail() { + const taskId = this.selectedTaskForDetail?.uuid; + if (!taskId) return; + this.closeTaskDetail(); + this.startEditTask(taskId); + }, + + async deleteFromDetail() { + const taskId = this.selectedTaskForDetail?.uuid; + if (!taskId) return; + await this.deleteTask(taskId); + }, + async startCreateTask() { this.isCreating = true; this.isEditing = false; diff --git a/webui/components/settings/scheduler/scheduler-task-detail.html b/webui/components/settings/scheduler/scheduler-task-detail.html index 85dae0187..050a2ac58 100644 --- a/webui/components/settings/scheduler/scheduler-task-detail.html +++ b/webui/components/settings/scheduler/scheduler-task-detail.html @@ -11,12 +11,31 @@
-

-
- +
+

+
+
+
+ + + + +
diff --git a/webui/components/settings/scheduler/scheduler-task-list.html b/webui/components/settings/scheduler/scheduler-task-list.html index 0d60e45a9..f140be8fc 100644 --- a/webui/components/settings/scheduler/scheduler-task-list.html +++ b/webui/components/settings/scheduler/scheduler-task-list.html @@ -53,57 +53,46 @@ x-effect="$el.style.display = (!$store.schedulerStore.isLoading && $store.schedulerStore.filteredTasks.length > 0) ? '' : 'none'"> - + Name - + State - Type - Project - Schedule - + Project + Last Run - Actions + Actions