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:
10
app.py
10
app.py
@@ -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():
|
||||
|
||||
@@ -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('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(' exec 3<&-; exec 3>&-')
|
||||
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(' 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('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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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`.
|
||||
}
|
||||
|
||||
|
||||
193
src/cookbook_serve_lifecycle.py
Normal file
193
src/cookbook_serve_lifecycle.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {});
|
||||
});
|
||||
} catch (_) { /* unload best-effort */ }
|
||||
}
|
||||
fetch('/api/shell/exec', {
|
||||
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: _tmuxGracefulKill(task) }),
|
||||
}).catch(() => {});
|
||||
});
|
||||
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
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
386
static/js/cookbookSchedule.js
Normal file
386
static/js/cookbookSchedule.js
Normal 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, "&").replace(/</g, "<").replace(/>/g, ">")
|
||||
.replace(/"/g, """).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// 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 (_) {} }
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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. "every weekday 7am summarize my unread email"" />'
|
||||
+ '<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. "every weekday 7am summarize my unread email"" />'
|
||||
+ '<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) => {
|
||||
|
||||
228
static/style.css
228
static/style.css
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user