diff --git "a/adam/remote_access.py" "b/adam/remote_access.py" new file mode 100644--- /dev/null +++ "b/adam/remote_access.py" @@ -0,0 +1,2751 @@ +from __future__ import annotations + +import json +import mimetypes +import secrets +import shutil +import socket +import subprocess +import threading +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from hmac import compare_digest +from ipaddress import ip_address +from pathlib import Path +from typing import Any +from urllib.parse import parse_qs, urlencode, urlparse + +from adam.generations import ( + ChatGenerationRequest, + build_generation_plan, + generation_model_match_score, + generation_tools, + load_generation_history, + parse_chat_generation_request, +) +from adam.remote_dispatcher import RemoteCommandDispatcher +from adam.remote_media import OpaqueIdCodec, RemoteMediaStore +from adam.remote_v1 import RemoteV1Service + + +REMOTE_MODE_DISABLED = "disabled" +REMOTE_MODE_LOCAL = "local_wifi" +REMOTE_MODE_TAILSCALE = "tailscale" + + +class RemoteHTTPServer(ThreadingHTTPServer): + """Bound concurrent connections so slow clients cannot spawn unlimited threads.""" + + allow_reuse_address = True + daemon_threads = True + + def __init__(self, *args, **kwargs): + self._slots = threading.BoundedSemaphore(16) + super().__init__(*args, **kwargs) + + def process_request(self, request, client_address): + if not self._slots.acquire(blocking=False): + self.shutdown_request(request) + return + try: + super().process_request(request, client_address) + except BaseException: + self._slots.release() + raise + + def process_request_thread(self, request, client_address): + try: + super().process_request_thread(request, client_address) + finally: + self._slots.release() + + +@dataclass(frozen=True, slots=True) +class TailscaleStatus: + installed: bool = False + connected: bool = False + device_name: str = "" + dns_name: str = "" + tailscale_ip: str = "" + backend_state: str = "" + serve_available: bool = False + serve_running: bool = False + message: str = "Tailscale is not installed." + + +def default_remote_settings() -> dict[str, Any]: + return { + "enabled": False, + "remote_mode": REMOTE_MODE_LOCAL, + "bind_address": "127.0.0.1", + "port": 8765, + "token": secrets.token_urlsafe(24), + "allow_job_control": False, + "auto_approve_training": False, + } + + +def remote_scope(bind_address: str) -> str: + bind = bind_address.strip().casefold() + if bind == "localhost": + return "local-device only" + if bind in {"0.0.0.0", "::"}: + return "all network interfaces" + try: + address = ip_address(bind.strip("[]")) + except ValueError: + return "custom bind address" + if address.is_loopback: + return "local-device only" + if address.is_private or address.is_link_local: + return "local network" + return "custom bind address" + + +def local_network_host() -> str: + """Best-effort address other devices on the same network can use.""" + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock: + sock.connect(("8.8.8.8", 80)) + host = str(sock.getsockname()[0]) + except OSError: + try: + host = socket.gethostbyname(socket.gethostname()) + except OSError: + return "" + try: + address = ip_address(host) + except ValueError: + return "" + if address.is_loopback or address.is_unspecified: + return "" + return host if address.is_private or address.is_link_local else "" + + +def inspect_tailscale( + runner: Any | None = None, + which: Any | None = None, +) -> TailscaleStatus: + which = which or shutil.which + executable = which("tailscale") + if not executable: + return TailscaleStatus() + runner = runner or _run_tailscale + try: + status = runner([executable, "status", "--json"]) + except OSError as exc: + return TailscaleStatus(installed=True, message=f"Tailscale could not be checked: {exc}") + if getattr(status, "returncode", 1) != 0: + error = _command_text(getattr(status, "stderr", "")) or "Tailscale is installed but not connected." + return TailscaleStatus(installed=True, message=error) + try: + payload = json.loads(_command_text(getattr(status, "stdout", "")) or "{}") + except json.JSONDecodeError: + return TailscaleStatus(installed=True, message="Tailscale returned an unreadable status response.") + self_node = payload.get("Self") if isinstance(payload, dict) else {} + self_node = self_node if isinstance(self_node, dict) else {} + ips = [str(item) for item in self_node.get("TailscaleIPs", []) if str(item)] + backend = str(payload.get("BackendState", "") or "") + connected = backend.casefold() == "running" or bool(ips) + serve_status = _tailscale_serve_running(runner, executable) + return TailscaleStatus( + installed=True, + connected=connected, + device_name=str(self_node.get("HostName", "") or ""), + dns_name=str(self_node.get("DNSName", "") or "").rstrip("."), + tailscale_ip=next((ip for ip in ips if "." in ip), ips[0] if ips else ""), + backend_state=backend, + serve_available=serve_status is not None, + serve_running=bool(serve_status), + message="Tailscale is connected." if connected else "Tailscale is installed but disconnected.", + ) + + +def _tailscale_serve_running(runner: Any, executable: str) -> bool | None: + try: + result = runner([executable, "serve", "status", "--json"]) + except OSError: + return None + if getattr(result, "returncode", 1) != 0: + return None + text = _command_text(getattr(result, "stdout", "")).strip() + return bool(text and text not in {"{}", "null"}) + + +def _run_tailscale(command: list[str]) -> subprocess.CompletedProcess[str]: + return subprocess.run(command, capture_output=True, text=True, timeout=8, check=False) + + +def _command_text(value: Any) -> str: + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return str(value or "") + + +def _remote_prompt_from_payload(payload: dict[str, Any]) -> str: + for key in ("prompt", "message", "text", "request", "input"): + value = payload.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "" + + +def _remote_dashboard_html() -> str: + return _remote_dashboard_app_html() + return """ + + + + +ADAM Remote + + + +
+
+
+ ADAM + AI Development and Automation Manager +
+
Connecting
+
+ +
+
+

Prompt ADAM

+
+ +
+ + + + +
+ +

Plans that need desktop approval will wait safely inside the main ADAM app.

+
+
+
+

Generate

+
+ +
+ + + + + + + +
+
+ + + +
+ +

Generation uses ADAM's existing desktop backend and queue.

+
+
+
+

Active Job

+
+
+
Checking ADAM...
+
Waiting for status.
+
+
...
+
+
+
+
--
+
--
+
--
+
--
+
+
+
+
+

Live Preview

+
+ Latest ADAM preview +
The latest training or generation preview will appear here.
+
+

Waiting for preview output.

+
+
+

Latest Generation

+
+ Latest generated ADAM image +
Finished generated images will appear here.
+
+ +

Waiting for a completed generation.

+
+
+
+

System

+
+
--
+
--
+
--
+
--
+
+

+
+
+

Remote Control

+

--

+

Status-only remote access is loading.

+ + +

Remote settings are synced from ADAM.

+ +
+
+
+

Recent Queue

+
+
+
+

Completed Jobs

+
+
+
+

Failed Jobs

+
+
+
+
+ + +""" + + +def _remote_dashboard_app_html() -> str: + from adam.remote_dashboard import remote_dashboard_app_html + + return remote_dashboard_app_html() + return """ + + + + +ADAM Remote + + + +
+
ADAM Remote
Connecting...
+
+
+
Active Job
No active job.
+
Live Preview
Live Preview
Waiting for a preview.
+
Latest Generation
+
Prompt ADAM
+
+
+
+
Structured Training
+ + + + + +
+
+
+ +
+

+    
+
+
+
Generate Image
+ + + + + +
+
+
+
+
+
+
+
Datasets
+ + +
Models
+
+
+
System
+
Remote Control
+
Jobs
+
+
+ + + +""" + + +class RemoteAccessService: + """Small authenticated local API foundation for browser/device clients.""" + + def __init__(self, config: Any, jobs: Any, monitor: Any, planner: Any = None) -> None: + self.config = config + self.jobs = jobs + self.monitor = monitor + self.planner = planner + self.root = Path(getattr(config, "root", None) or getattr(planner, "root", None) or Path.cwd()).resolve() + self.dispatcher = RemoteCommandDispatcher() + token = "" + try: + token = str(self.settings().get("token", "")) + except Exception: + token = "" + self.codec = OpaqueIdCodec(f"{self.root}|{token}") + self.media = RemoteMediaStore(self.root, self.codec) + self.api_v1 = RemoteV1Service( + root=self.root, + config=config, + jobs=jobs, + planner=planner, + dispatcher=self.dispatcher, + codec=self.codec, + media=self.media, + auto_approve_training=self._should_auto_approve_training, + ) + self._server: ThreadingHTTPServer | None = None + self._thread: threading.Thread | None = None + + @property + def running(self) -> bool: + return self._server is not None + + def settings(self) -> dict[str, Any]: + values = default_remote_settings() + stored = self.config.get("remote_access", {}) + if isinstance(stored, dict): + values.update(stored) + if not isinstance(stored, dict) or not stored.get("token"): + values["token"] = secrets.token_urlsafe(24) + self.config.update({"remote_access": values}) + return values + + def save_settings(self, values: dict[str, Any]) -> None: + clean = self.settings() + port = clean["port"] + try: + port = int(values.get("port", clean["port"])) + except (TypeError, ValueError): + port = clean["port"] + clean.update( + { + "enabled": bool(values.get("enabled", clean["enabled"])), + "remote_mode": self._clean_mode(str(values.get("remote_mode", clean["remote_mode"]))), + "bind_address": str(values.get("bind_address", clean["bind_address"])).strip() or "127.0.0.1", + "port": max(1024, min(port, 65535)), + "token": str(values.get("token", clean["token"])).strip() or secrets.token_urlsafe(24), + "allow_job_control": bool(values.get("allow_job_control", clean["allow_job_control"])), + "auto_approve_training": bool(values.get("auto_approve_training", clean["auto_approve_training"])), + } + ) + self.config.update({"remote_access": clean}) + + def start(self) -> str: + if self.running: + return self.url() + settings = self.settings() + mode = self._clean_mode(str(settings.get("remote_mode", REMOTE_MODE_LOCAL))) + if mode == REMOTE_MODE_DISABLED: + raise RuntimeError("Remote access is disabled by Remote Mode.") + if not settings.get("enabled"): + raise RuntimeError("Remote access is disabled.") + bind = "127.0.0.1" if mode == REMOTE_MODE_TAILSCALE else str(settings["bind_address"]) + port = int(settings["port"]) + jobs = self.jobs + monitor = self.monitor + service = self + api_v1 = self.api_v1 + + class Handler(BaseHTTPRequestHandler): + def setup(self) -> None: + self.request.settimeout(10) + super().setup() + + def _authorized(self) -> bool: + current = service.settings() + if not current.get("enabled") or current.get("remote_mode") == REMOTE_MODE_DISABLED: + return False + token = str(current["token"]).encode("utf-8") + header = self.headers.get("Authorization", "") + query_token = "" + parsed = urlparse(self.path) + values = parse_qs(parsed.query) + if values.get("token"): + query_token = values["token"][0] + bearer = header.removeprefix("Bearer ").strip() + return compare_digest(bearer.encode("utf-8"), token) or ( + bool(query_token) and self._browser_token_allowed() and compare_digest(query_token.encode("utf-8"), token) + ) + + def _security_headers(self) -> None: + self.send_header("X-Content-Type-Options", "nosniff") + self.send_header("Referrer-Policy", "no-referrer") + self.send_header("X-Frame-Options", "DENY") + self.send_header("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'") + + def _valid_post(self) -> bool: + origin = self.headers.get("Origin") + if self.headers.get("Sec-Fetch-Site") == "cross-site" or (origin and ( + urlparse(origin).scheme not in {"http", "https"} + or urlparse(origin).netloc.casefold() != self.headers.get("Host", "").casefold() + )): + self._send(403, {"error": "Cross-site requests are not allowed."}) + return False + if self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() != "application/json": + self._send(415, {"error": "Use application/json for remote commands."}) + return False + lengths = self.headers.get_all("Content-Length", []) + try: + length = int(lengths[0]) if len(lengths) == 1 else -1 + except ValueError: + length = -1 + if self.headers.get("Transfer-Encoding") or length < 0 or length > 20_000: + self._send(413 if length > 20_000 else 400, {"error": "Invalid request size (maximum 20000 bytes)."}) + return False + return True + + def _browser_token_allowed(self) -> bool: + try: + client = ip_address(str(self.client_address[0]).strip("[]")) + except ValueError: + return False + if client.is_loopback: + return True + return remote_scope(bind) != "local-device only" and (client.is_private or client.is_link_local) + + def _send(self, status: int, payload: dict[str, Any]) -> None: + body = json.dumps(payload).encode("utf-8") + self._send_bytes(status, body, "application/json") + + def _send_html(self, status: int, html: str) -> None: + self._send_bytes(status, html.encode("utf-8"), "text/html; charset=utf-8") + + def _send_bytes(self, status: int, body: bytes, content_type: str) -> None: + if status >= 400 and self.command == "POST" and not getattr(self, "_body_read", False): + # Drain a small, already-sent body before closing; Windows can + # otherwise reset the connection before the error is delivered. + try: + length = int(self.headers.get("Content-Length", "0")) + if 0 < length <= 20_000 and not self.headers.get("Transfer-Encoding"): + self.connection.settimeout(0.25) + self.rfile.read(length) + except (ValueError, OSError): + pass + finally: + self.connection.settimeout(10) + self._body_read = True + try: + self.send_response(status) + self.send_header("Content-Type", content_type) + self.send_header("Cache-Control", "no-store") + self._security_headers() + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError): + return + + def do_GET(self) -> None: + if not self._authorized(): + self._send(401, {"error": "Missing or invalid remote access token."}) + return + path = urlparse(self.path).path + response = api_v1.route("GET", path, urlparse(self.path).query) + if response is not None: + self._send_response(response) + return + if path == "/": + self._send_html(200, _remote_dashboard_html()) + return + if path == "/api/preview": + self._send_preview() + return + if path == "/api/generation-image": + self._send_generation_image() + return + if path != "/api/status": + self._send(404, {"error": "Unknown endpoint."}) + return + self._send(200, service._status_payload(bind, service.settings())) + + def do_POST(self) -> None: + if not self._authorized(): + self._send(401, {"error": "Missing or invalid remote access token."}) + return + if not self._valid_post(): + return + path = urlparse(self.path).path + payload = None + if path.startswith("/api/v1/"): + payload = self._read_json_body() + if payload is None: + self._send(400, {"error": "Send a valid JSON object."}) + return + response = api_v1.route("POST", path, urlparse(self.path).query, payload) + if response is not None: + self._send_response(response) + return + if path == "/api/job": + self._handle_job_action() + return + if path == "/api/remote-settings": + self._handle_remote_settings() + return + if path != "/api/prompt": + self._send(404, {"error": "Unknown endpoint."}) + return + payload = self._read_json_body() + if payload is None: + self._send(400, {"error": "Send a valid prompt."}) + return + prompt = _remote_prompt_from_payload(payload) + if not prompt: + self._send(400, {"error": "Type a prompt for ADAM first."}) + return + if len(prompt) > 2_000: + self._send(400, {"error": "Keep remote prompts under 2000 characters."}) + return + result = service.submit_prompt(prompt) + self._send(200 if result.get("ok") else 400, result) + + def _send_response(self, response: Any) -> None: + try: + self.send_response(int(response.status)) + self.send_header("Content-Type", str(response.content_type)) + headers = response.headers or {} + if "Cache-Control" in headers: + self.send_header("Cache-Control", headers["Cache-Control"]) + else: + self.send_header("Cache-Control", "no-store") + self._security_headers() + self.send_header("Content-Length", str(len(response.body))) + self.end_headers() + self.wfile.write(response.body) + except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError): + return + + def _read_json_body(self) -> dict[str, Any] | None: + self._body_read = True + try: + length = int(self.headers.get("Content-Length", "0") or "0") + except ValueError: + length = 0 + try: + payload = json.loads(self.rfile.read(length).decode("utf-8")) if length else {} + except (UnicodeDecodeError, json.JSONDecodeError, OSError): + return None + return payload if isinstance(payload, dict) else None + + def _handle_job_action(self) -> None: + payload = self._read_json_body() + if payload is None: + self._send(400, {"error": "Send a valid job action."}) + return + action = str(payload.get("action", "")).strip().casefold() + job_id = str(payload.get("job_id", "")).strip() + result = service.job_action(job_id, action, bool(service.settings().get("allow_job_control"))) + self._send(200 if result.get("ok") else 400, result) + + def _handle_remote_settings(self) -> None: + payload = self._read_json_body() + if payload is None: + self._send(400, {"error": "Send valid remote settings."}) + return + current = service.settings() + enabled = payload.get("auto_approve_training", False) + if not isinstance(enabled, bool): + self._send(400, {"error": "Auto-approval must be true or false."}) + return + if enabled and not current.get("allow_job_control"): + self._send(403, {"error": "Enable remote job controls in the desktop app before changing approval permissions."}) + return + def update_approval(): + # Recheck on the owning thread and only change this permission. + # A queued request must not restore an older token or settings. + if enabled and not service.settings().get("allow_job_control"): + return False + service.save_settings({"auto_approve_training": enabled}) + return True + + if not service.dispatcher.call_ui(update_approval): + self._send(403, {"error": "Remote job controls have been disabled."}) + return + state = "on" if enabled else "off" + self._send(200, {"ok": True, "message": f"Auto-approval is {state}."}) + + def _send_preview(self) -> None: + path = service.preview_path() + if path is None or not path.is_file(): + self._send(404, {"error": "No preview image is available yet."}) + return + content_type = mimetypes.guess_type(str(path))[0] or "image/png" + try: + body = path.read_bytes() + except OSError: + self._send(404, {"error": "Preview image is no longer available."}) + return + self._send_bytes(200, body, content_type) + + def _send_generation_image(self) -> None: + parsed = urlparse(self.path) + values = parse_qs(parsed.query) + try: + record_index = int(values.get("record", ["0"])[0]) + image_index = int(values.get("image", ["0"])[0]) + except (TypeError, ValueError): + self._send(400, {"error": "Choose a valid generation image."}) + return + path = service.generation_image_path(record_index, image_index) + if path is None or not path.is_file(): + self._send(404, {"error": "Generated image is no longer available."}) + return + content_type = mimetypes.guess_type(str(path))[0] or "image/png" + try: + body = path.read_bytes() + except OSError: + self._send(404, {"error": "Generated image is no longer available."}) + return + self._send_bytes(200, body, content_type) + + def log_message(self, _format: str, *_args: Any) -> None: + return + + self._server = RemoteHTTPServer((bind, port), Handler) + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + self._thread.start() + return self.url() + + def _status_payload(self, bind: str, settings: dict[str, Any]) -> dict[str, Any]: + snapshot = self.monitor.snapshot() if self.monitor is not None else None + active = self.jobs.active_job if self.jobs is not None else None + return { + "app": "ADAM", + "scope": remote_scope(bind), + "permissions": { + "status": True, + "system": True, + "queue_view": True, + "prompt": self.planner is not None and self.jobs is not None, + "job_control": bool(settings.get("allow_job_control")), + "auto_approve_training": bool(settings.get("auto_approve_training")), + "dangerous_actions": False, + }, + "active_job": None + if active is None + else { + "id": active.id, + "project": active.plan.project_name, + "status": active.status.value, + "progress": active.progress, + "timing": self._job_timing(active), + "preview": self._job_preview(active), + }, + "preview": self.preview_payload(), + "latest_generation": self.latest_generation_payload(), + "queue": [ + self._job_summary(job) + for job in (self.jobs.jobs[:20] if self.jobs is not None else []) + if job.status.value not in {"Finished", "Failed", "Cancelled"} + ], + "completed_jobs": [ + self._job_summary(job) + for job in (self.jobs.jobs[:30] if self.jobs is not None else []) + if job.status.value == "Finished" + ], + "failed_jobs": [ + self._job_summary(job) + for job in (self.jobs.jobs[:30] if self.jobs is not None else []) + if job.status.value in {"Failed", "Cancelled", "Interrupted"} + ], + "system": { + "cpu_percent": snapshot.cpu_percent if snapshot else None, + "memory_percent": snapshot.memory_percent if snapshot else None, + "gpu_name": snapshot.gpu_name if snapshot else "", + "gpu_percent": snapshot.gpu_percent if snapshot else None, + "vram_percent": snapshot.vram_percent if snapshot else None, + "gpu_temperature": snapshot.gpu_temperature if snapshot else None, + }, + } + + @staticmethod + def _job_summary(job: Any) -> dict[str, Any]: + try: + current_step = int(getattr(job, "current_step", -1)) + except (TypeError, ValueError): + current_step = -1 + steps = list(getattr(getattr(job, "plan", None), "steps", []) or []) + current_step_title = "" + if 0 <= current_step < len(steps): + current_step_title = str(getattr(steps[current_step], "title", "") or "") + return { + "id": getattr(job, "id", ""), + "project": getattr(getattr(job, "plan", None), "project_name", ""), + "status": getattr(getattr(job, "status", None), "value", str(getattr(job, "status", ""))), + "progress": getattr(job, "progress", 0), + "timing": RemoteAccessService._job_timing(job), + "error": getattr(job, "error", "") or "", + "started_at": getattr(job, "started_at", "") or "", + "ended_at": getattr(job, "ended_at", "") or "", + "scheduled_for": getattr(job, "scheduled_for", "") or "", + "current_step": current_step, + "step_count": len(steps), + "current_step_title": current_step_title, + "requires_confirmation": bool(getattr(getattr(job, "plan", None), "requires_confirmation", False)), + "output_folder": Path(str(getattr(job, "output_folder", "") or "")).name, + "progress_current": getattr(job, "progress_current", 0) or 0, + "progress_total": getattr(job, "progress_total", 0) or 0, + "progress_unit": getattr(job, "progress_unit", "") or "", + "logs": list(getattr(job, "logs", []) or [])[-3:], + } + + @staticmethod + def _job_timing(job: Any) -> dict[str, Any]: + started = RemoteAccessService._parse_time(getattr(job, "started_at", "") or "") + ended = RemoteAccessService._parse_time(getattr(job, "ended_at", "") or "") + progress = max(0, min(100, int(getattr(job, "progress", 0) or 0))) + status = getattr(getattr(job, "status", None), "value", str(getattr(job, "status", ""))) + review = getattr(getattr(job, "plan", None), "orion_review", {}) or {} + try: + estimate_seconds = int(float(review.get("estimated_high_minutes", 0) or 0) * 60) + except (TypeError, ValueError): + estimate_seconds = 0 + + now = datetime.now(timezone.utc) + elapsed_seconds = 0 + if started is not None: + finish = ended or now + elapsed_seconds = max(0, int((finish - started).total_seconds())) + + remaining_seconds: int | None = None + basis = "" + if status in {"Finished", "Failed", "Cancelled", "Interrupted"}: + remaining_seconds = 0 + basis = "complete" + elif started is not None and progress > 0: + remaining_seconds = max(0, int(elapsed_seconds * (100 - progress) / progress)) + basis = "progress" + elif estimate_seconds: + remaining_seconds = max(0, estimate_seconds - elapsed_seconds) + basis = "planning estimate" + + finish_label = "" + if remaining_seconds is not None and remaining_seconds > 0: + finish_label = (now + timedelta(seconds=remaining_seconds)).astimezone().strftime("%I:%M %p").lstrip("0") + + return { + "elapsed_seconds": elapsed_seconds if started is not None else None, + "remaining_seconds": remaining_seconds, + "estimated_total_seconds": estimate_seconds or None, + "elapsed_label": RemoteAccessService._format_duration(elapsed_seconds) if started is not None else "", + "remaining_label": ( + "done" if remaining_seconds == 0 and basis == "complete" + else f"about {RemoteAccessService._format_duration(remaining_seconds)}" if remaining_seconds is not None else "" + ), + "finish_label": finish_label, + "estimate_label": f"up to {RemoteAccessService._format_duration(estimate_seconds)}" if estimate_seconds else "", + "basis": basis, + } + + @staticmethod + def _parse_time(value: str) -> datetime | None: + if not value: + return None + try: + parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + + @staticmethod + def _format_duration(seconds: int | float | None) -> str: + if seconds is None: + return "" + total = max(0, int(seconds)) + if total < 60: + return f"{total}s" + minutes, sec = divmod(total, 60) + if minutes < 60: + return f"{minutes}m {sec}s" if sec else f"{minutes}m" + hours, minute = divmod(minutes, 60) + if hours < 24: + return f"{hours}h {minute}m" if minute else f"{hours}h" + days, hour = divmod(hours, 24) + return f"{days}d {hour}h" if hour else f"{days}d" + + def preview_payload(self) -> dict[str, Any]: + if self.jobs is None: + return {"available": False} + candidates = [] + if self.jobs.active_job is not None: + candidates.append(self.jobs.active_job) + candidates.extend(self.jobs.jobs[:20]) + for job in candidates: + preview = self._job_preview(job) + if preview["available"]: + return preview + return {"available": False} + + def preview_path(self) -> Path | None: + if self.jobs is None: + return None + candidates = [] + if self.jobs.active_job is not None: + candidates.append(self.jobs.active_job) + candidates.extend(self.jobs.jobs[:20]) + for job in candidates: + path = self._job_preview_path(job) + if path is not None: + return path + return None + + @staticmethod + def _job_preview(job: Any) -> dict[str, Any]: + if RemoteAccessService._job_preview_path(job) is None: + return {"available": False} + return { + "available": True, + "url": "/api/preview", + "kind": getattr(job, "preview_kind", ""), + "epoch": getattr(job, "preview_epoch", 0), + "current": getattr(job, "preview_current", 0), + "total": getattr(job, "preview_total", 0), + "prompt": getattr(job, "preview_prompt", ""), + } + + @staticmethod + def _job_preview_path(job: Any) -> Path | None: + path = str(getattr(job, "preview_path", "") or "") + if not path: + return None + target = Path(path) + return target if target.is_file() else None + + def latest_generation_payload(self) -> dict[str, Any]: + records = self._generation_records() + if not records: + return {"available": False} + record = records[0] + return { + "available": True, + "url": "/api/generation-image?record=0&image=0", + "images": [ + { + "index": index, + "url": f"/api/generation-image?record=0&image={index}", + } + for index, _path in enumerate(record.images) + ], + "model_name": record.model_name, + "provider_id": record.provider_id, + "provider_name": record.provider_name, + "prompt": record.prompt, + "seed": record.seed, + "steps": record.steps, + "sampler": record.sampler, + "aspect_ratio": record.aspect_ratio, + "created_at": record.created_at, + "image_count": len(record.images), + } + + def generation_image_path(self, record_index: int, image_index: int) -> Path | None: + if record_index < 0 or image_index < 0: + return None + records = self._generation_records(limit=max(1, record_index + 1)) + if record_index >= len(records): + return None + record = records[record_index] + if image_index >= len(record.images): + return None + path = record.images[image_index] + return path if path.is_file() else None + + def _generation_records(self, *, limit: int = 30): + root = self._generation_root() + if root is None: + return [] + return load_generation_history(root, limit=limit) + + def _generation_root(self) -> Path | None: + for candidate in ( + getattr(self.planner, "root", None), + getattr(getattr(self.planner, "registry", None), "root", None), + ): + if candidate: + return Path(candidate) + return None + + def submit_prompt(self, prompt: str) -> dict[str, Any]: + generation_result = self._submit_generation_prompt(prompt) + if generation_result is not None: + return generation_result + if self.planner is None or self.jobs is None: + return {"ok": False, "error": "Remote prompting is not available in this ADAM session."} + try: + def prepare_plan(): + from adam.training_assistant import append_preflight_summary + + plan = self.planner.plan(prompt) + append_preflight_summary(plan, self.config) + return plan + + plan = self.dispatcher.call_background(prepare_plan) + except Exception as exc: + return {"ok": False, "error": f"ADAM could not plan that request: {exc}"} + if not plan.steps: + return {"ok": True, "message": plan.summary or "ADAM received your message.", "requires_approval": False} + job = self.dispatcher.submit_job(self.jobs, plan) + if self._should_auto_approve_training(plan): + self.dispatcher.confirm_job(self.jobs, job.id) + return { + "ok": True, + "message": f"Queued {job.plan.project_name}. Remote training auto-approval is on.", + "job_id": job.id, + "requires_approval": False, + "auto_approved": True, + } + if plan.requires_confirmation: + return { + "ok": True, + "message": f"Plan created for {job.plan.project_name}. It needs approval in the desktop app before it runs.", + "job_id": job.id, + "requires_approval": True, + } + return { + "ok": True, + "message": f"Queued {job.plan.project_name}.", + "job_id": job.id, + "requires_approval": False, + } + + def job_action(self, job_id: str, action: str, allowed: bool) -> dict[str, Any]: + if not allowed: + return {"ok": False, "error": "Remote job controls are disabled in ADAM."} + if self.jobs is None: + return {"ok": False, "error": "Job controls are not available in this ADAM session."} + if not job_id: + return {"ok": False, "error": "Choose a job first."} + if action not in {"cancel", "retry", "confirm", "pause", "resume", "end"}: + return {"ok": False, "error": "Unsupported remote job action."} + try: + result = self.dispatcher.job_action(self.jobs, job_id, action) + job = result.get("job") + if action == "cancel": + return {"ok": True, "message": f"Cancellation requested for {job.plan.project_name}."} + if action == "confirm": + return {"ok": True, "message": f"Approved {job.plan.project_name}."} + if action == "pause": + return {"ok": True, "message": f"Paused {job.plan.project_name}."} + if action == "resume": + return {"ok": True, "message": f"Resumed {job.plan.project_name}."} + if action == "end": + return {"ok": True, "message": f"Ended {job.plan.project_name}."} + retried = result.get("retried") + return {"ok": True, "message": f"Retry queued for {retried.plan.project_name}.", "job_id": retried.id} + except Exception as exc: + return {"ok": False, "error": f"ADAM could not update that job: {exc}"} + + def _submit_generation_prompt(self, prompt: str) -> dict[str, Any] | None: + parsed = parse_chat_generation_request(prompt) + if parsed is None: + return None + if self.planner is None or self.jobs is None: + return {"ok": False, "error": "Remote image generation is not available in this ADAM session."} + if not all(hasattr(self.planner, name) for name in ("assets", "registry")): + return {"ok": False, "error": "Remote image generation needs the full ADAM planner session."} + try: + plan = self._generation_plan(parsed) + except ValueError as exc: + return {"ok": False, "error": str(exc)} + try: + job = self.dispatcher.submit_job(self.jobs, plan) + except Exception as exc: + return {"ok": False, "error": f"ADAM could not queue that generation: {exc}"} + return { + "ok": True, + "message": f"Queued {job.plan.project_name}.", + "job_id": job.id, + "requires_approval": bool(job.plan.requires_confirmation), + } + + def _should_auto_approve_training(self, plan: Any) -> bool: + if getattr(plan, "orion_review", {}).get("level") == "warning": + return False + if not getattr(plan, "requires_confirmation", False): + return False + if not bool(self.settings().get("auto_approve_training")): + return False + return any( + str(getattr(step, "tool_id", "")).endswith("_trainer") + for step in getattr(plan, "steps", []) + ) + + def _generation_plan(self, parsed: ChatGenerationRequest): + assets = self.planner.assets + registry = self.planner.registry + if hasattr(assets, "discover"): + assets.discover(self.config) + tools = generation_tools(registry) + if not tools: + raise ValueError("No image generators are currently available in ADAM.") + + stable_diffusion_request = ( + parsed.has_positive_prompt + or bool(parsed.base_model_query) + or bool(parsed.negative_prompt) + or parsed.cfg_scale is not None + or parsed.lora_strength is not None + or parsed.denoise_strength is not None + ) + plain_model_search = ( + not parsed.provider_hint + and not stable_diffusion_request + and not parsed.model_query + ) + base_only = ( + stable_diffusion_request + and parsed.provider_hint != "lora" + and not parsed.model_query + ) + preferred_id = { + "ddpm": "ddpm_generator", + "flow": "flow_generator", + "lora": "lora_generator", + }.get(parsed.provider_hint, "") + if stable_diffusion_request and parsed.provider_hint not in {"ddpm", "flow"}: + preferred_id = "lora_generator" + preferred_tool = next((item for item in tools if item.id == preferred_id), None) + if parsed.provider_hint and preferred_tool is None: + raise ValueError(f"The requested {parsed.provider_hint.upper()} image generator is not currently available.") + + candidate_tools = [preferred_tool] if preferred_tool else tools + candidates = [ + asset + for asset in getattr(assets, "assets", []) + if asset.kind == "model" + and (not plain_model_search or asset.trainer in {"ddpm", "flow"}) + and not (plain_model_search and parsed.reference_image and asset.trainer == "flow") + and any( + item is not None and asset.trainer in item.model_trainers + for item in candidate_tools + ) + and self._generation_model_is_ready(asset) + ] + model_query = parsed.model_query or (parsed.subject if not base_only else "") + scored = sorted( + ( + (generation_model_match_score(model_query, asset.name), asset) + for asset in candidates + ), + key=lambda item: item[0], + reverse=True, + ) + model = scored[0][1] if scored and scored[0][0] > 0 else None + if model is None and not model_query and len(candidates) == 1: + model = candidates[0] + if model is None and plain_model_search: + base_only = True + preferred_tool = next((item for item in tools if item.id == "lora_generator"), None) + candidate_tools = [preferred_tool] if preferred_tool else tools + model_query = "" + if model is None and not base_only: + detail = f' matching "{model_query}"' if model_query else "" + examples: list[str] = [] + for asset in candidates: + if asset.name not in examples: + examples.append(asset.name) + if len(examples) == 4: + break + available = f" Available examples: {', '.join(examples)}." if examples else "" + raise ValueError( + f"I could not find a completed image model{detail}.{available} " + 'Try: Generate an image using model "Model Name".' + ) + + tool = next( + ( + item + for item in candidate_tools + if item is not None and (base_only or model.trainer in item.model_trainers) + ), + None, + ) + if tool is None: + raise ValueError("The matching model does not have an available image generator.") + if parsed.reference_image and "reference_image" not in tool.capabilities: + raise ValueError( + f"{tool.name} does not support reference-image conditioning. " + "Remove the attachment or choose LoRA/Stable Diffusion or DDPM." + ) + + options = tool.generation_options + saved_generation = self.config.get("generation_settings", {}) + saved_generation = saved_generation if isinstance(saved_generation, dict) else {} + sampler_options = [str(value) for value in options.get("samplers", [])] + sampler = parsed.sampler or ( + str(saved_generation.get("sampler", "")) if tool.id == "lora_generator" else "" + ) + if sampler not in sampler_options: + sampler = sampler_options[0] if sampler_options else sampler or "DDIM" + aspect_options = [str(value) for value in options.get("aspect_ratios", [])] + aspect = parsed.aspect_ratio or ( + str(saved_generation.get("aspect", "")) if tool.id == "lora_generator" else "" + ) + if aspect and aspect not in aspect_options: + aspect = next( + (value for value in aspect_options if value.startswith(f"{aspect} ") or value == aspect), + "", + ) + if not aspect: + aspect = aspect_options[0] if aspect_options else "1:1 (Square)" + step_min = int(options.get("step_min", 1) or 1) + step_max = int(options.get("step_max", 500) or 500) + default_steps = ( + saved_generation.get("steps", options.get("step_default", 50)) + if tool.id == "lora_generator" + else options.get("step_default", 50) + ) + steps = parsed.steps if parsed.steps is not None else int(default_steps or 50) + steps = max(step_min, min(steps, step_max)) + count_limit = 8 if tool.id == "lora_generator" else 32 + default_count = ( + int(saved_generation.get("images", 1) or 1) + if tool.id == "lora_generator" + else 1 + ) + count = max(1, min(parsed.image_count or default_count, count_limit)) + seed = parsed.seed if parsed.seed is not None else 0 + extra_arguments: dict[str, Any] = {} + if tool.id == "ddpm_generator": + extra_arguments = { + "reference_image": parsed.reference_image, + "reference_strength": max( + 0, + min(parsed.reference_strength if parsed.reference_strength is not None else 65, 100), + ), + "width": 0, + "height": 0, + } + elif tool.id == "lora_generator": + base_model_path = self._stable_diffusion_base_model_path(parsed, saved_generation) + extra_arguments = { + "negative_prompt": parsed.negative_prompt or str(saved_generation.get("negative_prompt", "")), + "base_model_path": base_model_path, + "width": 0, + "height": 0, + "cfg_scale": parsed.cfg_scale if parsed.cfg_scale is not None else float(saved_generation.get("cfg_scale", 0) or 0), + "lora_strength": 0.0 if base_only else ( + parsed.lora_strength if parsed.lora_strength is not None else float(saved_generation.get("lora_strength", 0) or 0) + ), + "reference_image": parsed.reference_image, + "denoise_strength": parsed.denoise_strength if parsed.denoise_strength is not None else float(saved_generation.get("denoise_strength", 0) or 0), + "prompt_weighting": bool(saved_generation.get("prompt_weighting", True)), + } + + return build_generation_plan( + tool, + model_name=(Path(extra_arguments.get("base_model_path", "")).stem if base_only else model.name), + model_path="" if base_only else model.path, + prompt=parsed.prompt, + image_count=count, + steps=steps, + seed=seed, + sampler=sampler, + aspect_ratio=aspect, + extra_arguments=extra_arguments, + ) + + def _stable_diffusion_base_model_path( + self, + parsed: ChatGenerationRequest, + saved_generation: dict[str, Any], + ) -> str: + base_assets = [ + asset + for asset in getattr(self.planner.assets, "assets", []) + if asset.kind == "base_model" and Path(asset.path).exists() + ] + if parsed.base_model_query: + scored_bases = sorted( + ( + (generation_model_match_score(parsed.base_model_query, asset.name), asset) + for asset in base_assets + ), + key=lambda item: item[0], + reverse=True, + ) + if scored_bases and scored_bases[0][0] > 0: + return scored_bases[0][1].path + raise ValueError( + f'I could not find a Stable Diffusion base model matching "{parsed.base_model_query}".' + ) + preferred_base = next( + ( + asset + for asset in base_assets + if "waiillustrious" in "".join( + character for character in asset.name.casefold() if character.isalnum() + ) + or "wallilustrious" in "".join( + character for character in asset.name.casefold() if character.isalnum() + ) + ), + None, + ) + if preferred_base is not None: + return preferred_base.path + selected_base = str(saved_generation.get("base_model_path", "")) + if selected_base and Path(selected_base).expanduser().exists(): + return selected_base + trainer_root = Path(str(self.config.get("tool_folders", {}).get("lora_trainer", ""))) + try: + trainer_settings = json.loads( + (trainer_root / "config" / "app_settings.json").read_text(encoding="utf-8") + ) + configured_base = str( + trainer_settings.get("generate_model") + or trainer_settings.get("last_model") + or "" + ) + configured_path = Path(configured_base).expanduser() + if configured_base and not configured_path.is_absolute(): + configured_path = trainer_root / configured_path + if configured_base and configured_path.exists(): + return str(configured_path.resolve()) + except (OSError, ValueError, TypeError, json.JSONDecodeError): + pass + if len(base_assets) == 1: + return base_assets[0].path + names = ", ".join(asset.name for asset in base_assets[:4]) + available = f" Available base models: {names}." if names else "" + raise ValueError( + "LoRA generation also needs a Stable Diffusion base model. Put one in " + '"LoRA StableDiffusionModels Here", or select one in the Generations tab.' + + available + ) + + @staticmethod + def _generation_model_is_ready(asset: Any) -> bool: + path = Path(asset.path) + if asset.trainer == "ddpm": + return path.is_dir() and (path / "model_index.json").is_file() + if asset.trainer == "flow": + return ( + path.is_dir() + and (path / "flow_model_info.json").is_file() + and (path / "unet" / "config.json").is_file() + ) + if asset.trainer == "lora": + return ( + path.is_file() + and path.suffix.casefold() == ".safetensors" + and "_comfy" not in path.stem.casefold() + ) or ( + path.is_dir() + and any( + item.is_file() + and item.suffix.casefold() == ".safetensors" + and "_comfy" not in item.stem.casefold() + for item in path.glob("*.safetensors") + ) + ) + return path.exists() + + def stop(self) -> None: + if self._server is None: + return + self._server.shutdown() + self._server.server_close() + self._server = None + self._thread = None + + def shutdown(self) -> None: + self.stop() + self.dispatcher.shutdown() + + def url(self) -> str: + settings = self.settings() + host = "127.0.0.1" if self._clean_mode(str(settings.get("remote_mode"))) == REMOTE_MODE_TAILSCALE else str(settings["bind_address"]) + return f"http://{self._url_host(host)}:{int(settings['port'])}/api/status" + + def local_test_url(self) -> str: + settings = self.settings() + host = "127.0.0.1" if self._clean_mode(str(settings.get("remote_mode"))) == REMOTE_MODE_TAILSCALE else str(settings["bind_address"]) + if host in {"0.0.0.0", "::"}: + host = "127.0.0.1" + query = urlencode({"token": str(settings["token"])}) + return f"http://{self._url_host(host)}:{int(settings['port'])}/?{query}" + + def phone_test_url(self) -> str: + settings = self.settings() + if self._clean_mode(str(settings.get("remote_mode"))) == REMOTE_MODE_TAILSCALE: + return self.tailscale_url() + host = str(settings["bind_address"]) + scope = remote_scope(host) + if scope == "local-device only": + return "" + if host in {"0.0.0.0", "::"}: + host = local_network_host() + if not host: + return "" + query = urlencode({"token": str(settings["token"])}) + return f"http://{self._url_host(host)}:{int(settings['port'])}/?{query}" + + def tailscale_url(self) -> str: + status = inspect_tailscale() + if not status.installed or not status.connected: + return "" + host = status.dns_name or status.tailscale_ip + if not host: + return "" + settings = self.settings() + query = urlencode({"token": str(settings["token"])}) + return f"https://{self._url_host(host)}/?{query}" + + def tailscale_status(self) -> TailscaleStatus: + return inspect_tailscale() + + def start_tailscale_serve(self) -> tuple[bool, str]: + status = inspect_tailscale() + if not status.installed: + return False, "Tailscale is not installed." + if not status.connected: + return False, "Tailscale is installed but not connected." + executable = shutil.which("tailscale") + if not executable: + return False, "Tailscale is not installed." + port = int(self.settings()["port"]) + try: + result = _run_tailscale([executable, "serve", "--bg", str(port)]) + except (OSError, subprocess.TimeoutExpired) as exc: + return False, f"Tailscale Serve could not start: {exc}" + if result.returncode != 0: + return False, _command_text(result.stderr) or "Tailscale Serve could not start." + return True, "Tailscale Serve is forwarding private tailnet traffic to ADAM." + + def stop_tailscale_serve(self) -> tuple[bool, str]: + executable = shutil.which("tailscale") + if not executable: + return False, "Tailscale is not installed." + try: + result = _run_tailscale([executable, "serve", "reset"]) + except (OSError, subprocess.TimeoutExpired) as exc: + return False, f"Tailscale Serve could not stop: {exc}" + if result.returncode != 0: + return False, _command_text(result.stderr) or "Tailscale Serve could not stop." + return True, "Tailscale Serve forwarding was reset." + + @staticmethod + def _url_host(host: str) -> str: + value = host.strip() or "127.0.0.1" + if ":" in value and not value.startswith("["): + return f"[{value}]" + return value + + @staticmethod + def _clean_mode(value: str) -> str: + mode = value.strip().casefold() + return mode if mode in {REMOTE_MODE_DISABLED, REMOTE_MODE_LOCAL, REMOTE_MODE_TAILSCALE} else REMOTE_MODE_LOCAL