1// Cookbook Schedule â opens a small inline form (styled with the app's 2// existing .cookbook-* classes) that creates a ScheduledTask with 3// action=cookbook_serve. Mounted from two places: 4// 5// 1. The ^ button next to Launch in a serve panel. 6// 2. The "Scheduleâ¦" entry in the cached-model ⯠dropdown menu (which 7// programmatically clicks the ^ button so this module owns the 8// single source of truth). 9// 10// Feedback uses uiModule.showToast() â the same toast the rest of the 11// app uses for "Saved", "Favorited", etc. â so the success message 12// doesn't introduce a parallel notification style. 13// 14// To remove: delete this file + the <script> tag in index.html + the 15// ^ button in cookbookServe.js + the "cookbook_serve" entry in 16// BUILTIN_ACTIONS + src/cookbook_serve_lifecycle.py + its 17// registration line in app.py. 18 19try { (function () { 20 function _safe(fn) { 21 return function () { 22 try { return fn.apply(this, arguments); } 23 catch (e) { try { console.warn("[cookbookSchedule]", e); } catch (_) {} } 24 }; 25 } 26 function esc(s) { 27 return String(s == null ? "" : s) 28 .replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">") 29 .replace(/"/g, """).replace(/'/g, "'"); 30 } 31 32 // Cached handle to the ui.js showToast function. Bound lazily on 33 // first use because ui.js is an ES module â it's not on `window` 34 // unless something else has explicitly exposed it. 35 let _toastFn = null; 36 async function _getToast() { 37 if (_toastFn) return _toastFn; 38 try { 39 const m = await import("/static/js/ui.js"); 40 _toastFn = m.default?.showToast || m.showToast || null; 41 } catch (_) { _toastFn = null; } 42 return _toastFn; 43 } 44 // Optional opts: {action, onAction, duration, leadingIcon} 45 async function toast(msg, opts) { 46 const fn = await _getToast(); 47 if (fn) { 48 try { fn(msg, opts); return; } catch (_) {} 49 } 50 try { console.log("[toast]", msg); } catch (_) {} 51 } 52 53 // Cached handle to the tasks module so the success toast's "Open" 54 // action can jump straight to the new task in the Tasks tab. 55 let _tasksMod = null; 56 async function _getTasksMod() { 57 if (_tasksMod) return _tasksMod; 58 try { _tasksMod = await import("/static/js/tasks.js"); } catch (_) {} 59 return _tasksMod; 60 } 61 async function openTaskInTasksTab(taskId) { 62 const m = await _getTasksMod(); 63 if (m && typeof m.openTasks === "function") { 64 try { m.openTasks(taskId); return; } catch (_) {} 65 } 66 // Last-resort fallback: click the sidebar Tasks button. 67 document.getElementById("tool-tasks-btn")?.click(); 68 } 69 70 const DAYS = [ 71 { k: "MO", l: "Mon", idx: 0 }, 72 { k: "TU", l: "Tue", idx: 1 }, 73 { k: "WE", l: "Wed", idx: 2 }, 74 { k: "TH", l: "Thu", idx: 3 }, 75 { k: "FR", l: "Fri", idx: 4 }, 76 { k: "SA", l: "Sat", idx: 5 }, 77 { k: "SU", l: "Sun", idx: 6 }, 78 ]; 79 const WEEKDAYS = new Set(["MO","TU","WE","TH","FR"]); 80 81 // Resolve the model identity from the closest .memory-item card â 82 // that's the canonical container the cookbook serve UI uses, with 83 // the model repo on data-repo. We do NOT grab the title via 84 // textContent, because the title row also contains inline status 85 // pills ("running", "downloading") and an "HF â" link â pulling all 86 // of it in turns a clean preset name like "Qwen3.5-397B-A17B-AWQ" 87 // into "Qwen3.5-397B-A17B-AWQ running HF â", which then fails the 88 // preset lookup in action_cookbook_serve. 89 function readPanelConfig(arrowBtn) { 90 const item = arrowBtn.closest(".memory-item") || arrowBtn.closest(".hwfit-cached-item"); 91 const panel = arrowBtn.closest(".hwfit-serve-panel"); 92 const repo = item?.dataset?.repo 93 || arrowBtn.closest(".hwfit-serve-panel")?.dataset?.repo 94 || ""; 95 // Title = last segment of the repo (after the final /), which is 96 // exactly what the cookbook UI renders in the card title and what 97 // the preset registry uses as its short name. e.g. 98 // cyankiwi/Qwen3.5-397B-A17B-AWQ â Qwen3.5-397B-A17B-AWQ 99 // Falls back to data-modelName or the bare repo for ollama-style 100 // entries that don't have a slash. 101 let title = ""; 102 if (repo) { 103 title = repo.includes("/") ? repo.split("/").pop() : repo; 104 } 105 if (!title) { 106 title = item?.dataset?.modelName || "model"; 107 } 108 return { panel, item, title, repo_id: repo, host: item?.dataset?.host || "" }; 109 } 110 111 function buildFormHtml(cfg) { 112 return ` 113 <div class="hwfit-schedule-form cookbook-panel"> 114 <div class="hwfit-schedule-title"> 115 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> 116 <rect x="3" y="4" width="18" height="18" rx="2"/> 117 <line x1="16" y1="2" x2="16" y2="6"/> 118 <line x1="8" y1="2" x2="8" y2="6"/> 119 <line x1="3" y1="10" x2="21" y2="10"/> 120 </svg>
121 <span class="hwfit-schedule-title-text">Schedule serve: <strong>${esc(cfg.title)}</strong></span> 122 <span class="hwfit-schedule-title-spacer"></span> 123 <label class="hwfit-schedule-mirror-toggle" title="Also create a calendar event on the Cookbook calendar"> 124 <span class="hwfit-schedule-mirror-label">Create event in calendar</span> 125 <span class="admin-switch hwfit-schedule-mirror-switch"> 126 <input type="checkbox" class="hwfit-sched-calendar-mirror" /> 127 <span class="admin-slider"></span> 128 </span> 129 </label> 130 </div> 131 132 <div class="hwfit-schedule-row hwfit-schedule-when-row"> 133 <label class="hwfit-schedule-field"> 134 <span>From</span> 135 <input type="time" class="hwfit-sched-start cookbook-field-input" value="09:00" /> 136 </label> 137 <label class="hwfit-schedule-field"> 138 <span>Until</span> 139 <input type="time" class="hwfit-sched-end cookbook-field-input" value="17:00" /> 140 </label> 141 <label class="hwfit-schedule-field hwfit-schedule-days-field"> 142 <span>Days</span> 143 <div class="hwfit-sched-days"> 144 ${DAYS.map(d => ` 145 <button type="button" class="hwfit-sched-day-chip${WEEKDAYS.has(d.k) ? " is-on" : ""}" data-day="${d.k}">${d.l}</button> 146 `).join("")} 147 </div> 148 </label> 149 <div class="hwfit-schedule-actions-inline"> 150 <button type="button" class="cookbook-btn hwfit-sched-cancel" title="Cancel">
151 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;flex-shrink:0;"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg> 152 <span>Cancel</span> 153 </button> 154 <button type="button" class="cookbook-btn hwfit-sched-save" title="Save schedule" aria-label="Save schedule"> 155 <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:5px;flex-shrink:0;"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg> 156 <span>Save</span> 157 </button> 158 </div> 159 </div> 160 161 <div class="hwfit-sched-err"></div> 162 </div>`; 163 } 164 165 function openForm(arrowBtn) { 166 const cfg = readPanelConfig(arrowBtn); 167 const anchor = cfg.panel 168 || cfg.item 169 || arrowBtn.closest(".cookbook-saved-item") 170 || arrowBtn.parentElement?.parentElement 171 || arrowBtn.parentElement; 172 if (!anchor) { 173 toast("Couldn't find a panel to mount the schedule form"); 174 return; 175 } 176 // Toggle. 177 const existing = anchor.querySelector(".hwfit-schedule-form"); 178 if (existing) { existing.remove(); return; } 179 const tmp = document.createElement("div"); 180 tmp.innerHTML = buildFormHtml(cfg); 181 const form = tmp.firstElementChild; 182 anchor.appendChild(form); 183 setTimeout(() => { 184 try { form.scrollIntoView({ behavior: "smooth", block: "nearest" }); } catch (_) {} 185 }, 50); 186 wireForm(form, cfg); 187 } 188 189 function wireForm(form, cfg) { 190 form.querySelectorAll(".hwfit-sched-day-chip").forEach(chip => { 191 chip.addEventListener("click", () => chip.classList.toggle("is-on")); 192 }); 193 form.querySelector(".hwfit-sched-cancel").addEventListener("click", () => form.remove()); 194 form.querySelector(".hwfit-sched-save").addEventListener("click", _safe(async () => { 195 const startTime = form.querySelector(".hwfit-sched-start").value; 196 const endTime = form.querySelector(".hwfit-sched-end").value; 197 const days = Array.from(form.querySelectorAll(".hwfit-sched-day-chip.is-on")).map(c => c.dataset.day); 198 const mirrorToCalendar = !!form.querySelector(".hwfit-sched-calendar-mirror")?.checked; 199 const errEl = form.querySelector(".hwfit-sched-err"); 200 errEl.textContent = ""; 201 errEl.classList.remove("is-visible"); 202 203 function fail(msg) { 204 errEl.textContent = msg; 205 errEl.classList.add("is-visible"); 206 } 207 if (!/^\d\d:\d\d$/.test(startTime) || !/^\d\d:\d\d$/.test(endTime)) { 208 return fail("Start and end must be HH:MM"); 209 } 210 if (!days.length) { 211 return fail("Pick at least one day"); 212 } 213 214 const [sh, sm] = startTime.split(":").map(Number); 215 const [eh, em] = endTime.split(":").map(Number); 216 let dur = (eh * 60 + em) - (sh * 60 + sm); 217 if (dur <= 0) dur += 24 * 60; 218 219 // The backend stores scheduled_time as UTC. The user picks 220 // wall-clock LOCAL time. Without converting, "09:55" in a UTC+9 221 // timezone gets stored as 09:55 UTC = 18:55 local â next-run 222 // shows ~9 hours later instead of "in 5 min". Mirror what 223 // tasks.js does via its _localTimeToUtc helper. 224 const _localHHMMToUtc = (hhmm) => { 225 const [h, m] = hhmm.split(":").map(Number); 226 const d = new Date(); 227 d.setHours(h, m, 0, 0); 228 return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`; 229 }; 230 const startUtc = _localHHMMToUtc(startTime); 231 const [shUtc, smUtc] = startUtc.split(":").map(Number); 232 233 const allDays = days.length === 7; 234 const weekdaysOnly = days.length === 5 && ["MO","TU","WE","TH","FR"].every(d => days.includes(d)); 235 const sched = {}; 236 if (allDays) { 237 sched.schedule = "daily"; 238 sched.scheduled_time = startUtc; 239 } else if (weekdaysOnly) { 240 sched.schedule = "cron"; 241 sched.cron_expression = `${smUtc} ${shUtc} * * 1-5`; 242 } else if (days.length === 1) { 243 const dayIdx = DAYS.find(d => d.k === days[0]).idx; 244 sched.schedule = "weekly"; 245 sched.scheduled_time = startUtc; 246 sched.scheduled_day = dayIdx; 247 } else { 248 const dayNum = days.map(k => { 249 const i = DAYS.find(d => d.k === k).idx; 250 return i === 6 ? 0 : i + 1; 251 }); 252 sched.schedule = "cron"; 253 sched.cron_expression = `${smUtc} ${shUtc} * * ${dayNum.join(",")}`; 254 } 255 256 // Name: "Serve: <full model name>" â pulled from .memory-item-title 257 // so it's the user's display name (e.g. "Qwen3.5-397B-A17B-AWQ") 258 // not a placeholder like "model". 259 const fullName = (cfg.title || cfg.repo_id || "").trim() || "model"; 260 const payload = { 261 name: `Serve: ${fullName}`, 262 task_type: "action", 263 action: "cookbook_serve", 264 trigger_type: "schedule", 265 prompt: JSON.stringify({ 266 preset: fullName, 267 repo_id: cfg.repo_id || "", 268 host: cfg.host || "", 269 end_after_min: dur, 270 }), 271 ...sched, 272 }; 273 const saveBtn = form.querySelector(".hwfit-sched-save"); 274 saveBtn.disabled = true; 275 saveBtn.textContent = "Savingâ¦";
276 try { 277 const r = await fetch("/api/tasks", { 278 method: "POST", credentials: "same-origin", 279 headers: { "Content-Type": "application/json" }, 280 body: JSON.stringify(payload), 281 }); 282 const data = await r.json(); 283 if (!r.ok || data.error) { 284 fail(data.error || data.detail || `HTTP ${r.status}`); 285 saveBtn.disabled = false; 286 saveBtn.textContent = "Save schedule"; 287 toast(`Schedule save failed: ${data.error || data.detail || r.status}`); 288 return; 289 } 290 if (mirrorToCalendar) { 291 // Mirror onto a dedicated "Cookbook" calendar so the user can 292 // toggle the whole set on/off as a unit in the calendar UI. 293 // Best-effort: if anything here fails, we still consider the 294 // task creation a success (the task itself works regardless). 295 try { 296 const calsRes = await fetch("/api/calendar/calendars", { credentials: "same-origin" }); 297 const calsBody = calsRes.ok ? await calsRes.json() : {}; 298 let cookbookCal = (calsBody.calendars || []).find(c => (c.name || "").toLowerCase() === "cookbook"); 299 if (!cookbookCal) { 300 const mk = await fetch("/api/calendar/calendars?name=Cookbook&color=%233b82f6", { 301 method: "POST", credentials: "same-origin", 302 }); 303 if (mk.ok) { 304 const mkData = await mk.json(); 305 // The create endpoint returns {ok, id, name, color}; the 306 // list endpoint returns {href, name, color}. The two map 307 // 1:1 (href === id) so we synthesize the same shape. 308 cookbookCal = { href: mkData.id, name: mkData.name, color: mkData.color }; 309 } 310 } 311 // The `cookbook_task_id:` marker on its own line lets 312 // calendar.js's event-form code detect that this event was 313 // created from a Cookbook schedule and render an 314 // "Open task" button alongside the description, so the user 315 // can jump straight to the source task from the calendar UI. 316 const evBody = { 317 summary: payload.name, 318 dtstart: new Date().toISOString(), 319 dtend: new Date(Date.now() + dur * 60 * 1000).toISOString(), 320 all_day: false, 321 description: `Auto-mirrored from Cookbook schedule task ${data.id || ""}.\n` 322 + `Edit/delete the task in the Tasks tab â this event will follow.\n` 323 + `cookbook_task_id: ${data.id || ""}`, 324 rrule: weekdaysOnly 325 ? "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR" 326 : (sched.schedule === "weekly" ? `FREQ=WEEKLY;BYDAY=${days.join(",")}` 327 : (sched.schedule === "daily" ? "FREQ=DAILY" : "FREQ=WEEKLY")), 328 color: "#3b82f6", 329 }; 330 if (cookbookCal?.href) evBody.calendar_href = cookbookCal.href; 331 const evRes = await fetch("/api/calendar/events", { 332 method: "POST", credentials: "same-origin", 333 headers: { "Content-Type": "application/json" }, 334 body: JSON.stringify(evBody), 335 }); 336 const evData = evRes.ok ? await evRes.json() : null; 337 // Stash the event uid + calendar href on the task's prompt 338 // JSON so the task-delete hook can cascade the calendar 339 // cleanup. PATCH the task with an updated prompt. 340 if (evData && (evData.uid || evData.id)) { 341 const eventUid = evData.uid || evData.id; 342 try { 343 const updatedPrompt = JSON.stringify({ 344 ...JSON.parse(payload.prompt), 345 cookbook_event_uid: eventUid, 346 cookbook_event_calendar: cookbookCal?.href || "", 347 }); 348 // /api/tasks/{id} accepts PUT, not PATCH â sending PATCH 349 // here silently failed (no such method on that route), so 350 // the task never got the cookbook_event_uid marker and the 351 // server-side delete-cascade had nothing to follow when the 352 // user later deleted the task. 353 await fetch(`/api/tasks/${encodeURIComponent(data.id)}`, { 354 method: "PUT", credentials: "same-origin", 355 headers: { "Content-Type": "application/json" }, 356 body: JSON.stringify({ prompt: updatedPrompt }), 357 }); 358 } catch (_) {} 359 } 360 } catch (_) {} 361 } 362 form.remove(); 363 const newTaskId = data.id || data.task_id || ""; 364 toast(`Created task: Serve: ${fullName}`, { 365 leadingIcon: "check", 366 action: "Open", 367 duration: 5000, 368 onAction: () => openTaskInTasksTab(newTaskId), 369 }); 370 } catch (e) { 371 fail(String(e)); 372 saveBtn.disabled = false;
373 saveBtn.textContent = "Save schedule"; 374 toast(`Schedule save failed: ${e}`); 375 } 376 })); 377 } 378 379 document.addEventListener("click", _safe((e) => { 380 const arrow = e.target.closest && e.target.closest(".hwfit-serve-schedule-arrow"); 381 if (!arrow) return; 382 e.preventDefault(); 383 e.stopPropagation(); 384 openForm(arrow); 385 })); 386})(); } catch (e) { try { console.warn("[cookbookSchedule] top-level error:", e); } catch (_) {} }
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.