PageSourceSearch

https://jayreddinjr-jr-odysseus-hf.hf.space/static/js/cookbookSchedule.js

js jayreddinjr-jr-odysseus-hf.hf.space collected 2026-10-04 16:59:23 UTC 17,444 bytes, 386 lines download raw bytes

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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
29      .replace(/"/g, "&quot;").replace(/'/g, "&#39;");
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">
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        </div>
142
143        <div class="hwfit-schedule-row hwfit-schedule-days-row">
144          <span class="hwfit-schedule-label">Days</span>
145          <div class="hwfit-sched-days">
146            ${DAYS.map(d => `
147              <button type="button" class="hwfit-sched-day-chip${WEEKDAYS.has(d.k) ? " is-on" : ""}" data-day="${d.k}">${d.l}</button>
148            `).join("")}
149          </div>
150          <span class="hwfit-schedule-actions-spacer"></span>
151          <button type="button" class="cookbook-btn hwfit-sched-cancel" title="Cancel">
152            <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>
153            <span>Cancel</span>
154          </button>
155          <button type="button" class="cookbook-btn hwfit-sched-save" title="Save schedule" aria-label="Save schedule">
156            <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>
157            <span>Save</span>
158          </button>
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.