Download adam/remote_access.py from SyntheticMDProductions/AI_Development_Automation_Manager: direct link, hf CLI and curl.
- Browser
- Download file 116 kB
-
https://huggingface.co/SyntheticMDProductions/AI_Development_Automation_Manager/resolve/main/adam/remote_access.py
- Command line
-
hf download hf://SyntheticMDProductions/AI_Development_Automation_Manager/adam/remote_access.py
-
curl -L -o remote_access.py https://huggingface.co/SyntheticMDProductions/AI_Development_Automation_Manager/resolve/main/adam/remote_access.py
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() | |
| 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 | |
| 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, | |
| }, | |
| } | |
| 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:], | |
| } | |
| 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, | |
| } | |
| 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) | |
| 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 | |
| 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", ""), | |
| } | |
| 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 | |
| ) | |
| 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." | |
| 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 | |
| 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 | |