Cookbook scheduler + serve: schedule via Tasks, Stop verifies kill, Ollama auto port-pick

- Schedule cookbook serves through the existing ScheduledTask system: the
  serve preset gets a ^ button next to Launch that opens a daily/hourly/
  weekly form mirroring the admin-switch style; the schedule action runs
  action_cookbook_serve, which delegates to /api/model/serve and stamps
  the resulting task with _scheduledStopAtMs. A background
  cookbook_serve_lifecycle loop ticks every 60s and kills any serve
  whose window has ended, also dropping the auto-registered endpoint
  so the model picker doesn't keep pointing at a dead server.
- Stop and remove on a Running serve now awaits the SSH/tmux kill,
  re-checks tmux has-session, and surfaces an error toast (leaving the
  row) when the kill failed. Previously fire-and-forget, so a failed
  SSH/tmux call silently left the live serve running while the row
  vanished from the UI.
- Cookbook tasks/status orphan-adoption sweep no longer requires the
  serve-/cookbook- session-id prefix; any tmux session whose pane is
  running a known model-server process gets auto-pulled into Running.
  Without this loosening, a cookbook-launched serve whose tmux id
  fell back to a bare number was invisible — you couldn't see it,
  let alone stop it.
- Ollama serve always launches a fresh process under cookbook's tmux
  (no more monitor-mode reattach to a systemd/Docker ollama Stop can't
  reach). The handler pre-picks a free port by probing the target
  host over SSH and mutates req.cmd's OLLAMA_HOST so the runner script
  AND the auto-registered endpoint agree on the same bind port.
- Auto-register uses host.docker.internal (when running inside Docker)
  instead of localhost, matching the URL /setup adds for Ollama by
  hand. Local cookbook serves now produce a chat-reachable endpoint
  on first launch.
- Cascade-delete: removing a scheduled cookbook task also deletes any
  linked calendar event (cookbook_task_id marker in the description).
- Tasks list groups cookbook_serve under a "Cookbook" category that
  sorts above the rest, so scheduler-launched serves are easy to find.
This commit is contained in:
pewdiepie-archdaemon
2026-06-05 14:41:43 +09:00
parent f8aaeab245
commit e2f449f4ef
12 changed files with 1434 additions and 67 deletions

10
app.py
View File

@@ -1067,6 +1067,16 @@ async def _startup_event():
logger.warning(f"Nightly skill audit failed: {e}")
_startup_tasks.append(asyncio.create_task(_skill_audit_nightly_loop()))
# Cookbook serve lifecycle — kills scheduler-launched serves whose
# window-end has passed. Paired with the cookbook_serve builtin
# action; both are no-ops unless a scheduled task actually launches
# something with end_after_min set. Removing this line + the
# cookbook_serve entry in BUILTIN_ACTIONS + src/cookbook_serve_lifecycle.py
# removes the feature.
from src.cookbook_serve_lifecycle import cookbook_serve_lifecycle_loop
_startup_tasks.append(asyncio.create_task(cookbook_serve_lifecycle_loop()))
logger.info("Application startup complete")
async def _shutdown_event():

View File

@@ -801,6 +801,55 @@ def setup_cookbook_routes() -> APIRouter:
finally:
db.close()
def _pick_free_port_for_ollama(
remote: str | None, ssh_port: str | None, start_port: int, max_offset: int
) -> int | None:
"""Return the first free port in [start_port, start_port+max_offset] on
the target host. Used to pick a real bind for `ollama serve` so we
don't reattach to an external systemd ollama (or other listener) the
Cookbook Stop button can't kill."""
import socket
if remote:
# Probe over SSH. Bash's /dev/tcp gives a portable "is anything
# listening" check without requiring ss/netstat/nmap.
ssh_base = ["ssh", "-o", "ConnectTimeout=4", "-o", "StrictHostKeyChecking=no"]
if ssh_port and str(ssh_port) != "22":
if not _SSH_PORT_RE.match(str(ssh_port)):
return None
ssh_base.extend(["-p", str(ssh_port)])
host_arg = remote
if not _REMOTE_HOST_RE.match(host_arg):
return None
probe_ports = " ".join(str(start_port + i) for i in range(max_offset + 1))
script = (
f"for p in {probe_ports}; do "
"if ! (exec 3<>/dev/tcp/127.0.0.1/$p) 2>/dev/null; then "
"echo $p; exit 0; fi; exec 3<&-; exec 3>&-; done; exit 1"
)
try:
import subprocess
r = subprocess.run(
ssh_base + [host_arg, script],
capture_output=True, text=True, timeout=8,
)
if r.returncode == 0:
out = (r.stdout or "").strip().splitlines()
if out and out[0].isdigit():
return int(out[0])
except Exception:
return None
return None
# Local: just try to connect.
for off in range(max_offset + 1):
p = start_port + off
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.25)
try:
s.connect(("127.0.0.1", p))
except (ConnectionRefusedError, socket.timeout, OSError):
return p
return None
def _auto_register_llm_endpoint(req: ServeRequest, remote: str | None) -> str | None:
"""Register a freshly-served LLM as a model endpoint so it appears in the
model picker without a manual /setup step — the text-model sibling of
@@ -815,21 +864,37 @@ def setup_cookbook_routes() -> APIRouter:
import re
from core.database import SessionLocal, ModelEndpoint
# Port: an explicit --port wins. Otherwise fall back by backend — Ollama
# is the only server in our generated commands that omits --port.
# Port: ordered fallbacks so we match whatever the user actually
# asked for, not a hardcoded default:
# 1. explicit `--port N` (vllm / sglang / llama-server)
# 2. `OLLAMA_HOST=host:port` (the way Ollama specifies its bind)
# 3. fallback by backend (11434 ollama / 8080 llama.cpp)
# Previously the OLLAMA_HOST form was silently ignored and we
# registered every Ollama endpoint at 11434 — even if the user
# set OLLAMA_HOST=0.0.0.0:11435 to avoid colliding with an
# existing systemd Ollama, the registered endpoint pointed at
# the OLD port and showed as offline.
port_match = re.search(r'--port\s+(\d+)', req.cmd)
ollama_host_match = re.search(r'OLLAMA_HOST=[^\s]*?:(\d+)', req.cmd)
if port_match:
port = int(port_match.group(1))
elif ollama_host_match:
port = int(ollama_host_match.group(1))
elif "ollama" in req.cmd:
port = 11434
else:
port = 8080 # llama.cpp's llama-server default — the Apple Silicon path
# Determine host (mirrors the image path: SSH alias for remote serves).
# For local serves while Odysseus runs inside Docker, "localhost"
# resolves to the container itself — useless. Use host.docker.internal
# which compose maps to the actual host, matching what /setup adds
# for Ollama by hand.
if remote:
host = remote.split("@")[-1] if "@" in remote else remote
else:
host = "localhost"
from routes.model_routes import _docker_host_gateway_reachable
host = "host.docker.internal" if _docker_host_gateway_reachable() else "localhost"
base_url = f"http://{host}:{port}/v1"
@@ -927,6 +992,19 @@ def setup_cookbook_routes() -> APIRouter:
session_id = f"serve-{uuid.uuid4().hex[:8]}"
remote = req.remote_host
is_windows = req.platform == "windows"
# Ollama: if the user didn't pin a port, resolve the actual port we'll
# bind to here (before runner construction) by probing the target host.
# Otherwise the runner script picks one at runtime and `_auto_register`
# below still registers the stale 11434 default — which on a host with
# a systemd ollama lands on the wrong (unreachable-from-docker) service.
if "ollama" in req.cmd and "OLLAMA_HOST=" not in req.cmd:
_ollama_bind_host = "0.0.0.0" if remote else "127.0.0.1"
_ollama_chosen_port = _pick_free_port_for_ollama(
remote, req.ssh_port, start_port=11434, max_offset=10,
)
if _ollama_chosen_port:
req.cmd = f"OLLAMA_HOST={_ollama_bind_host}:{_ollama_chosen_port} {req.cmd}"
# LOCAL execution on a native-Windows host never uses tmux (detached
# process path below), regardless of the UI-supplied platform.
local_windows = IS_WINDOWS and not remote
@@ -1089,38 +1167,24 @@ def setup_cookbook_routes() -> APIRouter:
req.cmd,
default_host=_ollama_default_host,
)
# Ollama can be a host binary, a system service, or a Docker
# container. If the HTTP API is already reachable, the model is
# already served and we should not require a host `ollama` CLI.
# Always launch a fresh ollama under tmux so Stop reliably
# kills it. If the requested port is busy (e.g. a systemd
# ollama on 11434), scan upward for a free one rather than
# silently reattaching to an external service that Stop
# can't reach.
runner_lines.append(f'ODYSSEUS_OLLAMA_HOST={_bash_squote(_ollama_host)}')
runner_lines.append(f'ODYSSEUS_OLLAMA_PORT="{_ollama_port}"')
runner_lines.append('ODYSSEUS_OLLAMA_URL=""')
runner_lines.append('for _ody_ollama_try in $(seq 1 20); do')
runner_lines.append(' for _ody_ollama_port in "$ODYSSEUS_OLLAMA_PORT" 11434; do')
runner_lines.append(' [ -z "$_ody_ollama_port" ] && continue')
runner_lines.append(' for _ody_ollama_host in 127.0.0.1 localhost host.docker.internal; do')
runner_lines.append(' _ody_ollama_url="http://${_ody_ollama_host}:${_ody_ollama_port}"')
runner_lines.append(' if curl -sf "$_ody_ollama_url/api/tags" >/dev/null 2>&1; then')
runner_lines.append(' ODYSSEUS_OLLAMA_URL="$_ody_ollama_url"')
runner_lines.append(' ODYSSEUS_OLLAMA_PORT="$_ody_ollama_port"')
runner_lines.append(' break 3')
runner_lines.append(' fi')
runner_lines.append(' done')
runner_lines.append(' done')
runner_lines.append(' [ "$_ody_ollama_try" -eq 1 ] && echo "[odysseus] Waiting for an existing Ollama API on ports ${ODYSSEUS_OLLAMA_PORT}/11434..."')
runner_lines.append(' sleep 1')
runner_lines.append('done')
runner_lines.append('if [ -n "$ODYSSEUS_OLLAMA_URL" ]; then')
runner_lines.append(' if [ "$ODYSSEUS_OLLAMA_PORT" != "' + _ollama_port + '" ]; then')
runner_lines.append(' echo "[odysseus] Selected Ollama port ' + _ollama_port + ' was not reachable; using running Ollama on port ${ODYSSEUS_OLLAMA_PORT}."')
runner_lines.append('for _ody_off in 0 1 2 3 4 5 6 7 8 9; do')
runner_lines.append(' _ody_try_port=$((ODYSSEUS_OLLAMA_PORT + _ody_off))')
runner_lines.append(' if ! (exec 3<>/dev/tcp/127.0.0.1/$_ody_try_port) 2>/dev/null; then')
runner_lines.append(' exec 3<&-; exec 3>&-')
runner_lines.append(' ODYSSEUS_OLLAMA_PORT="$_ody_try_port"')
runner_lines.append(' break')
runner_lines.append(' fi')
runner_lines.append(' echo "[odysseus] Ollama API ready on port ${ODYSSEUS_OLLAMA_PORT}: ${ODYSSEUS_OLLAMA_URL}"')
runner_lines.append(' echo "[odysseus] This task is monitoring an existing Ollama server; stopping it here will not stop an external Docker/system service."')
runner_lines.append(' exec bash -i')
runner_lines.append('fi')
runner_lines.append(' exec 3<&-; exec 3>&-')
runner_lines.append('done')
runner_lines.append('if ! command -v ollama &>/dev/null; then')
runner_lines.append(' echo "ERROR: Ollama not found and no Ollama API is reachable on 127.0.0.1, localhost, or host.docker.internal (ports ${ODYSSEUS_OLLAMA_PORT}/11434)."')
runner_lines.append(' echo "Install Ollama, start an Ollama service/container on this server, or pick the port where it is already listening."')
runner_lines.append(' echo "ERROR: Ollama not found on this server. Install it from https://ollama.com/download or `curl -fsSL https://ollama.com/install.sh | sh`."')
runner_lines.append(' echo')
runner_lines.append(' echo "=== Process exited with code 127 ==="')
runner_lines.append(' exec bash -i')
@@ -2017,12 +2081,14 @@ def setup_cookbook_routes() -> APIRouter:
sid = line.split(":", 1)[0].strip()
if not sid or not _SESSION_ID_RE.match(sid):
continue
# Only adopt sessions that LOOK like model serves; ignore
# bare numeric tmux sessions and unrelated work.
if not (sid.startswith("serve-") or sid.startswith("cookbook-")):
continue
if sid in known_sids:
continue
# Adopt any session whose pane is currently running a
# known model-server process (checked below). The earlier
# prefix gate (serve-/cookbook-) dropped legitimate
# serves whenever tmux fell back to numeric IDs, leaving
# them invisible in the Cookbook UI — so the user could
# neither see nor stop them.
# Skip zombie / idle-shell sessions. A tmux session left
# over from a crashed vllm just shows a bash prompt —
# adopting it would pollute the UI with "running" tasks

View File

@@ -18,6 +18,119 @@ from routes.prefs_routes import _load_for_user, _save_for_user
logger = logging.getLogger(__name__)
def _maybe_cascade_calendar_event(task) -> None:
"""Delete the linked calendar event when a cookbook_serve task is
removed. Two lookup strategies:
1. PRIMARY — `cookbook_event_uid` marker stashed in task.prompt
by cookbookSchedule.js right after creating the event. Direct
UID match, no ambiguity.
2. FALLBACK — for tasks created before the marker was wired up
(or when the PATCH to add the marker failed silently), scan
the Cookbook calendar for events whose summary equals the
task name and delete the matches.
Best-effort throughout: errors are logged but never block the task
deletion itself."""
if not task or task.task_type != "action" or task.action != "cookbook_serve":
return
import httpx
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
headers = {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}
if task.owner:
headers["X-Odysseus-Owner"] = task.owner
# Strategy 1: explicit UID marker in prompt.
event_uid = ""
if task.prompt:
try:
cfg = json.loads(task.prompt)
if isinstance(cfg, dict):
event_uid = (cfg.get("cookbook_event_uid") or "").strip()
except Exception:
pass
def _try_delete(uid: str) -> bool:
try:
with httpx.Client(timeout=10) as client:
r = client.delete(
f"http://localhost:7000/api/calendar/events/{uid}",
headers=headers,
)
if r.status_code >= 400:
logger.info(
f"task delete: cascade calendar event {uid} returned "
f"HTTP {r.status_code}"
)
return False
return True
except Exception as e:
logger.warning(f"task delete: cascade calendar event {uid} failed: {e}")
return False
if event_uid:
_try_delete(event_uid)
return
# Strategy 2: scan the Cookbook calendar for matching summaries.
# Only runs for tasks missing the marker (old tasks or PATCH failures).
if not task.name:
return
try:
with httpx.Client(timeout=10) as client:
# Find the Cookbook calendar.
cal_r = client.get("http://localhost:7000/api/calendar/calendars", headers=headers)
if cal_r.status_code >= 400:
return
cals = (cal_r.json() or {}).get("calendars", [])
cookbook_cal = next(
(c for c in cals if (c.get("name") or "").lower() == "cookbook"),
None,
)
if not cookbook_cal:
return
cal_href = cookbook_cal.get("href") or cookbook_cal.get("id") or ""
# List events in a wide window to catch recurring + upcoming.
from datetime import datetime as _dt, timedelta as _td, timezone as _tz
now = _dt.now(_tz.utc)
start = (now - _td(days=30)).isoformat()
end = (now + _td(days=365)).isoformat()
ev_r = client.get(
"http://localhost:7000/api/calendar/events",
params={"start": start, "end": end, "calendar": cal_href},
headers=headers,
)
if ev_r.status_code >= 400:
return
events = (ev_r.json() or {}).get("events", [])
# Match by exact summary. Tasks named "Serve: <model>" are
# created from the schedule modal; the event's summary mirrors
# the task name 1:1 by design.
target = (task.name or "").strip()
uids_to_delete = set()
for ev in events:
if (ev.get("summary") or "").strip() != target:
continue
uid = ev.get("uid") or ev.get("id") or ""
# Strip the "::occurrence" suffix on recurring expansions —
# we want to delete the MASTER once, not each instance.
if "::" in uid:
uid = uid.split("::", 1)[0]
if uid:
uids_to_delete.add(uid)
for uid in uids_to_delete:
_try_delete(uid)
if uids_to_delete:
logger.info(
f"task delete: cascade matched {len(uids_to_delete)} calendar event(s) "
f"by summary fallback for task {task.id} ({target!r})"
)
except Exception as e:
logger.warning(f"task delete: cascade fallback scan failed: {e}")
class TaskCreate(BaseModel):
name: Optional[str] = None
prompt: Optional[str] = None
@@ -616,6 +729,12 @@ def setup_task_routes(task_scheduler) -> APIRouter:
raise HTTPException(404, "Task not found")
if user and task.owner != user:
raise HTTPException(403, "Access denied")
# Cascade: cookbook_serve tasks may have a linked calendar
# event (created via the "Create event in calendar" toggle
# in the schedule modal). If so, delete the calendar event
# too so the calendar doesn't end up holding a phantom event
# for a task that no longer exists.
_maybe_cascade_calendar_event(task)
db.delete(task)
db.commit()
return {"ok": True}

View File

@@ -2001,6 +2001,197 @@ async def action_check_email_urgency(owner: str, **kwargs) -> Tuple[str, bool]:
return str(e), False
async def action_cookbook_serve(
owner: str,
task_name: str = "",
progress_cb=None,
command: str = "",
**kwargs,
) -> Tuple[str, bool]:
"""Launch a Cookbook model serve as a scheduled task.
`command` is the JSON config string the task carries in `prompt`,
of shape: {"preset": "name"} OR {"repo_id": "...", "cmd": "...", "host": "..."}.
Optional `end_after_min: N` schedules a hard-stop N minutes after launch
(handled by cookbook_serve_lifecycle_loop in src/cookbook_serve_lifecycle.py).
"""
import json
import time as _time
import httpx
from pathlib import Path
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
from core.atomic_io import atomic_write_json
headers = {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}
try:
cfg = json.loads(command or "{}")
except Exception:
return f"Invalid JSON config: {command!r}", False
if not isinstance(cfg, dict):
return "Config must be a JSON object", False
# Resolve the preset (if named) OR fall through with explicit fields.
preset_name = (cfg.get("preset") or "").strip()
repo_id = (cfg.get("repo_id") or "").strip()
cmd = (cfg.get("cmd") or "").strip()
host = (cfg.get("host") or cfg.get("remote_host") or "").strip()
try:
end_after_min = int(cfg.get("end_after_min") or 0)
except Exception:
end_after_min = 0
state_path = Path("/app/data/cookbook_state.json")
try:
state = json.loads(state_path.read_text(encoding="utf-8")) if state_path.exists() else {}
except Exception:
state = {}
# Preset lookup. Try three matching strategies in order so the
# schedule still works even when the user's preset is named
# differently from the model's short name:
#
# 1. Exact preset.name == preset_name (case-insensitive)
# 2. preset.model / preset.modelId == repo_id (caller knows the repo)
# 3. preset.model's short name (after final /) == preset_name
#
# Without #2 and #3, scheduling "Qwen3.5-397B-A17B-AWQ" failed when
# the saved preset was named "vllm-qwen-397b" or had the model field
# populated with the full HF repo path. Either should resolve.
def _short(name: str) -> str:
return (name or "").rsplit("/", 1)[-1].lower()
if not cmd or not repo_id:
presets = state.get("presets") or []
chosen = None
# Strategy 1: exact name match.
if preset_name:
chosen = next(
(p for p in presets if isinstance(p, dict)
and (p.get("name") or "").lower() == preset_name.lower()),
None,
)
# Strategy 2: repo_id matches the preset's model field.
if chosen is None and repo_id:
chosen = next(
(p for p in presets if isinstance(p, dict)
and (p.get("model") or p.get("modelId") or "").lower() == repo_id.lower()),
None,
)
# Strategy 3: model's short name matches the preset_name.
if chosen is None and preset_name:
chosen = next(
(p for p in presets if isinstance(p, dict)
and _short(p.get("model") or p.get("modelId") or "") == preset_name.lower()),
None,
)
if chosen is not None:
repo_id = repo_id or chosen.get("model") or chosen.get("modelId") or ""
cmd = cmd or (chosen.get("cmd") or "").strip()
host = host or chosen.get("host") or chosen.get("remoteHost") or ""
if not repo_id or not cmd or cmd.startswith("(adopted"):
# Surface what we tried so the user can name their preset to match.
preset_names = [(p.get("name") or "") for p in (state.get("presets") or []) if isinstance(p, dict)]
hint = f" Saved presets: {preset_names!r}" if preset_names else ""
return (f"No launchable config for {preset_name!r} (repo_id={repo_id!r}). "
f"Check Cookbook → Presets has a real cmd, not 'adopted'.{hint}", False)
# Resolve env_prefix etc. from the host's saved cookbook server entry,
# matching the chat agent's serve_model path.
body = {"repo_id": repo_id, "cmd": cmd}
if host:
body["remote_host"] = host
env = (state.get("env") or {})
srv = next(
(s for s in (env.get("servers") or [])
if isinstance(s, dict) and (s.get("host") == host or s.get("name") == host)),
{},
)
if srv.get("env") == "venv" and srv.get("envPath"):
body["env_prefix"] = f"source {srv['envPath']}/bin/activate"
elif srv.get("env") == "conda" and srv.get("envPath"):
body["env_prefix"] = f"conda activate {srv['envPath']}"
if srv.get("hfToken"): body["hf_token"] = srv["hfToken"]
if srv.get("port"): body["ssh_port"] = str(srv["port"])
if srv.get("platform"): body["platform"] = srv["platform"]
try:
async with httpx.AsyncClient(timeout=30) as client:
r = await client.post("http://localhost:7000/api/model/serve",
json=body, headers=headers)
data = r.json() if r.content else {}
except Exception as e:
return f"Launch HTTP failed: {e}", False
if not data.get("ok"):
return f"Launch rejected: {data.get('error') or data.get('detail') or 'unknown'}", False
sid = data.get("session_id") or ""
# Register the new task in cookbook_state.json + stamp it with our
# scheduler-owner markers. /api/model/serve spawns the tmux session
# but leaves the state-write to the UI — when a scheduled action
# launches a serve from server-side, NOBODY writes the task into
# state, so the Cookbook tab never shows it. We do the write here.
if sid:
try:
# Re-read fresh (the route may have updated state already).
try:
fresh = json.loads(state_path.read_text(encoding="utf-8"))
except Exception:
fresh = {}
if not isinstance(fresh, dict):
fresh = {}
tasks = fresh.get("tasks") if isinstance(fresh.get("tasks"), list) else []
existing = next(
(t for t in tasks if isinstance(t, dict) and t.get("sessionId") == sid),
None,
)
if existing is None:
display_name = repo_id.split("/")[-1] if "/" in repo_id else repo_id
placeholder = (
f"Launched by scheduled task {task_name!r} — waiting for tmux output…\n"
f" session: {sid}\n"
f" target: {host or 'local'}\n"
f" cmd: {cmd[:200]}{'' if len(cmd) > 200 else ''}"
)
existing = {
"id": sid,
"sessionId": sid,
"name": display_name,
"modelId": repo_id,
"type": "serve",
"status": "running",
"output": placeholder,
"ts": int(_time.time() * 1000),
"payload": {"repo_id": repo_id, "remote_host": host or "", "_cmd": cmd},
"remoteHost": host or "",
"sshPort": "",
"platform": "linux",
"_serveReady": False,
"_endpointAdded": False,
}
tasks.append(existing)
# Stamp ownership + end-at on the task entry.
existing["_scheduledByTask"] = task_name or ""
existing["_scheduledByOwner"] = owner or ""
if end_after_min > 0:
existing["_scheduledStopAtMs"] = int(_time.time() * 1000) + end_after_min * 60 * 1000
fresh["tasks"] = tasks
atomic_write_json(state_path, fresh)
except Exception as e:
logger.warning(f"cookbook_serve: state register/stamp failed: {e}")
# Don't try to render absolute clock time in the message — the
# server runs in UTC (Docker default), the user reads it as local,
# and the offset depends on the user's TZ which the action doesn't
# have a reliable handle on. The Tasks UI already shows the RUN
# timestamp in the user's local time right above this message, so
# "stops 8 min after that" gives the user everything they need.
if end_after_min:
return (
f"Launched {repo_id} (session {sid}); stops {end_after_min} min after this ran",
True,
)
return f"Launched {repo_id} (session {sid})", True
BUILTIN_ACTIONS = {
"tidy_sessions": action_tidy_sessions,
"tidy_documents": action_tidy_documents,
@@ -2020,6 +2211,7 @@ BUILTIN_ACTIONS = {
"test_skills": action_test_skills,
"audit_skills": action_audit_skills,
"check_email_urgency": action_check_email_urgency,
"cookbook_serve": action_cookbook_serve,
# ping_notes removed from the registry — runs only inside `_note_pings_loop`.
}

View File

@@ -0,0 +1,193 @@
"""Cookbook serve lifecycle: kills scheduler-owned serves whose end-of-
window has passed.
Pairs with action_cookbook_serve in builtin_actions.py — that action
stamps the task it launches with `_scheduledStopAtMs`, this loop ticks
every 60s and kills any serve whose stamp is in the past.
Single small module. Delete this file + the registration line in app.py
and the feature stops doing anything; scheduler-launched serves just
stay up until the user kills them manually.
"""
from __future__ import annotations
import asyncio
import json
import logging
import time
from pathlib import Path
import httpx
logger = logging.getLogger(__name__)
def _internal_headers() -> dict:
from core.middleware import INTERNAL_TOOL_HEADER, INTERNAL_TOOL_TOKEN
return {INTERNAL_TOOL_HEADER: INTERNAL_TOOL_TOKEN}
async def _delete_endpoint_for_task(task: dict) -> None:
"""Drop the auto-registered model endpoint for a scheduled-stop serve.
Without this, killing the tmux session leaves the endpoint sitting in
the picker (probe goes offline; chats still try to route there) and
the user has to delete it by hand in Settings -> Endpoints.
"""
import re as _re
payload = task.get("payload") or {}
cmd = str(payload.get("_cmd") or "")
remote = task.get("remoteHost") or ""
# Build host the same way _auto_register_llm_endpoint does so URL match wins.
if remote:
host = remote.split("@")[-1] if "@" in remote else remote
else:
host = "host.docker.internal"
port_match = _re.search(r"--port\s+(\d+)", cmd)
ollama_host_match = _re.search(r"OLLAMA_HOST=[^\s]*?:(\d+)", cmd)
if port_match:
port = int(port_match.group(1))
elif ollama_host_match:
port = int(ollama_host_match.group(1))
elif "ollama" in cmd:
port = 11434
else:
port = 8080
base_url = f"http://{host}:{port}/v1"
try:
async with httpx.AsyncClient(timeout=8) as client:
r = await client.get(
"http://localhost:7000/api/model-endpoints",
headers=_internal_headers(),
)
if r.status_code >= 400:
return
eps = r.json() if r.content else []
# Prefer exact URL match; fall back to host:port substring so we
# still catch the case where 0.0.0.0 vs the registered host
# representation diverged.
ep = next((e for e in eps if e.get("base_url") == base_url), None)
if not ep:
hostport = f"{host}:{port}"
ep = next((e for e in eps if hostport in (e.get("base_url") or "")), None)
if ep:
await client.delete(
f"http://localhost:7000/api/model-endpoints/{ep['id']}",
headers=_internal_headers(),
)
logger.info(
f"cookbook_serve_lifecycle: deleted endpoint {ep.get('id')} "
f"({ep.get('base_url')}) after scheduled stop"
)
except Exception as e:
logger.warning(f"cookbook_serve_lifecycle: endpoint delete failed: {e}")
async def _stop_serve(session_id: str, remote_host: str = "", ssh_port: str = "") -> bool:
"""Kill the tmux session that hosts the serve.
There's no `/api/model/stop` route — the cookbook UI and the chat
agent both kill via `/api/shell/exec` running a `tmux kill-session`
(wrapped in ssh for remote hosts). Mirror that here so the
lifecycle loop can actually stop scheduler-launched serves at
window-end. Without this, the action stamped `_scheduledStopAtMs`
correctly but every kill attempt failed silently (the route
returned 404 and the result was logged as "failed").
"""
import shlex
if remote_host:
port_flag = f"-p {shlex.quote(str(ssh_port))} " if ssh_port and str(ssh_port) != "22" else ""
cmd = (
f"ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no "
f"{port_flag}{shlex.quote(remote_host)} "
f"'tmux kill-session -t {shlex.quote(session_id)}'"
)
else:
cmd = f"tmux kill-session -t {shlex.quote(session_id)}"
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.post(
"http://localhost:7000/api/shell/exec",
json={"command": cmd},
headers=_internal_headers(),
)
if r.status_code >= 400:
return False
data = r.json() if r.content else {}
ec = data.get("exit_code")
# tmux returns non-zero when the session is already gone
# ("can't find session: ..."). That's still "stop succeeded"
# from our POV — the goal is no live session at the end.
if ec in (None, 0):
return True
stderr = (data.get("stderr") or "").lower()
return "no server" in stderr or "can't find session" in stderr or "session not found" in stderr
except Exception as e:
logger.warning(f"cookbook_serve_lifecycle: stop {session_id} failed: {e}")
return False
async def _tick() -> None:
state_path = Path("/app/data/cookbook_state.json")
if not state_path.exists():
return
try:
state = json.loads(state_path.read_text(encoding="utf-8"))
except Exception:
return
tasks = state.get("tasks") or []
now_ms = int(time.time() * 1000)
to_stop = []
for t in tasks:
if not isinstance(t, dict):
continue
stop_at = t.get("_scheduledStopAtMs")
if not isinstance(stop_at, (int, float)):
continue
if stop_at > now_ms:
continue
if (t.get("status") or "").lower() in {"stopped", "ended", "killed", "crashed"}:
continue
sid = t.get("sessionId") or t.get("id")
if not sid:
continue
to_stop.append((sid, t.get("remoteHost") or "", t.get("sshPort") or ""))
if not to_stop:
return
# Re-read state once before writing so we capture any updates from
# concurrent UI syncs.
stopped_any = False
for sid, host, port in to_stop:
ok = await _stop_serve(sid, host, port)
logger.info(f"cookbook_serve_lifecycle: stop {sid} (host={host or 'local'}): {'ok' if ok else 'failed'}")
if ok:
stopped_any = True
# Drop the auto-registered endpoint so the model picker and
# the chat router don't keep pointing at a dead server.
for t in tasks:
if isinstance(t, dict) and (t.get("sessionId") == sid or t.get("id") == sid):
if t.get("type") == "serve":
await _delete_endpoint_for_task(t)
t["status"] = "stopped"
t["_scheduledStopAtMs"] = None
t["_lastStatusFlipAt"] = now_ms
break
if stopped_any:
try:
from core.atomic_io import atomic_write_json
state["tasks"] = tasks
atomic_write_json(state_path, state)
except Exception as e:
logger.warning(f"cookbook_serve_lifecycle: state write failed: {e}")
async def cookbook_serve_lifecycle_loop() -> None:
"""Forever-loop. Registered as a startup task in app.py."""
await asyncio.sleep(20) # let the rest of startup settle
while True:
try:
await _tick()
except Exception as e:
logger.warning(f"cookbook_serve_lifecycle tick failed: {e}")
await asyncio.sleep(60)

View File

@@ -1018,6 +1018,10 @@ class TaskScheduler:
kwargs = {"owner": task.owner, "task_name": task.name, "progress_cb": _progress}
if task.action in ("run_script", "run_local", "ssh_command") and task.prompt:
kwargs["script" if task.action in ("run_script", "run_local") else "command"] = task.prompt
# cookbook_serve carries its JSON config in task.prompt — feed it
# through as `command` so action_cookbook_serve can json.loads it.
elif task.action == "cookbook_serve" and task.prompt:
kwargs["command"] = task.prompt
result, success = await action_fn(**kwargs)
return result, success
except TaskNoop:

View File

@@ -2283,6 +2283,7 @@
<script type="module" src="/static/js/chatStream.js"></script>
<script type="module" src="/static/js/chat.js?v=20260604s"></script>
<script type="module" src="/static/js/cookbook.js"></script>
<script src="/static/js/cookbookSchedule.js"></script>
<script type="module" src="/static/js/search-chat.js"></script>
<script type="module" src="/static/js/compare/index.js"></script>
<script type="module" src="/static/js/theme.js"></script>

View File

@@ -2094,7 +2094,7 @@ export function _renderRunningTab() {
// Edit serve — open the full serve panel (same as the edit icon),
// switching to this task's server first so the model is found.
if (task.type === 'serve' && task.payload?.repo_id) {
items.push({ label: 'Edit serve', action: 'edit-panel', custom: () => _openEdit() });
items.push({ label: 'Edit in serve panel', action: 'edit-panel', tooltip: 'Open the full Serve config panel pre-filled with this task — pick a different backend, change GPUs, edit env vars, then Launch from there', custom: () => _openEdit() });
}
// Save serve — save current launch config as a preset.
if (task.type === 'serve' && task.payload?._cmd) {
@@ -2107,7 +2107,7 @@ export function _renderRunningTab() {
// Edit command — only meaningful for serve tasks that aren't running.
// Lets the user tweak flags after a crash/error and relaunch.
if (task.type === 'serve' && task.status !== 'running' && task.payload?._cmd) {
items.push({ label: 'Edit command', action: 'edit', custom: async () => {
items.push({ label: 'Edit cmd & relaunch', action: 'edit', tooltip: 'Edit the raw vllm/llama-server cmd string in a dialog and relaunch immediately on the same host', custom: async () => {
const newCmd = await _promptEditServeCmd(task.payload._cmd);
if (newCmd == null) return; // cancelled
try {
@@ -2201,7 +2201,19 @@ export function _renderRunningTab() {
_copyText(last);
uiModule.showToast('Copied last 50 lines');
}});
items.push({ label: 'Remove', action: 'kill', danger: true });
// Label matches behavior — the kill handler ALWAYS first kills
// the live tmux session and (for serve tasks) deletes the
// matching model-endpoint, THEN animates the task card out.
// Just "Remove" hid that it stops the live serve too.
const _isLive = task.type === 'serve' && ['running', 'ready', 'loading', 'warming', 'starting'].includes(task.status || '');
items.push({
label: _isLive ? 'Stop and remove' : 'Remove',
action: 'kill',
tooltip: _isLive
? 'Kill the live tmux session, deregister the chat endpoint, and remove this row'
: 'Remove this row',
danger: true,
});
// Cancel = mobile-only dismiss item. Same pattern as the email kebab:
// the `dropdown-cancel-mobile` class is hidden on desktop and styled
// as a separated bottom row on mobile (border-top + extra padding).
@@ -2228,6 +2240,7 @@ export function _renderRunningTab() {
+ (item.danger ? ' cookbook-dropdown-danger' : '')
+ (item.mobileOnly ? ' dropdown-cancel-mobile' : '');
div.style.cssText = 'display:flex;align-items:center;gap:8px;';
if (item.tooltip) div.title = item.tooltip;
const ic = _MENU_ICONS[item.action] || '';
div.innerHTML = `<span style="display:inline-flex;flex-shrink:0;opacity:0.7;"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${ic}</svg></span><span>${item.label}</span>`;
div.addEventListener('click', () => {
@@ -2347,22 +2360,57 @@ export function _renderRunningTab() {
_animateOutThenRemove(el, task.sessionId);
});
// Wire kill
el.querySelector('.cookbook-task-action-kill').addEventListener('click', () => {
// Wire kill — awaits the SSH/tmux kill and verifies the session is
// actually gone before removing the row. Previously fire-and-forget,
// which meant a failed kill (wrong remoteHost, SSH error, tmux server
// already exited) silently left the live serve running while the
// row disappeared from the UI.
el.querySelector('.cookbook-task-action-kill').addEventListener('click', async () => {
const outputText = el.querySelector('.cookbook-output-pre')?.textContent || task.output || '';
const isLive = task.type === 'serve' && ['running', 'ready', 'loading', 'warming', 'starting'].includes(task.status || '');
const ollamaUnload = _ollamaUnloadCommand(task, outputText);
if (ollamaUnload) {
fetch('/api/shell/exec', {
try {
await fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: ollamaUnload }),
});
} catch (_) { /* unload best-effort */ }
}
let killOk = true;
try {
const r = await fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: ollamaUnload }),
}).catch(() => {});
body: JSON.stringify({ command: _tmuxGracefulKill(task) }),
});
if (r.ok) {
const out = await r.json();
// Don't trust exit_code alone — tmux kill returns 0 even when
// there was nothing to kill. Verify the session is actually gone.
if (task.sessionId && isLive) {
try {
const probe = await fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: _tmuxCmd(task, `has-session -t ${task.sessionId}`) }),
});
if (probe.ok) {
const pj = await probe.json();
// has-session exits 0 when session STILL exists; non-zero = gone.
if ((pj.exit_code || 0) === 0) killOk = false;
}
} catch (_) { /* probe best-effort; trust kill */ }
}
} else {
killOk = false;
}
} catch (_) { killOk = false; }
if (!killOk) {
try { uiModule.showToast('Kill failed — session may still be running. Check `tmux ls` on the server.', 'error'); } catch (_) {}
return; // leave the row so the user can retry
}
fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: _tmuxGracefulKill(task) }),
}).catch(() => {});
if (task.type === 'serve' && task.payload) {
const endpointUrl = _endpointUrlForTask(task, outputText);
_removeEndpointByUrl(endpointUrl);
@@ -2401,7 +2449,13 @@ export function _renderRunningTab() {
if (targetBody) targetBody.appendChild(el);
else group.appendChild(el);
if (task.status === 'running') {
// Auto-attach the tmux output stream for any task whose underlying
// session could still be alive — not just 'running'. Scheduler-
// launched serves transition to 'ready' as soon as /v1/models
// responds; without this, the user opens the Running tab and sees
// only the placeholder ("Launched by scheduled task …") because
// _reconnectTask never fires for status 'ready'/'loading'/'warming'.
if (['running', 'ready', 'loading', 'warming', 'starting'].includes(task.status)) {
_reconnectTask(el, task);
}
}

View File

@@ -0,0 +1,386 @@
// Cookbook Schedule — opens a small inline form (styled with the app's
// existing .cookbook-* classes) that creates a ScheduledTask with
// action=cookbook_serve. Mounted from two places:
//
// 1. The ^ button next to Launch in a serve panel.
// 2. The "Schedule…" entry in the cached-model ⋯ dropdown menu (which
// programmatically clicks the ^ button so this module owns the
// single source of truth).
//
// Feedback uses uiModule.showToast() — the same toast the rest of the
// app uses for "Saved", "Favorited", etc. — so the success message
// doesn't introduce a parallel notification style.
//
// To remove: delete this file + the <script> tag in index.html + the
// ^ button in cookbookServe.js + the "cookbook_serve" entry in
// BUILTIN_ACTIONS + src/cookbook_serve_lifecycle.py + its
// registration line in app.py.
try { (function () {
function _safe(fn) {
return function () {
try { return fn.apply(this, arguments); }
catch (e) { try { console.warn("[cookbookSchedule]", e); } catch (_) {} }
};
}
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
// Cached handle to the ui.js showToast function. Bound lazily on
// first use because ui.js is an ES module — it's not on `window`
// unless something else has explicitly exposed it.
let _toastFn = null;
async function _getToast() {
if (_toastFn) return _toastFn;
try {
const m = await import("/static/js/ui.js");
_toastFn = m.default?.showToast || m.showToast || null;
} catch (_) { _toastFn = null; }
return _toastFn;
}
// Optional opts: {action, onAction, duration, leadingIcon}
async function toast(msg, opts) {
const fn = await _getToast();
if (fn) {
try { fn(msg, opts); return; } catch (_) {}
}
try { console.log("[toast]", msg); } catch (_) {}
}
// Cached handle to the tasks module so the success toast's "Open"
// action can jump straight to the new task in the Tasks tab.
let _tasksMod = null;
async function _getTasksMod() {
if (_tasksMod) return _tasksMod;
try { _tasksMod = await import("/static/js/tasks.js"); } catch (_) {}
return _tasksMod;
}
async function openTaskInTasksTab(taskId) {
const m = await _getTasksMod();
if (m && typeof m.openTasks === "function") {
try { m.openTasks(taskId); return; } catch (_) {}
}
// Last-resort fallback: click the sidebar Tasks button.
document.getElementById("tool-tasks-btn")?.click();
}
const DAYS = [
{ k: "MO", l: "Mon", idx: 0 },
{ k: "TU", l: "Tue", idx: 1 },
{ k: "WE", l: "Wed", idx: 2 },
{ k: "TH", l: "Thu", idx: 3 },
{ k: "FR", l: "Fri", idx: 4 },
{ k: "SA", l: "Sat", idx: 5 },
{ k: "SU", l: "Sun", idx: 6 },
];
const WEEKDAYS = new Set(["MO","TU","WE","TH","FR"]);
// Resolve the model identity from the closest .memory-item card —
// that's the canonical container the cookbook serve UI uses, with
// the model repo on data-repo. We do NOT grab the title via
// textContent, because the title row also contains inline status
// pills ("running", "downloading") and an "HF ↗" link — pulling all
// of it in turns a clean preset name like "Qwen3.5-397B-A17B-AWQ"
// into "Qwen3.5-397B-A17B-AWQ running HF ↗", which then fails the
// preset lookup in action_cookbook_serve.
function readPanelConfig(arrowBtn) {
const item = arrowBtn.closest(".memory-item") || arrowBtn.closest(".hwfit-cached-item");
const panel = arrowBtn.closest(".hwfit-serve-panel");
const repo = item?.dataset?.repo
|| arrowBtn.closest(".hwfit-serve-panel")?.dataset?.repo
|| "";
// Title = last segment of the repo (after the final /), which is
// exactly what the cookbook UI renders in the card title and what
// the preset registry uses as its short name. e.g.
// cyankiwi/Qwen3.5-397B-A17B-AWQ → Qwen3.5-397B-A17B-AWQ
// Falls back to data-modelName or the bare repo for ollama-style
// entries that don't have a slash.
let title = "";
if (repo) {
title = repo.includes("/") ? repo.split("/").pop() : repo;
}
if (!title) {
title = item?.dataset?.modelName || "model";
}
return { panel, item, title, repo_id: repo, host: item?.dataset?.host || "" };
}
function buildFormHtml(cfg) {
return `
<div class="hwfit-schedule-form cookbook-panel">
<div class="hwfit-schedule-title">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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>
<span class="hwfit-schedule-title-text">Schedule serve: <strong>${esc(cfg.title)}</strong></span>
<span class="hwfit-schedule-title-spacer"></span>
<label class="hwfit-schedule-mirror-toggle" title="Also create a calendar event on the Cookbook calendar">
<span class="hwfit-schedule-mirror-label">Create event in calendar</span>
<span class="admin-switch hwfit-schedule-mirror-switch">
<input type="checkbox" class="hwfit-sched-calendar-mirror" />
<span class="admin-slider"></span>
</span>
</label>
</div>
<div class="hwfit-schedule-row">
<label class="hwfit-schedule-field">
<span>From</span>
<input type="time" class="hwfit-sched-start cookbook-field-input" value="09:00" />
</label>
<label class="hwfit-schedule-field">
<span>Until</span>
<input type="time" class="hwfit-sched-end cookbook-field-input" value="17:00" />
</label>
</div>
<div class="hwfit-schedule-row hwfit-schedule-days-row">
<span class="hwfit-schedule-label">Days</span>
<div class="hwfit-sched-days">
${DAYS.map(d => `
<button type="button" class="hwfit-sched-day-chip${WEEKDAYS.has(d.k) ? " is-on" : ""}" data-day="${d.k}">${d.l}</button>
`).join("")}
</div>
<span class="hwfit-schedule-actions-spacer"></span>
<button type="button" class="cookbook-btn hwfit-sched-cancel" title="Cancel">
<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>
<span>Cancel</span>
</button>
<button type="button" class="cookbook-btn hwfit-sched-save" title="Save schedule" aria-label="Save schedule">
<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>
<span>Save</span>
</button>
</div>
<div class="hwfit-sched-err"></div>
</div>`;
}
function openForm(arrowBtn) {
const cfg = readPanelConfig(arrowBtn);
const anchor = cfg.panel
|| cfg.item
|| arrowBtn.closest(".cookbook-saved-item")
|| arrowBtn.parentElement?.parentElement
|| arrowBtn.parentElement;
if (!anchor) {
toast("Couldn't find a panel to mount the schedule form");
return;
}
// Toggle.
const existing = anchor.querySelector(".hwfit-schedule-form");
if (existing) { existing.remove(); return; }
const tmp = document.createElement("div");
tmp.innerHTML = buildFormHtml(cfg);
const form = tmp.firstElementChild;
anchor.appendChild(form);
setTimeout(() => {
try { form.scrollIntoView({ behavior: "smooth", block: "nearest" }); } catch (_) {}
}, 50);
wireForm(form, cfg);
}
function wireForm(form, cfg) {
form.querySelectorAll(".hwfit-sched-day-chip").forEach(chip => {
chip.addEventListener("click", () => chip.classList.toggle("is-on"));
});
form.querySelector(".hwfit-sched-cancel").addEventListener("click", () => form.remove());
form.querySelector(".hwfit-sched-save").addEventListener("click", _safe(async () => {
const startTime = form.querySelector(".hwfit-sched-start").value;
const endTime = form.querySelector(".hwfit-sched-end").value;
const days = Array.from(form.querySelectorAll(".hwfit-sched-day-chip.is-on")).map(c => c.dataset.day);
const mirrorToCalendar = !!form.querySelector(".hwfit-sched-calendar-mirror")?.checked;
const errEl = form.querySelector(".hwfit-sched-err");
errEl.textContent = "";
errEl.classList.remove("is-visible");
function fail(msg) {
errEl.textContent = msg;
errEl.classList.add("is-visible");
}
if (!/^\d\d:\d\d$/.test(startTime) || !/^\d\d:\d\d$/.test(endTime)) {
return fail("Start and end must be HH:MM");
}
if (!days.length) {
return fail("Pick at least one day");
}
const [sh, sm] = startTime.split(":").map(Number);
const [eh, em] = endTime.split(":").map(Number);
let dur = (eh * 60 + em) - (sh * 60 + sm);
if (dur <= 0) dur += 24 * 60;
// The backend stores scheduled_time as UTC. The user picks
// wall-clock LOCAL time. Without converting, "09:55" in a UTC+9
// timezone gets stored as 09:55 UTC = 18:55 local → next-run
// shows ~9 hours later instead of "in 5 min". Mirror what
// tasks.js does via its _localTimeToUtc helper.
const _localHHMMToUtc = (hhmm) => {
const [h, m] = hhmm.split(":").map(Number);
const d = new Date();
d.setHours(h, m, 0, 0);
return `${String(d.getUTCHours()).padStart(2, "0")}:${String(d.getUTCMinutes()).padStart(2, "0")}`;
};
const startUtc = _localHHMMToUtc(startTime);
const [shUtc, smUtc] = startUtc.split(":").map(Number);
const allDays = days.length === 7;
const weekdaysOnly = days.length === 5 && ["MO","TU","WE","TH","FR"].every(d => days.includes(d));
const sched = {};
if (allDays) {
sched.schedule = "daily";
sched.scheduled_time = startUtc;
} else if (weekdaysOnly) {
sched.schedule = "cron";
sched.cron_expression = `${smUtc} ${shUtc} * * 1-5`;
} else if (days.length === 1) {
const dayIdx = DAYS.find(d => d.k === days[0]).idx;
sched.schedule = "weekly";
sched.scheduled_time = startUtc;
sched.scheduled_day = dayIdx;
} else {
const dayNum = days.map(k => {
const i = DAYS.find(d => d.k === k).idx;
return i === 6 ? 0 : i + 1;
});
sched.schedule = "cron";
sched.cron_expression = `${smUtc} ${shUtc} * * ${dayNum.join(",")}`;
}
// Name: "Serve: <full model name>" — pulled from .memory-item-title
// so it's the user's display name (e.g. "Qwen3.5-397B-A17B-AWQ")
// not a placeholder like "model".
const fullName = (cfg.title || cfg.repo_id || "").trim() || "model";
const payload = {
name: `Serve: ${fullName}`,
task_type: "action",
action: "cookbook_serve",
trigger_type: "schedule",
prompt: JSON.stringify({
preset: fullName,
repo_id: cfg.repo_id || "",
host: cfg.host || "",
end_after_min: dur,
}),
...sched,
};
const saveBtn = form.querySelector(".hwfit-sched-save");
saveBtn.disabled = true;
saveBtn.textContent = "Saving…";
try {
const r = await fetch("/api/tasks", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
const data = await r.json();
if (!r.ok || data.error) {
fail(data.error || data.detail || `HTTP ${r.status}`);
saveBtn.disabled = false;
saveBtn.textContent = "Save schedule";
toast(`Schedule save failed: ${data.error || data.detail || r.status}`);
return;
}
if (mirrorToCalendar) {
// Mirror onto a dedicated "Cookbook" calendar so the user can
// toggle the whole set on/off as a unit in the calendar UI.
// Best-effort: if anything here fails, we still consider the
// task creation a success (the task itself works regardless).
try {
const calsRes = await fetch("/api/calendar/calendars", { credentials: "same-origin" });
const calsBody = calsRes.ok ? await calsRes.json() : {};
let cookbookCal = (calsBody.calendars || []).find(c => (c.name || "").toLowerCase() === "cookbook");
if (!cookbookCal) {
const mk = await fetch("/api/calendar/calendars?name=Cookbook&color=%233b82f6", {
method: "POST", credentials: "same-origin",
});
if (mk.ok) {
const mkData = await mk.json();
// The create endpoint returns {ok, id, name, color}; the
// list endpoint returns {href, name, color}. The two map
// 1:1 (href === id) so we synthesize the same shape.
cookbookCal = { href: mkData.id, name: mkData.name, color: mkData.color };
}
}
// The `cookbook_task_id:` marker on its own line lets
// calendar.js's event-form code detect that this event was
// created from a Cookbook schedule and render an
// "Open task" button alongside the description, so the user
// can jump straight to the source task from the calendar UI.
const evBody = {
summary: payload.name,
dtstart: new Date().toISOString(),
dtend: new Date(Date.now() + dur * 60 * 1000).toISOString(),
all_day: false,
description: `Auto-mirrored from Cookbook schedule task ${data.id || ""}.\n`
+ `Edit/delete the task in the Tasks tab — this event will follow.\n`
+ `cookbook_task_id: ${data.id || ""}`,
rrule: weekdaysOnly
? "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR"
: (sched.schedule === "weekly" ? `FREQ=WEEKLY;BYDAY=${days.join(",")}`
: (sched.schedule === "daily" ? "FREQ=DAILY" : "FREQ=WEEKLY")),
color: "#3b82f6",
};
if (cookbookCal?.href) evBody.calendar_href = cookbookCal.href;
const evRes = await fetch("/api/calendar/events", {
method: "POST", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(evBody),
});
const evData = evRes.ok ? await evRes.json() : null;
// Stash the event uid + calendar href on the task's prompt
// JSON so the task-delete hook can cascade the calendar
// cleanup. PATCH the task with an updated prompt.
if (evData && (evData.uid || evData.id)) {
const eventUid = evData.uid || evData.id;
try {
const updatedPrompt = JSON.stringify({
...JSON.parse(payload.prompt),
cookbook_event_uid: eventUid,
cookbook_event_calendar: cookbookCal?.href || "",
});
// /api/tasks/{id} accepts PUT, not PATCH — sending PATCH
// here silently failed (no such method on that route), so
// the task never got the cookbook_event_uid marker and the
// server-side delete-cascade had nothing to follow when the
// user later deleted the task.
await fetch(`/api/tasks/${encodeURIComponent(data.id)}`, {
method: "PUT", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: updatedPrompt }),
});
} catch (_) {}
}
} catch (_) {}
}
form.remove();
const newTaskId = data.id || data.task_id || "";
toast(`Created task: Serve: ${fullName}`, {
leadingIcon: "check",
action: "Open",
duration: 5000,
onAction: () => openTaskInTasksTab(newTaskId),
});
} catch (e) {
fail(String(e));
saveBtn.disabled = false;
saveBtn.textContent = "Save schedule";
toast(`Schedule save failed: ${e}`);
}
}));
}
document.addEventListener("click", _safe((e) => {
const arrow = e.target.closest && e.target.closest(".hwfit-serve-schedule-arrow");
if (!arrow) return;
e.preventDefault();
e.stopPropagation();
openForm(arrow);
}));
})(); } catch (e) { try { console.warn("[cookbookSchedule] top-level error:", e); } catch (_) {} }

View File

@@ -294,19 +294,23 @@ function _rerenderCachedModels() {
}
const ggufCount = _runnableGgufFiles(m).length;
if (ggufCount > 1) metaParts.push(`${ggufCount} GGUFs`);
if (m.status === 'downloading') {
const _active = _isActivelyDownloading(m.repo_id);
metaParts.push(`<span class="cookbook-dl-status" style="color:var(--accent,var(--red));">${_active ? 'downloading' : 'download stalled'}</span>`);
}
// "downloading" status now renders as a title-row pill instead of
// a meta-row text label, matching the "running" pill style and
// living on the same line as the model name.
const _isDownloading = m.status === 'downloading';
const _isDlActive = _isDownloading ? _isActivelyDownloading(m.repo_id) : false;
const isSelectMode = document.getElementById('hwfit-cache-select')?.classList.contains('active');
html += `<div class="doclib-card memory-item" data-repo="${esc(m.repo_id)}" data-tag="${m._tag || ''}" data-family="${m._family || ''}" style="cursor:pointer;">`;
html += `<span class="serve-select-cb memory-select-dot" style="display:${isSelectMode ? 'inline-block' : 'none'};cursor:pointer;"></span>`;
html += `<div style="flex:1;min-width:0;">`;
const _mc = modelColor(m.repo_id) || '';
const _runningPill = _isActivelyServing(m.repo_id)
? ' <span class="cookbook-serve-running-pill" title="This model is currently being served">running</span>'
? ` <span class="cookbook-serve-running-pill is-clickable" title="This model is currently being served — click to open in Running" data-repo="${esc(m.repo_id)}" role="button" tabindex="0">running</span>`
: '';
html += `<div class="memory-item-title"${_mc ? ` style="color:${_mc}"` : ''}>${modelLogo(m.repo_id)}${esc(shortName)}${hfLink ? ` <a href="${esc(hfLink)}" target="_blank" rel="noopener" class="cookbook-hf-link">HF ↗</a>` : ''}${_runningPill}</div>`;
const _downloadingPill = _isDownloading
? ` <span class="cookbook-serve-downloading-pill${_isDlActive ? '' : ' is-stalled'}" title="${_isDlActive ? 'Download in progress' : 'Download stalled — retry to resume'}">${_isDlActive ? 'downloading' : 'stalled'}</span>`
: '';
html += `<div class="memory-item-title"${_mc ? ` style="color:${_mc}"` : ''}>${modelLogo(m.repo_id)}${esc(shortName)}${hfLink ? ` <a href="${esc(hfLink)}" target="_blank" rel="noopener" class="cookbook-hf-link">HF ↗</a>` : ''}${_runningPill}${_downloadingPill}</div>`;
html += `<div class="memory-item-meta" style="font-size:10px;opacity:0.4;margin-top:2px;">${metaParts.join(' \u00b7 ')}</div>`;
html += `</div>`;
const _bk = _detectBackend(m).backend;
@@ -388,9 +392,11 @@ function _rerenderCachedModels() {
const _retryIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>';
const _deleteIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/></svg>';
const _selectIco = '<span style="font-size:16px;line-height:1;position:relative;top:-2px;">●</span>';
const _schedIco = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>';
const items = [];
if (m && m.status === 'ready') items.push({ label: 'Serve', icon: _serveIco, action: 'serve' });
if (m && m.status === 'downloading') items.push({ label: 'Retry', icon: _retryIco, action: 'retry' });
if (m && m.status === 'ready') items.push({ label: 'Schedule…', icon: _schedIco, action: 'schedule' });
items.push({ label: 'Select', icon: _selectIco, action: 'select' });
items.push({ label: 'Delete', icon: _deleteIco, action: 'delete', danger: true });
for (const opt of items) {
@@ -402,6 +408,16 @@ function _rerenderCachedModels() {
if (opt.action === 'serve') item.click();
else if (opt.action === 'delete') _deleteCachedModel(repo, item, false, m);
else if (opt.action === 'retry') _retryCachedModel(repo, m);
else if (opt.action === 'schedule') {
// Same entry point as the ^ button next to Launch — let
// cookbookSchedule.js handle it. Expand the panel first
// so the form has somewhere to mount.
if (!item.querySelector('.hwfit-serve-panel')) item.click();
setTimeout(() => {
const arrow = item.querySelector('.hwfit-serve-schedule-arrow');
if (arrow) arrow.click();
}, 120);
}
else if (opt.action === 'select') {
const selectBtn = document.getElementById('hwfit-cache-select');
const bulkBar = document.getElementById('serve-bulk-bar');
@@ -743,8 +759,16 @@ function _rerenderCachedModels() {
// Copy moved inside the command textarea (top-right). Spacer then
// pushes Cancel + Launch to the right.
panelHtml += `<span class="hwfit-serve-actions-spacer"></span>`;
panelHtml += `<button class="cookbook-btn hwfit-serve-cancel" type="button" title="Close this configuration panel">Cancel</button>`;
panelHtml += `<button class="cookbook-btn hwfit-serve-cancel" type="button" title="Close this configuration panel"><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>Cancel</button>`;
// Launch + a small ^ that opens an inline schedule form. The form
// creates a ScheduledTask (action=cookbook_serve), so the schedule
// ends up in the existing Tasks UI for edit/delete/pause.
panelHtml += `<span class="hwfit-serve-launch-group">`;
panelHtml += `<button class="cookbook-btn hwfit-serve-launch"><svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:-1px;margin-right:4px;flex-shrink:0;"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>Launch</button>`;
// Chevron points DOWN because the schedule form opens beneath the
// panel — the arrow signals the direction of motion, not menu state.
panelHtml += `<button class="cookbook-btn hwfit-serve-schedule-arrow" type="button" aria-haspopup="true" aria-label="Schedule this serve on a recurring window" title="Schedule this serve as a recurring task"><svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg></button>`;
panelHtml += `</span>`;
panelHtml += `</div>`;
panelHtml += `</div>`;
@@ -1748,6 +1772,56 @@ function _rerenderCachedModels() {
// hiccuped (the user can read the real error in the task output).
}
}
// Pre-launch PORT probe — second most common failure pattern is
// collision with an already-running server (vllm crashing with
// "Address already in use" because Ollama owns 11434, or a
// previous vllm on the same port wasn't killed). The post-mortem
// "Suggested action: Kill existing vLLM" came AFTER the failed
// launch — user wants to know BEFORE clicking Launch. Parse the
// port out of the cmd, ssh-check who owns it on the target host,
// and offer to abort or proceed.
try {
const _portMatch = launchCmd.match(/(?:^|\s)(?:--port|-p|--host\s+\S+\s+--port)\s+(\d{2,5})\b/)
|| launchCmd.match(/(?:^|\s)--port=(\d{2,5})\b/)
|| launchCmd.match(/OLLAMA_HOST=[^:\s]+:(\d{2,5})\b/);
const _port = _portMatch ? _portMatch[1] : '';
if (_port) {
const _portHost = (_envState.remoteHost || '').trim();
const _checkInner = `ss -tlnp 2>/dev/null | awk '$4 ~ /:${_port}$/ {print; exit}' || netstat -tlnp 2>/dev/null | awk '$4 ~ /:${_port}$/ {print; exit}'`;
const _cmd = _portHost
? `ss h ${_portHost} <<<"" 2>/dev/null; ssh -o ConnectTimeout=4 -o StrictHostKeyChecking=no ${_portHost} ${JSON.stringify(_checkInner)}`
: _checkInner;
const _res = await fetch('/api/shell/exec', {
method: 'POST', credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command: _cmd }),
});
const _data = await _res.json().catch(() => ({}));
const _stdout = (_data.stdout || '').trim();
if (_stdout) {
// Try to surface the process name from `users:(("name",pid=...,...))`.
const _procMatch = _stdout.match(/users:\(\("([^"]+)",pid=(\d+)/);
const _procDesc = _procMatch
? `${_procMatch[1]} (PID ${_procMatch[2]})`
: 'another process';
const _hostLabel = _portHost ? _portHost : 'this host';
const _proceed = await window.styledConfirm(
`Port ${_port} on ${_hostLabel} is already in use by ${_procDesc}. Launching ${serveState.backend.toUpperCase()} now will fail with "Address already in use".\n\nStop the existing process first, OR change the --port in the command above, OR launch anyway and watch it crash.`,
{
title: `Port ${_port} taken`,
confirmText: 'Launch anyway',
cancelText: 'Cancel',
danger: true,
},
);
if (!_proceed) { _restoreLaunchBtn(); return; }
}
}
} catch {
// Probe failure — don't block. If the port check can't run we'd
// rather let the launch try than silently refuse.
}
// Save in the { _byRepo, _lastUsed } schema — no legacy flat keys at
// the root so per-model state doesn't leak between models.
try {
@@ -1801,7 +1875,12 @@ function _rerenderCachedModels() {
// Copy button — now icon-only, so flash a green checkmark on success
// instead of swapping to text (which would also break the width).
panel.querySelector('.hwfit-serve-copy').addEventListener('click', () => {
panel.querySelector('.hwfit-serve-copy').addEventListener('click', (e) => {
// Without stopPropagation the click bubbles up to the
// .doclib-card click handler that toggles the expand state →
// copying collapses the whole serve panel mid-flight.
e.preventDefault();
e.stopPropagation();
const cmd = panel.querySelector('.hwfit-serve-cmd').value;
_copyText(cmd).then(() => {
const btn = panel.querySelector('.hwfit-serve-copy');
@@ -2177,3 +2256,39 @@ export function initServe(shared) {
}
export { _cachedAllModels, _filterCachedList, _rerenderCachedModels, _deleteCachedModel };
// Click the "running" pill on a serve-card → switch to Cookbook → Running
// tab and scroll the matching task into view, with a brief flash so the
// user can find it among a long list. Tracks the click via event
// delegation so it survives every _rerenderCachedModels() pass.
function _openRunningTabForRepo(repo) {
const body = document.querySelector('#cookbook-modal .cookbook-body');
if (!body) return;
const runTab = body.querySelector('.cookbook-tab[data-backend="Running"]');
if (runTab) runTab.click();
// The Running tab needs a tick to mount/render before we can find
// task cards inside it.
setTimeout(() => {
const candidates = Array.from(body.querySelectorAll('.cookbook-task'));
const match = candidates.find(c => {
// task cards expose modelId or name via dataset / inner title
const dsRepo = c.dataset?.modelId || c.dataset?.repoId || '';
if (dsRepo === repo) return true;
const title = c.querySelector('.cookbook-task-title, .memory-item-title')?.textContent?.trim() || '';
return title === repo || title === (repo.split('/').pop() || '');
});
if (match) {
try { match.scrollIntoView({ behavior: 'smooth', block: 'center' }); } catch (_) {}
match.classList.add('cookbook-task-flash');
setTimeout(() => match.classList.remove('cookbook-task-flash'), 1600);
}
}, 180);
}
document.addEventListener('click', (e) => {
const pill = e.target.closest && e.target.closest('.cookbook-serve-running-pill.is-clickable');
if (!pill) return;
e.preventDefault();
e.stopPropagation();
const repo = pill.dataset.repo || '';
if (repo) _openRunningTabForRepo(repo);
});

View File

@@ -504,8 +504,13 @@ const _CATEGORY_MAP = {
ssh_command: 'System',
run_script: 'System',
run_local: 'System',
cookbook_serve: 'Cookbook',
};
const _CATEGORY_ORDER = ['Other', 'Calendar', 'Email', 'Chats', 'Documents', 'Memory', 'Research', 'Skills', 'Assistant', 'System'];
// Cookbook serves listed FIRST so a just-saved schedule shows at the
// top instead of scrolling off the bottom of the list. The remaining
// order is preserved for backwards-compatibility with users who've
// learned where things are.
const _CATEGORY_ORDER = ['Cookbook', 'Other', 'Calendar', 'Email', 'Chats', 'Documents', 'Memory', 'Research', 'Skills', 'Assistant', 'System'];
const _CATEGORY_ICONS = {
Calendar: '<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"/>',
Email: '<rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/>',
@@ -516,6 +521,8 @@ const _CATEGORY_ICONS = {
Skills: '<path d="M9 11l3 3L22 4"/><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M4 4.5A2.5 2.5 0 0 1 6.5 2H20v15H6.5A2.5 2.5 0 0 0 4 19.5z"/>',
Assistant: '<circle cx="12" cy="12" r="10"/><circle cx="12" cy="10" r="3"/><path d="M7 18a5 5 0 0 1 10 0"/>',
System: '<rect x="2" y="3" width="20" height="14" rx="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>',
// Cookbook icon — matches the recipe-book glyph used on the sidebar.
Cookbook: '<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>',
Other: '<circle cx="12" cy="12" r="3"/>',
};
@@ -955,9 +962,13 @@ function _showPresetPicker() {
let html = '<div class="admin-card" style="flex:1;display:flex;flex-direction:column;overflow:hidden;">';
html += '<div style="display:flex;align-items:baseline;gap:8px;margin-bottom:2px;"><h2 style="margin:0;padding:0;line-height:1;">Add Task</h2></div>';
html += '<p class="memory-desc" style="position:relative;top:4px;">Describe a task for the AI to draft, or pick a type below to set one up manually.</p>';
html += '<div class="task-ai-compose" style="display:flex;gap:6px;margin:6px 0 10px;">'
+ '<input type="text" id="task-ai-input" class="memory-search-input" style="flex:1;" placeholder="Describe a task — e.g. &quot;every weekday 7am summarize my unread email&quot;" />'
+ '<button class="memory-toolbar-btn active" id="task-ai-btn" title="Draft a task with AI" style="white-space:nowrap;height:28px;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Draft with AI</button>'
// flex-wrap + min-width:0 on the input lets the row collapse cleanly
// on narrow modal widths instead of pushing the AI button past the
// right edge. margin-left:-4px nudges the compose row 4px into the
// description bar above so the input lines up with it visually.
html += '<div class="task-ai-compose" style="display:flex;gap:6px;margin:6px 0 10px -4px;flex-wrap:wrap;align-items:center;">'
+ '<input type="text" id="task-ai-input" class="memory-search-input" style="flex:1 1 220px;min-width:0;" placeholder="Describe a task — e.g. &quot;every weekday 7am summarize my unread email&quot;" />'
+ '<button class="memory-toolbar-btn active" id="task-ai-btn" title="Draft a task with AI" style="white-space:nowrap;height:28px;flex:0 0 auto;"><svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" style="vertical-align:-1px;margin-right:3px;"><path d="M12 0L14.59 8.41L23 12L14.59 15.59L12 24L9.41 15.59L1 12L9.41 8.41Z"/></svg>Draft with AI</button>'
+ '</div>';
html += '<div class="memory-list" style="max-height:none;flex:1;gap:0px;margin-top:2px;padding-right:8px;">';
_TASK_PRESETS.forEach((p, i) => {

View File

@@ -14059,8 +14059,28 @@ body:has(.doc-version-panel:not(.hidden)) .hamburger-btn {
.admin-model-form-row {
display: flex;
gap: 6px;
flex-wrap: wrap; /* let buttons drop to a new row on narrow widths
instead of overflowing the modal */
align-items: center;
}
.admin-model-form-row input {
flex: 1 1 180px; /* api-key input takes the long axis but allows
dropping below the buttons at small widths */
min-width: 0; /* don't refuse to shrink past content width */
}
.admin-model-form-row select {
flex: 0 0 auto;
}
.admin-model-form-row button {
flex: 0 0 auto;
white-space: nowrap;
}
/* On narrow screens, give buttons their own row + push the Add button
to the right so it remains the obvious primary action. */
@media (max-width: 540px) {
.admin-model-form-row input { flex-basis: 100%; }
.admin-model-form-row .admin-btn-add { margin-left: auto; }
}
.admin-model-form-row input { flex: 1; }
.adm-ep-inline-msg {
min-height: 16px;
margin-top: 5px;
@@ -18219,8 +18239,12 @@ body.gallery-selecting .gallery-dl-btn,
color: var(--fg-muted);
letter-spacing: 0.2px;
}
/* "running" pill on a Serve-tab card when the model has a live serve task. */
.cookbook-serve-running-pill {
/* Status pills shown inline in a Serve-tab card title (next to the model
name) when the model has a live serve / download task. Shared base
class so "running" and "downloading" sit on the same row with the
same chrome; only color varies. */
.cookbook-serve-running-pill,
.cookbook-serve-downloading-pill {
display: inline-block;
margin-left: 6px;
padding: 1px 7px;
@@ -18231,11 +18255,50 @@ body.gallery-selecting .gallery-dl-btn,
letter-spacing: 0.3px;
vertical-align: 2px;
position: relative;
top: -1px;
top: 1px; /* nudged down 2px from the old -1px so it sits
flush with the cap-height of the title text */
}
.cookbook-serve-running-pill {
color: var(--accent, var(--red));
background: color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 35%, transparent);
}
.cookbook-serve-downloading-pill {
color: var(--accent, var(--red));
background: color-mix(in srgb, var(--accent, var(--red)) 12%, transparent);
border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 35%, transparent);
opacity: 0.85;
}
.cookbook-serve-downloading-pill.is-stalled {
/* Stalled downloads stay visible but read as warning, not progress. */
color: var(--fg-muted, #888);
background: color-mix(in srgb, var(--fg-muted, #888) 10%, transparent);
border-color: color-mix(in srgb, var(--fg-muted, #888) 30%, transparent);
opacity: 1;
}
.cookbook-serve-running-pill.is-clickable {
cursor: pointer;
transition: background 0.12s, border-color 0.12s;
}
.cookbook-serve-running-pill.is-clickable:hover {
background: color-mix(in srgb, var(--accent, var(--red)) 22%, transparent);
border-color: color-mix(in srgb, var(--accent, var(--red)) 55%, transparent);
}
/* Brief highlight on the matched task card when jumping from the
running pill, so the user can spot it among a long list. */
.cookbook-task-flash {
animation: cookbook-task-flash-anim 1.6s ease-out;
}
@keyframes cookbook-task-flash-anim {
0% { box-shadow: 0 0 0 2px var(--accent, var(--red)); }
100% { box-shadow: 0 0 0 2px transparent; }
}
/* Cookbook header "downloading" status label sits 2px too far left
against the rest of the cookbook chrome nudge it right. */
#cookbook-bg-status {
left: 2px;
}
.cookbook-serve-dir-edit {
font-size: 9px;
color: var(--fg-muted);
@@ -33535,8 +33598,15 @@ button.cal-view-btn {
of days it covers within the row, --slot stacks parallel bars. */
.cal-multiday {
position: absolute;
left: calc(var(--col, 0) * (100% / 7));
width: calc(var(--span, 1) * (100% / 7));
/* Fractional offsets let timed events that cross midnight render at
true proportional width. start-frac shifts the left edge into the
first day; end-frac trims the right edge inside the last day.
All-day events default to (0, 1) and still fill whole columns. */
left: calc((var(--col, 0) + var(--start-frac, 0)) * (100% / 7));
width: calc(
(var(--span, 1) - var(--start-frac, 0) - (1 - var(--end-frac, 1)))
* (100% / 7)
);
top: calc(2px + var(--slot, 0) * 12px);
height: 11px;
font-size: 8px;
@@ -35925,3 +35995,149 @@ body.theme-frosted .modal {
padding: 10px 18px;
border-top: 1px solid var(--border);
}
/* Cookbook serve panel: Launch + ^ split button pair */
.hwfit-serve-launch-group {
display: inline-flex;
align-items: stretch;
vertical-align: middle;
}
.hwfit-serve-launch-group .hwfit-serve-launch {
border-top-right-radius: 0 !important;
border-bottom-right-radius: 0 !important;
margin-right: 0 !important;
}
.hwfit-serve-launch-group .hwfit-serve-schedule-arrow {
border-top-left-radius: 0 !important;
border-bottom-left-radius: 0 !important;
border-left: 1px solid var(--border) !important;
padding: 0 8px !important;
min-width: 26px;
display: inline-flex !important;
align-items: center;
justify-content: center;
}
/* Schedule form mounted inside the cookbook serve panel. Uses the
theme tokens (--bg, --panel, --border, --accent, --red) so it
matches the rest of the cookbook chrome instead of inline whites. */
.hwfit-schedule-form {
margin: 10px 0 4px;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--bg);
display: flex;
flex-direction: column;
gap: 10px;
}
.hwfit-schedule-title {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
font-weight: 600;
opacity: 0.95;
flex-wrap: wrap;
}
.hwfit-schedule-title svg { opacity: 0.7; flex-shrink: 0; }
.hwfit-schedule-title-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hwfit-schedule-title-spacer { flex: 1; min-width: 8px; }
.hwfit-schedule-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.hwfit-schedule-field {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 11px;
opacity: 0.75;
}
.hwfit-schedule-field input[type="time"] {
font: inherit;
min-width: 90px;
}
.hwfit-schedule-label {
font-size: 11px;
opacity: 0.75;
}
.hwfit-sched-days {
display: inline-flex;
flex-wrap: wrap;
gap: 5px;
}
.hwfit-sched-day-chip {
width: 32px;
height: 32px;
border-radius: 50%;
border: 1px solid var(--border);
background: transparent;
color: inherit;
font: inherit;
font-size: 10px;
line-height: 1;
padding: 0;
cursor: pointer;
transition: background 0.12s, color 0.12s, border-color 0.12s;
}
.hwfit-sched-day-chip:hover { border-color: var(--accent, var(--red)); }
.hwfit-sched-day-chip.is-on {
background: var(--accent, var(--red));
color: var(--panel, #fff);
border-color: var(--accent, var(--red));
}
.hwfit-schedule-actions-row {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.hwfit-schedule-actions-spacer { flex: 1; }
/* Days row hosts the day-chip strip on the left and the Cancel / Save
action buttons on the right. The spacer between them pushes the
actions to the right edge. */
.hwfit-schedule-days-row {
align-items: center;
}
/* Cancel + Save sit on the right side of the days row with matching
icon-plus-label chrome. */
.hwfit-sched-cancel,
.hwfit-sched-save {
display: inline-flex !important;
align-items: center;
}
.hwfit-schedule-mirror-toggle {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12px;
opacity: 0.9;
cursor: pointer;
user-select: none;
}
.hwfit-schedule-mirror-label { white-space: nowrap; }
.hwfit-schedule-mirror-switch { transform: scale(0.85); transform-origin: left center; }
.hwfit-sched-err {
font-size: 12px;
color: var(--red, #ff6b6b);
display: none;
}
.hwfit-sched-err.is-visible { display: block; }
@media (max-width: 600px) {
.hwfit-schedule-row { gap: 8px; }
.hwfit-sched-day-chip { width: 36px; height: 36px; font-size: 11px; }
}
/* Brief outline flash on a note card when it's the target of a
#note-<id> link click from chat same pattern as the cookbook
task flash, just scoped to .note-card. */
.note-card-flash {
animation: note-card-flash-anim 1.6s ease-out;
}
@keyframes note-card-flash-anim {
0% { box-shadow: 0 0 0 2px var(--accent, var(--red)); }
100% { box-shadow: 0 0 0 2px transparent; }
}