SyntheticMDProductions's picture
Update ADAM safety, UI, and model workflows (#1)
c61c435
Raw History Blame Contribute Delete
116 kB
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 """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>ADAM Remote</title>
<style>
:root {
color-scheme: dark;
--bg: #05090e;
--panel: #09131d;
--panel-2: #0c1925;
--border: #183246;
--cyan: #5bc0ff;
--blue: #159eff;
--text: #edf5fb;
--muted: #8ca2b3;
--green: #57e65c;
--orange: #ffb547;
--red: #ff5964;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at top left, #102235 0, #05090e 42%);
color: var(--text);
font: 15px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
.app {
width: min(760px, 100%);
margin: 0 auto;
padding: 18px 14px 28px;
}
.top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 0 14px;
}
.brand {
display: flex;
flex-direction: column;
gap: 2px;
}
.brand strong {
font-size: 28px;
letter-spacing: 8px;
}
.brand span, .muted {
color: var(--muted);
font-size: 12px;
}
.pill {
border: 1px solid var(--border);
border-radius: 999px;
padding: 7px 10px;
color: var(--cyan);
background: rgba(12, 25, 37, .82);
white-space: nowrap;
}
.tabs {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
margin-bottom: 12px;
}
.tabs a {
border: 1px solid var(--border);
border-radius: 9px;
color: var(--cyan);
background: rgba(12, 25, 37, .8);
padding: 8px 6px;
text-align: center;
text-decoration: none;
font-size: 12px;
font-weight: 700;
}
.grid {
display: grid;
gap: 12px;
}
.remote-prompt {
display: grid;
gap: 10px;
}
textarea {
width: 100%;
min-height: 112px;
resize: vertical;
border: 1px solid var(--border);
border-radius: 10px;
background: #06111a;
color: var(--text);
padding: 12px;
font: inherit;
}
.card {
border: 1px solid var(--border);
border-radius: 12px;
background: rgba(9, 19, 29, .92);
padding: 14px;
box-shadow: 0 12px 34px rgba(0,0,0,.25);
}
.card h2 {
margin: 0 0 10px;
color: var(--cyan);
font-size: 12px;
letter-spacing: 1.6px;
text-transform: uppercase;
}
.hero {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
align-items: end;
}
.job-title {
font-size: 20px;
font-weight: 700;
overflow-wrap: anywhere;
}
.state {
color: var(--green);
font-weight: 700;
text-transform: uppercase;
font-size: 12px;
}
.metric-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.metric {
border: 1px solid var(--border);
border-radius: 9px;
background: var(--panel-2);
padding: 11px;
}
.metric label {
display: block;
color: var(--muted);
font-size: 11px;
margin-bottom: 5px;
}
.metric strong {
font-size: 22px;
}
.bar {
height: 7px;
border-radius: 999px;
background: #132634;
overflow: hidden;
margin-top: 8px;
}
.bar i {
display: block;
height: 100%;
width: 0;
background: linear-gradient(90deg, var(--blue), var(--green));
}
.queue {
display: grid;
gap: 8px;
}
.preview-frame {
display: grid;
place-items: center;
min-height: 260px;
border: 1px solid var(--border);
border-radius: 10px;
background: #03080d;
overflow: hidden;
}
.preview-frame img {
display: none;
width: 100%;
height: auto;
max-height: 520px;
object-fit: contain;
}
.preview-empty {
padding: 24px;
color: var(--muted);
text-align: center;
}
.queue-item {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
align-items: center;
border: 1px solid var(--border);
border-radius: 9px;
background: var(--panel-2);
padding: 10px;
}
.queue-item strong {
overflow-wrap: anywhere;
}
.badge {
color: var(--green);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.error {
border-color: #65323a;
color: #ffd6da;
}
button {
width: 100%;
border: 1px solid #24506e;
border-radius: 9px;
color: #00101b;
background: var(--cyan);
font-weight: 800;
min-height: 42px;
}
.secondary-button {
color: var(--cyan);
background: rgba(12, 25, 37, .9);
}
.button-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 10px;
}
.quick-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.quick-row button {
min-height: 36px;
font-size: 12px;
}
.option-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.option-row button {
min-height: 36px;
font-size: 12px;
}
.job-meta {
grid-column: 1 / -1;
color: var(--muted);
font-size: 12px;
}
.job-actions {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.job-actions button {
min-height: 36px;
font-size: 12px;
}
.wide-button {
grid-column: 1 / -1;
}
.gallery-controls {
display: none;
grid-template-columns: 1fr auto 1fr;
gap: 8px;
align-items: center;
margin-top: 10px;
}
.gallery-controls span {
color: var(--muted);
font-size: 12px;
min-width: 72px;
text-align: center;
}
.timing-grid {
margin-top: 12px;
}
.toggle-row {
display: grid;
grid-template-columns: auto 1fr;
gap: 10px;
align-items: start;
margin: 10px 0;
}
.toggle-row input {
width: 22px;
min-height: 22px;
margin-top: 2px;
}
.toggle-row strong {
display: block;
}
input, select {
width: 100%;
border: 1px solid var(--border);
border-radius: 9px;
background: #06111a;
color: var(--text);
min-height: 42px;
padding: 0 10px;
font: inherit;
}
.form-grid {
display: grid;
gap: 9px;
}
.form-grid.two {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (min-width: 700px) {
.grid.two { grid-template-columns: 1.1fr .9fr; }
.tabs { grid-template-columns: repeat(7, minmax(0, 1fr)); }
}
</style>
</head>
<body>
<main class="app">
<section class="top">
<div class="brand">
<strong>ADAM</strong>
<span>AI Development and Automation Manager</span>
</div>
<div class="pill" id="connection">Connecting</div>
</section>
<nav class="tabs">
<a href="#home">Home</a>
<a href="#promptCard">Prompt</a>
<a href="#jobs">Jobs</a>
<a href="#generate">Generate</a>
<a href="#previews">Previews</a>
<a href="#latest">Latest</a>
<a href="#system">System</a>
</nav>
<section class="grid">
<article class="card" id="promptCard">
<h2>Prompt ADAM</h2>
<form class="remote-prompt" id="promptForm">
<textarea id="adamPrompt" placeholder="Ask ADAM to generate images, prepare a dataset, inspect the system, or plan training."></textarea>
<div class="quick-row">
<button type="button" class="secondary-button" data-prompt-template="model">Create Model</button>
<button type="button" class="secondary-button" data-prompt-template="dataset">Collect Dataset</button>
<button type="button" class="secondary-button" data-prompt-template="inspect">Inspect ADAM</button>
<button type="button" class="secondary-button" data-prompt-template="continue">Continue Work</button>
</div>
<button type="submit">Send To ADAM</button>
<p class="muted" id="promptStatus">Plans that need desktop approval will wait safely inside the main ADAM app.</p>
</form>
</article>
<article class="card" id="generate">
<h2>Generate</h2>
<form class="remote-prompt" id="generateForm">
<textarea id="genPrompt" placeholder="Describe the image you want ADAM to generate."></textarea>
<div class="form-grid two">
<input id="genModel" placeholder="Model name, optional">
<input id="genNegative" placeholder="Negative prompt, optional">
<input id="genCount" type="number" min="1" max="32" placeholder="Images">
<input id="genSteps" type="number" min="1" max="999" placeholder="Steps">
<input id="genSeed" type="number" min="0" max="2147483647" placeholder="Seed">
<select id="genSampler">
<option value="">Sampler</option>
<option>DDIM</option>
<option>DDPM</option>
<option>Euler</option>
<option>Euler a</option>
<option>Heun</option>
<option>DPM++ 2M</option>
</select>
<select id="genAspect">
<option value="">Aspect</option>
<option>1:1</option>
<option>16:9</option>
<option>9:16</option>
<option>4:3</option>
<option>3:4</option>
</select>
</div>
<div class="option-row">
<button type="button" class="secondary-button" data-gen-preset="quick">Quick</button>
<button type="button" class="secondary-button" data-gen-preset="balanced">Balanced</button>
<button type="button" class="secondary-button" data-gen-preset="polished">Polished</button>
</div>
<button type="submit">Generate Image</button>
<p class="muted" id="generateStatus">Generation uses ADAM's existing desktop backend and queue.</p>
</form>
</article>
<article class="card" id="home">
<h2>Active Job</h2>
<div class="hero">
<div>
<div class="job-title" id="jobTitle">Checking ADAM...</div>
<div class="muted" id="jobDetail">Waiting for status.</div>
</div>
<div class="state" id="jobState">...</div>
</div>
<div class="bar"><i id="jobProgress"></i></div>
<div class="metric-grid timing-grid">
<div class="metric"><label>Elapsed</label><strong id="elapsedTime">--</strong></div>
<div class="metric"><label>Time Left</label><strong id="remainingTime">--</strong></div>
<div class="metric"><label>Finish</label><strong id="finishTime">--</strong></div>
<div class="metric"><label>Estimate</label><strong id="estimateTime">--</strong></div>
</div>
<div class="button-row" id="activeControls"></div>
</article>
<article class="card" id="previews">
<h2>Live Preview</h2>
<div class="preview-frame">
<img id="previewImage" alt="Latest ADAM preview">
<div class="preview-empty" id="previewEmpty">The latest training or generation preview will appear here.</div>
</div>
<p class="muted" id="previewMeta">Waiting for preview output.</p>
</article>
<article class="card" id="latest">
<h2>Latest Generation</h2>
<div class="preview-frame">
<img id="generationImage" alt="Latest generated ADAM image">
<div class="preview-empty" id="generationEmpty">Finished generated images will appear here.</div>
</div>
<div class="gallery-controls" id="generationControls">
<button type="button" class="secondary-button" id="generationPrev">Previous</button>
<span id="generationCounter">1 of 1</span>
<button type="button" class="secondary-button" id="generationNext">Next</button>
</div>
<p class="muted" id="generationMeta">Waiting for a completed generation.</p>
</article>
<section class="grid two">
<article class="card" id="system">
<h2>System</h2>
<div class="metric-grid">
<div class="metric"><label>CPU</label><strong id="cpu">--</strong><div class="bar"><i id="cpuBar"></i></div></div>
<div class="metric"><label>RAM</label><strong id="ram">--</strong><div class="bar"><i id="ramBar"></i></div></div>
<div class="metric"><label>GPU</label><strong id="gpu">--</strong><div class="bar"><i id="gpuBar"></i></div></div>
<div class="metric"><label>VRAM</label><strong id="vram">--</strong><div class="bar"><i id="vramBar"></i></div></div>
</div>
<p class="muted" id="gpuName"></p>
</article>
<article class="card">
<h2>Remote Control</h2>
<p><span class="badge" id="scope">--</span></p>
<p class="muted" id="permissions">Status-only remote access is loading.</p>
<label class="toggle-row">
<input type="checkbox" id="autoApproveTraining">
<span><strong>Auto-approve remote training</strong><span class="muted">Remote training prompts can start without desktop approval.</span></span>
</label>
<label class="toggle-row">
<input type="checkbox" id="keepAwake">
<span><strong>Keep screen updated</strong><span class="muted">Refresh more often while this page is open.</span></span>
</label>
<p class="muted" id="settingsStatus">Remote settings are synced from ADAM.</p>
<button id="refresh">Refresh Now</button>
</article>
</section>
<article class="card" id="jobs">
<h2>Recent Queue</h2>
<div class="queue" id="queue"></div>
</article>
<article class="card">
<h2>Completed Jobs</h2>
<div class="queue" id="completed"></div>
</article>
<article class="card">
<h2>Failed Jobs</h2>
<div class="queue" id="failed"></div>
</article>
</section>
</main>
<script>
const qs = window.location.search || "";
const $ = id => document.getElementById(id);
let generationIndex = 0;
let generationSignature = "";
let refreshMs = Number(localStorage.getItem("adamRemoteRefreshMs") || "3000");
let refreshTimer = null;
function text(id, value) { $(id).textContent = value; }
function percent(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return "--";
return `${Math.max(0, Math.min(100, Number(value))).toFixed(0)}%`;
}
function label(value) {
return value === null || value === undefined || value === "" ? "--" : value;
}
function bar(id, value) {
const number = Number(value);
$(id).style.width = Number.isFinite(number) ? `${Math.max(0, Math.min(100, number))}%` : "0";
}
function shortDate(value) {
if (!value) return "";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "" : date.toLocaleString([], { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
}
function apiUrl(path) {
const query = qs ? qs.slice(1) : "";
const separator = path.includes("?") ? "&" : "?";
return `${path}${query ? separator + query + "&" : separator}_=${Date.now()}`;
}
function addJobButton(parent, labelText, jobId, action, style = "") {
const button = document.createElement("button");
button.type = "button";
button.textContent = labelText;
button.className = style;
button.addEventListener("click", () => jobAction(jobId, action));
parent.appendChild(button);
}
function render(data) {
text("connection", "Live");
const active = data.active_job;
if (active) {
const timing = active.timing || {};
text("jobTitle", active.project || active.id || "Running job");
text("jobDetail", `Progress ${percent(active.progress)}${timing.remaining_label ? ` - ${timing.remaining_label} left` : ""}`);
text("jobState", active.status || "Running");
bar("jobProgress", active.progress);
text("elapsedTime", label(timing.elapsed_label));
text("remainingTime", label(timing.remaining_label));
text("finishTime", label(timing.finish_label));
text("estimateTime", label(timing.estimate_label));
} else {
text("jobTitle", "No active job");
text("jobDetail", "ADAM is ready. Recent work is listed below.");
text("jobState", "Ready");
bar("jobProgress", 0);
text("elapsedTime", "--");
text("remainingTime", "--");
text("finishTime", "--");
text("estimateTime", "--");
}
const system = data.system || {};
text("cpu", percent(system.cpu_percent)); bar("cpuBar", system.cpu_percent);
text("ram", percent(system.memory_percent)); bar("ramBar", system.memory_percent);
text("gpu", percent(system.gpu_percent)); bar("gpuBar", system.gpu_percent);
text("vram", percent(system.vram_percent)); bar("vramBar", system.vram_percent);
text("gpuName", system.gpu_name ? `${system.gpu_name}${system.gpu_temperature ? ` - ${system.gpu_temperature.toFixed(0)} C` : ""}` : "GPU details unavailable.");
text("scope", data.scope || "unknown scope");
const permissions = data.permissions || {};
text("permissions", permissions.dangerous_actions ? "Dangerous remote actions are enabled." : "Dangerous remote actions are unavailable. Status, system, and queue viewing are allowed.");
$("autoApproveTraining").checked = Boolean(permissions.auto_approve_training);
const controls = $("activeControls");
controls.replaceChildren();
if (active && permissions.job_control) {
if (active.status === "Running") addJobButton(controls, "Pause Job", active.id, "pause", "secondary-button");
if (active.status === "Paused") addJobButton(controls, "Resume Job", active.id, "resume");
addJobButton(controls, "Cancel Job", active.id, "cancel");
}
const preview = data.preview || {};
const image = $("previewImage");
const empty = $("previewEmpty");
if (preview.available && preview.url) {
image.src = apiUrl(preview.url);
image.style.display = "block";
empty.style.display = "none";
const parts = [];
if (preview.kind) parts.push(preview.kind);
if (preview.epoch) parts.push(`epoch ${preview.epoch}`);
if (preview.current) parts.push(`step ${preview.current}${preview.total ? ` of ${preview.total}` : ""}`);
if (preview.prompt) parts.push(preview.prompt);
text("previewMeta", parts.join(" - ") || "Latest preview");
} else {
image.removeAttribute("src");
image.style.display = "none";
empty.style.display = "block";
text("previewMeta", "No preview image is available yet.");
}
const generation = data.latest_generation || {};
const generationImage = $("generationImage");
const generationEmpty = $("generationEmpty");
const generationControls = $("generationControls");
if (generation.available && generation.images && generation.images.length) {
const signature = `${generation.created_at || ""}|${generation.provider_id || ""}|${generation.model_name || ""}|${generation.prompt || ""}|${generation.images.length}`;
if (signature !== generationSignature) {
generationSignature = signature;
generationIndex = 0;
}
generationIndex = Math.max(0, Math.min(generationIndex, generation.images.length - 1));
const selected = generation.images[generationIndex];
generationImage.src = apiUrl(selected.url);
generationImage.style.display = "block";
generationEmpty.style.display = "none";
generationControls.style.display = generation.images.length > 1 ? "grid" : "none";
text("generationCounter", `Image ${generationIndex + 1} of ${generation.images.length}`);
const parts = [];
if (generation.model_name) parts.push(generation.model_name);
if (generation.provider_name) parts.push(generation.provider_name);
if (generation.images.length) parts.push(`${generation.images.length} image${generation.images.length === 1 ? "" : "s"}`);
if (generation.prompt) parts.push(generation.prompt);
text("generationMeta", parts.join(" - ") || "Latest generated image");
} else {
generationImage.removeAttribute("src");
generationImage.style.display = "none";
generationEmpty.style.display = "block";
generationControls.style.display = "none";
text("generationMeta", "No completed generated image is available yet.");
}
$("keepAwake").checked = refreshMs <= 1500;
renderJobs("queue", (data.queue || []).slice(0, 12), permissions);
renderJobs("completed", (data.completed_jobs || []).slice(0, 8), permissions);
renderJobs("failed", (data.failed_jobs || []).slice(0, 8), permissions);
}
function renderJobs(id, items, permissions) {
const queue = $(id);
queue.replaceChildren();
if (!items.length) {
const empty = document.createElement("p");
empty.className = "muted";
empty.textContent = id === "failed" ? "No failed jobs." : id === "completed" ? "No completed jobs yet." : "Queue is empty.";
queue.appendChild(empty);
} else {
for (const item of items) {
const row = document.createElement("div");
row.className = "queue-item";
const name = document.createElement("strong");
name.textContent = item.project || item.id || "ADAM job";
const status = document.createElement("span");
status.className = "badge";
status.textContent = item.status || "";
row.append(name, status);
const metaParts = [];
const timing = item.timing || {};
if (item.current_step_title) metaParts.push(item.current_step_title);
if (item.progress !== undefined) metaParts.push(`Progress ${percent(item.progress)}`);
if (timing.remaining_label) metaParts.push(`${timing.remaining_label} left`);
if (item.scheduled_for) metaParts.push(`Scheduled ${shortDate(item.scheduled_for)}`);
if (item.logs && item.logs.length) metaParts.push(item.logs[item.logs.length - 1]);
if (metaParts.length) {
const meta = document.createElement("div");
meta.className = "job-meta";
meta.textContent = metaParts.join(" - ");
row.appendChild(meta);
}
if (id === "failed" && item.error) {
const error = document.createElement("p");
error.className = "muted";
error.textContent = item.error;
row.appendChild(error);
}
if (permissions.job_control) {
const actions = document.createElement("div");
actions.className = "job-actions";
if (item.status === "Awaiting confirmation") {
addJobButton(actions, "Approve", item.id, "confirm");
addJobButton(actions, "Reject", item.id, "cancel", "secondary-button");
}
if (["Queued", "Scheduled"].includes(item.status)) {
addJobButton(actions, "Cancel", item.id, "cancel", "secondary-button");
}
if (id === "failed" || ["Failed", "Cancelled", "Interrupted"].includes(item.status)) {
addJobButton(actions, "Retry", item.id, "retry");
}
if (item.status === "Interrupted") {
addJobButton(actions, "End", item.id, "end", "secondary-button");
}
if (actions.childElementCount) row.appendChild(actions);
}
queue.appendChild(row);
}
}
}
async function load() {
try {
const response = await fetch(`/api/status${qs}`, { cache: "no-store" });
if (!response.ok) throw new Error(`Status ${response.status}`);
render(await response.json());
} catch (error) {
text("connection", "Offline");
text("jobTitle", "Could not reach ADAM");
text("jobDetail", "Check that the desktop app is open and your phone is on the same Wi-Fi.");
text("jobState", "Offline");
}
}
$("promptForm").addEventListener("submit", async event => {
event.preventDefault();
const value = $("adamPrompt").value.trim();
if (!value) return;
text("promptStatus", "Sending to ADAM...");
try {
const response = await fetch(`/api/prompt${qs}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: value })
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || `Status ${response.status}`);
$("adamPrompt").value = "";
text("promptStatus", payload.message || "Sent to ADAM.");
load();
} catch (error) {
text("promptStatus", `Could not send: ${error.message}`);
}
});
const promptTemplates = {
model: 'Grab a dataset of Example off the internet with up to 40 images, name the model Example, train it on a DDPM for 25 epochs, and save it to the DDPM output. [ADAM_TRAINING_OPTIONS:{"batch_size": 1, "dataloader_num_workers": 4, "gradient_accumulation_steps": 1, "learning_rate": 0.0001, "mixed_precision": "fp16", "preview_enabled": true, "preview_every": 5, "preview_prompt": "", "preview_seed": 123456789, "resolution": 128, "save_every": 10, "training_intensity": 100}] [ADAM_TRAINER:ddpm]',
dataset: 'Collect up to 60 images of Example from the internet, clean the dataset, and save it as an ADAM dataset named Example.',
inspect: 'Inspect the current ADAM system, summarize what is running, what is queued, and what needs attention.',
continue: 'Continue the most recent unfinished ADAM work if it is safe, otherwise explain what needs approval.'
};
document.querySelectorAll("[data-prompt-template]").forEach(button => {
button.addEventListener("click", () => {
const prompt = $("adamPrompt");
const template = promptTemplates[button.dataset.promptTemplate] || "";
prompt.value = template;
prompt.focus();
const firstExample = template.indexOf("Example");
if (firstExample >= 0) prompt.setSelectionRange(firstExample, firstExample + "Example".length);
text("promptStatus", firstExample >= 0 ? "Template added. Replace Example with your subject." : "Template added.");
});
});
const generationPresets = {
quick: { genCount: "1", genSteps: "20", genAspect: "1:1" },
balanced: { genCount: "2", genSteps: "35", genAspect: "1:1" },
polished: { genCount: "4", genSteps: "50", genAspect: "1:1" }
};
document.querySelectorAll("[data-gen-preset]").forEach(button => {
button.addEventListener("click", () => {
const preset = generationPresets[button.dataset.genPreset] || {};
for (const [id, value] of Object.entries(preset)) {
if ($(id)) $(id).value = value;
}
text("generateStatus", `${button.textContent} generation preset applied.`);
});
});
["genModel", "genNegative", "genCount", "genSteps", "genSeed", "genSampler", "genAspect"].forEach(id => {
const saved = localStorage.getItem(`adamRemote.${id}`);
if (saved !== null) $(id).value = saved;
$(id).addEventListener("change", event => localStorage.setItem(`adamRemote.${id}`, event.target.value));
});
$("generateForm").addEventListener("submit", async event => {
event.preventDefault();
const prompt = $("genPrompt").value.trim();
if (!prompt) return;
const rawCount = Number($("genCount").value);
const count = Number.isFinite(rawCount) && rawCount > 1 ? Math.min(32, Math.floor(rawCount)) : 1;
const parts = [`Generate ${count} image${count === 1 ? "" : "s"} of "${prompt}"`];
const model = $("genModel").value.trim();
const negative = $("genNegative").value.trim();
const steps = $("genSteps").value.trim();
const seed = $("genSeed").value.trim();
const sampler = $("genSampler").value;
const aspect = $("genAspect").value;
if (model) parts.push(`using model "${model}"`);
if (negative) parts.push(`negative prompt "${negative}"`);
if (steps) parts.push(`${steps} steps`);
if (seed) parts.push(`seed ${seed}`);
if (sampler) parts.push(`sampler ${sampler}`);
if (aspect) parts.push(`aspect ratio ${aspect}`);
text("generateStatus", "Sending generation request...");
try {
const response = await fetch(`/api/prompt${qs}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prompt: parts.join(", ") })
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || `Status ${response.status}`);
$("genPrompt").value = "";
text("generateStatus", payload.message || "Generation request sent.");
load();
} catch (error) {
text("generateStatus", `Could not send: ${error.message}`);
}
});
$("generationPrev").addEventListener("click", () => {
generationIndex = Math.max(0, generationIndex - 1);
load();
});
$("generationNext").addEventListener("click", () => {
generationIndex += 1;
load();
});
async function jobAction(jobId, action) {
if (!jobId) return;
try {
const response = await fetch(`/api/job${qs}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ job_id: jobId, action })
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || `Status ${response.status}`);
text("promptStatus", payload.message || "Job updated.");
load();
} catch (error) {
text("promptStatus", `Job action failed: ${error.message}`);
}
}
$("refresh").addEventListener("click", load);
$("keepAwake").addEventListener("change", event => {
refreshMs = event.target.checked ? 1500 : 3000;
localStorage.setItem("adamRemoteRefreshMs", String(refreshMs));
startRefreshTimer();
text("settingsStatus", event.target.checked ? "Fast refresh is on." : "Normal refresh is on.");
});
$("autoApproveTraining").addEventListener("change", async event => {
const enabled = Boolean(event.target.checked);
text("settingsStatus", enabled ? "Turning on auto-approval..." : "Turning off auto-approval...");
try {
const response = await fetch(`/api/remote-settings${qs}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ auto_approve_training: enabled })
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || `Status ${response.status}`);
text("settingsStatus", payload.message || "Remote setting saved.");
load();
} catch (error) {
event.target.checked = !enabled;
text("settingsStatus", `Could not save setting: ${error.message}`);
}
});
load();
function startRefreshTimer() {
if (refreshTimer) window.clearInterval(refreshTimer);
refreshTimer = window.setInterval(load, refreshMs);
}
startRefreshTimer();
</script>
</body>
</html>"""
def _remote_dashboard_app_html() -> str:
from adam.remote_dashboard import remote_dashboard_app_html
return remote_dashboard_app_html()
return """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>ADAM Remote</title>
<style>
:root{color-scheme:dark;--bg:#101315;--panel:#191f23;--line:#2c3439;--text:#f4f7f8;--muted:#aeb8bd;--accent:#6ee7b7;--accent2:#7dd3fc;--warn:#fbbf24;--bad:#fb7185}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.35}button,input,select,textarea{font:inherit}button{border:0;border-radius:8px;background:#283138;color:var(--text);padding:10px 12px;font-weight:700}button:disabled{opacity:.45;filter:saturate(.7)}button.primary{background:linear-gradient(135deg,var(--accent),var(--accent2));color:#071012}button.warn{background:#3c2f16;color:#fde68a}button.bad{background:#41202a;color:#fecdd3}input,select,textarea{width:100%;border:1px solid var(--line);border-radius:8px;background:#0f1417;color:var(--text);padding:10px}textarea{min-height:86px;resize:vertical}.app{min-height:100vh;padding:14px 14px 84px}.top{display:flex;gap:10px;align-items:center;justify-content:space-between;margin-bottom:12px}.brand{font-size:24px;font-weight:900}.pill{border:1px solid var(--line);border-radius:999px;padding:6px 10px;color:var(--muted);font-size:13px}.tabs{position:fixed;left:0;right:0;bottom:0;display:grid;grid-template-columns:repeat(5,1fr);gap:1px;background:#050607;border-top:1px solid var(--line);z-index:10}.tabs button{border-radius:0;background:#11171a;color:var(--muted);padding:10px 4px;font-size:12px}.tabs button.active{color:#051014;background:var(--accent)}.view{display:none}.view.active{display:block}.grid{display:grid;gap:10px}.card{background:var(--panel);border:1px solid var(--line);border-radius:8px;padding:12px}.row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.split{display:grid;grid-template-columns:1fr 1fr;gap:8px}.kpis{display:grid;grid-template-columns:repeat(3,1fr);gap:8px}.kpi{background:#11171a;border:1px solid var(--line);border-radius:8px;padding:10px}.kpi b{display:block;font-size:18px}.muted{color:var(--muted)}.section{font-size:15px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);font-weight:800;margin:16px 0 8px}.progress{height:10px;border-radius:999px;background:#0a0d0f;overflow:hidden}.bar{height:100%;background:linear-gradient(90deg,var(--accent),var(--accent2));width:0}.preview{width:100%;max-height:54vh;object-fit:contain;border-radius:8px;background:#080b0d}.thumbs{display:grid;grid-template-columns:repeat(auto-fill,minmax(96px,1fr));gap:8px}.thumb{border:1px solid var(--line);border-radius:8px;overflow:hidden;background:#101518;padding:0;text-align:left;width:100%}.thumb img{width:100%;aspect-ratio:1;object-fit:cover;display:block}.thumb div{padding:6px;font-size:12px;color:var(--muted);word-break:break-word}.list{display:grid;gap:8px}.item{border:1px solid var(--line);border-radius:8px;padding:10px;background:#11171a;text-align:left}.item.selected{outline:2px solid var(--accent)}.status{min-height:20px;color:var(--muted);font-size:13px;white-space:pre-wrap}.danger-text{color:var(--bad)}.good{color:var(--accent)}@media(min-width:760px){.app{max-width:980px;margin:0 auto}.grid.two{grid-template-columns:1fr 1fr}.grid.three{grid-template-columns:repeat(3,1fr)}}
</style>
</head>
<body>
<div class="app">
<div class="top"><div><div class="brand">ADAM Remote</div><div id="connection" class="muted">Connecting...</div></div><button id="refresh">Refresh</button></div>
<div id="home" class="view active">
<div class="grid">
<div class="card"><div class="section">Active Job</div><div id="activeJob">No active job.</div><div class="progress"><div id="activeBar" class="bar"></div></div><div class="row" style="margin-top:10px"><button data-action="pause">Pause</button><button data-action="resume">Resume</button><button class="bad" data-action="cancel">Cancel</button></div></div>
<div class="card"><div class="section">Live Preview</div><img id="preview" class="preview" alt="Live Preview"><div id="previewNote" class="muted">Waiting for a preview.</div></div>
<div class="card"><div class="section">Latest Generation</div><div id="latestGeneration" class="thumbs"></div></div>
<div class="card"><div class="section">Prompt ADAM</div><textarea id="prompt" placeholder="Ask ADAM to collect data, train, inspect, or generate."></textarea><div class="row"><button class="primary" id="sendPrompt">Send To ADAM</button><button class="quick">Create Model</button><button class="quick">Collect Dataset</button><button class="quick">Quick</button></div><div id="promptStatus" class="status"></div></div>
</div>
</div>
<div id="train" class="view">
<div class="card"><div class="section">Structured Training</div><div class="grid">
<label>Trainer<select id="trainer"></select></label>
<label>Dataset<select id="trainDataset"></select></label>
<label>Base model<select id="baseModel"></select></label>
<label>Model name<input id="modelName" placeholder="Example: Adam_OC_LoRA_v2"></label>
<label>LoRA trigger word<input id="triggerWord" placeholder="Example: adam_oc"></label>
<div class="split"><label>Epochs<input id="epochs" type="number" min="1" value="10"></label><label>Resolution<input id="resolution" type="number" min="64" value="128"></label></div>
<div class="split"><label>Batch<input id="batchSize" type="number" min="1" value="1"></label><label>LR<input id="learningRate" value="0.0001"></label></div>
<div class="split"><label>Save every<input id="saveEvery" type="number" min="1" value="10"></label><label>Preview every<input id="previewEvery" type="number" min="1" value="5"></label></div>
<label>Preview prompt<input id="previewPrompt"></label>
<div class="row"><button id="reviewPlan">Review Training Plan</button><button class="primary" id="startTraining">Start Training</button></div>
<pre id="trainingReview" class="status"></pre>
</div></div>
</div>
<div id="generate" class="view">
<div class="card"><div class="section">Generate Image</div><div class="grid">
<label>Provider<select id="provider"></select></label>
<label>Model<select id="generationModel"></select></label>
<label>Base model<select id="generationBase"></select></label>
<label>Prompt<textarea id="genPrompt"></textarea></label>
<label>Negative prompt<textarea id="genNegative"></textarea></label>
<div class="split"><label>Images<input id="genCount" type="number" min="1" max="8" value="1"></label><label>Steps<input id="genSteps" type="number" min="1" value="30"></label></div>
<div class="split"><label>Seed<input id="genSeed" type="number" min="0" value="0"></label><label>CFG<input id="cfgScale" value="7"></label></div>
<div class="split"><label>Sampler<select id="sampler"><option>DPM++ 2M</option><option>Euler</option><option>Euler a</option><option>Heun</option><option>DDIM</option><option>DDPM</option></select></label><label>Aspect<select id="aspect"><option>1:1 (Square)</option><option>16:9 (Widescreen)</option><option>9:16 (Vertical)</option><option>4:3 (Landscape)</option><option>3:4 (Portrait)</option></select></label></div>
<div class="row"><button class="primary" id="startGeneration">Generate</button></div><div id="generationStatus" class="status"></div>
</div></div>
</div>
<div id="library" class="view">
<div class="section">Datasets</div><div id="datasets" class="list"></div>
<div id="datasetDetail" class="card" style="display:none"><div id="datasetTitle"></div><div id="datasetStats" class="muted"></div><div id="datasetGrid" class="thumbs" style="margin-top:10px"></div><div class="row"><button id="prevPage">Prev</button><span id="pageLabel" class="pill">Page 1</span><button id="nextPage">Next</button></div></div>
<div id="imageDetail" class="card" style="display:none"><img id="detailImage" class="preview" alt="Dataset image"><div id="detailName"></div><div id="detailDims" class="muted"></div><label>Caption<textarea id="captionEditor"></textarea></label><div class="row"><button id="saveCaption">Save Caption</button><button id="keepImage">Accept</button><button class="warn" id="rejectImage">Reject</button><button id="unreviewImage">Unreview</button></div><div id="imageStatus" class="status"></div></div>
<div class="section">Models</div><div id="models" class="list"></div>
</div>
<div id="more" class="view">
<div class="card"><div class="section">System</div><div id="system" class="kpis"></div></div>
<div class="card"><div class="section">Remote Control</div><label class="row"><input id="autoApproveTraining" type="checkbox" style="width:auto"> Auto-approve remote training</label><label class="row"><input id="keepAwake" type="checkbox" style="width:auto"> Keep screen updated</label><div id="settingsStatus" class="status"></div></div>
<div class="card"><div class="section">Jobs</div><div id="queues" class="list"></div></div>
</div>
</div>
<nav class="tabs"><button class="active" data-view="home">Home</button><button data-view="train">Train</button><button data-view="generate">Generate</button><button data-view="library">Library</button><button data-view="more">More</button></nav>
<script>
(function(){
var queryString = window.location.search || "";
var state = {
status: null,
datasets: [],
models: [],
trainingSchema: null,
generationSchema: null,
selectedDataset: null,
datasetPage: 1,
selectedItem: null,
activeJobId: "",
refreshMs: 3000
};
var timer = null;
function $(id) { return document.getElementById(id); }
function hasOwn(obj, key) { return Object.prototype.hasOwnProperty.call(obj || {}, key); }
function asList(value) { return Array.isArray(value) ? value : []; }
function displayValue(value) { return value === null || value === undefined || value === "" ? "-" : String(value); }
function text(id, value) {
var node = $(id);
if (node) node.textContent = value === null || value === undefined ? "" : String(value);
}
function clear(node) {
if (!node) return;
while (node.firstChild) node.removeChild(node.firstChild);
}
function option(select, label, value) {
if (!select) return;
var item = document.createElement("option");
item.textContent = label || "";
item.value = value || "";
select.appendChild(item);
}
function appendLine(parent, className, value) {
var node = document.createElement("div");
if (className) node.className = className;
node.textContent = value || "";
parent.appendChild(node);
return node;
}
function authUrl(path, extra) {
var token = queryString.replace(/^\\?/, "");
var url = path;
if (token) url += (url.indexOf("?") >= 0 ? "&" : "?") + token;
if (extra) url += (url.indexOf("?") >= 0 ? "&" : "?") + extra;
return url;
}
function errorMessage(error) {
if (!error) return "Unknown remote error.";
if (typeof error === "string") return error;
return error.message || String(error);
}
function requestJson(path, method, payload) {
return new Promise(function(resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open(method || "GET", authUrl(path), true);
xhr.timeout = 20000;
xhr.setRequestHeader("Accept", "application/json");
if (payload !== undefined) xhr.setRequestHeader("Content-Type", "application/json");
xhr.onreadystatechange = function() {
if (xhr.readyState !== 4) return;
var parsed = null;
try {
parsed = xhr.responseText ? JSON.parse(xhr.responseText) : {};
} catch (parseError) {
reject(new Error("ADAM returned an unreadable response. Refresh and try again."));
return;
}
if (xhr.status < 200 || xhr.status >= 300) {
reject(new Error(parsed && parsed.error ? parsed.error : "Remote request failed (" + xhr.status + ")."));
return;
}
resolve(parsed || {});
};
xhr.onerror = function() { reject(new Error("Connection failed. Check that ADAM Remote is still running on the PC.")); };
xhr.ontimeout = function() { reject(new Error("Connection timed out. The PC may be busy or unreachable.")); };
xhr.send(payload === undefined ? null : JSON.stringify(payload));
});
}
function getJson(path) { return requestJson(path, "GET"); }
function postJson(path, payload) { return requestJson(path, "POST", payload || {}); }
function closestButton(node) {
while (node && node !== document.body) {
if (node.tagName && node.tagName.toLowerCase() === "button") return node;
node = node.parentNode;
}
return null;
}
function switchView(viewId, button) {
var nodes = document.querySelectorAll(".view,.tabs button");
for (var i = 0; i < nodes.length; i += 1) nodes[i].classList.remove("active");
if ($(viewId)) $(viewId).classList.add("active");
if (button) button.classList.add("active");
}
function setButtonBusy(button, busy) {
if (!button) return;
button.disabled = !!busy;
}
function imageWithFallback(src, alt, note) {
var img = document.createElement("img");
img.alt = alt || "";
img.src = src;
img.onerror = function() {
img.style.display = "none";
if (note) note.textContent = "Image could not load. Refresh after the PC finishes writing it.";
};
return img;
}
window.addEventListener("error", function(event) {
text("connection", "Phone app error: " + (event.message || "unknown script error"));
});
window.addEventListener("unhandledrejection", function(event) {
text("connection", "Remote request error: " + errorMessage(event.reason));
});
var tabButtons = document.querySelectorAll(".tabs button");
for (var i = 0; i < tabButtons.length; i += 1) {
tabButtons[i].addEventListener("click", function() {
switchView(this.getAttribute("data-view"), this);
});
}
function renderStatus(payload) {
payload = payload || {};
state.status = payload;
text("connection", (payload.app || "ADAM") + " online - " + (payload.scope || "remote"));
var permissions = payload.permissions || {};
var job = payload.active_job || null;
state.activeJobId = job && job.id ? job.id : "";
if (job) {
$("activeBar").style.width = String(job.progress || 0) + "%";
text(
"activeJob",
(job.project || "Active job") + " - " + (job.status || "") + " - " +
String(job.progress || 0) + "% - Time Left " +
(job.timing && job.timing.remaining_label ? job.timing.remaining_label : "")
);
} else {
text("activeJob", "No active job.");
$("activeBar").style.width = "0%";
}
var actionButtons = document.querySelectorAll("[data-action]");
for (var a = 0; a < actionButtons.length; a += 1) {
actionButtons[a].disabled = !job || !permissions.job_control;
}
$("autoApproveTraining").checked = !!permissions.auto_approve_training;
var preview = $("preview");
var previewInfo = payload.preview || {};
if (previewInfo.available) {
preview.style.display = "block";
preview.onerror = function() {
preview.style.display = "none";
text("previewNote", "Preview image could not load yet. Refresh after ADAM writes the next one.");
};
preview.src = authUrl("/api/preview", "t=" + Date.now());
text(
"previewNote",
(previewInfo.kind || "preview") + " " +
(previewInfo.current || previewInfo.epoch || "") + "/" + (previewInfo.total || "")
);
} else {
preview.removeAttribute("src");
preview.style.display = "none";
text("previewNote", "Waiting for a preview.");
}
renderLatestGeneration(payload.latest_generation || {});
renderSystem(payload.system || {});
renderQueues(payload);
}
function renderLatestGeneration(latest) {
var root = $("latestGeneration");
clear(root);
var images = asList(latest.images).slice(0, 8);
if (!latest.available || images.length === 0) {
appendLine(root, "status", "Finished generated images will appear here.");
return;
}
for (var i = 0; i < images.length; i += 1) {
var item = document.createElement("button");
item.className = "thumb";
item.type = "button";
var note = document.createElement("div");
note.textContent = latest.model_name || "Generated image";
item.appendChild(imageWithFallback(authUrl(images[i].url, "t=" + Date.now()), "Generated image", note));
item.appendChild(note);
item.addEventListener("click", (function(url) {
return function() { window.open(authUrl(url, "t=" + Date.now()), "_blank"); };
})(images[i].url));
root.appendChild(item);
}
}
function renderSystem(sys) {
var root = $("system");
clear(root);
[["CPU", sys.cpu_percent], ["RAM", sys.memory_percent], ["GPU", sys.gpu_percent]].forEach(function(metric) {
var card = document.createElement("div");
card.className = "kpi";
appendLine(card, "", metric[0]);
var value = document.createElement("b");
value.textContent = displayValue(metric[1]) + (metric[1] === null || metric[1] === undefined ? "" : "%");
card.appendChild(value);
root.appendChild(card);
});
}
function renderQueues(payload) {
var root = $("queues");
clear(root);
var jobs = asList(payload.queue).concat(asList(payload.completed_jobs), asList(payload.failed_jobs)).slice(0, 30);
if (jobs.length === 0) {
appendLine(root, "status", "No jobs in the queue.");
return;
}
jobs.forEach(function(job) {
var item = document.createElement("div");
item.className = "item";
var title = document.createElement("b");
title.textContent = job.project || "ADAM Job";
item.appendChild(title);
appendLine(item, "muted", (job.status || "") + " - " + String(job.progress || 0) + "%");
if (job.current_step_title) appendLine(item, "muted", job.current_step_title);
if (job.error) appendLine(item, "danger-text", job.error);
root.appendChild(item);
});
}
function load() {
return getJson("/api/status").then(renderStatus).catch(function(error) {
text("connection", "Offline: " + errorMessage(error));
});
}
function loadLibrary() {
var tasks = [
getJson("/api/v1/datasets").then(function(payload) {
state.datasets = asList(payload.datasets);
renderDatasets();
}).catch(function(error) {
text("datasets", "Datasets could not load: " + errorMessage(error));
}),
getJson("/api/v1/models").then(function(payload) {
state.models = asList(payload.models);
renderModels();
}).catch(function(error) {
text("models", "Models could not load: " + errorMessage(error));
}),
getJson("/api/v1/training/schema").then(function(payload) {
state.trainingSchema = payload;
}).catch(function(error) {
state.trainingSchema = {trainers: [], base_models: []};
text("trainingReview", "Training controls could not load: " + errorMessage(error));
}),
getJson("/api/v1/generation/schema").then(function(payload) {
state.generationSchema = payload;
}).catch(function(error) {
state.generationSchema = {providers: []};
text("generationStatus", "Generation controls could not load: " + errorMessage(error));
})
];
return Promise.all(tasks).then(fillForms);
}
function fillForms() {
["trainer", "trainDataset", "baseModel", "provider", "generationModel", "generationBase"].forEach(function(id) {
clear($(id));
});
asList(state.trainingSchema && state.trainingSchema.trainers).forEach(function(trainer) {
option($("trainer"), trainer.name, trainer.id);
});
state.datasets.forEach(function(dataset) {
option($("trainDataset"), dataset.name + " (" + String(dataset.image_count || 0) + ")", dataset.id);
});
asList(state.trainingSchema && state.trainingSchema.base_models).forEach(function(model) {
option($("baseModel"), model.name, model.id);
});
asList(state.generationSchema && state.generationSchema.providers).forEach(function(provider) {
option($("provider"), provider.name, provider.id);
});
state.models.filter(function(model) { return model.kind === "model"; }).forEach(function(model) {
option($("generationModel"), model.name + (model.trigger_word ? " - " + model.trigger_word : ""), model.id);
});
state.models.filter(function(model) { return model.kind === "base_model"; }).forEach(function(model) {
option($("generationBase"), model.name, model.id);
});
}
function renderDatasets() {
var root = $("datasets");
clear(root);
if (state.datasets.length === 0) {
appendLine(root, "status", "No datasets found yet.");
return;
}
state.datasets.forEach(function(dataset) {
var row = document.createElement("button");
row.type = "button";
row.className = "item";
var title = document.createElement("b");
title.textContent = dataset.name || "Dataset";
row.appendChild(title);
var review = dataset.review || {};
appendLine(
row,
"muted",
String(dataset.image_count || 0) + " images - " +
String(dataset.caption_count || 0) + " captions - " +
String(review.accepted || 0) + " accepted - " +
String(review.rejected || 0) + " rejected"
);
row.addEventListener("click", function() { openDataset(dataset.id, 1); });
root.appendChild(row);
});
}
function renderModels() {
var root = $("models");
clear(root);
if (state.models.length === 0) {
appendLine(root, "status", "No models found yet.");
return;
}
state.models.forEach(function(model) {
var row = document.createElement("div");
row.className = "item";
var title = document.createElement("b");
title.textContent = model.name || "Model";
row.appendChild(title);
appendLine(
row,
"muted",
(model.architecture || model.kind || "model") + " - " +
(model.checkpoint_name || "") + (model.trigger_word ? " - trigger " + model.trigger_word : "")
);
if (model.kind === "model") {
var holder = document.createElement("div");
holder.className = "row";
var button = document.createElement("button");
button.type = "button";
button.setAttribute("data-generate", model.id || "");
button.textContent = "Generate With This Model";
holder.appendChild(button);
row.appendChild(holder);
}
root.appendChild(row);
});
}
function openDataset(id, page) {
if (!id) return;
state.selectedDataset = id;
state.datasetPage = page;
text("datasetStats", "Loading images...");
getJson("/api/v1/datasets/" + encodeURIComponent(id) + "/items?page=" + encodeURIComponent(page) + "&page_size=24")
.then(function(payload) {
$("datasetDetail").style.display = "block";
text("datasetTitle", payload.dataset && payload.dataset.name ? payload.dataset.name : "Dataset");
text(
"datasetStats",
String(payload.dataset && payload.dataset.image_count || 0) + " images - " +
String(payload.dataset && payload.dataset.missing_caption_count || 0) + " missing captions - " +
String(payload.dataset && payload.dataset.duplicate_groups || 0) + " duplicate groups"
);
text("pageLabel", "Page " + String(payload.pagination && payload.pagination.page || page));
$("prevPage").disabled = page <= 1;
$("nextPage").disabled = !(payload.pagination && payload.pagination.has_next);
var grid = $("datasetGrid");
clear(grid);
asList(payload.items).forEach(function(item) {
var button = document.createElement("button");
button.type = "button";
button.className = "thumb";
var note = document.createElement("div");
note.textContent = (item.display_name || "Image") + "\\n" + (item.decision || "unreviewed");
button.appendChild(imageWithFallback(authUrl(item.thumbnail_url), "Dataset image", note));
button.appendChild(note);
button.addEventListener("click", function() { showImage(item); });
grid.appendChild(button);
});
})
.catch(function(error) {
text("datasetStats", "Dataset images could not load: " + errorMessage(error));
});
}
function showImage(item) {
state.selectedItem = item;
$("imageDetail").style.display = "block";
var detailImage = $("detailImage");
detailImage.style.display = "block";
detailImage.onerror = function() {
detailImage.style.display = "none";
text("imageStatus", "Preview image could not load.");
};
detailImage.src = authUrl(item.preview_url);
text("detailName", item.display_name || "Image");
text("detailDims", String(item.dimensions && item.dimensions.width || 0) + " x " + String(item.dimensions && item.dimensions.height || 0));
$("captionEditor").value = item.caption || "";
text("imageStatus", item.decision || "unreviewed");
}
function trainingPayload() {
return {
trainer: $("trainer").value,
dataset_id: $("trainDataset").value,
base_model_id: $("baseModel").value,
model_name: $("modelName").value,
trigger_word: $("triggerWord").value,
epochs: Number($("epochs").value || 10),
settings: {
resolution: Number($("resolution").value || 0),
batch_size: Number($("batchSize").value || 1),
learning_rate: Number($("learningRate").value || 0),
save_every: Number($("saveEvery").value || 10),
preview_enabled: true,
preview_every: Number($("previewEvery").value || 5),
preview_prompt: $("previewPrompt").value,
preview_seed: 123456789
}
};
}
function generationPayload() {
return {
provider_id: $("provider").value,
model_id: $("generationModel").value,
base_model_id: $("generationBase").value,
prompt: $("genPrompt").value,
negative_prompt: $("genNegative").value,
image_count: Number($("genCount").value || 1),
steps: Number($("genSteps").value || 30),
seed: Number($("genSeed").value || 0),
sampler: $("sampler").value,
aspect_ratio: $("aspect").value,
cfg_scale: Number($("cfgScale").value || 0),
lora_strength: 1,
prompt_weighting: true
};
}
document.body.addEventListener("click", function(event) {
var button = closestButton(event.target);
if (!button) return;
var action = button.getAttribute("data-action");
if (action) {
if (!state.activeJobId) {
text("promptStatus", "There is no active job to control.");
return;
}
setButtonBusy(button, true);
postJson("/api/job", {job_id: state.activeJobId, action: action})
.then(function(payload) {
text("promptStatus", payload.message || "Job updated.");
load();
})
.catch(function(error) {
text("promptStatus", errorMessage(error));
})
.then(function() {
setButtonBusy(button, false);
});
}
var generateId = button.getAttribute("data-generate");
if (generateId) {
$("generationModel").value = generateId;
var generateTab = document.querySelector('[data-view="generate"]');
if (generateTab) generateTab.click();
}
});
$("sendPrompt").onclick = function() {
var button = this;
setButtonBusy(button, true);
postJson("/api/prompt", {prompt: $("prompt").value})
.then(function(payload) {
text("promptStatus", payload.message || "Sent to ADAM.");
$("prompt").value = "";
load();
})
.catch(function(error) {
text("promptStatus", errorMessage(error));
})
.then(function() {
setButtonBusy(button, false);
});
};
var quicks = document.querySelectorAll(".quick");
for (var q = 0; q < quicks.length; q += 1) {
quicks[q].onclick = function() {
var templates = {
"Create Model": "Create and train a LoRA of Example for 10 epochs using up to 40 images. Name the model Example.",
"Collect Dataset": "Collect up to 40 images of Example.",
"Quick": "Generate an image of Example"
};
$("prompt").value = templates[this.textContent] || "";
};
}
$("reviewPlan").onclick = function() {
postJson("/api/v1/training/plan", trainingPayload())
.then(function(payload) {
$("trainingReview").textContent = JSON.stringify(payload.plan || payload, null, 2);
})
.catch(function(error) {
text("trainingReview", errorMessage(error));
});
};
$("startTraining").onclick = function() {
if (!confirm("Start this training plan on the PC?")) return;
postJson("/api/v1/training/start", trainingPayload())
.then(function(payload) {
text("trainingReview", payload.message || "Training queued.");
load();
})
.catch(function(error) {
text("trainingReview", errorMessage(error));
});
};
$("startGeneration").onclick = function() {
postJson("/api/v1/generation/start", generationPayload())
.then(function(payload) {
text("generationStatus", payload.message || "Generation queued.");
load();
})
.catch(function(error) {
text("generationStatus", errorMessage(error));
});
};
$("prevPage").onclick = function() { openDataset(state.selectedDataset, Math.max(1, state.datasetPage - 1)); };
$("nextPage").onclick = function() { openDataset(state.selectedDataset, state.datasetPage + 1); };
$("saveCaption").onclick = function() {
if (!state.selectedDataset || !state.selectedItem) return;
postJson(
"/api/v1/datasets/" + encodeURIComponent(state.selectedDataset) + "/items/" + encodeURIComponent(state.selectedItem.id) + "/caption",
{caption: $("captionEditor").value}
).then(function(payload) {
text("imageStatus", payload.message || "Caption saved.");
}).catch(function(error) {
text("imageStatus", errorMessage(error));
});
};
function decide(decision) {
if (!state.selectedDataset || !state.selectedItem) return;
postJson(
"/api/v1/datasets/" + encodeURIComponent(state.selectedDataset) + "/items/" + encodeURIComponent(state.selectedItem.id) + "/decision",
{decision: decision}
).then(function(payload) {
text("imageStatus", payload.message || decision);
openDataset(state.selectedDataset, state.datasetPage);
}).catch(function(error) {
text("imageStatus", errorMessage(error));
});
}
$("keepImage").onclick = function() { decide("keep"); };
$("rejectImage").onclick = function() { decide("reject"); };
$("unreviewImage").onclick = function() { decide("unreviewed"); };
$("refresh").onclick = function() {
load();
loadLibrary();
};
$("autoApproveTraining").onchange = function(event) {
postJson("/api/remote-settings", {auto_approve_training: event.target.checked})
.then(function(payload) {
text("settingsStatus", payload.message || "Remote setting saved.");
load();
})
.catch(function(error) {
text("settingsStatus", errorMessage(error));
});
};
$("keepAwake").onchange = function(event) {
state.refreshMs = event.target.checked ? 1500 : 5000;
startTimer();
text("settingsStatus", event.target.checked ? "Fast refresh is on." : "Quiet refresh is on.");
};
function startTimer() {
if (timer) window.clearInterval(timer);
timer = window.setInterval(load, state.refreshMs);
}
load();
loadLibrary();
startTimer();
}());
</script>
</body>
</html>"""
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