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
Generate
Active Job
Checking ADAM...
Waiting for status.
...
--
--
--
--
Live Preview
The latest training or generation preview will appear here.
Waiting for preview output.
Latest Generation
Finished generated images will appear here.
1 of 1
Waiting for a completed generation.
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
Active Job
No active job.
Live Preview
![Live Preview]()
Waiting for a preview.
Prompt ADAM
Datasets
![Dataset image]()
Models
"""
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