from __future__ import annotations from io import BytesIO from datetime import datetime from dataclasses import replace import json from pathlib import Path import re import shutil import sys from PySide6.QtCore import QDateTime, QThread, QTimer, Qt, QUrl, Signal from PySide6.QtGui import QCloseEvent, QDesktopServices, QIcon, QPixmap from PySide6.QtWidgets import ( QAbstractItemView, QApplication, QCheckBox, QComboBox, QDialog, QDialogButtonBox, QDateTimeEdit, QDoubleSpinBox, QFileDialog, QFrame, QGridLayout, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QMessageBox, QPlainTextEdit, QProgressBar, QPushButton, QScrollArea, QSizePolicy, QSpinBox, QStackedWidget, QSystemTrayIcon, QTabBar, QTabWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) from adam.config import ConfigManager from adam.assets import Asset from adam.ui.background import BackgroundRead from adam.ui.asset_refresh import asset_refresh from adam.dataset_lab import scan_dataset from adam.cnn_reviewer import reviewer_checkpoint, score_frames, train_reviewer from adam.experiment_tracker import ExperimentStore, ExperimentRun from adam.generations import ( ChatGenerationRequest, build_generation_plan, generation_model_match_score, generation_tools, parse_chat_generation_request, ) from adam.model_inspector import ModelComparison, ModelInspection, compare_models, inspect_model from adam.model_inspector.statistics import bytes_label, shape_label from adam.external_tools import ( ExternalToolStore, ToolAnalysis, analyze_selection, scan_folder, ) from adam.job_manager import JobManager from adam.models import Job, JobStatus, SystemSnapshot from adam.model_plugins import ModelPluginError, safe_plugin_id, scaffold_model_plugin from adam.model_profiles import ModelProfileRegistry from adam.monitoring import SystemMonitor from adam.orion import dataset_image_count, recommend_training_settings from adam.ollama import OllamaClient from adam.planner import Planner, PlanningError from adam.progressive_training import parse_stages, suggested_stages from adam.recommendations import recommend_for_profile from adam.remote_access import ( REMOTE_MODE_DISABLED, REMOTE_MODE_LOCAL, REMOTE_MODE_TAILSCALE, RemoteAccessService, remote_scope, ) from adam.registry import ToolRegistry from adam.studio import StudioStore from adam.tool_folders import ToolFolderManager, ToolFolderStatus from adam.transcript_dataset import available_transcription_backends, transcript_videos_to_dataset from adam.training_assistant import ( append_preflight_summary, build_fine_tune_request, build_training_request, combine_training_plans, completion_recommendation, presets_from_config, parse_model_batch_names, build_dataset_collection_request, suggest_existing_dataset, ) from adam.ui.theme import APP_STYLESHEET, COLORS from adam.ui.studio import StudioPage from adam.ui.generations import GenerationsPage from adam.ui.showcase import ShowcasePage from adam.ui.video_lora import VideoLoraPage from adam.ui.oasis_player import OasisPlayerPage from adam.ui.model_intelligence import ModelIntelligencePage from adam.ui.settings_ui import SettingsForm from adam.ui.widgets import ( ActiveJobPanel, ChatBubble, GenerationChatCard, MetricCard, PlanPanel, PromptEdit, PromptResizeHandle, SparklineWidget, ) def is_explicit_action_request(request: str) -> bool: """Recognize clear commands that must be handled by ADAM's planner, not chat.""" text = " ".join(request.casefold().split()) action = r"(?:train|collect|create|generate|start|run|prepare|resume|continue|open)" return bool( re.search(rf"^(?:please\s+)?{action}\b", text) or re.search(rf"\b(?:can|could|will|would)\s+you\s+(?:please\s+)?{action}\b", text) or re.search(rf"\b(?:yes[,!]?\s+)?{action}\s+(?:this|the)\s+(?:job|plan)\b", text) ) def is_pending_job_confirmation_request(request: str) -> bool: text = " ".join(request.casefold().split()) return bool( re.search(r"^(?:yes|confirm|approve|start)\b.*\b(?:this|the)\s+(?:job|plan)\b", text) or re.search(r"^yes[,!]?\s+(?:start|run)\b", text) ) def subject_idea_from_chat(response: str) -> str | None: """Extract a plainly labelled subject suggestion from the latest chat reply.""" plain = re.sub(r"[*`]", "", response) match = re.search( r"\b(?:subject\s+idea|suggested\s+subject)\s*:\s*" r"(?:\n\s*)?(?:>\s*)?[\"“]?(.+?)(?:[\"”]\s*$|\n|$)", plain, re.I, ) if not match: return None subject = match.group(1).strip(" \t>—–-.,!?:;\"'”") return subject if 2 <= len(subject) <= 180 else None def expand_subject_reference(request: str, response: str) -> tuple[str, str | None]: """Replace an unambiguous reference such as 'that subject' with its chat idea.""" if not re.search( r"\b(?:(?:the\s+)?subject\s+you\s+mentioned|that\s+subject(?:\s+you\s+mentioned)?)\b", request, re.I, ): return request, None subject = subject_idea_from_chat(response) if not subject: return request, None expanded = re.sub( r"\b(?:(?:the\s+)?subject\s+you\s+mentioned|that\s+subject(?:\s+you\s+mentioned)?)\b", subject, request, flags=re.I, ) return expanded, subject def _card() -> QFrame: frame = QFrame() frame.setProperty("card", True) return frame def _card_title(text: str) -> QLabel: label = QLabel(text) label.setObjectName("CardTitle") return label def _page_header(title: str, subtitle: str) -> QWidget: widget = QWidget() layout = QVBoxLayout(widget) layout.setContentsMargins(0, 0, 0, 15) layout.setSpacing(3) title_label = QLabel(title) title_label.setObjectName("PageTitle") subtitle_label = QLabel(subtitle) subtitle_label.setProperty("muted", True) subtitle_label.setWordWrap(True) layout.addWidget(title_label) layout.addWidget(subtitle_label) return widget class CollapsiblePanel(QFrame): """A compact card shell that lets dashboard panels give their space back.""" collapsed_changed = Signal(bool) def __init__( self, title: str, content: QWidget, config: ConfigManager, setting_key: str, collapse_direction: str = "up", ) -> None: super().__init__() self.setProperty("card", True) self.content = content self.panel_title = title.lower() self.config = config self.setting_key = setting_key self.collapse_direction = collapse_direction self._expanded_minimum = content.minimumHeight() self._expanded_maximum = content.maximumHeight() self._expanded_policy = content.sizePolicy() root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(0) header = QWidget() header_layout = QHBoxLayout(header) header_layout.setContentsMargins(15, 10, 12, 9) self.title_label = _card_title(title) header_layout.addWidget(self.title_label) header_layout.addStretch() self.toggle = QPushButton() self.toggle.setProperty("chip", True) self.toggle.setFixedSize(30, 25) self.toggle.clicked.connect(self._toggle) header_layout.addWidget(self.toggle) root.addWidget(header) # The shell owns the card border and heading. Hide the panel's original # heading while preserving every existing control and signal. for label in content.findChildren(QLabel): if label.objectName() == "CardTitle" and label.text() == title: label.hide() break content.setProperty("card", False) content.style().unpolish(content) content.style().polish(content) root.addWidget(content, 1) self.set_collapsed(bool(config.get(setting_key, False)), persist=False) def _toggle(self) -> None: self.set_collapsed(not self.collapsed, persist=True) def set_collapsed(self, collapsed: bool, *, persist: bool = True) -> None: self.collapsed = collapsed self.content.setVisible(not collapsed) self.toggle.setText("+" if collapsed else "−") self.toggle.setToolTip(("Show " if collapsed else "Hide ") + self.panel_title) self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed if collapsed else QSizePolicy.Preferred) horizontal = self.collapse_direction == "right" self.title_label.setVisible(not (collapsed and horizontal)) if collapsed and horizontal: self.setFixedWidth(45) self.setMinimumHeight(45) self.setMaximumHeight(45) elif collapsed: self.setMinimumWidth(0) self.setMaximumWidth(16777215) self.setMinimumHeight(45) self.setMaximumHeight(45) else: if horizontal: self.setMinimumWidth(330) self.setMaximumWidth(390) else: self.setMinimumWidth(0) self.setMaximumWidth(16777215) self.setMinimumHeight(0) self.setMaximumHeight(16777215) if persist: self.config.update({self.setting_key: collapsed}) self.collapsed_changed.emit(collapsed) class ChatHistoryStore: """Small, local JSON store for archived Command Center conversations.""" def __init__(self, root: Path) -> None: self.path = root / "data" / "chat_history.json" self.path.parent.mkdir(parents=True, exist_ok=True) def load(self) -> list[dict]: try: value = json.loads(self.path.read_text(encoding="utf-8")) return value if isinstance(value, list) else [] except (OSError, json.JSONDecodeError): return [] def save_conversation(self, entries: list[dict[str, str]], mode: str) -> dict | None: useful = [entry for entry in entries if entry.get("text", "").strip()] if not useful: return None first_user = next((entry["text"] for entry in useful if entry.get("user")), useful[0]["text"]) now = datetime.now() conversation = { "id": now.strftime("%Y%m%d%H%M%S%f"), "title": first_user.replace("\n", " ").strip()[:72] or "Untitled conversation", "created_at": now.isoformat(timespec="seconds"), "mode": mode, "entries": useful, } history = self.load() history.insert(0, conversation) temporary = self.path.with_suffix(".tmp") temporary.write_text(json.dumps(history[:100], indent=2), encoding="utf-8") temporary.replace(self.path) return conversation class ChatHistoryPage(QWidget): open_requested = Signal(object) def __init__(self, store: ChatHistoryStore) -> None: super().__init__() self.store = store root = QVBoxLayout(self) root.setContentsMargins(28, 24, 28, 24) root.setSpacing(14) root.addWidget(_page_header("Chat history", "Reopen earlier prompts and conversations in Command Center.")) self.list = QListWidget() self.list.setSpacing(6) self.list.itemDoubleClicked.connect(self._open_item) root.addWidget(self.list, 1) self.open_button = QPushButton("Open selected chat →") self.open_button.setProperty("primary", True) self.open_button.clicked.connect(self._open_selected) root.addWidget(self.open_button, 0, Qt.AlignRight) self.refresh() def refresh(self) -> None: self.list.clear() for conversation in self.store.load(): stamp = str(conversation.get("created_at", "")).replace("T", " ") item = QListWidgetItem(f"{conversation.get('title', 'Untitled conversation')}\n{stamp} · {conversation.get('mode', 'trainer').title()} Mode") item.setData(Qt.UserRole, conversation) self.list.addItem(item) self.open_button.setEnabled(self.list.count() > 0) def _open_selected(self) -> None: item = self.list.currentItem() or (self.list.item(0) if self.list.count() else None) if item: self.open_requested.emit(item.data(Qt.UserRole)) def _open_item(self, item: QListWidgetItem) -> None: self.open_requested.emit(item.data(Qt.UserRole)) class PlanningWorker(QThread): chunk = Signal(str) planned = Signal(object) failed = Signal(str) def __init__(self, planner: Planner, request: str) -> None: super().__init__() self.planner = planner self.request = request def run(self) -> None: try: self.planned.emit(self.planner.plan(self.request, self.chunk.emit)) except Exception as exc: self.failed.emit(str(exc)) class BatchPlanningWorker(QThread): """Plans every requested model, then returns one ordered execution plan.""" chunk = Signal(str) planned = Signal(object) failed = Signal(str) def __init__(self, planner: Planner, requests: list[str]) -> None: super().__init__() self.planner = planner self.requests = requests def run(self) -> None: try: plans = [] total = len(self.requests) for index, request in enumerate(self.requests, 1): self.chunk.emit(f"Planning model {index} of {total}…\n") plan = self.planner.plan(request) if not plan.steps: raise PlanningError( f"Model {index} could not be turned into an actionable plan: {plan.summary}" ) plans.append(plan) self.planned.emit(combine_training_plans(plans)) except Exception as exc: self.failed.emit(str(exc)) class ToolScanWorker(QThread): scanned = Signal(object) failed = Signal(str) def __init__(self, folder: str) -> None: super().__init__() self.folder = folder def run(self) -> None: try: self.scanned.emit(scan_folder(self.folder)) except Exception as exc: self.failed.emit(str(exc)) class DatasetScanWorker(QThread): scanned = Signal(object) failed = Signal(str) def __init__(self, folder: str) -> None: super().__init__() self.folder = folder def run(self) -> None: try: self.scanned.emit(scan_dataset(self.folder)) except Exception as exc: self.failed.emit(str(exc)) class CnnReviewerWorker(QThread): """Run local CNN training/scoring away from the Qt UI thread.""" progress = Signal(str) completed = Signal(str, object) failed = Signal(str) def __init__(self, mode: str, root: Path, folder: str, decisions: dict[str, str]) -> None: super().__init__() self.mode, self.root, self.folder, self.decisions = mode, root, folder, decisions def run(self) -> None: try: if self.mode == "train": result = train_reviewer(self.root, self.folder, self.decisions, progress=self.progress.emit) else: checkpoint = reviewer_checkpoint(self.root, self.folder) if not checkpoint.is_file(): raise ValueError("Train the CNN reviewer for this dataset first.") result = score_frames(checkpoint, self.folder, progress=self.progress.emit) self.completed.emit(self.mode, result) except Exception as exc: self.failed.emit(str(exc)) class TranscriptExportWorker(QThread): completed = Signal(object) def __init__(self, videos: list[str], output: str) -> None: super().__init__() self.videos = videos self.output = output def run(self) -> None: self.completed.emit(transcript_videos_to_dataset(self.videos, self.output)) class ChatWorker(QThread): chunk = Signal(str) answered = Signal(str) failed = Signal(str) def __init__( self, planner: Planner, request: str, history: list[dict[str, str]], image_paths: list[str] | None = None, ) -> None: super().__init__() self.planner = planner self.request = request self.history = history self.image_paths = list(image_paths or []) def run(self) -> None: try: response = self.planner.chat( self.request, self.history, self.chunk.emit, self.image_paths ) self.answered.emit(response) except Exception as exc: self.failed.emit(str(exc)) class ModelPluginWizardDialog(QDialog): """Scaffold a copyable model plugin folder from a few fields.""" def __init__(self, root: Path, parent: QWidget | None = None) -> None: super().__init__(parent) self.root = root.resolve() self.created_folder = "" self.setWindowTitle("Create Model Plugin") self.setMinimumWidth(520) layout = QVBoxLayout(self) layout.setSpacing(10) layout.addWidget( _page_header( "Create a model plugin", "Scaffold the manifest and Python files for a new architecture.", ) ) form = QGridLayout() form.setHorizontalSpacing(12) form.setVerticalSpacing(9) self.name = QLineEdit() self.name.setPlaceholderText("Example: Neural Cellular Automata") self.plugin_id = QLineEdit() self.plugin_id.setPlaceholderText("neural_cellular_automata") self.architecture = QLineEdit() self.architecture.setPlaceholderText("nca, maskgit, vae, autoregressive") self.output_type = QComboBox() for label in ("image", "video", "audio", "text", "other"): self.output_type.addItem(label.title(), label) self.training = QCheckBox("Training") self.training.setChecked(True) self.generation = QCheckBox("Generation") self.generation.setChecked(True) capability_row = QHBoxLayout() capability_row.addWidget(self.training) capability_row.addWidget(self.generation) capability_row.addStretch() form.addWidget(QLabel("Model name"), 0, 0) form.addWidget(self.name, 0, 1) form.addWidget(QLabel("Plugin folder"), 1, 0) form.addWidget(self.plugin_id, 1, 1) form.addWidget(QLabel("Architecture"), 2, 0) form.addWidget(self.architecture, 2, 1) form.addWidget(QLabel("Output type"), 3, 0) form.addWidget(self.output_type, 3, 1) form.addWidget(QLabel("Capabilities"), 4, 0) form.addLayout(capability_row, 4, 1) layout.addLayout(form) self.status = QLabel() self.status.setProperty("muted", True) self.status.setWordWrap(True) layout.addWidget(self.status) buttons = QDialogButtonBox(QDialogButtonBox.Cancel | QDialogButtonBox.Ok) buttons.button(QDialogButtonBox.Ok).setText("Create plugin folder") buttons.accepted.connect(self._create) buttons.rejected.connect(self.reject) layout.addWidget(buttons) self.name.textChanged.connect(self._suggest_id) def _suggest_id(self, value: str) -> None: if not self.plugin_id.text().strip(): self.plugin_id.setPlaceholderText(safe_plugin_id(value)) def _create(self) -> None: name = self.name.text().strip() if not name: self.status.setText("Enter a model name.") return plugin_id = self.plugin_id.text().strip() or self.plugin_id.placeholderText() if not self.training.isChecked() and not self.generation.isChecked(): self.status.setText("Choose training, generation, or both.") return try: folder = scaffold_model_plugin( self.root, plugin_id=plugin_id, name=name, architecture=self.architecture.text().strip() or "custom", output_type=str(self.output_type.currentData() or "image"), include_training=self.training.isChecked(), include_generation=self.generation.isChecked(), ) except ModelPluginError as exc: self.status.setText(str(exc)) return self.created_folder = str(folder) self.accept() class ModelCreationDialog(QDialog): """Collects training choices in plain language and produces a planner request.""" BUILTIN_PREVIEW_TRAINERS = {"ddpm", "flow", "lora", "oasis"} def __init__(self, planner: Planner, config: ConfigManager, parent: QWidget | None = None) -> None: super().__init__(parent) self.planner = planner self.config = config self.request = "" self.requests: list[str] = [] self.collection_only = False self.scheduled_for: str | None = None self._model_states: list[dict[str, object]] = [] self._current_model_index = 0 self.setWindowTitle("Model Creation Assistant") self.setMinimumWidth(560) available = self.screen().availableGeometry() self.resize(min(940, available.width() - 40), min(850, available.height() - 60)) outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) content = QWidget() root = QVBoxLayout(content) root.setSizeConstraint(QVBoxLayout.SetMinimumSize) scroll.setWidget(content) outer.addWidget(scroll) root.setSpacing(10) root.addWidget( _page_header( "Create a model", "Choose what you know. ADAM will turn it into a complete, reviewable training request.", ) ) self._asset_refresh = asset_refresh(planner.assets, config) self._asset_refresh.changed.connect(self._refresh_asset_choices) self._asset_refresh.request() journey = QLabel( "1 GOAL → 2 DATASET → 3 TRAINING RECIPE → 4 REVIEW & APPROVE" ) journey.setWordWrap(True) journey.setStyleSheet( f"color: {COLORS['blue_2']}; background: #081a27; " f"border: 1px solid {COLORS['border_bright']}; border-radius: 8px; " "padding: 10px; font-size: 10px; font-weight: 700;" ) root.addWidget(journey) model_tabs_row = QHBoxLayout() self.model_tabs = QTabBar() self.model_tabs.setMovable(True) self.model_tabs.setTabsClosable(False) self.model_tabs.setExpanding(False) self.model_tabs.setStyleSheet("QTabBar::tab { min-width: 120px; padding: 8px 14px; }") self.model_tabs.addTab("Model 1") self._install_remove_button(0) self.add_model_button = QPushButton("+ Add model") self.add_model_button.setToolTip("Add another model after this one") model_tabs_row.addWidget(self.model_tabs, 1) model_tabs_row.addWidget(self.add_model_button) root.addLayout(model_tabs_row) batch_tools = QGridLayout() self.bulk_add_button = QPushButton("Paste model list…") self.create_plugin_button = QPushButton("Create plugin…") self.apply_many_button = QPushButton("Apply current settings…") self.save_draft_button = QPushButton("Save draft") self.load_draft_button = QPushButton("Load draft") self.match_existing_button = QPushButton("Match existing datasets") self.refresh_datasets_button = QPushButton("Find collected datasets") for index, button in enumerate(( self.bulk_add_button, self.apply_many_button, self.save_draft_button, self.load_draft_button, self.match_existing_button, self.refresh_datasets_button, self.create_plugin_button, )): button.setProperty("chip", True) batch_tools.addWidget(button, index // 3, index % 3) root.addLayout(batch_tools) form = QGridLayout() form.setHorizontalSpacing(12) form.setVerticalSpacing(9) self.preset = QComboBox() self.presets = presets_from_config(config) self.trainer = QComboBox() preferred_trainers = ["lora", "ddpm", "flow", "inrflow", "pixelrow", "oasis"] plugins = { plugin.id: plugin for plugin in planner.registry.model_plugins.all() if plugin.training_settings and plugin.info.get("category") != "Template" and plugin.info.get("workspace") != "video_lora" } for trainer_id in preferred_trainers: plugin = plugins.pop(trainer_id, None) if plugin: self.trainer.addItem(plugin.name, plugin.id) for plugin in sorted(plugins.values(), key=lambda item: item.name.casefold()): self.trainer.addItem(plugin.name, plugin.id) self.training_mode = QComboBox() self.training_mode.addItem("Train a new model", "new") self.training_mode.addItem("Continue one of my models", "continue") self.continue_model = QComboBox() self.source = QComboBox() self.source.addItem("Create a new dataset", "new") self.source.addItem("Use an existing dataset", "existing") self.source.addItem("Use the continued model's original dataset", "original") self.subject = QLineEdit() self.subject.setPlaceholderText("Example: Hatsune Miku") self.dataset = QComboBox() self.dataset.setEditable(True) self.dataset.setPlaceholderText("Select or type a dataset name") for asset in planner.assets.assets: if asset.kind == "dataset" and Path(asset.path).is_dir(): self.dataset.addItem(asset.name) self.model_name = QLineEdit() self.model_name.setPlaceholderText("Defaults to the subject or dataset name") self.trigger_word = QLineEdit() self.trigger_word.setPlaceholderText("Defaults to the model name") self.epochs = QSpinBox() self.epochs.setRange(1, 100_000) self.progressive_enabled = QCheckBox("Train from low to high resolution") self.progressive_schedule = QLineEdit() self.progressive_schedule.setPlaceholderText("Example: 64:300, 128:100, 256:50, 384:25, 512:10") self.progressive_auto_batch = QCheckBox("Choose a conservative batch size for each stage") self.progressive_auto_batch.setChecked(True) self.progressive_suggest = QPushButton("Suggest stages") self.progressive_suggest.setProperty("chip", True) progressive_row = QHBoxLayout() progressive_row.addWidget(self.progressive_schedule, 1) progressive_row.addWidget(self.progressive_suggest) self.progressive_stage_widget = QWidget() self.progressive_stage_widget.setLayout(progressive_row) self.images = QSpinBox() self.images.setRange(10, 100_000) self.images.setSuffix(" images") self.collection_mode = QComboBox() self.collection_mode.addItem("Collect this exact target", "target") self.collection_mode.addItem("Collect every available result (up to 5,000)", "all_available") self.preset_hint = QLabel() self.preset_hint.setWordWrap(True) self.preset_hint.setProperty("muted", True) rows = [ ("Preset", self.preset), ("Trainer", self.trainer), ("Starting point", self.training_mode), ("Model to continue", self.continue_model), ("Dataset choice", self.source), ("What should it learn?", self.subject), ("Existing dataset", self.dataset), ("Model name", self.model_name), ("LoRA trigger word", self.trigger_word), ("Training length", self.epochs), ("Progressive resolution", self.progressive_enabled), ("Resolution stages", self.progressive_stage_widget), ("Stage batch size", self.progressive_auto_batch), ("Internet image collection", self.collection_mode), ("New dataset size", self.images), ] self.form_labels: dict[str, QLabel] = {} for row, (label, widget) in enumerate(rows): label_widget = QLabel(label) self.form_labels[label] = label_widget form.addWidget(label_widget, row, 0) form.addWidget(widget, row, 1) root.addLayout(form) self.options_group = QGroupBox() options = QVBoxLayout(self.options_group) options_title = QLabel("Training options") options_title.setProperty("sectionTitle", True) self.training_form = SettingsForm() self.options_hint = QLabel(); self.options_hint.setProperty("muted", True); self.options_hint.setWordWrap(True) self.orion_settings_button = QPushButton("ORION: apply a starting recipe") self.orion_settings_button.setToolTip("Fill in a conservative draft from the image count and resolution. You can change every value afterward.") self.orion_settings_button.setProperty("chip", True) options.addWidget(options_title) options.addWidget(self.training_form) options.addWidget(self.orion_settings_button) options.addWidget(self.options_hint) root.addWidget(self.options_group) self.preview_group = QGroupBox("Live training preview") preview_form = QGridLayout(self.preview_group) self.preview_enabled = QCheckBox("Generate previews while training") self.preview_enabled.setChecked(True) self.preview_every = QSpinBox() self.preview_every.setRange(1, 100_000) self.preview_every.setValue(5) self.preview_every.setSuffix(" epochs") self.preview_prompt = QLineEdit() self.preview_prompt.setPlaceholderText("Optional prompt for conditioned models") self.preview_seed = QSpinBox() self.preview_seed.setRange(0, 2_147_483_647) self.preview_seed.setValue(123456789) preview_form.addWidget(self.preview_enabled, 0, 0, 1, 2) preview_form.addWidget(QLabel("Preview interval"), 1, 0) preview_form.addWidget(self.preview_every, 1, 1) preview_form.addWidget(QLabel("Preview prompt"), 2, 0) preview_form.addWidget(self.preview_prompt, 2, 1) preview_form.addWidget(QLabel("Reproducible seed"), 3, 0) preview_form.addWidget(self.preview_seed, 3, 1) root.addWidget(self.preview_group) root.addWidget(self.preset_hint) self.dataset_reviewed = QCheckBox( "I reviewed this dataset in Training Studio and it is ready to train" ) self.dataset_reviewed.setToolTip( "Training remains locked for this model until you explicitly mark its dataset ready." ) dataset_approval_row = QHBoxLayout() dataset_approval_row.addWidget(self.dataset_reviewed, 1) self.approve_all_datasets_button = QPushButton("Approve all datasets") self.approve_all_datasets_button.setProperty("chip", True) self.approve_all_datasets_button.setToolTip( "Mark every linked dataset in this batch as reviewed and ready to train." ) dataset_approval_row.addWidget(self.approve_all_datasets_button) root.addLayout(dataset_approval_row) self.review_summary = QLabel() self.review_summary.setWordWrap(True) self.review_summary.setProperty("muted", True) root.addWidget(self.review_summary) schedule_group = QGroupBox("Model Scheduler") schedule_layout = QGridLayout(schedule_group) self.schedule_enabled = QCheckBox("Start this training batch at a specific time") self.schedule_time = QDateTimeEdit(QDateTime.currentDateTime().addSecs(3600)) self.schedule_time.setCalendarPopup(True) self.schedule_time.setDisplayFormat("MMM d, yyyy h:mm AP") self.schedule_time.setMinimumDateTime(QDateTime.currentDateTime()) self.schedule_time.setEnabled(False) schedule_note = QLabel( "This is the earliest start time. If another job is still running, ADAM starts this batch when that job finishes." ) schedule_note.setProperty("muted", True) schedule_note.setWordWrap(True) schedule_layout.addWidget(self.schedule_enabled, 0, 0, 1, 2) schedule_layout.addWidget(QLabel("Schedule training"), 1, 0) schedule_layout.addWidget(self.schedule_time, 1, 1) schedule_layout.addWidget(schedule_note, 2, 0, 1, 2) root.addWidget(schedule_group) save_row = QHBoxLayout() self.preset_name = QLineEdit() self.preset_name.setPlaceholderText("Optional custom preset name") save_preset = QPushButton("Save current preset") save_preset.clicked.connect(self._save_preset) save_row.addWidget(self.preset_name, 1) save_row.addWidget(save_preset) root.addLayout(save_row) self.validation = QLabel() self.validation.setWordWrap(True) root.addWidget(self.validation) action_row = QHBoxLayout() self.collect_first_button = QPushButton("Collect missing datasets first") self.collect_first_button.setToolTip( "Queue dataset collection only, then keep this batch as a draft for review." ) action_row.addWidget(self.collect_first_button) action_row.addStretch() root.addLayout(action_row) buttons = QDialogButtonBox(QDialogButtonBox.Cancel | QDialogButtonBox.Ok) buttons.button(QDialogButtonBox.Ok).setText("Build training plan") buttons.accepted.connect(self._accept_request) buttons.rejected.connect(self.reject) outer.addWidget(buttons) self.preset.currentTextChanged.connect(self._apply_preset) self.trainer.currentIndexChanged.connect(self._trainer_changed) self.trainer.currentIndexChanged.connect(self._refresh_continue_models) self.training_mode.currentIndexChanged.connect(self._training_mode_changed) self.training_mode.currentIndexChanged.connect(self._update_review) self.continue_model.currentIndexChanged.connect(self._training_mode_changed) self.continue_model.currentIndexChanged.connect(self._update_review) self.source.currentIndexChanged.connect(self._update_source) self.source.currentIndexChanged.connect(self._update_review) self.subject.textChanged.connect(self._suggest_name) self.subject.textChanged.connect(self._update_review) self.dataset.currentTextChanged.connect(self._update_review) self.model_name.textChanged.connect(self._update_review) self.trigger_word.textChanged.connect(self._update_review) self.epochs.valueChanged.connect(self._update_review) self.progressive_enabled.toggled.connect(self._update_progressive_controls) self.progressive_enabled.toggled.connect(self._update_review) self.progressive_schedule.textChanged.connect(self._update_review) self.progressive_auto_batch.toggled.connect(self._update_review) self.progressive_suggest.clicked.connect(self._suggest_progressive_stages) self.images.valueChanged.connect(self._update_review) self.collection_mode.currentIndexChanged.connect(self._update_collection_mode) self.collection_mode.currentIndexChanged.connect(self._update_review) self.trainer.currentIndexChanged.connect(self._update_review) self.training_form.changed.connect(self._update_review) self.training_form.changed.connect(self._update_progressive_controls) self.preview_enabled.toggled.connect(self._update_preview_controls) self.preview_enabled.toggled.connect(self._update_review) self.preview_every.valueChanged.connect(self._update_review) self.preview_prompt.textChanged.connect(self._update_review) self.preview_seed.valueChanged.connect(self._update_review) self._refresh_presets_for_trainer() self._refresh_continue_models() self._apply_preset(self.preset.currentText()) self._set_training_defaults() self._update_progressive_controls() self._training_mode_changed() self._update_source() self._update_review() self._model_states = [self._capture_state()] self.model_tabs.currentChanged.connect(self._switch_model) self.model_tabs.tabMoved.connect(self._move_model) self.add_model_button.clicked.connect(self._add_model) self.bulk_add_button.clicked.connect(self._bulk_add_models) self.create_plugin_button.clicked.connect(self._create_model_plugin) self.apply_many_button.clicked.connect(self._apply_settings_to_models) self.save_draft_button.clicked.connect(self._save_batch_draft) self.load_draft_button.clicked.connect(self._load_batch_draft) self.match_existing_button.clicked.connect(self._match_existing_datasets) self.refresh_datasets_button.clicked.connect(self._find_collected_datasets) self.collect_first_button.clicked.connect(self._accept_collection_requests) self.dataset_reviewed.toggled.connect(self._update_review) self.approve_all_datasets_button.clicked.connect(self._approve_all_datasets) self.orion_settings_button.clicked.connect(self._apply_orion_settings) self.schedule_enabled.toggled.connect(self.schedule_time.setEnabled) self.schedule_enabled.toggled.connect(self._update_review) self.schedule_time.dateTimeChanged.connect(self._update_review) def _capture_state(self) -> dict[str, object]: return { "preset": self.preset.currentText(), "trainer": self.trainer.currentData(), "training_mode": self.training_mode.currentData(), "continue_model_id": getattr(self.continue_model.currentData(), "id", ""), "source": self.source.currentData(), "subject": self.subject.text(), "dataset": self.dataset.currentText(), "model_name": self.model_name.text(), "trigger_word": self.trigger_word.text(), "epochs": self.epochs.value(), "images": self.images.value(), "collection_mode": self.collection_mode.currentData(), "dataset_reviewed": self.dataset_reviewed.isChecked(), "progressive_enabled": self.progressive_enabled.isChecked(), "progressive_schedule": self.progressive_schedule.text(), "progressive_auto_batch": self.progressive_auto_batch.isChecked(), "training_options": self._training_options(), } def _refresh_asset_choices(self) -> None: """Update asset selectors after background discovery completes.""" current_dataset = self.dataset.currentText() self.dataset.clear() for asset in self.planner.assets.assets: if asset.kind == "dataset" and Path(asset.path).is_dir(): self.dataset.addItem(asset.name) self.dataset.setCurrentText(current_dataset) self._refresh_continue_models() def _load_state(self, state: dict[str, object]) -> None: trainer_index = self.trainer.findData(state.get("trainer", "lora")) self.trainer.setCurrentIndex(max(0, trainer_index)) self._refresh_presets_for_trainer(str(state.get("preset", ""))) self._refresh_continue_models(str(state.get("continue_model_id", ""))) mode_index = self.training_mode.findData(state.get("training_mode", "new")) self.training_mode.setCurrentIndex(max(0, mode_index)) source_index = self.source.findData(state.get("source", "new")) self.source.setCurrentIndex(max(0, source_index)) self.subject.setText(str(state.get("subject", ""))) self.dataset.setCurrentText(str(state.get("dataset", ""))) self.model_name.setText(str(state.get("model_name", ""))) self.trigger_word.setText(str(state.get("trigger_word", ""))) self.epochs.setValue(int(state.get("epochs", 100))) self.images.setValue(int(state.get("images", 60))) mode_index = self.collection_mode.findData(state.get("collection_mode", "target")) self.collection_mode.setCurrentIndex(max(0, mode_index)) self.dataset_reviewed.setChecked(bool(state.get("dataset_reviewed", False))) self.progressive_enabled.setChecked(bool(state.get("progressive_enabled", False))) self.progressive_schedule.setText(str(state.get("progressive_schedule", ""))) self.progressive_auto_batch.setChecked(bool(state.get("progressive_auto_batch", True))) options = state.get("training_options", {}) if isinstance(options, dict): self.training_form.set_values(options) self.preview_enabled.setChecked(bool(options.get("preview_enabled", True))) self.preview_every.setValue(int(options.get("preview_every", 5))) self.preview_prompt.setText(str(options.get("preview_prompt", ""))) self.preview_seed.setValue(int(options.get("preview_seed", 123456789))) self._update_source() self._training_mode_changed() self._update_progressive_controls() self._update_review() def _switch_model(self, index: int) -> None: if index < 0 or index >= len(self._model_states): return if 0 <= self._current_model_index < len(self._model_states): self._model_states[self._current_model_index] = self._capture_state() self._current_model_index = index self._load_state(self._model_states[index]) def _add_model(self) -> None: self._model_states[self._current_model_index] = self._capture_state() blank = dict(self._model_states[0]) blank.update({"subject": "", "dataset": "", "model_name": "", "continue_model_id": ""}) self._model_states.append(blank) index = self.model_tabs.addTab(f"Model {len(self._model_states)}") self._install_remove_button(index) self.model_tabs.setCurrentIndex(index) def _bulk_add_models(self) -> None: dialog = QDialog(self) dialog.setWindowTitle("Paste model list") dialog.setMinimumWidth(520) layout = QVBoxLayout(dialog) hint = QLabel("Enter one model subject per line. Numbered and bulleted lists are accepted.") hint.setWordWrap(True) editor = QPlainTextEdit() editor.setPlaceholderText("Windows XP\nAdventure Time\nLuigi\nEarthBound") editor.setMinimumHeight(260) controls = QDialogButtonBox(QDialogButtonBox.Cancel | QDialogButtonBox.Ok) controls.button(QDialogButtonBox.Ok).setText("Add to batch") controls.accepted.connect(dialog.accept) controls.rejected.connect(dialog.reject) layout.addWidget(hint) layout.addWidget(editor) layout.addWidget(controls) if dialog.exec() != QDialog.Accepted: return names = parse_model_batch_names(editor.toPlainText()) if not names: self.validation.setText("Paste at least one model name.") return self._model_states[self._current_model_index] = self._capture_state() template = dict(self._model_states[self._current_model_index]) template["dataset_reviewed"] = False states = [] for name in names: state = dict(template) state.update({"source": "new", "subject": name, "dataset": "", "model_name": name}) states.append(state) current_blank = not any( str(self._model_states[0].get(key, "")).strip() for key in ("subject", "dataset", "model_name") ) if current_blank and len(self._model_states) == 1: self._model_states = states else: self._model_states.extend(states) self._rebuild_model_tabs() self.model_tabs.setCurrentIndex(0 if current_blank else len(self._model_states) - len(states)) self.validation.setText(f"Added {len(names)} models. Their shared settings came from the current model.") def _create_model_plugin(self) -> None: dialog = ModelPluginWizardDialog(self.planner.root, self) if dialog.exec() != QDialog.Accepted: return self.validation.setText( f"Created {dialog.created_folder}. Restart ADAM after editing the plugin code." ) def _rebuild_model_tabs(self) -> None: self.model_tabs.blockSignals(True) while self.model_tabs.count(): self.model_tabs.removeTab(0) for index, state in enumerate(self._model_states): name = str(state.get("model_name", "")).strip() or f"Model {index + 1}" tab = self.model_tabs.addTab(name) self._install_remove_button(tab) self.model_tabs.blockSignals(False) self._current_model_index = min(self._current_model_index, len(self._model_states) - 1) self.model_tabs.setCurrentIndex(self._current_model_index) self._load_state(self._model_states[self._current_model_index]) def _apply_settings_to_models(self) -> None: self._model_states[self._current_model_index] = self._capture_state() dialog = QDialog(self) dialog.setWindowTitle("Apply current settings") layout = QVBoxLayout(dialog) layout.addWidget(QLabel("Select the models that should receive the current trainer and recipe:")) choices = QListWidget() choices.setSelectionMode(QAbstractItemView.MultiSelection) for index, state in enumerate(self._model_states): item = QListWidgetItem(str(state.get("model_name", "")).strip() or f"Model {index + 1}") item.setData(Qt.UserRole, index) choices.addItem(item) controls = QDialogButtonBox(QDialogButtonBox.Cancel | QDialogButtonBox.Ok) controls.button(QDialogButtonBox.Ok).setText("Apply settings") controls.accepted.connect(dialog.accept); controls.rejected.connect(dialog.reject) layout.addWidget(choices); layout.addWidget(controls) if dialog.exec() != QDialog.Accepted or not choices.selectedItems(): return source = self._capture_state() shared_keys = { "preset", "trainer", "training_mode", "continue_model_id", "epochs", "images", "collection_mode", "training_options", } for item in choices.selectedItems(): target = self._model_states[int(item.data(Qt.UserRole))] for key in shared_keys: target[key] = source[key] self._load_state(self._model_states[self._current_model_index]) self.validation.setText(f"Applied the current settings to {len(choices.selectedItems())} models.") def _save_batch_draft(self) -> None: self._model_states[self._current_model_index] = self._capture_state() self.config.update({"model_batch_draft": { "saved_at": datetime.now().isoformat(timespec="seconds"), "models": self._model_states, }}) self.validation.setText(f"Saved a draft with {len(self._model_states)} models.") def _load_batch_draft(self) -> None: payload = self.config.get("model_batch_draft", {}) states = payload.get("models", []) if isinstance(payload, dict) else [] if not isinstance(states, list) or not states: self.validation.setText("There is no saved model batch draft yet.") return self._model_states = [dict(state) for state in states if isinstance(state, dict)] self._current_model_index = 0 self._rebuild_model_tabs() self.validation.setText(f"Loaded the saved draft with {len(self._model_states)} models.") def _find_collected_datasets(self) -> None: self._model_states[self._current_model_index] = self._capture_state() self.planner.assets.discover(self.config) found = 0 for state in self._model_states: if state.get("source") != "new": continue subject = str(state.get("subject", "")).strip() matches = self.planner.assets.find("dataset", subject) ready = next((asset for asset in matches if Path(asset.path).is_dir()), None) if ready: state["source"] = "existing" state["dataset"] = ready.name found += 1 self._load_state(self._model_states[self._current_model_index]) self._save_batch_draft() self.validation.setText( f"Linked {found} collected datasets. Review them in Training Studio, then mark each model ready." ) def _match_existing_datasets(self) -> None: """Link batch models to clearly matching registered datasets without guessing.""" self._model_states[self._current_model_index] = self._capture_state() self.planner.assets.discover(self.config) datasets = [asset for asset in self.planner.assets.assets if asset.kind == "dataset"] matched = 0 ambiguous: list[str] = [] unmatched: list[str] = [] for index, state in enumerate(self._model_states, 1): if state.get("source") == "existing" and str(state.get("dataset", "")).strip(): continue suggestion = suggest_existing_dataset(state, datasets) label = str(state.get("model_name", "")).strip() or str(state.get("subject", "")).strip() or f"Model {index}" if suggestion.status == "matched": state["source"] = "existing" state["dataset"] = suggestion.dataset_name state["dataset_reviewed"] = False matched += 1 elif suggestion.status == "ambiguous": ambiguous.append(label) else: unmatched.append(label) self._load_state(self._model_states[self._current_model_index]) self._save_batch_draft() details = [f"Matched {matched} model(s) to existing datasets."] if ambiguous: details.append("Needs your choice (similar datasets): " + ", ".join(ambiguous[:4]) + ("…" if len(ambiguous) > 4 else "") + ".") if unmatched: details.append("No confident match: " + ", ".join(unmatched[:4]) + ("…" if len(unmatched) > 4 else "") + ".") details.append("Matches are not marked reviewed; inspect them, then approve the batch when ready.") self.validation.setText(" ".join(details)) def _accept_collection_requests(self) -> None: self._model_states[self._current_model_index] = self._capture_state() requests = [] for state in self._model_states: if state.get("source") != "new": continue subject = str(state.get("subject", "")).strip() if not subject: continue requests.append(build_dataset_collection_request( subject, image_count=int(state.get("images", 100)), collection_mode=str(state.get("collection_mode", "target")), )) if not requests: self.validation.setText("Every model already uses an existing dataset, or a subject is missing.") return self._save_batch_draft() self.collection_only = True self.requests = requests self.request = requests[0] self.accept() def _approve_all_datasets(self) -> None: self._model_states[self._current_model_index] = self._capture_state() missing = [ index + 1 for index, state in enumerate(self._model_states) if state.get("source") != "existing" or not str(state.get("dataset", "")).strip() ] if missing: shown = ", ".join(str(index) for index in missing[:8]) suffix = "…" if len(missing) > 8 else "" self.validation.setText( f"Link the collected datasets for model(s) {shown}{suffix} before approving the batch. " "Use Find collected datasets first." ) return answer = QMessageBox.question( self, "Approve all datasets", f"Mark all {len(self._model_states)} linked datasets as reviewed and ready to train?\n\n" "This accepts each dataset as-is. It will not review individual images or exclude any images marked rejected.", ) if answer != QMessageBox.Yes: return for state in self._model_states: state["dataset_reviewed"] = True self._load_state(self._model_states[self._current_model_index]) self._save_batch_draft() self.validation.setText( f"Approved all {len(self._model_states)} datasets. The batch is ready to build a training plan." ) def _install_remove_button(self, index: int) -> None: remove = QPushButton("−") remove.setFixedSize(26, 24) remove.setToolTip("Remove this model from the batch") remove.setStyleSheet("padding: 0; font-size: 16px; font-weight: 700;") remove.clicked.connect( lambda _checked=False, button=remove: self._remove_button_clicked(button) ) self.model_tabs.setTabButton(index, QTabBar.RightSide, remove) def _remove_button_clicked(self, button: QPushButton) -> None: for index in range(self.model_tabs.count()): if self.model_tabs.tabButton(index, QTabBar.RightSide) is button: self._remove_model(index) return def _remove_model(self, index: int) -> None: if len(self._model_states) == 1: self.validation.setText("Keep at least one model in the training batch.") return if index == self._current_model_index: self._model_states[index] = self._capture_state() self._model_states.pop(index) self.model_tabs.removeTab(index) self._current_model_index = self.model_tabs.currentIndex() self._load_state(self._model_states[self._current_model_index]) self._renumber_tabs() def _move_model(self, old: int, new: int) -> None: if old == new or old >= len(self._model_states) or new >= len(self._model_states): return state = self._model_states.pop(old) self._model_states.insert(new, state) self._current_model_index = new self._renumber_tabs() def _renumber_tabs(self) -> None: for index, state in enumerate(self._model_states): name = str(state.get("model_name", "")).strip() self.model_tabs.setTabText(index, name or f"Model {index + 1}") def _refresh_presets_for_trainer(self, preferred: str = "") -> None: trainer = str(self.trainer.currentData() or "") names = [ name for name, values in self.presets.items() if str(values.get("trainer", "")) == trainer ] self.preset.blockSignals(True) self.preset.clear() if names: self.preset.addItems(names) target = preferred if preferred in names else names[0] self.preset.setCurrentText(target) else: self.preset.addItem("Plugin defaults") self.preset.blockSignals(False) def _apply_preset(self, name: str) -> None: values = self.presets.get(name, {}) trainer = str(self.trainer.currentData() or "") if not values: plugin = self.planner.registry.model_plugins.by_trainer(trainer) label = plugin.name if plugin else self.trainer.currentText() self.preset_hint.setText(f"{label}: using the plugin's default settings.") self._set_training_defaults() return if str(values.get("trainer", "")) != trainer: return self._set_training_defaults() self.epochs.setValue(int(values.get("epochs", 100))) self.images.setValue(int(values.get("image_count", 60))) options = values.get("training_options", {}) if isinstance(options, dict): self.training_form.set_values(options) stages = options.get("progressive_stages") if isinstance(stages, list) and stages: self.progressive_enabled.setChecked(True) self.progressive_schedule.setText( ", ".join( f"{item.get('resolution')}:{item.get('epochs')}" for item in stages if isinstance(item, dict) ) ) self.progressive_auto_batch.setChecked(bool(options.get("progressive_auto_batch", True))) if self.preview_group.isVisible(): self.preview_enabled.setChecked(bool(options.get("preview_enabled", True))) self.preview_every.setValue(int(options.get("preview_every", 5))) self.preview_prompt.setText(str(options.get("preview_prompt", ""))) self.preview_seed.setValue(int(options.get("preview_seed", 123456789))) self.preset_hint.setText(str(values.get("description", ""))) self._update_progressive_controls() def _update_source(self) -> None: continuing = self.training_mode.currentData() == "continue" creating = self.source.currentData() == "new" self.subject.setEnabled(creating) self.collection_mode.setEnabled(creating) self.images.setEnabled(creating and self.collection_mode.currentData() == "target") self.dataset.setEnabled(not creating and self.source.currentData() != "original") if not creating: self._suggest_name(self.dataset.currentText()) def _update_collection_mode(self) -> None: self.images.setEnabled( self.source.currentData() == "new" and self.collection_mode.currentData() == "target" ) def _model_can_continue(self, asset: object) -> bool: trainer = str(getattr(asset, "trainer", "")) path = Path(str(getattr(asset, "path", ""))) checkpoint = str(getattr(asset, "checkpoint", "")) checkpoint_ready = bool(checkpoint and Path(checkpoint).exists()) ddpm_pipeline = trainer == "ddpm" and (path / "model_index.json").is_file() flow_model = trainer == "flow" and ( (path / "flow_model_info.json").is_file() and (path / "unet" / "config.json").is_file() ) oasis_model = trainer == "oasis" and ( (path / "action_flow_model_info.json").is_file() and (path / "unet" / "config.json").is_file() ) try: supports_resume = "resume_training" in self.planner.registry.get( f"{trainer}_trainer" ).capabilities except Exception: supports_resume = False return supports_resume and (checkpoint_ready or ddpm_pipeline or flow_model or oasis_model) def _refresh_continue_models(self, preferred_id: str = "") -> None: trainer = str(self.trainer.currentData() or "") current = preferred_id or str(getattr(self.continue_model.currentData(), "id", "")) self.continue_model.blockSignals(True) self.continue_model.clear() models = [ asset for asset in self.planner.assets.assets if asset.kind == "model" and asset.trainer == trainer and self._model_can_continue(asset) ] for asset in sorted(models, key=lambda item: item.name.casefold()): self.continue_model.addItem(asset.name, asset) if not models: self.continue_model.addItem("No resumable models found") self.continue_model.model().item(0).setEnabled(False) elif current: index = next( ( i for i in range(self.continue_model.count()) if getattr(self.continue_model.itemData(i), "id", "") == current ), -1, ) if index >= 0: self.continue_model.setCurrentIndex(index) self.continue_model.blockSignals(False) def _continued_model_has_original_dataset(self) -> bool: asset = self.continue_model.currentData() if not asset: return False return any( item.kind == "dataset" and item.id == getattr(asset, "dataset_id", "") and Path(item.path).is_dir() for item in self.planner.assets.assets ) def _training_mode_changed(self) -> None: continuing = self.training_mode.currentData() == "continue" has_original_dataset = self._continued_model_has_original_dataset() self.continue_model.setVisible(continuing) if hasattr(self, "form_labels"): self.form_labels["Model to continue"].setVisible(continuing) self.source.model().item(self.source.findData("original")).setEnabled( continuing and has_original_dataset ) if not continuing and self.source.currentData() == "original": self.source.setCurrentIndex(self.source.findData("existing")) if continuing and self.source.currentData() == "new" and self.trainer.currentData() in {"flow", "oasis"}: target = "original" if has_original_dataset else "existing" self.source.setCurrentIndex(self.source.findData(target)) if continuing and self.source.currentData() == "original" and not has_original_dataset: self.source.setCurrentIndex(self.source.findData("existing")) self._update_source() def _trainer_changed(self) -> None: self._refresh_presets_for_trainer() flow = self.trainer.currentData() == "flow" oasis = self.trainer.currentData() == "oasis" self.trigger_word.setVisible(self.trainer.currentData() == "lora") if self.training_mode.currentData() != "continue" and (flow or oasis): self.source.setCurrentIndex(self.source.findData("existing")) self.source.model().item(self.source.findData("new")).setEnabled( self.training_mode.currentData() == "continue" or not (flow or oasis) ) if flow: self.preset_hint.setText( "Flow Matching currently uses an existing reviewed dataset. " "Create a dataset first if you do not have one yet." ) if oasis: self.preset_hint.setText( "Oasis uses existing gameplay folders with frames and synchronized action labels. " "Record or convert an action dataset before training." ) self._apply_preset(self.preset.currentText()) self._update_progressive_controls() def _set_training_defaults(self) -> None: trainer = str(self.trainer.currentData()) full_schema = self.planner.registry.model_plugins.training_schema(trainer) builtin_preview = trainer in self.BUILTIN_PREVIEW_TRAINERS schema = { key: spec for key, spec in full_schema.items() if not (builtin_preview and key.startswith("preview_")) } self.training_form.set_schema(schema) self.preview_group.setVisible(builtin_preview) self.orion_settings_button.setEnabled(trainer in {"ddpm", "flow", "lora", "oasis"}) plugin = self.planner.registry.model_plugins.by_trainer(trainer) self.options_group.setEnabled(bool(schema)) if plugin: self.options_hint.setText( f"{plugin.name}: settings are generated from the model plugin manifest." ) else: self.options_hint.setText("This trainer has no model plugin manifest yet.") def _progressive_supported(self) -> bool: return str(self.trainer.currentData() or "") in {"ddpm", "flow"} def _progressive_final_resolution(self) -> int: value = self.training_form.values().get("resolution", 128) try: return int(str(value).split("x", 1)[0]) except (TypeError, ValueError): return 128 def _update_progressive_controls(self) -> None: supported = self._progressive_supported() can_progress = supported and self._progressive_final_resolution() >= 128 for label in ("Progressive resolution", "Resolution stages", "Stage batch size"): if label in self.form_labels: self.form_labels[label].setVisible(supported) self.progressive_enabled.setVisible(supported) self.progressive_stage_widget.setVisible(supported) self.progressive_auto_batch.setVisible(supported) if not can_progress: self.progressive_enabled.setChecked(False) active = can_progress and self.progressive_enabled.isChecked() self.progressive_enabled.setEnabled(can_progress) self.progressive_schedule.setEnabled(active) self.progressive_suggest.setEnabled(active) self.progressive_auto_batch.setEnabled(active) def _suggest_progressive_stages(self) -> None: stages = suggested_stages(self._progressive_final_resolution(), self.epochs.value()) if len(stages) < 2: self.validation.setText("Choose a final resolution of at least 128px for progressive training.") return self.progressive_schedule.setText( ", ".join(f"{stage.resolution}:{stage.epochs}" for stage in stages) ) def _parse_progressive_schedule( self, value: str, total_epochs: int, trainer: str | None = None, ) -> list[dict[str, int]]: trainer = trainer or str(self.trainer.currentData() or "") entries: list[dict[str, int]] = [] for token in value.split(","): parts = token.strip().lower().replace("px", "").split(":", 1) if len(parts) != 2: raise ValueError("Use stages in the form 64:300, 128:100, 256:50.") try: entries.append({"resolution": int(parts[0].strip()), "epochs": int(parts[1].strip())}) except ValueError as exc: raise ValueError("Stage resolutions and epochs must be whole numbers.") from exc try: return [ {"resolution": stage.resolution, "epochs": stage.epochs} for stage in parse_stages(entries, trainer=trainer, total_epochs=total_epochs) ] except Exception as exc: raise ValueError(str(exc)) from exc def _state_training_options(self, state: dict[str, object]) -> dict[str, object]: options = state.get("training_options", {}) resolved = dict(options) if isinstance(options, dict) else {} if not bool(state.get("progressive_enabled", False)): resolved.pop("progressive_stages", None) resolved.pop("progressive_auto_batch", None) return resolved trainer = str(state.get("trainer", "")) if trainer not in {"ddpm", "flow"}: raise ValueError("Progressive resolution training is currently available for DDPM and Flow Matching.") stages = self._parse_progressive_schedule( str(state.get("progressive_schedule", "")), int(state.get("epochs", 0)), trainer, ) configured_resolution = resolved.get("resolution", 0) try: final_resolution = int(str(configured_resolution).split("x", 1)[0]) except (TypeError, ValueError): final_resolution = 0 if stages[-1]["resolution"] != final_resolution: raise ValueError( f"The final progressive stage is {stages[-1]['resolution']}px, " f"but the training resolution is {final_resolution}px." ) resolved["progressive_stages"] = stages resolved["progressive_auto_batch"] = bool(state.get("progressive_auto_batch", True)) return resolved def _orion_image_count(self) -> int: if self.source.currentData() == "new": return 5_000 if self.collection_mode.currentData() == "all_available" else self.images.value() name = self.dataset.currentText().strip() for asset in self.planner.assets.find("dataset", name): count = dataset_image_count(asset.path) if count: return count return self.images.value() def _orion_dataset_path(self) -> str: """Return a real Oasis dataset path when the current selection has one.""" if self.source.currentData() == "new": return "" name = self.dataset.currentText().strip() for asset in self.planner.assets.find("dataset", name): if Path(asset.path).is_dir(): return str(asset.path) return name if Path(name).is_dir() else "" def _apply_orion_settings(self) -> None: trainer = str(self.trainer.currentData()) images = self._orion_image_count() current = self.training_form.values() raw_resolution = current.get("resolution", 128) or 128 if isinstance(raw_resolution, str) and "x" in raw_resolution: resolution = int(raw_resolution.lower().split("x", 1)[0]) else: resolution = int(raw_resolution) profile = ModelProfileRegistry(self.planner.registry.model_plugins).get(trainer) if profile: result = recommend_for_profile( profile, dataset_items=images, dataset_path=self._orion_dataset_path(), resolution=resolution, snapshot=getattr(self.parent(), "latest_snapshot", None), ).to_dict() else: result = recommend_training_settings(trainer, images, resolution) self.epochs.setValue(int(result["epochs"])) settings = result["settings"] self.training_form.set_values(settings) if "preview_every" in settings: self.preview_every.setValue(int(settings["preview_every"])) warnings = result.get("warnings", []) self.preset_hint.setText(str(result["summary"])) reasons = result.get("reasons", []) explanation = "" if reasons: explanation = "\nWhy: " + " ".join(str(reason) for reason in reasons[:3]) self.validation.setText( "ORION applied a reviewable starting recipe. Nothing has been queued or started." + explanation + ("\n" + "\n".join(str(item) for item in warnings) if warnings else "") ) self._update_review() def _training_options(self) -> dict[str, object]: plugin_options = self.training_form.values() if self.preview_group.isVisible(): plugin_options.update({ "preview_enabled": self.preview_enabled.isChecked(), "preview_every": self.preview_every.value(), }) if self.trainer.currentData() != "oasis": plugin_options.update({ "preview_prompt": self.preview_prompt.text().strip(), "preview_seed": self.preview_seed.value(), }) if self.trainer.currentData() == "lora": trigger_word = self.trigger_word.text().strip() if trigger_word: plugin_options["trigger_word"] = trigger_word if self._progressive_supported() and self.progressive_enabled.isChecked(): try: plugin_options["progressive_stages"] = self._parse_progressive_schedule( self.progressive_schedule.text(), self.epochs.value() ) plugin_options["progressive_auto_batch"] = self.progressive_auto_batch.isChecked() except ValueError: # Keep the draft editable. _accept_request provides the concrete # message before a malformed schedule can enter a job plan. pass return plugin_options def _update_preview_controls(self) -> None: enabled = self.preview_enabled.isChecked() self.preview_every.setEnabled(enabled) self.preview_prompt.setEnabled(enabled) self.preview_seed.setEnabled(enabled) def _suggest_name(self, value: str) -> None: if not self.model_name.text().strip(): self.model_name.setPlaceholderText(value.strip() or "Model name") def _update_review(self) -> None: continuing = self.training_mode.currentData() == "continue" creating = self.source.currentData() == "new" subject = self.subject.text().strip() if creating else self.dataset.currentText().strip() model = self.model_name.text().strip() or subject or "Unnamed model" if self.source.currentData() == "original": base = self.continue_model.currentData() dataset = f"reuse the dataset linked to {getattr(base, 'name', 'the selected model')}" elif creating: dataset = ( f"collect every result Bing makes available (up to 5,000) for {subject or 'the subject'}" if self.collection_mode.currentData() == "all_available" else f"collect up to {self.images.value()} images of {subject or 'the subject'}" ) else: dataset = f"use the registered {subject or 'selected'} dataset" if continuing: base = self.continue_model.currentData() action = f"continue {getattr(base, 'name', 'a selected model')}" else: action = f"train {model}" self.review_summary.setText( f"Review: {dataset}; {action} with " f"{self.trainer.currentText()} for {self.epochs.value():,} epochs. " + ( f"Trigger: {self.trigger_word.text().strip() or model}. " if self.trainer.currentData() == "lora" and not continuing else "" ) + self._training_option_summary() + self._preview_summary() + ( f"Scheduled for {self.schedule_time.dateTime().toString('MMM d, yyyy h:mm AP')}. " if hasattr(self, "schedule_enabled") and self.schedule_enabled.isChecked() else "" ) + "ADAM will run preflight checks and still ask for approval." ) if hasattr(self, "model_tabs") and self.model_tabs.count(): self.model_tabs.setTabText(self.model_tabs.currentIndex(), model) def _preview_summary(self) -> str: if self.preview_group.isVisible(): if self.preview_enabled.isChecked(): return f"Live preview every {self.preview_every.value()} epochs. " return "Live previews off. " values = self.training_form.values() if values.get("preview_every"): return f"Plugin preview every {values['preview_every']} steps. " return "" def _training_option_summary(self) -> str: values = self.training_form.values() details = [] if values.get("resolution"): details.append(f"{values['resolution']}px") if values.get("batch_size"): details.append(f"batch {values['batch_size']}") if values.get("learning_rate"): details.append(f"lr {float(values['learning_rate']):.7f}") if self._progressive_supported() and self.progressive_enabled.isChecked(): try: self._parse_progressive_schedule(self.progressive_schedule.text(), self.epochs.value()) details.append("progressive " + self.progressive_schedule.text().strip()) except ValueError: details.append("progressive schedule needs attention") if self.trainer.currentData() == "oasis": from adam.oasis_dataset import oasis_pace pace = oasis_pace( self._orion_dataset_path(), frame_gap=int(values.get("frame_gap", 1) or 1), ) capture_fps = pace["capture_fps"] native_fps = pace["native_ai_fps"] if isinstance(capture_fps, (int, float)) and isinstance(native_fps, (int, float)): details.append(f"{float(capture_fps):g} FPS capture → {float(native_fps):g} native AI FPS") return " · ".join(details) + ". " if details else "" def _save_preset(self) -> None: name = self.preset_name.text().strip() if not name: self.validation.setText("Enter a name before saving the preset.") return stored = self.config.get("training_presets", {}) stored = dict(stored) if isinstance(stored, dict) else {} stored[name] = { "trainer": self.trainer.currentData(), "epochs": self.epochs.value(), "image_count": self.images.value(), "training_options": self._training_options(), "description": "Your saved training settings.", } self.config.update({"training_presets": stored}) self.presets[name] = stored[name] self._refresh_presets_for_trainer(name) self.preset.setCurrentText(name) self.validation.setText(f"Saved preset: {name}") def _accept_request(self) -> None: self._model_states[self._current_model_index] = self._capture_state() requests: list[str] = [] for index, state in enumerate(self._model_states, 1): continuing = state.get("training_mode") == "continue" creating = state.get("source") == "new" subject = str(state.get("subject", "")).strip() dataset = str(state.get("dataset", "")).strip() try: resolved_options = self._state_training_options(state) except ValueError as exc: self.validation.setText(f"Model {index}: {exc}") self.model_tabs.setCurrentIndex(index - 1) return if continuing: model = next( ( asset for asset in self.planner.assets.assets if asset.kind == "model" and asset.id == str(state.get("continue_model_id", "")) and self._model_can_continue(asset) ), None, ) if not model: self.validation.setText(f"Model {index}: choose a completed model to continue.") self.model_tabs.setCurrentIndex(index - 1) return if state.get("source") == "existing" and not dataset: self.validation.setText(f"Model {index}: choose the gameplay dataset for continuation.") self.model_tabs.setCurrentIndex(index - 1) return if state.get("source") == "new" and not subject: self.validation.setText(f"Model {index}: tell ADAM what the new dataset should contain.") self.model_tabs.setCurrentIndex(index - 1) return requests.append(build_fine_tune_request( model_name=model.name, trainer=model.trainer, epochs=int(state.get("epochs", 100)), dataset_mode=str(state.get("source", "original")), dataset_name=dataset, new_subject=subject, image_count=int(state.get("images", 60)), training_options=resolved_options, )) continue if creating and not subject: self.validation.setText(f"Model {index}: tell ADAM what it should learn.") self.model_tabs.setCurrentIndex(index - 1) return if not creating and not dataset: self.validation.setText(f"Model {index}: choose or type an existing dataset name.") self.model_tabs.setCurrentIndex(index - 1) return if not bool(state.get("dataset_reviewed", False)): self.validation.setText( f"Model {index}: review its dataset in Training Studio, then mark it ready to train." ) self.model_tabs.setCurrentIndex(index - 1) return name = str(state.get("model_name", "")).strip() or subject or dataset requests.append(build_training_request( trainer=str(state.get("trainer", "lora")), subject=subject, dataset_name=dataset, create_dataset=creating, epochs=int(state.get("epochs", 100)), image_count=int(state.get("images", 60)), collection_mode=str(state.get("collection_mode", "target")), model_name=name, training_options=resolved_options, )) self.requests = requests self.request = requests[0] if self.schedule_enabled.isChecked(): scheduled = self.schedule_time.dateTime() if scheduled <= QDateTime.currentDateTime(): self.validation.setText("Choose a scheduled training time in the future.") return self.scheduled_for = scheduled.toPython().astimezone().isoformat() else: self.scheduled_for = None self.config.update({"model_batch_draft": { "saved_at": datetime.now().isoformat(timespec="seconds"), "models": self._model_states, }}) self.accept() class FineTuneDialog(QDialog): """Select a registered resumable model and request additional training.""" def __init__(self, planner: Planner, parent: QWidget | None = None) -> None: super().__init__(parent) self.planner = planner self.request = "" self.setWindowTitle("Fine-Tune Assistant") self.setMinimumWidth(560) root = QVBoxLayout(self) root.setSpacing(10) root.addWidget( _page_header( "Fine-tune a model", "Continue a completed model from its saved checkpoint with more training on its original dataset.", ) ) notice = QLabel( "Fine-tuning never starts immediately. ADAM will validate the checkpoint, dataset, " "tool connection, and available disk space before asking for approval." ) notice.setWordWrap(True) notice.setProperty("muted", True) root.addWidget(notice) form = QGridLayout() self.model = QComboBox() self.dataset_mode = QComboBox() self.dataset_mode.addItem("Use the model's original dataset", "original") self.dataset_mode.addItem("Use another registered dataset", "existing") self.dataset_mode.addItem("Collect a new dataset", "new") self.dataset = QComboBox() for asset in planner.assets.assets: if asset.kind == "dataset" and Path(asset.path).is_dir(): self.dataset.addItem(asset.name, asset) self.new_subject = QLineEdit() self.new_subject.setPlaceholderText("What should the new dataset contain?") self.output_model_name = QLineEdit() self.output_model_name.setPlaceholderText("A distinct name for this fine-tuned model") self.image_count = QSpinBox() self.image_count.setRange(10, 5000) self.image_count.setValue(60) self.image_count.setSuffix(" images") self.epochs = QSpinBox() self.epochs.setRange(1, 100_000) self.epochs.setValue(25) self.epochs.setSuffix(" additional epochs") form.addWidget(QLabel("Completed model"), 0, 0) form.addWidget(self.model, 0, 1) form.addWidget(QLabel("Dataset choice"), 1, 0) form.addWidget(self.dataset_mode, 1, 1) form.addWidget(QLabel("Registered dataset"), 2, 0) form.addWidget(self.dataset, 2, 1) form.addWidget(QLabel("New dataset subject"), 3, 0) form.addWidget(self.new_subject, 3, 1) form.addWidget(QLabel("New dataset size"), 4, 0) form.addWidget(self.image_count, 4, 1) form.addWidget(QLabel("Fine-tuned model name"), 5, 0) form.addWidget(self.output_model_name, 5, 1) form.addWidget(QLabel("Continue training"), 6, 0) form.addWidget(self.epochs, 6, 1) root.addLayout(form) self.options_group = QGroupBox("Training settings") options = QGridLayout(self.options_group) self.resolution = QComboBox(); self.resolution.addItems(["64", "128", "256", "384", "512"]); self.resolution.setCurrentText("128") self.batch_size = QSpinBox(); self.batch_size.setRange(1, 64); self.batch_size.setValue(1) self.learning_rate = QDoubleSpinBox(); self.learning_rate.setDecimals(7); self.learning_rate.setRange(0.0000001, 0.1); self.learning_rate.setSingleStep(0.00005); self.learning_rate.setValue(0.0001) self.gradient_accumulation = QSpinBox(); self.gradient_accumulation.setRange(1, 64); self.gradient_accumulation.setValue(1) self.workers = QSpinBox(); self.workers.setRange(0, 16); self.workers.setValue(4) self.precision = QComboBox(); self.precision.addItem("FP16 (faster / less VRAM)", "fp16"); self.precision.addItem("Full precision", "no") self.save_every = QSpinBox(); self.save_every.setRange(1, 1000); self.save_every.setValue(10) self.preview_steps = QSpinBox(); self.preview_steps.setRange(1, 500); self.preview_steps.setValue(50) self.intensity = QSpinBox(); self.intensity.setRange(10, 100); self.intensity.setValue(100); self.intensity.setSuffix("%") for row, (label, widget) in enumerate(( ("Resolution", self.resolution), ("Batch size", self.batch_size), ("Learning rate", self.learning_rate), ("Gradient accumulation", self.gradient_accumulation), ("Loader workers", self.workers), ("Precision", self.precision), ("Save every", self.save_every), ("Preview steps", self.preview_steps), ("Training intensity", self.intensity), )): options.addWidget(QLabel(label), row, 0) options.addWidget(widget, row, 1) self.options_hint = QLabel() self.options_hint.setWordWrap(True) self.options_hint.setProperty("muted", True) options.addWidget(self.options_hint, 9, 0, 1, 2) root.addWidget(self.options_group) self.summary = QLabel() self.summary.setWordWrap(True) self.summary.setProperty("muted", True) root.addWidget(self.summary) self.validation = QLabel() self.validation.setWordWrap(True) root.addWidget(self.validation) buttons = QDialogButtonBox(QDialogButtonBox.Cancel | QDialogButtonBox.Ok) self.plan_button = buttons.button(QDialogButtonBox.Ok) self.plan_button.setText("Build fine-tune plan") buttons.accepted.connect(self._accept_request) buttons.rejected.connect(self.reject) root.addWidget(buttons) self._add_model_groups() if not any(self.model.itemData(index) for index in range(self.model.count())): self.plan_button.setEnabled(False) self.validation.setText( "No completed model can be fine-tuned yet. Complete a DDPM, Flow Matching, " "or LoRA run, then return here." ) self.model.currentIndexChanged.connect(self._update_summary) self.model.currentIndexChanged.connect(self._model_changed) self.dataset_mode.currentIndexChanged.connect(self._dataset_mode_changed) self.dataset_mode.currentIndexChanged.connect(self._update_summary) self.dataset.currentIndexChanged.connect(self._update_summary) self.new_subject.textChanged.connect(self._update_summary) self.output_model_name.textChanged.connect(self._update_summary) self.image_count.valueChanged.connect(self._update_summary) self.epochs.valueChanged.connect(self._update_summary) for widget in (self.resolution, self.batch_size, self.learning_rate, self.gradient_accumulation, self.workers, self.precision, self.save_every, self.preview_steps, self.intensity): signal = getattr(widget, "valueChanged", None) or getattr(widget, "currentIndexChanged", None) if signal: signal.connect(self._update_summary) self._dataset_mode_changed() self._model_changed() self._update_summary() def _add_model_groups(self) -> None: """Show every model family, while allowing only safe continuation choices.""" labels = {"ddpm": "DDPM models", "flow": "Flow Matching models", "lora": "LoRA models", "oasis": "Oasis models"} models = [asset for asset in self.planner.assets.assets if asset.kind == "model"] for trainer in ("ddpm", "flow", "lora", "oasis"): header_index = self.model.count() self.model.addItem(f"— {labels[trainer]} —") self.model.model().item(header_index).setEnabled(False) group = [asset for asset in models if asset.trainer == trainer] if not group: index = self.model.count() self.model.addItem("No registered models found") self.model.model().item(index).setEnabled(False) continue for asset in group: checkpoint_ready = bool(asset.checkpoint and Path(asset.checkpoint).is_file()) ddpm_pipeline = trainer == "ddpm" and (Path(asset.path) / "model_index.json").is_file() flow_model = trainer == "flow" and ( (Path(asset.path) / "flow_model_info.json").is_file() and (Path(asset.path) / "unet" / "config.json").is_file() ) oasis_model = trainer == "oasis" and ( (Path(asset.path) / "action_flow_model_info.json").is_file() and (Path(asset.path) / "unet" / "config.json").is_file() ) try: supports_resume = "resume_training" in self.planner.registry.get( f"{trainer}_trainer" ).capabilities except Exception: supports_resume = False ready = supports_resume and (checkpoint_ready or ddpm_pipeline or flow_model or oasis_model) if ready: detail = ( "saved Flow model" if flow_model else "saved checkpoint" if checkpoint_ready else "saved DDPM model" ) self.model.addItem(f"{asset.name} · {detail}", asset) else: reason = ( "continuation not connected" if not supports_resume else "no usable checkpoint" ) index = self.model.count() self.model.addItem(f"{asset.name} · {reason}") self.model.model().item(index).setEnabled(False) def _update_summary(self) -> None: asset = self.model.currentData() if not asset: self.summary.setText( "Choose a model marked with a saved checkpoint or saved DDPM model. " "Unavailable entries stay visible so you can see every model family." ) return mode = self.dataset_mode.currentData() if mode == "existing": dataset_text = f"use {self.dataset.currentText() or 'another registered dataset'}" elif mode == "new": dataset_text = f"collect {self.image_count.value()} images of {self.new_subject.text().strip() or 'a new subject'}" else: dataset_text = "reuse the dataset linked to the original run" output_name = self.output_model_name.text().strip() or asset.name self.summary.setText( f"Review: continue {asset.name} as {output_name} with {asset.trainer.upper()} for " f"{self.epochs.value():,} additional epochs; {dataset_text}. " "ADAM will validate everything and ask for approval before starting." ) def _dataset_mode_changed(self) -> None: mode = self.dataset_mode.currentData() self.dataset.setEnabled(mode == "existing") self.new_subject.setEnabled(mode == "new") self.image_count.setEnabled(mode == "new") def _model_changed(self) -> None: asset = self.model.currentData() trainer = asset.trainer if asset else "" if asset and not self.output_model_name.text().strip(): self.output_model_name.setText(f"{asset.name} Fine Tune") original_index = self.dataset_mode.findData("original") has_original_dataset = bool( asset and any( item.kind == "dataset" and item.id == asset.dataset_id and Path(item.path).is_dir() for item in self.planner.assets.assets ) ) if original_index >= 0: self.dataset_mode.model().item(original_index).setEnabled(has_original_dataset or not asset) if asset and not has_original_dataset and self.dataset_mode.currentData() == "original": self.dataset_mode.setCurrentIndex(self.dataset_mode.findData("existing")) self.options_group.setEnabled(trainer in {"ddpm", "flow", "oasis"}) self.resolution.setEnabled(trainer != "flow") if trainer == "flow": try: info = json.loads((Path(asset.path) / "flow_model_info.json").read_text(encoding="utf-8")) self.resolution.setCurrentText(str(int(info["resolution"]))) except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError): pass self.options_hint.setText( "Flow continuation keeps the original model resolution and starts a fresh optimizer schedule. " + ( "ADAM saves the fine-tuned model in a new folder." if has_original_dataset else "Choose another registered dataset; this older model has no recoverable original dataset link." ) ) elif trainer == "ddpm": self.options_hint.setText("These settings are passed to the DDPM trainer for this continuation run.") elif trainer == "oasis": self.options_hint.setText("Oasis continuation uses an existing action model folder and a reviewed gameplay action dataset.") else: self.options_hint.setText("The connected LoRA trainer currently reuses its saved training settings; choose the additional epochs above.") self._update_summary() def _training_options(self, trainer: str) -> dict[str, object]: if trainer == "ddpm": return { "resolution": int(self.resolution.currentText()), "batch_size": self.batch_size.value(), "learning_rate": self.learning_rate.value(), "gradient_accumulation_steps": self.gradient_accumulation.value(), "dataloader_num_workers": self.workers.value(), "mixed_precision": self.precision.currentData(), "save_every": self.save_every.value(), "preview_steps": self.preview_steps.value(), "training_intensity": self.intensity.value(), } if trainer == "flow": return { "resolution": int(self.resolution.currentText()), "batch_size": self.batch_size.value(), "learning_rate": self.learning_rate.value(), "gradient_accumulation": self.gradient_accumulation.value(), "workers": self.workers.value(), "mixed_precision": self.precision.currentData(), "save_every": self.save_every.value(), "preview_every": self.save_every.value(), "preview_steps": self.preview_steps.value(), "gradient_checkpointing": False, } if trainer == "oasis": return { "resolution": "256x144", "batch_size": min(2, self.batch_size.value()), "learning_rate": self.learning_rate.value(), "gradient_accumulation": self.gradient_accumulation.value(), "workers": min(2, self.workers.value()), "mixed_precision": "fp32", "save_every": self.save_every.value(), "preview_every": self.save_every.value(), "preview_steps": 1, } return {} def _accept_request(self) -> None: asset = self.model.currentData() if not asset: return mode = str(self.dataset_mode.currentData()) dataset_asset = self.dataset.currentData() if mode == "existing" and not dataset_asset: self.validation.setText("Choose a registered dataset.") return if mode == "new" and not self.new_subject.text().strip(): self.validation.setText("Tell ADAM what the new dataset should contain.") return self.request = build_fine_tune_request( model_name=asset.name, output_model_name=self.output_model_name.text(), trainer=asset.trainer, epochs=self.epochs.value(), dataset_mode=mode, dataset_name=dataset_asset.name if mode == "existing" else "", new_subject=self.new_subject.text(), image_count=self.image_count.value(), training_options=self._training_options(asset.trainer), ) self.accept() class VideoDatasetDialog(QDialog): """Builds a complete, reviewable YouTube dataset collection request.""" def __init__(self, parent: QWidget | None = None) -> None: super().__init__(parent) self.request = "" self.setWindowTitle("Video Dataset Collection Assistant") self.setMinimumSize(680, 760) root = QVBoxLayout(self) root.setSpacing(10) root.addWidget(_page_header( "Collect a video dataset", "Supply YouTube links and choose how ADAM should download, extract, filter, and document the dataset.", )) journey = QLabel("1 SOURCES → 2 DOWNLOAD LIMITS → 3 FRAME EXTRACTION → 4 REVIEW & APPROVE") journey.setStyleSheet( f"color: {COLORS['blue_2']}; background: #081a27; " f"border: 1px solid {COLORS['border_bright']}; border-radius: 8px; " "padding: 10px; font-size: 10px; font-weight: 700;" ) root.addWidget(journey) scroll = QScrollArea() scroll.setWidgetResizable(True) body = QWidget() form = QGridLayout(body) form.setHorizontalSpacing(14) form.setVerticalSpacing(9) self.dataset_name = QLineEdit("Video_Dataset") self.dataset_name.setPlaceholderText("Example: Roblox_Obby") self.urls = QPlainTextEdit() self.urls.setPlaceholderText("Paste one YouTube video or playlist URL per line") self.urls.setFixedHeight(88) self.max_videos = QSpinBox(); self.max_videos.setRange(1, 500); self.max_videos.setValue(5) self.max_duration = QDoubleSpinBox(); self.max_duration.setRange(0, 1440); self.max_duration.setValue(20); self.max_duration.setSuffix(" minutes") self.total_duration = QDoubleSpinBox(); self.total_duration.setRange(0, 100000); self.total_duration.setValue(100); self.total_duration.setSuffix(" minutes") self.max_size = QDoubleSpinBox(); self.max_size.setRange(0, 1_000_000); self.max_size.setValue(0); self.max_size.setSuffix(" MB (0 = no limit)") self.resolution = QComboBox() for label, value in (("480p", 480), ("720p (recommended)", 720), ("1080p", 1080), ("1440p", 1440), ("2160p / 4K", 2160)): self.resolution.addItem(label, value) self.resolution.setCurrentIndex(self.resolution.findData(720)) self.audio = QCheckBox("Include audio in the normalized MP4") self.skip_start = QDoubleSpinBox(); self.skip_start.setRange(0, 3600); self.skip_start.setValue(5); self.skip_start.setSuffix(" seconds") self.skip_end = QDoubleSpinBox(); self.skip_end.setRange(0, 3600); self.skip_end.setValue(5); self.skip_end.setSuffix(" seconds") self.mode = QComboBox() self.mode.addItem("General image dataset (filter repetition)", "image") self.mode.addItem("Sequential video training (preserve neighbors)", "sequential") self.frame_rate = QDoubleSpinBox(); self.frame_rate.setRange(0.01, 120); self.frame_rate.setDecimals(2); self.frame_rate.setValue(2); self.frame_rate.setSuffix(" frames/second") self.max_frames = QSpinBox(); self.max_frames.setRange(1, 1_000_000); self.max_frames.setValue(2000); self.max_frames.setSuffix(" accepted frames") self.remove_blur = QCheckBox("Reject blurry frames"); self.remove_blur.setChecked(True) self.remove_black = QCheckBox("Reject black frames"); self.remove_black.setChecked(True) self.remove_duplicates = QCheckBox("Reject near-duplicate frames"); self.remove_duplicates.setChecked(True) self.duplicate_threshold = QDoubleSpinBox(); self.duplicate_threshold.setRange(0, 1); self.duplicate_threshold.setDecimals(2); self.duplicate_threshold.setSingleStep(0.01); self.duplicate_threshold.setValue(0.96) self.keep_video = QCheckBox("Keep normalized MP4 files"); self.keep_video.setChecked(True) self.mix_frames = QCheckBox("Also mix accepted frames into one final folder") self.captions = QCheckBox("Generate basic frame captions") self.credits = QCheckBox("Generate source credits"); self.credits.setChecked(True) self.timestamps = QCheckBox("Save exact source timestamps"); self.timestamps.setChecked(True) self.permission = QComboBox() for value in ("not_verified", "user_owned", "permission_confirmed_by_user", "creative_commons_reported", "standard_youtube_license", "public_domain_claimed", "license_unknown"): self.permission.addItem(value.replace("_", " ").title(), value) self.dry_run = QCheckBox("Metadata preview only — do not download") rows = [ ("Dataset folder name", self.dataset_name), ("YouTube URLs", self.urls), ("Maximum videos", self.max_videos), ("Maximum length per video", self.max_duration), ("Maximum combined duration", self.total_duration), ("Maximum estimated download size", self.max_size), ("Preferred resolution", self.resolution), ("Audio", self.audio), ("Skip beginning", self.skip_start), ("Skip ending", self.skip_end), ("Dataset mode", self.mode), ("Extraction rate", self.frame_rate), ("Dataset limit", self.max_frames), ("Blur filter", self.remove_blur), ("Black-frame filter", self.remove_black), ("Duplicate filter", self.remove_duplicates), ("Duplicate threshold", self.duplicate_threshold), ("Original videos", self.keep_video), ("Frame layout", self.mix_frames), ("Captions", self.captions), ("Attribution", self.credits), ("Frame provenance", self.timestamps), ("Permission status", self.permission), ("Run mode", self.dry_run), ] for row, (label, widget) in enumerate(rows): form.addWidget(QLabel(label), row, 0, Qt.AlignTop) form.addWidget(widget, row, 1) scroll.setWidget(body) root.addWidget(scroll, 1) self.summary = QLabel() self.summary.setWordWrap(True) self.summary.setProperty("muted", True) root.addWidget(self.summary) self.validation = QLabel() self.validation.setWordWrap(True) root.addWidget(self.validation) buttons = QDialogButtonBox(QDialogButtonBox.Cancel | QDialogButtonBox.Ok) buttons.button(QDialogButtonBox.Ok).setText("Build collection plan") buttons.accepted.connect(self._accept_request) buttons.rejected.connect(self.reject) root.addWidget(buttons) for widget in (self.dataset_name, self.urls): signal = widget.textChanged if isinstance(widget, QLineEdit) else widget.textChanged signal.connect(self._update_summary) for widget in (self.max_videos, self.max_duration, self.total_duration, self.max_size, self.skip_start, self.skip_end, self.frame_rate, self.max_frames, self.duplicate_threshold): widget.valueChanged.connect(self._update_summary) for widget in (self.resolution, self.mode, self.permission): widget.currentIndexChanged.connect(self._update_summary) for widget in (self.audio, self.dry_run): widget.toggled.connect(self._update_summary) self.mode.currentIndexChanged.connect(self._mode_changed) self._update_summary() def _mode_changed(self) -> None: sequential = self.mode.currentData() == "sequential" self.remove_duplicates.setEnabled(not sequential) if sequential: self.remove_duplicates.setChecked(False) self._update_summary() def _update_summary(self) -> None: action = "preview metadata for" if self.dry_run.isChecked() else "collect" self.summary.setText( f"Review: {action} up to {self.max_videos.value()} video(s) at {self.resolution.currentData()}p, " f"{'with' if self.audio.isChecked() else 'without'} audio; extract {self.frame_rate.value():g} frames/second " f"in {self.mode.currentData()} mode, capped at {self.max_frames.value():,} accepted frames. " "ADAM will still show the final plan before downloading." ) def _accept_request(self) -> None: name = self.dataset_name.text().strip() urls = [line.strip() for line in self.urls.toPlainText().splitlines() if line.strip()] if not name: self.validation.setText("Enter a dataset folder name.") return if not urls or any(not re.match(r"https?://(?:www\.)?(?:youtube\.com|youtu\.be)/", url, re.I) for url in urls): self.validation.setText("Paste at least one valid YouTube video or playlist URL, one per line.") return settings = [ f"maximum {self.max_videos.value()} videos", f"maximum video duration {self.max_duration.value():g} minutes", f"maximum total duration {self.total_duration.value():g} minutes", f"maximum total size {self.max_size.value():g} MB", f"{self.resolution.currentData()}p", "with audio" if self.audio.isChecked() else "without audio", f"skip beginning {self.skip_start.value():g} seconds", f"skip ending {self.skip_end.value():g} seconds", f"{self.mode.currentData()} mode", f"{self.frame_rate.value():g} frames per second", f"maximum {self.max_frames.value()} accepted frames", "remove blurry frames" if self.remove_blur.isChecked() else "keep blurry frames", "remove black frames" if self.remove_black.isChecked() else "keep black frames", "remove near duplicates" if self.remove_duplicates.isChecked() else "keep duplicates", f"duplicate threshold {self.duplicate_threshold.value():.2f}", "keep MP4 files" if self.keep_video.isChecked() else "delete MP4 files", "mix accepted frames" if self.mix_frames.isChecked() else "separate source folders", "generate captions" if self.captions.isChecked() else "no captions", "generate source credits" if self.credits.isChecked() else "no source credits", "save exact timestamps" if self.timestamps.isChecked() else "do not save exact timestamps", f"permission status {self.permission.currentData()}", ] prefix = "Metadata-only inspect" if self.dry_run.isChecked() else "Collect a video dataset from" self.request = f"{prefix} {' '.join(urls)}. {', '.join(settings)}. Store everything in the {name} dataset folder." self.accept() class RecentPlansPanel(QFrame): selected = Signal(str) view_all_requested = Signal() def __init__(self, jobs: JobManager) -> None: super().__init__() self.jobs = jobs self.setProperty("card", True) self.setMaximumHeight(215) root = QVBoxLayout(self) root.setContentsMargins(15, 13, 15, 13) root.setSpacing(7) header = QHBoxLayout() header.addWidget(_card_title("RECENT PLANS")) self.queue_label = QLabel() self.queue_label.setProperty("muted", True) self.queue_label.setStyleSheet("font-size: 10px;") header.addStretch() header.addWidget(self.queue_label) root.addLayout(header) self.rows = QWidget() self.rows_layout = QVBoxLayout(self.rows) self.rows_layout.setContentsMargins(0, 0, 0, 0) self.rows_layout.setSpacing(5) root.addWidget(self.rows) self.view_all = QPushButton("View all plans →") self.view_all.setProperty("chip", True) self.view_all.clicked.connect(self.view_all_requested) root.addWidget(self.view_all) self.refresh() def refresh(self) -> None: while self.rows_layout.count(): item = self.rows_layout.takeAt(0) if item.widget(): item.widget().deleteLater() queued = sum( job.status in {JobStatus.SCHEDULED, JobStatus.QUEUED, JobStatus.AWAITING_CONFIRMATION} for job in self.jobs.jobs ) self.queue_label.setText(f"{queued} queued" if queued else "Queue clear") recent = self.jobs.jobs[:3] if not recent: empty = QLabel("Completed and active plans will appear here.") empty.setProperty("muted", True) empty.setWordWrap(True) self.rows_layout.addWidget(empty) return status_markers = { JobStatus.FINISHED: "✓", JobStatus.RUNNING: "●", JobStatus.PAUSED: "Ⅱ", JobStatus.FAILED: "!", JobStatus.CANCELLED: "×", JobStatus.INTERRUPTED: "!", JobStatus.AWAITING_CONFIRMATION: "?", JobStatus.SCHEDULED: "◷", JobStatus.QUEUED: "…", } for job in recent: tools = ", ".join( dict.fromkeys(step.tool_id.replace("_", " ").title() for step in job.plan.steps) ) or "Conversation" marker = status_markers.get(job.status, "·") button = QPushButton( f"{marker} {job.plan.project_name}\n {job.status.value} · {tools}" ) button.setProperty("recentPlan", True) button.setToolTip(job.plan.summary) button.clicked.connect( lambda _checked=False, job_id=job.id: self.selected.emit(job_id) ) self.rows_layout.addWidget(button) class SystemSummaryPanel(QFrame): def __init__(self) -> None: super().__init__() self.setProperty("card", True) self.setMinimumHeight(124) root = QVBoxLayout(self) root.setContentsMargins(16, 11, 16, 11) root.setSpacing(6) root.addWidget(_card_title("SYSTEM MONITOR")) row = QHBoxLayout() row.setSpacing(0) self.gpu = self._metric("GPU", "Waiting for GPU") self.cpu = self._metric("CPU", "Waiting for CPU") self.storage = self._metric("STORAGE", "Checking drive") row.addLayout(self.gpu[0], 2) row.addWidget(self._divider()) row.addLayout(self.cpu[0], 2) row.addWidget(self._divider()) row.addLayout(self.storage[0], 2) row.addWidget(self._divider()) activity_box = QVBoxLayout() activity_box.setContentsMargins(14, 0, 14, 0) activity_title = QLabel("ACTIVITY") activity_title.setProperty("muted", True) activity_title.setStyleSheet("font-size: 9px; font-weight: 700;") self.activity = SparklineWidget("Collecting activity…") self.activity.setMinimumHeight(43) self.activity.setMaximumHeight(43) activity_box.addWidget(activity_title) activity_box.addWidget(self.activity) row.addLayout(activity_box, 2) row.addWidget(self._divider()) tools_box = QVBoxLayout() tools_box.setContentsMargins(14, 0, 0, 0) tools_title = QLabel("TOOLS STATUS") tools_title.setProperty("muted", True) tools_title.setStyleSheet("font-size: 9px; font-weight: 700;") tools_box.addWidget(tools_title) tool_grid = QGridLayout() tool_grid.setHorizontalSpacing(13) tool_grid.setVerticalSpacing(3) checks = ( ("Python", bool(sys.executable)), ("Git", bool(shutil.which("git"))), ("Ollama", bool(shutil.which("ollama"))), ("FFmpeg", bool(shutil.which("ffmpeg"))), ) for index, (name, available) in enumerate(checks): label = QLabel(f"{'✓' if available else '○'} {name}") label.setStyleSheet( f"font-size: 10px; color: {COLORS['green'] if available else COLORS['muted']};" ) tool_grid.addWidget(label, index // 2, index % 2) tools_box.addLayout(tool_grid) row.addLayout(tools_box, 2) root.addLayout(row) self._activity_values: list[float] = [] @staticmethod def _divider() -> QFrame: divider = QFrame() divider.setFrameShape(QFrame.VLine) divider.setStyleSheet(f"color: {COLORS['border']};") return divider @staticmethod def _metric(title: str, initial: str) -> tuple[QVBoxLayout, QLabel, QLabel, QProgressBar]: layout = QVBoxLayout() layout.setContentsMargins(14, 0, 14, 0) layout.setSpacing(3) heading = QLabel(title) heading.setProperty("muted", True) heading.setStyleSheet("font-size: 9px; font-weight: 700;") value = QLabel(initial) value.setStyleSheet("font-size: 11px; font-weight: 600;") detail = QLabel("—") detail.setProperty("muted", True) detail.setStyleSheet("font-size: 9px;") progress = QProgressBar() progress.setRange(0, 100) layout.addWidget(heading) layout.addWidget(value) layout.addWidget(detail) layout.addWidget(progress) return layout, value, detail, progress def update_snapshot(self, snapshot: SystemSnapshot) -> None: self.gpu[1].setText(snapshot.gpu_name) temp = f" · {snapshot.gpu_temperature:.0f}°C" if snapshot.gpu_temperature is not None else "" self.gpu[2].setText( f"VRAM {snapshot.vram_used_gb:.1f} / {snapshot.vram_total_gb:.1f} GB{temp}" ) self.gpu[3].setValue(int(snapshot.vram_percent)) self.cpu[1].setText(f"Usage {snapshot.cpu_percent:.0f}%") self.cpu[2].setText( f"RAM {snapshot.memory_used_gb:.1f} / {snapshot.memory_total_gb:.1f} GB" ) self.cpu[3].setValue(int(snapshot.memory_percent)) if snapshot.disk_total_gb >= 1024: storage_text = ( f"{snapshot.disk_used_gb / 1024:.2f} / " f"{snapshot.disk_total_gb / 1024:.2f} TB" ) else: storage_text = f"{snapshot.disk_used_gb:.0f} / {snapshot.disk_total_gb:.0f} GB" self.storage[1].setText(storage_text) self.storage[2].setText(f"{snapshot.disk_percent:.0f}% used") self.storage[3].setValue(int(snapshot.disk_percent)) self._activity_values.append(max(snapshot.cpu_percent, snapshot.gpu_percent)) self._activity_values = self._activity_values[-50:] self.activity.set_values(self._activity_values) class CommandCenterPage(QWidget): provider_changed = Signal(str) tool_folders_changed = Signal() open_jobs_requested = Signal() open_dataset_lab_requested = Signal() open_experiments_requested = Signal() open_remote_requested = Signal() history_changed = Signal() def __init__( self, planner: Planner, jobs: JobManager, config: ConfigManager, tool_folders: ToolFolderManager, root_path: Path, remote_service: RemoteAccessService | None = None, ) -> None: super().__init__() self.setMinimumHeight(820) self.planner = planner self.jobs = jobs self.config = config self.tool_folders = tool_folders self.root_path = root_path self.remote_service = remote_service self.selected_job: Job | None = None self._announced: set[tuple[str, JobStatus]] = set() self._planning_worker: PlanningWorker | None = None self._chat_worker: ChatWorker | None = None self._planning_bubble: ChatBubble | None = None self._streamed_text = "" self._chat_request = "" self._chat_history: list[dict[str, str]] = [] self._conversation_entries: list[dict[str, str]] = [] self.history_store = ChatHistoryStore(root_path) self._generation_cards: dict[str, GenerationChatCard] = {} self._prompt_reference_image = "" self._pending_schedule_for: str | None = None self.latest_snapshot = SystemSnapshot() root = QVBoxLayout(self) root.setContentsMargins(24, 20, 24, 17) root.setSpacing(12) header_row = QHBoxLayout() header = _page_header( "Command center", "Describe the outcome. ADAM will propose an allow-listed plan before any work begins.", ) header.setMinimumHeight(58) header_row.addWidget(header, 1) self.new_chat_button = QPushButton("+ New Chat") self.new_chat_button.setProperty("chip", True) self.new_chat_button.setToolTip("Archive this conversation and start fresh") self.new_chat_button.clicked.connect(self.start_new_chat) header_row.addWidget(self.new_chat_button, 0, Qt.AlignTop) self.mode_selector = QComboBox() self.mode_selector.addItem("Trainer Mode", "trainer") self.mode_selector.addItem("Chat Mode", "chat") mode_index = self.mode_selector.findData( self.config.get("command_center_mode", "trainer") ) self.mode_selector.setCurrentIndex(max(0, mode_index)) self.mode_selector.setMinimumWidth(145) self.mode_selector.setToolTip( "Trainer Mode plans registered work. Chat Mode only talks with Ollama." ) header_row.addWidget(self.mode_selector, 0, Qt.AlignTop) self.provider_badge = QLabel() self.provider_badge.setAlignment(Qt.AlignCenter) self.provider_badge.setMinimumWidth(190) self.provider_badge.setStyleSheet( f"background: #091a27; border: 1px solid {COLORS['border_bright']}; " f"border-radius: 15px; padding: 7px 12px; color: {COLORS['blue_2']}; " "font-size: 11px; font-weight: 700;" ) self.refresh_provider_badge() header_row.addWidget(self.provider_badge, 0, Qt.AlignTop) root.addLayout(header_row) columns = QHBoxLayout() columns.setSpacing(12) left = self._build_chat() self.right_panels = QWidget() self.right_panels.setMaximumWidth(390) right_layout = QVBoxLayout(self.right_panels) right_layout.setContentsMargins(0, 0, 0, 0) right_layout.setSpacing(12) self.plan_panel = PlanPanel() self.recent_panel = RecentPlansPanel(self.jobs) self.active_panel = ActiveJobPanel() self.plan_shell = CollapsiblePanel( "CURRENT PLAN", self.plan_panel, config, "command_center_current_plan_collapsed", "right" ) self.recent_shell = CollapsiblePanel( "RECENT PLANS", self.recent_panel, config, "command_center_recent_plans_collapsed", "right" ) self.active_shell = CollapsiblePanel( "ACTIVE JOB", self.active_panel, config, "command_center_active_job_collapsed", "right" ) for shell in (self.plan_shell, self.recent_shell, self.active_shell): shell.collapsed_changed.connect(self._update_right_panel_width) right_layout.addWidget(shell, 0, Qt.AlignRight) self.remote_shell = CollapsiblePanel( "PHONE REMOTE", self._build_remote_shortcut(), config, "command_center_phone_remote_collapsed", "right", ) self.remote_shell.collapsed_changed.connect(self._update_right_panel_width) right_layout.addWidget(self.remote_shell, 0, Qt.AlignRight) right_layout.addStretch(1) columns.addWidget(left, 1) columns.addWidget(self.right_panels) self._update_right_panel_width() root.addLayout(columns, 1) self.system_summary = SystemSummaryPanel() self.system_shell = CollapsiblePanel( "SYSTEM MONITOR", self.system_summary, config, "command_center_system_monitor_collapsed" ) root.addWidget(self.system_shell) self.plan_panel.approved.connect(self.jobs.confirm) self.plan_panel.rejected.connect(self.jobs.reject) self.active_panel.pause_requested.connect(self.jobs.pause) self.active_panel.resume_requested.connect(self.jobs.resume) self.active_panel.cancel_requested.connect(self.jobs.cancel) self.active_panel.open_requested.connect(self.open_output) self.jobs.job_created.connect(self._job_created) self.jobs.job_updated.connect(self._job_updated) self.jobs.active_changed.connect(self.active_panel.set_job) self.jobs.active_changed.connect( lambda job: self.recent_shell.setVisible(job is None) ) self.recent_panel.selected.connect(self._select_recent_job) self.recent_panel.view_all_requested.connect(self.open_jobs_requested) if self.jobs.active_job: self.active_panel.set_job(self.jobs.active_job) self.mode_selector.currentIndexChanged.connect(self._mode_changed) self._apply_mode_ui(announce=False) self._set_controls_collapsed( bool(self.config.get("command_center_controls_collapsed", False)), persist=False, ) def _build_remote_shortcut(self) -> QFrame: card = _card() card.setMaximumWidth(390) layout = QVBoxLayout(card) layout.setContentsMargins(14, 12, 14, 12) layout.setSpacing(7) layout.addWidget(_card_title("PHONE REMOTE")) self.command_qr = QLabel("Enable phone access in Remote Access to show a QR code.") self.command_qr.setAlignment(Qt.AlignCenter) self.command_qr.setMinimumHeight(120) self.command_qr.setStyleSheet("background: white; color: #183246; border-radius: 6px; padding: 6px;") layout.addWidget(self.command_qr) self.command_remote_link = QPushButton("Open phone remote site") self.command_remote_link.setProperty("chip", True) self.command_remote_link.clicked.connect(self._open_phone_remote) layout.addWidget(self.command_remote_link) self.command_remote_hint = QLabel() self.command_remote_hint.setWordWrap(True) self.command_remote_hint.setProperty("muted", True) layout.addWidget(self.command_remote_hint) self.refresh_remote_access() return card def refresh_remote_access(self) -> None: service = self.remote_service if not hasattr(self, "_remote_link_reader"): self._remote_link_reader = BackgroundRead(self) self._remote_link_reader.ready.connect(self._remote_url_loaded) self._remote_link_reader.request(lambda: (service.settings(), service.phone_test_url()) if service else ({}, ""), force=True) def _remote_url_loaded(self, result: object) -> None: settings, url = result if self.remote_service and settings != self.remote_service.settings(): self.refresh_remote_access() return if getattr(self, "_remote_qr_url", None) == url: return self._remote_qr_url = url self.command_remote_link.setEnabled(bool(url)) if not url: self.command_qr.setPixmap(QPixmap()) self.command_qr.setText("Enable phone access in Remote Access to show a QR code.") self.command_remote_hint.setText("Remote control opens from the same Wi-Fi after phone access is enabled.") return try: import qrcode image = qrcode.make(url).convert("RGB") buffer = BytesIO() image.save(buffer, format="PNG") pixmap = QPixmap() pixmap.loadFromData(buffer.getvalue(), "PNG") self.command_qr.setText("") self.command_qr.setPixmap(pixmap.scaled(180, 180, Qt.KeepAspectRatio, Qt.SmoothTransformation)) self.command_remote_hint.setText(url) except ImportError: self.command_qr.setPixmap(QPixmap()) self.command_qr.setText("QR code support is unavailable.") self.command_remote_hint.setText(url) def _open_phone_remote(self) -> None: if self.remote_service: url = self.remote_service.phone_test_url() if url: QDesktopServices.openUrl(QUrl(url)) def _build_chat(self) -> QFrame: frame = _card() layout = QVBoxLayout(frame) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(0) chat_header = QHBoxLayout() chat_header.setContentsMargins(18, 15, 18, 12) chat_header.addWidget(_card_title("CONVERSATION")) chat_header.addStretch() self.chat_new_button = QPushButton("+ New Chat") self.chat_new_button.setProperty("chip", True) self.chat_new_button.setToolTip("Archive this conversation and start fresh") self.chat_new_button.clicked.connect(self.start_new_chat) chat_header.addWidget(self.chat_new_button) online = QLabel("● LOCAL & PRIVATE") online.setStyleSheet( f"color: {COLORS['green']}; font-size: 10px; font-weight: 700;" ) chat_header.addWidget(online) layout.addLayout(chat_header) self.scroll = QScrollArea() self.scroll.setWidgetResizable(True) self.scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.scroll.viewport().setStyleSheet(f"background: {COLORS['surface']};") self.messages = QWidget() self.messages.setStyleSheet(f"background: {COLORS['surface']};") self.messages_layout = QVBoxLayout(self.messages) self.messages_layout.setContentsMargins(18, 6, 18, 14) self.messages_layout.setSpacing(11) self.messages_layout.addWidget(self._build_welcome()) self.messages_layout.addStretch(1) self.scroll.setWidget(self.messages) layout.addWidget(self.scroll, 1) actions = QWidget() actions_root = QVBoxLayout(actions) actions_root.setContentsMargins(14, 8, 14, 10) actions_root.setSpacing(7) actions_header = QHBoxLayout() actions_header.addWidget(_card_title("SUGGESTED ACTIONS")) actions_header.addStretch() self.controls_toggle = QPushButton("−") self.controls_toggle.setProperty("chip", True) self.controls_toggle.setFixedSize(30, 25) self.controls_toggle.setToolTip("Hide suggested actions and shortcuts") self.controls_toggle.clicked.connect(self._toggle_controls) actions_header.addWidget(self.controls_toggle) actions_root.addLayout(actions_header) self.actions_content = QWidget() actions_content_layout = QVBoxLayout(self.actions_content) actions_content_layout.setContentsMargins(0, 0, 0, 0) actions_content_layout.setSpacing(0) self.actions_grid = QGridLayout() self.actions_grid.setContentsMargins(0, 0, 0, 0) self.actions_grid.setHorizontalSpacing(8) self.actions_grid.setVerticalSpacing(8) actions_content_layout.addLayout(self.actions_grid) self.action_cards: list[QPushButton] = [] action_specs = ( ("Auto-detect settings", "Recommend a safe\ntraining recipe.", self._open_model_assistant), ("Dataset Lab / EVE", "Inspect datasets,\ncaptions, and files.", self.open_dataset_lab_requested.emit), ("Compare runs", "Review experiment\ndifferences side by side.", self.open_experiments_requested.emit), ("Remote control", "Configure browser\nstatus access.", self.open_remote_requested.emit), ("Transcript to dataset", "Convert local video\nspeech into samples.", self.open_dataset_lab_requested.emit), ("Overnight queue", "Plan sequential jobs\nfor unattended runs.", self._open_model_batch_assistant), ("Generate an image", "Create it here from a\ncompleted model.", lambda: self.submit('Generate a DDPM image of "A new sample" for 100 steps on DDIM sampler with aspect ratio 16:9')), ) for title, description, callback in action_specs: button = QPushButton(f"{title}\n{description}") button.setProperty("workflowCard", True) button.setMinimumWidth(0) button.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Preferred) button.setToolTip(f"Start: {title}") button.clicked.connect(lambda _checked=False, action=callback: action()) self.action_cards.append(button) self._reflow_actions(2) actions_root.addWidget(self.actions_content) layout.addWidget(actions) self.utilities = QWidget() utilities_layout = QHBoxLayout(self.utilities) utilities_layout.setContentsMargins(14, 0, 14, 7) utilities_layout.setSpacing(7) for title, callback in ( ("Create a model…", self._open_model_assistant), ("Create model batch…", self._open_model_batch_assistant), ("Fine-tune…", self._open_fine_tune_assistant), ("Collect video…", self._open_video_dataset_assistant), ): button = QPushButton(title) button.setProperty("chip", True) button.clicked.connect(callback) utilities_layout.addWidget(button) utilities_layout.addStretch() layout.addWidget(self.utilities) composer = QFrame() composer.setStyleSheet( f"border-top: 1px solid {COLORS['border']}; background: #071019;" ) composer_layout = QVBoxLayout(composer) composer_layout.setContentsMargins(15, 13, 15, 13) composer_layout.setSpacing(7) self.reference_chip = QFrame() self.reference_chip.setProperty("innerCard", True) reference_layout = QHBoxLayout(self.reference_chip) reference_layout.setContentsMargins(7, 5, 7, 5) reference_layout.setSpacing(7) self.reference_thumbnail = QLabel() self.reference_thumbnail.setFixedSize(42, 42) self.reference_thumbnail.setAlignment(Qt.AlignCenter) self.reference_name = QLabel() self.reference_name.setProperty("muted", True) remove_reference = QPushButton("×") remove_reference.setFixedSize(26, 26) remove_reference.setToolTip("Remove reference image") remove_reference.clicked.connect(self._clear_prompt_reference) reference_layout.addWidget(self.reference_thumbnail) reference_layout.addWidget(self.reference_name) reference_layout.addStretch() reference_layout.addWidget(remove_reference) self.reference_chip.hide() composer_layout.addWidget(self.reference_chip) input_row = QHBoxLayout() input_row.setSpacing(10) self.add_reference_button = QPushButton("+") self.add_reference_button.setFixedSize(34, 34) self.add_reference_button.setToolTip("Attach a reference image") self.add_reference_button.clicked.connect(self._choose_prompt_reference) self.prompt = PromptEdit() self.prompt.setPlaceholderText( "Tell ADAM what you want to accomplish… (Shift+Enter for a new line)" ) self.prompt.setMinimumHeight(66) self.prompt.send_requested.connect(self._submit_prompt) prompt_column = QVBoxLayout() prompt_column.setContentsMargins(0, 0, 0, 0) prompt_column.setSpacing(0) prompt_column.addWidget(self.prompt) prompt_column.addWidget(PromptResizeHandle(self.prompt)) self.send_button = QPushButton("Plan request →") self.send_button.setProperty("primary", True) self.send_button.setStyleSheet( f"background-color: {COLORS['blue']}; color: #00101b; " f"border: 1px solid {COLORS['blue_2']}; font-weight: 700;" ) self.send_button.setFixedHeight(43) self.send_button.clicked.connect(self._submit_prompt) input_row.addWidget(self.add_reference_button, 0, Qt.AlignVCenter) input_row.addLayout(prompt_column, 1) input_row.addWidget(self.send_button, 0, Qt.AlignVCenter) composer_layout.addLayout(input_row) layout.addWidget(composer) return frame def _update_right_panel_width(self, _collapsed: bool = False) -> None: shells = (self.plan_shell, self.recent_shell, self.active_shell, self.remote_shell) all_collapsed = all(shell.collapsed for shell in shells) if all_collapsed: self.right_panels.setFixedWidth(45) else: self.right_panels.setMinimumWidth(330) self.right_panels.setMaximumWidth(390) def _build_welcome(self) -> QWidget: welcome = QWidget() row = QHBoxLayout(welcome) row.setContentsMargins(0, 0, 0, 0) row.setSpacing(20) greeting = ChatBubble( "Good to see you. I can coordinate a LoRA pipeline, prepare datasets, " "generate previews, or inspect this system. I’ll always show the plan " "before collection or training begins.", label="ADAM · READY", ) greeting.setMaximumWidth(300) self.welcome_greeting = greeting greeting.hide() row.addWidget(greeting, 0, Qt.AlignTop) brand = QWidget() self.welcome_brand = brand brand_layout = QHBoxLayout(brand) brand_layout.setContentsMargins(8, 2, 8, 2) brand_layout.setSpacing(16) logo = QLabel() pixmap = QPixmap(str(self.root_path / "assets" / "adam_atom.png")) if not pixmap.isNull(): logo.setPixmap( pixmap.scaled(138, 138, Qt.KeepAspectRatio, Qt.SmoothTransformation) ) logo.setFixedSize(142, 142) copy = QVBoxLayout() copy.setSpacing(4) name = QLabel("A D A M") name.setStyleSheet("font-size: 31px; font-weight: 650; letter-spacing: 6px;") meaning = QLabel("AI DEVELOPMENT AND\nAUTOMATION MANAGER") meaning.setStyleSheet("font-size: 11px; font-weight: 600; letter-spacing: 2px;") slogan = QLabel("Plan it. Prepare it. Train it.") slogan.setProperty("muted", True) slogan.setStyleSheet("font-size: 12px; padding-top: 8px;") copy.addStretch() copy.addWidget(name) copy.addWidget(meaning) copy.addWidget(slogan) copy.addStretch() brand_layout.addWidget(logo) brand_layout.addLayout(copy) row.addWidget(brand, 1) return welcome def start_new_chat(self, _checked: bool = False, *, archive: bool = True) -> None: busy = bool( (self._planning_worker and self._planning_worker.isRunning()) or (self._chat_worker and self._chat_worker.isRunning()) ) if busy: QMessageBox.information( self, "ADAM is still working", "Wait for the current response to finish before starting a new chat.", ) return if archive and self.history_store.save_conversation( self._conversation_entries, str(self.mode_selector.currentData()) ): self.history_changed.emit() self._clear_message_canvas() self._chat_history = [] self._conversation_entries = [] self._generation_cards = {} self._planning_bubble = None self._streamed_text = "" self._chat_request = "" self._clear_prompt_reference() self.prompt.clear() self.messages_layout.insertWidget(0, self._build_welcome()) self.prompt.setFocus() def open_conversation(self, conversation: dict) -> None: self.start_new_chat(archive=True) mode = str(conversation.get("mode", "trainer")) mode_index = self.mode_selector.findData(mode) if mode_index >= 0: self.mode_selector.blockSignals(True) self.mode_selector.setCurrentIndex(mode_index) self.mode_selector.blockSignals(False) self._apply_mode_ui(announce=False) for entry in conversation.get("entries", []): self.add_message( str(entry.get("text", "")), user=bool(entry.get("user", False)), label=str(entry.get("label", "YOU" if entry.get("user") else "ADAM")), record=True, ) self._chat_history = [ {"role": "user" if entry.get("user") else "assistant", "content": str(entry.get("text", ""))} for entry in conversation.get("entries", []) if str(entry.get("text", "")).strip() ][-20:] def _clear_message_canvas(self) -> None: while self.messages_layout.count() > 1: item = self.messages_layout.takeAt(0) if item.widget(): item.widget().deleteLater() def _reflow_actions(self, columns: int) -> None: while self.actions_grid.count(): self.actions_grid.takeAt(0) for index, button in enumerate(self.action_cards): self.actions_grid.addWidget(button, index // columns, index % columns) def _toggle_controls(self) -> None: self._set_controls_collapsed( self.actions_content.isVisible(), persist=True ) def _set_controls_collapsed(self, collapsed: bool, *, persist: bool) -> None: self.actions_content.setVisible(not collapsed) self.utilities.setVisible(not collapsed) self.controls_toggle.setText("+" if collapsed else "−") self.controls_toggle.setToolTip( "Show suggested actions and shortcuts" if collapsed else "Hide suggested actions and shortcuts" ) if persist: self.config.update({"command_center_controls_collapsed": collapsed}) def resizeEvent(self, event) -> None: super().resizeEvent(event) if hasattr(self, "actions_grid"): available = max(1, self.width() - 390) columns = 7 if available >= 1120 else 4 if available >= 680 else 2 self.welcome_greeting.setVisible(available >= 650) if getattr(self, "_action_columns", None) != columns: self._action_columns = columns self._reflow_actions(columns) def _open_model_assistant(self) -> None: if self.mode_selector.currentData() != "trainer": self.mode_selector.setCurrentIndex(self.mode_selector.findData("trainer")) dialog = ModelCreationDialog(self.planner, self.config, self) if dialog.exec() == QDialog.Accepted and dialog.requests: self._pending_schedule_for = dialog.scheduled_for if len(dialog.requests) == 1: self.submit(dialog.requests[0]) else: self.submit_training_batch(dialog.requests) def _open_model_batch_assistant(self) -> None: if self.mode_selector.currentData() != "trainer": self.mode_selector.setCurrentIndex(self.mode_selector.findData("trainer")) dialog = ModelCreationDialog(self.planner, self.config, self) dialog.setWindowTitle("Model Batch Builder") QTimer.singleShot(0, dialog._bulk_add_models) if dialog.exec() == QDialog.Accepted and dialog.requests: self._pending_schedule_for = dialog.scheduled_for self.submit_training_batch(dialog.requests) def submit_training_batch(self, requests: list[str]) -> None: if self._planning_worker and self._planning_worker.isRunning(): self.add_message("I’m still interpreting the previous request.", label="ADAM · PLANNING") return self.add_message( f"Create a sequential training batch with {len(requests)} models.", user=True ) self._streamed_text = "" self._planning_bubble = self.add_message("Planning the model batch…", label="ADAM · PLANNING") self._planning_worker = BatchPlanningWorker(self.planner, requests) self._planning_worker.chunk.connect(self._planning_chunk) self._planning_worker.planned.connect(self._planning_finished) self._planning_worker.failed.connect(self._planning_failed) self._planning_worker.finished.connect(self._planning_worker_finished) self.send_button.setEnabled(False) self.mode_selector.setEnabled(False) self.send_button.setText("Planning…") self._planning_worker.start() def _open_fine_tune_assistant(self) -> None: if self.mode_selector.currentData() != "trainer": self.mode_selector.setCurrentIndex(self.mode_selector.findData("trainer")) dialog = FineTuneDialog(self.planner, self) if dialog.exec() == QDialog.Accepted and dialog.request: self.submit(dialog.request) def _open_video_dataset_assistant(self) -> None: if self.mode_selector.currentData() != "trainer": self.mode_selector.setCurrentIndex(self.mode_selector.findData("trainer")) dialog = VideoDatasetDialog(self) if dialog.exec() == QDialog.Accepted and dialog.request: self.submit(dialog.request) def add_message( self, text: str, *, user: bool = False, label: str = "", record: bool = True ) -> ChatBubble: wrapper = QWidget() wrapper_layout = QHBoxLayout(wrapper) wrapper_layout.setContentsMargins(0, 0, 0, 0) bubble = ChatBubble(text, user=user, label=label) if user: wrapper_layout.addStretch(1) wrapper_layout.addWidget(bubble) else: wrapper_layout.addWidget(bubble) wrapper_layout.addStretch(1) self.messages_layout.insertWidget(self.messages_layout.count() - 1, wrapper) if record and text.strip() and text.strip() not in {"Thinking…", "Planning the model batch…"}: self._conversation_entries.append( {"text": text, "user": user, "label": label or ("YOU" if user else "ADAM")} ) QTimer.singleShot( 0, lambda: self.scroll.verticalScrollBar().setValue( self.scroll.verticalScrollBar().maximum() ), ) return bubble def _submit_prompt(self) -> None: text = self.prompt.toPlainText().strip() if text: self.prompt.clear() self.submit(text) def _choose_prompt_reference(self) -> None: path, _ = QFileDialog.getOpenFileName( self, "Choose reference image", self._prompt_reference_image, "Images (*.png *.jpg *.jpeg *.webp *.bmp)", ) if not path: return self._prompt_reference_image = path pixmap = QPixmap(path) self.reference_thumbnail.setPixmap( pixmap.scaled(42, 42, Qt.KeepAspectRatio, Qt.SmoothTransformation) ) self.reference_name.setText(Path(path).name) self.reference_chip.show() def _clear_prompt_reference(self) -> None: self._prompt_reference_image = "" self.reference_thumbnail.clear() self.reference_name.clear() self.reference_chip.hide() def submit(self, request: str) -> None: planning_busy = self._planning_worker and self._planning_worker.isRunning() chatting_busy = self._chat_worker and self._chat_worker.isRunning() if planning_busy or chatting_busy: self.add_message( "I’m still interpreting the previous request. The rest of ADAM remains usable.", label="ADAM · PLANNING", ) return attached_image = self._prompt_reference_image visible_request = ( f"{request}\n\n[Attached image: {Path(attached_image).name}]" if attached_image and self.mode_selector.currentData() == "chat" else request ) self.add_message(visible_request, user=True) generation_request = parse_chat_generation_request(request) # Metadata is a structured generation command, even if the Command # Center was left in Chat mode when it was pasted. if generation_request and generation_request.is_pasted_metadata: if self._prompt_reference_image: generation_request = replace( generation_request, reference_image=self._prompt_reference_image ) self._start_generation(generation_request) return if self.mode_selector.currentData() == "chat": if is_pending_job_confirmation_request(request): pending = self.selected_job if pending and pending.status == JobStatus.AWAITING_CONFIRMATION: self.jobs.confirm(pending.id) self.add_message( f"Approved plan {pending.plan.id.upper()}. ADAM has queued the registered work.", label="ADAM · PLAN APPROVED", ) else: self.add_message( "There is no reviewed plan waiting for approval. Ask me to create the training plan first, then approve the plan ADAM shows you.", label="ADAM · NEEDS A PLAN", ) if attached_image: self._clear_prompt_reference() return if is_explicit_action_request(request): planning_request, subject = self._expand_chat_subject_reference(request) if attached_image: self._clear_prompt_reference() self._start_planning( planning_request, ( f"Using the earlier subject idea: {subject}\n\n" "Checking this request against ADAM’s registered workflows…" if subject else "Checking this request against ADAM’s registered workflows…" ), ) return self._start_chat(request, [attached_image] if attached_image else []) if attached_image: self._clear_prompt_reference() return if generation_request: if self._prompt_reference_image: generation_request = replace( generation_request, reference_image=self._prompt_reference_image ) self._start_generation(generation_request) return assignments = self.tool_folders.parse_assignments(request) if assignments: statuses = self.tool_folders.update(assignments) lines = [] valid_count = 0 for status in statuses.values(): if status.valid: valid_count += 1 entries = ", ".join(status.entry_points) lines.append(f"✓ {status.name}: connected ({entries})") else: lines.append(f"✕ {status.name}: {status.message}") self.add_message( "I saved the tool folder configuration.\n\n" + "\n".join(lines), label=( "ADAM · TOOLS CONNECTED" if valid_count == len(statuses) else "ADAM · FOLDER CHECK" ), ) self.tool_folders_changed.emit() return self._start_planning(request) def _expand_chat_subject_reference(self, request: str) -> tuple[str, str | None]: for item in reversed(self._chat_history): if item.get("role") != "assistant": continue expanded, subject = expand_subject_reference(request, item.get("content", "")) if subject: return expanded, subject return request, None def _start_planning(self, request: str, message: str = "Thinking…") -> None: self._streamed_text = "" self._planning_bubble = self.add_message(message, label="ADAM · PLANNING") self._planning_worker = PlanningWorker(self.planner, request) self._planning_worker.chunk.connect(self._planning_chunk) self._planning_worker.planned.connect(self._planning_finished) self._planning_worker.failed.connect(self._planning_failed) self._planning_worker.finished.connect(self._planning_worker_finished) self.send_button.setEnabled(False) self.mode_selector.setEnabled(False) self.send_button.setText("Planning…") self._planning_worker.start() def _generation_model_is_ready(self, asset) -> 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 _start_generation(self, parsed: ChatGenerationRequest) -> None: asset_refresh(self.planner.assets, self.config).request() tools = generation_tools(self.planner.registry) 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 ) # Positive Prompt is the explicit Stable Diffusion signal. A named LoRA # still remains active when a base checkpoint is supplied alongside it. 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", "pixelrow": "pixelrow_generator", "inrflow": "inrflow_generator", }.get(parsed.provider_hint, "") if stable_diffusion_request and parsed.provider_hint not in {"ddpm", "flow", "inrflow", "pixelrow"}: 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: self.add_message( f"The requested {parsed.provider_hint.upper()} image generator is not currently available.", label="ADAM · GENERATION NEEDS DETAILS", ) return candidates = [ asset for asset in self.planner.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( asset.trainer in item.model_trainers for item in ([preferred_tool] if preferred_tool else tools) if item is not None ) 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 = next( ( asset for asset in candidates if parsed.metadata_model_path and Path(asset.path).resolve() == Path(parsed.metadata_model_path).expanduser().resolve() ), None, ) if model is None and parsed.metadata_model_path: direct_path = Path(parsed.metadata_model_path).expanduser() if direct_path.is_file() and direct_path.suffix.casefold() == ".safetensors": model = Asset( id="pasted-metadata", kind="model", name=direct_path.stem, path=str(direct_path.resolve()), trainer="lora", ) if model is None: 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: # If there is no matching unconditional model, the subject can still # be rendered as a normal Stable Diffusion prompt. base_only = True preferred_tool = next( (item for item in tools if item.id == "lora_generator"), None ) 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 example_text = f" Available examples: {', '.join(examples)}." if examples else "" self.add_message( f"I couldn’t find a completed image model{detail}, so I did not substitute the model selected in Generations.{example_text} Try: Generate an image using model “Model Name”.", label="ADAM · GENERATION NEEDS MODEL", ) return tool = next( ( item for item in ([preferred_tool] if preferred_tool else tools) if item is not None and (base_only or model.trainer in item.model_trainers) ), None, ) if tool is None: self.add_message( "The matching model does not have an available image generator.", label="ADAM · GENERATION UNAVAILABLE", ) return if parsed.reference_image and "reference_image" not in tool.capabilities: self.add_message( f"{tool.name} does not support reference-image conditioning. Remove the attachment or choose LoRA/Stable Diffusion or DDPM.", label="ADAM · REFERENCE IMAGE UNSUPPORTED", ) return 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 = {} 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_assets = [ asset for asset in self.planner.assets.assets if asset.kind == "base_model" and Path(asset.path).exists() ] base_model_path = "" if parsed.metadata_base_model_path and Path(parsed.metadata_base_model_path).expanduser().is_file(): base_model_path = str(Path(parsed.metadata_base_model_path).expanduser().resolve()) elif 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: base_model_path = scored_bases[0][1].path if not base_model_path: self.add_message( f"I couldn’t find a Stable Diffusion base model matching “{parsed.base_model_query}”.", label="ADAM · LORA NEEDS BASE MODEL", ) return # A fresh Command Center image request should have a dependable SDXL # fallback instead of relying on whichever model the external trainer # happened to use last. if not base_model_path: 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: base_model_path = preferred_base.path if not base_model_path: selected_base = str(saved_generation.get("base_model_path", "")) if selected_base and Path(selected_base).expanduser().exists(): base_model_path = selected_base if not base_model_path: 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(): base_model_path = str(configured_path.resolve()) except (OSError, ValueError, TypeError, json.JSONDecodeError): pass if not base_model_path and len(base_assets) == 1: base_model_path = base_assets[0].path if not base_model_path: names = ", ".join(asset.name for asset in base_assets[:4]) available = f" Available base models: {names}." if names else "" self.add_message( "LoRA generation also needs a Stable Diffusion base model. Put one in “LoRA StableDiffusionModels Here”, or select one in the Generations tab." + available, label="ADAM · LORA NEEDS BASE MODEL", ) return extra_arguments = { "negative_prompt": parsed.negative_prompt or str(saved_generation.get("negative_prompt", "")), "base_model_path": base_model_path, "width": parsed.width or 0, "height": parsed.height or 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)), } plan = 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, ) job = self.jobs.submit(plan) if parsed.reference_image: self._clear_prompt_reference() self.selected_job = job self.plan_panel.set_job(job) card = GenerationChatCard(job, self.root_path / "assets" / "adam_atom.png") card.cancel_requested.connect(self.jobs.cancel) card.open_requested.connect( lambda path: QDesktopServices.openUrl(QUrl.fromLocalFile(path)) if path else None ) wrapper = QWidget() wrapper_layout = QHBoxLayout(wrapper) wrapper_layout.setContentsMargins(0, 0, 0, 0) wrapper_layout.addWidget(card) wrapper_layout.addStretch(1) self.messages_layout.insertWidget(self.messages_layout.count() - 1, wrapper) self._generation_cards[job.id] = card QTimer.singleShot(0, lambda: self.scroll.verticalScrollBar().setValue(self.scroll.verticalScrollBar().maximum())) def _planning_chunk(self, chunk: str) -> None: self._streamed_text += chunk if self._planning_bubble: self._planning_bubble.set_label("ADAM") self._planning_bubble.set_text(self._streamed_text) def _planning_failed(self, message: str) -> None: self._pending_schedule_for = None if self._planning_bubble: self._planning_bubble.set_label("ADAM · NEEDS INPUT") self._planning_bubble.set_text(message) self._conversation_entries.append({"text": message, "user": False, "label": "ADAM · NEEDS INPUT"}) def _planning_worker_finished(self) -> None: self.send_button.setEnabled(True) self.mode_selector.setEnabled(True) self._apply_mode_ui(announce=False) if self._planning_worker: self._planning_worker.deleteLater() self._planning_worker = None def _start_chat(self, request: str, image_paths: list[str] | None = None) -> None: self._chat_request = request self._streamed_text = "" self._planning_bubble = self.add_message("Thinking…", label="ADAM") self._chat_worker = ChatWorker( self.planner, request, list(self._chat_history), image_paths ) self._chat_worker.chunk.connect(self._planning_chunk) self._chat_worker.answered.connect(self._chat_finished) self._chat_worker.failed.connect(self._chat_failed) self._chat_worker.finished.connect(self._chat_worker_finished) self.send_button.setEnabled(False) self.mode_selector.setEnabled(False) self.send_button.setText("Thinking…") self._chat_worker.start() def _chat_finished(self, response: str) -> None: if self._planning_bubble and not self._streamed_text: self._type_into(self._planning_bubble, response) self._chat_history.extend( [ {"role": "user", "content": self._chat_request}, {"role": "assistant", "content": response}, ] ) self._chat_history = self._chat_history[-20:] final_response = self._streamed_text.strip() or response.strip() if final_response: self._conversation_entries.append( {"text": final_response, "user": False, "label": "ADAM"} ) def _chat_failed(self, message: str) -> None: if self._planning_bubble: self._planning_bubble.set_label("ADAM · CHAT UNAVAILABLE") self._planning_bubble.set_text(message) self._conversation_entries.append({"text": message, "user": False, "label": "ADAM · CHAT UNAVAILABLE"}) def _chat_worker_finished(self) -> None: self.send_button.setEnabled(True) self.mode_selector.setEnabled(True) self._apply_mode_ui(announce=False) if self._chat_worker: self._chat_worker.deleteLater() self._chat_worker = None def _mode_changed(self) -> None: self.config.update( {"command_center_mode": str(self.mode_selector.currentData())} ) self._apply_mode_ui(announce=True) def _apply_mode_ui(self, *, announce: bool) -> None: chat_mode = self.mode_selector.currentData() == "chat" self.send_button.setText("Send message →" if chat_mode else "Plan request →") self.prompt.setPlaceholderText( "Ask ADAM anything, or attach an image to discuss…" if chat_mode else "Tell ADAM what you want to accomplish… (Shift+Enter for a new line)" ) self.add_reference_button.setToolTip( "Attach an image for ADAM to describe or caption" if chat_mode else "Attach a reference image for image generation" ) if announce: self.add_message( ( "Chat Mode is active. I can answer questions and discuss your models " "and workflows through Ollama. Clear requests to train, collect, generate, " "or start work are checked by ADAM’s planner and shown as a reviewable plan." if chat_mode else "Trainer Mode is active. I can now build safe plans and run " "registered workflows after the required approval." ), label="ADAM · MODE", ) def _planning_finished(self, plan) -> None: self.refresh_provider_badge() final_response = self._streamed_text.strip() or str(plan.summary).strip() if final_response: self._conversation_entries.append( {"text": final_response, "user": False, "label": "ADAM"} ) if not plan.steps: self._pending_schedule_for = None label = { "Conversation": "ADAM", "DDPM training": "ADAM · NEEDS DETAILS", "Flow Matching training": "ADAM · NEEDS DETAILS", "Safety refusal": "ADAM · SAFETY", }.get(plan.project_name, "ADAM · NO ACTION TAKEN") if self._planning_bubble: self._planning_bubble.set_label(label) if not self._streamed_text: self._type_into(self._planning_bubble, plan.summary) return append_preflight_summary(plan, self.config) scheduled_for = self._pending_schedule_for self._pending_schedule_for = None job = self.jobs.submit(plan, scheduled_for=scheduled_for) self.selected_job = job self.plan_panel.set_job(job) trusted_start = self._can_trusted_start(plan) if trusted_start: self.jobs.confirm(job.id) state = ( "Trusted automation is enabled for this registered dataset-to-DDPM workflow, so it has started." if trusted_start else "Review the plan at right. I’m waiting for your approval." if plan.requires_confirmation else "The plan is scheduled and will start automatically when its time and the training slot are available." if job.status == JobStatus.SCHEDULED else "The plan uses safe, read-only or output-only tools, so it has been queued." ) text = f"{plan.summary}\n\n{len(plan.steps)} registered steps · {state}" if self._planning_bubble: self._planning_bubble.set_label(f"ADAM · PLAN {plan.id.upper()}") self._type_into(self._planning_bubble, text) def _type_into(self, bubble: ChatBubble, text: str) -> None: bubble.set_text("") position = {"value": 0} timer = QTimer(bubble) timer.setInterval(18) def advance() -> None: position["value"] = min(len(text), position["value"] + 8) bubble.set_text(text[: position["value"]]) if position["value"] >= len(text): timer.stop() timer.timeout.connect(advance) timer.start() def _can_trusted_start(self, plan) -> bool: # Trusted dataset-to-DDPM automation is only for ordinary reviewed plans. # ORION warnings deliberately restore the human approval gate. if getattr(plan, "orion_review", {}).get("level") == "warning": return False return bool( plan.requires_confirmation and self.config.get("trusted_dataset_ddpm_automation") and plan.steps and {step.tool_id for step in plan.steps}.issubset({"dataset_collector", "ddpm_trainer"}) ) def refresh_provider_badge(self) -> None: provider = str(self.config.get("provider", "ollama")).upper() if provider == "OLLAMA": self.provider_badge.setText(f"● OLLAMA · {self.config.get('ollama_model')}") else: self.provider_badge.setText("● SAFE PLANNER · MANUAL") def _select_recent_job(self, job_id: str) -> None: try: job = self.jobs.get(job_id) except KeyError: return self.selected_job = job self.plan_panel.set_job(job) def _job_created(self, job: Job) -> None: self.selected_job = job self.plan_panel.set_job(job) if job.status == JobStatus.AWAITING_CONFIRMATION: self.plan_shell.set_collapsed(False, persist=False) def update_snapshot(self, snapshot: SystemSnapshot) -> None: self.latest_snapshot = snapshot self.system_summary.update_snapshot(snapshot) def _job_updated(self, job: Job) -> None: self.recent_panel.refresh() generation_card = self._generation_cards.get(job.id) if generation_card: generation_card.update_job(job) if self.selected_job and job.id == self.selected_job.id: self.selected_job = job self.plan_panel.set_job(job) if self.jobs.active_job and job.id == self.jobs.active_job.id: self.active_panel.set_job(job) terminal = { JobStatus.FINISHED, JobStatus.FAILED, JobStatus.CANCELLED, JobStatus.INTERRUPTED, } marker = (job.id, job.status) if generation_card and job.status in terminal: self._announced.add(marker) return if job.status in terminal and marker not in self._announced: self._announced.add(marker) if job.status == JobStatus.FINISHED: demo_tools = [] real_tools = [] for step in job.plan.steps: try: if self.jobs.executor.registry.get(step.tool_id).demo: demo_tools.append(step.tool_id) else: real_tools.append(step.tool_id) except Exception: pass if demo_tools and real_tools: message = ( f"{job.plan.project_name} completed with a mixture of real and " "demo steps. The real collector output is available in Jobs; " "simulated preparation/training steps did not create a model." ) label = "ADAM · PARTIAL REAL WORKFLOW" elif demo_tools: message = ( f"{job.plan.project_name} demo simulation completed. No real " "images were downloaded and no model was trained. Review the " "generated manifests in Jobs." ) label = "ADAM · DEMO COMPLETE" else: message = ( f"{job.plan.project_name} completed successfully. " "The job record and full logs are available in Jobs." ) label = "ADAM · COMPLETE" recommendation = completion_recommendation(job.plan) if recommendation: message += "\n\n" + recommendation elif job.status == JobStatus.FAILED: message = f"{job.plan.project_name} failed safely: {job.error}" label = "ADAM · ERROR" else: message = f"{job.plan.project_name} was cancelled." label = "ADAM · STOPPED" self.add_message(message, label=label) @staticmethod def open_output(path: str) -> None: output = Path(path) if output.exists(): QDesktopServices.openUrl(QUrl.fromLocalFile(str(output))) class JobsPage(QWidget): def __init__(self, jobs: JobManager) -> None: super().__init__() self.jobs = jobs self.selected_job_id: str | None = None self._refresh_index = 0 self._refresh_token = 0 self._show_full_log = False self._log_job_id: str | None = None root = QVBoxLayout(self) root.setContentsMargins(24, 20, 24, 17) root.setSpacing(12) root.addWidget( _page_header( "Jobs & history", "Every workflow has durable state, timestamps, logs, progress, and an output location.", ) ) body = QHBoxLayout() body.setSpacing(12) self.table = QTableWidget(0, 6) self.table.setHorizontalHeaderLabels( ["JOB", "PROJECT", "STATUS", "PROGRESS", "CREATED", "OUTPUT"] ) self.table.setAlternatingRowColors(True) self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setSelectionMode(QAbstractItemView.SingleSelection) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.table.verticalHeader().hide() header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeToContents) header.setSectionResizeMode(1, QHeaderView.Stretch) header.setSectionResizeMode(2, QHeaderView.ResizeToContents) header.setSectionResizeMode(3, QHeaderView.ResizeToContents) header.setSectionResizeMode(4, QHeaderView.ResizeToContents) header.setSectionResizeMode(5, QHeaderView.ResizeToContents) self.table.itemSelectionChanged.connect(self._selection_changed) body.addWidget(self.table, 3) details = _card() details.setMinimumWidth(350) details_layout = QVBoxLayout(details) details_layout.setContentsMargins(17, 16, 17, 16) self.detail_title = QLabel("Select a job") self.detail_title.setStyleSheet("font-size: 17px; font-weight: 650;") self.detail_status = QLabel("No job selected") self.detail_status.setProperty("muted", True) self.agent_status = QLabel("ORION, ATLAS, and NOVA reports will appear here.") self.agent_status.setWordWrap(True) self.agent_status.setProperty("muted", True) self.agent_status.setStyleSheet("font-size: 10px;") self.log_view = QPlainTextEdit() self.log_view.setReadOnly(True) self.log_view.setPlaceholderText("Job logs will appear here.") details_layout.addWidget(_card_title("JOB DETAILS")) details_layout.addWidget(self.detail_title) details_layout.addWidget(self.detail_status) details_layout.addWidget(self.agent_status) details_layout.addWidget(self.log_view, 1) # Two rows keep every action available when the Jobs panel is narrow. actions = QGridLayout() actions.setHorizontalSpacing(7) actions.setVerticalSpacing(7) self.pause_button = QPushButton("Pause") self.adjust_button = QPushButton("Adjust after epoch") self.stop_button = QPushButton("Stop") self.stop_button.setProperty("danger", True) self.end_task_button = QPushButton("End task") self.end_task_button.setProperty("danger", True) self.output_button = QPushButton("Open output") self.retry_button = QPushButton("Retry plan") self.approve_button = QPushButton("Approve & run") self.approve_button.setProperty("primary", True) self.approve_button.setToolTip("Approve this waiting plan and start it when its place in the queue is reached.") self.approve_button.setVisible(False) self.export_button = QPushButton("Export log") self.full_log_button = QPushButton("Show full log") self.export_all_button = QPushButton("Export all") self.clear_terminal_button = QPushButton("Remove completed / failed") self.clear_terminal_button.setProperty("danger", True) actions.addWidget(self.pause_button, 0, 0) actions.addWidget(self.adjust_button, 0, 1) actions.addWidget(self.stop_button, 0, 2) actions.addWidget(self.end_task_button, 0, 3) actions.addWidget(self.retry_button, 0, 4) actions.addWidget(self.export_button, 1, 0) actions.addWidget(self.full_log_button, 1, 1) actions.addWidget(self.export_all_button, 1, 2) actions.addWidget(self.clear_terminal_button, 1, 3) actions.addWidget(self.output_button, 1, 4) actions.addWidget(self.approve_button, 2, 0, 1, 5) details_layout.addLayout(actions) body.addWidget(details, 2) root.addLayout(body, 1) self.pause_button.clicked.connect(self._pause_or_resume) self.adjust_button.clicked.connect(self._adjust_after_epoch) self.stop_button.clicked.connect(self._stop) self.end_task_button.clicked.connect(self._end_task) self.output_button.clicked.connect(self._open_output) self.retry_button.clicked.connect(self._retry) self.approve_button.clicked.connect(self._approve) self.export_button.clicked.connect(self._export_log) self.full_log_button.clicked.connect(self._show_entire_log) self.export_all_button.clicked.connect(self._export_all) self.clear_terminal_button.clicked.connect(self._remove_completed_or_failed) self.jobs.job_created.connect(lambda _job: self.refresh()) self.jobs.job_updated.connect(self._on_job_updated) self.refresh() def refresh(self) -> None: self._refresh_token += 1 token = self._refresh_token self._refresh_index = 0 # Do not suppress painting while history rows stream in. Large histories # used to leave an apparently blank, but clickable, table until its final # row had been populated. self.table.setRowCount(len(self.jobs.jobs)) QTimer.singleShot(0, lambda: self._refresh_next_row(token)) def _refresh_next_row(self, token: int) -> None: """Populate one history row per event-loop turn to avoid a tab-switch hitch.""" if token != self._refresh_token: return if self._refresh_index >= len(self.jobs.jobs): if not self.selected_job_id and self.jobs.jobs: self.table.selectRow(0) return row = self._refresh_index job = self.jobs.jobs[row] selected = self.selected_job_id try: self._populate_job_row(row, job) if selected and job.id == selected: self.table.selectRow(row) finally: self._refresh_index += 1 QTimer.singleShot(0, lambda: self._refresh_next_row(token)) def _populate_job_row(self, row: int, job: Job) -> None: demo_steps = sum( self._tool_is_demo(step.tool_id) for step in job.plan.steps ) is_demo = demo_steps == len(job.plan.steps) and demo_steps > 0 is_mixed = 0 < demo_steps < len(job.plan.steps) display_status = job.status.value if job.status == JobStatus.FINISHED and is_demo: display_status = "Finished · demo" elif job.status == JobStatus.FINISHED and is_mixed: display_status = "Finished · mixed" values = [ job.id, job.plan.project_name, display_status, f"{job.progress}%", self._format_time(job.created_at), "Ready" if job.output_folder else "—", ] for column, value in enumerate(values): item = QTableWidgetItem(value) if column in (0, 2, 3, 4, 5): item.setTextAlignment(Qt.AlignCenter) self.table.setItem(row, column, item) def _selection_changed(self) -> None: rows = self.table.selectionModel().selectedRows() if not rows: return job_id = self.table.item(rows[0].row(), 0).text() if job_id != self._log_job_id: self._show_full_log = False self.selected_job_id = job_id self._show_job(self.jobs.get(job_id)) def _show_job(self, job: Job) -> None: self.detail_title.setText(job.plan.project_name) step = "" if 0 <= job.current_step < len(job.plan.steps): step = f" · {job.plan.steps[job.current_step].title}" self.detail_status.setText( f"{job.status.value} · {job.progress}% · {len(job.plan.steps)} steps{step}" + ( f" · starts {self._format_time(job.scheduled_for)}" if job.status == JobStatus.SCHEDULED and job.scheduled_for else "" ) ) reports = [] if job.plan.orion_review: reports.append(f"ORION · {job.plan.orion_review.get('headline', 'Reviewed')}") if job.atlas_report: reports.append(f"ATLAS · {job.atlas_report.get('severity', 'watching').upper()} — {job.atlas_report.get('message', '')}") latest_nova = job.nova_report.get("latest", {}) if latest_nova: reports.append(f"NOVA · {latest_nova.get('status', 'Reviewed')} — {latest_nova.get('summary', '')}") self.agent_status.setText("\n".join(reports) or "No agent reports are available for this job yet.") self._log_job_id = job.id visible_logs = job.logs if len(job.logs) > 300 and not self._show_full_log: visible_logs = [ f"Showing the newest 300 of {len(job.logs)} lines. " "Use “Show full log” to load the rest.", "", *job.logs[-300:], ] self.log_view.setPlainText("\n".join(visible_logs)) self.log_view.verticalScrollBar().setValue( self.log_view.verticalScrollBar().maximum() ) running = job.status in {JobStatus.RUNNING, JobStatus.PAUSED} self.pause_button.setEnabled(running) active_ddpm = ( running and 0 <= job.current_step < len(job.plan.steps) and job.plan.steps[job.current_step].tool_id == "ddpm_trainer" ) self.adjust_button.setEnabled(active_ddpm) self.pause_button.setText("Resume" if job.status == JobStatus.PAUSED else "Pause") self.stop_button.setEnabled( running or job.status in { JobStatus.SCHEDULED, JobStatus.QUEUED, JobStatus.AWAITING_CONFIRMATION, } ) self.end_task_button.setEnabled(job.status == JobStatus.INTERRUPTED) self.output_button.setEnabled(bool(job.output_folder)) awaiting_confirmation = job.status == JobStatus.AWAITING_CONFIRMATION vram_failure = job.status == JobStatus.FAILED and self.jobs._looks_like_vram_failure(job) self.retry_button.setText( "Retry with safer batch" if vram_failure else "Retry plan" ) self.retry_button.setEnabled( job.status in { JobStatus.FINISHED, JobStatus.FAILED, JobStatus.CANCELLED, JobStatus.INTERRUPTED, } ) self.approve_button.setVisible(awaiting_confirmation) self.approve_button.setEnabled(awaiting_confirmation) self.export_button.setEnabled(bool(job.logs)) self.full_log_button.setVisible(len(job.logs) > 300) self.full_log_button.setEnabled(len(job.logs) > 300 and not self._show_full_log) def _show_entire_log(self) -> None: job = self._selected() if not job: return self._show_full_log = True self._show_job(job) def _on_job_updated(self, job: Job) -> None: row = next( ( row for row in range(self.table.rowCount()) if self.table.item(row, 0) and self.table.item(row, 0).text() == job.id ), -1, ) if row >= 0: self._populate_job_row(row, job) else: self.refresh() if job.id == self.selected_job_id: self._show_job(job) def _selected(self) -> Job | None: if not self.selected_job_id: return None try: return self.jobs.get(self.selected_job_id) except KeyError: return None def _pause_or_resume(self) -> None: job = self._selected() if not job: return if job.status == JobStatus.PAUSED: self.jobs.resume(job.id) else: self.jobs.pause(job.id) def _adjust_after_epoch(self) -> None: job = self._selected() if not job or not (0 <= job.current_step < len(job.plan.steps)): return arguments = job.plan.steps[job.current_step].arguments dialog = QDialog(self) dialog.setWindowTitle("Adjust training after this epoch") dialog.setMinimumWidth(440) layout = QVBoxLayout(dialog) explanation = QLabel( "Training will finish the current epoch, save a complete checkpoint, " "release VRAM, and resume with these settings." ) explanation.setWordWrap(True) layout.addWidget(explanation) grid = QGridLayout() batch = QSpinBox(); batch.setRange(1, 64); batch.setValue(int(arguments.get("batch_size", 1))) accumulation = QSpinBox(); accumulation.setRange(1, 64); accumulation.setValue(int(arguments.get("gradient_accumulation_steps", 1))) intensity = QSpinBox(); intensity.setRange(10, 100); intensity.setValue(int(arguments.get("training_intensity", 100))); intensity.setSuffix("%") grid.addWidget(QLabel("Batch size"), 0, 0); grid.addWidget(batch, 0, 1) grid.addWidget(QLabel("Gradient accumulation"), 1, 0); grid.addWidget(accumulation, 1, 1) grid.addWidget(QLabel("Training intensity"), 2, 0); grid.addWidget(intensity, 2, 1) layout.addLayout(grid) note = QLabel("Tip: when lowering batch size, increase gradient accumulation to preserve a similar effective batch.") note.setWordWrap(True) note.setProperty("muted", True) layout.addWidget(note) buttons = QDialogButtonBox(QDialogButtonBox.Cancel | QDialogButtonBox.Ok) buttons.button(QDialogButtonBox.Ok).setText("Apply after epoch") buttons.accepted.connect(dialog.accept) buttons.rejected.connect(dialog.reject) layout.addWidget(buttons) if dialog.exec() != QDialog.Accepted: return try: self.jobs.request_training_adjustment(job.id, { "batch_size": batch.value(), "gradient_accumulation_steps": accumulation.value(), "training_intensity": intensity.value(), }) except ValueError as exc: QMessageBox.information(self, "Settings not queued", str(exc)) def _stop(self) -> None: job = self._selected() if job: self.jobs.cancel(job.id) def _end_task(self) -> None: job = self._selected() if not job: return self.jobs.end_task(job.id) def _open_output(self) -> None: job = self._selected() if job and job.output_folder: QDesktopServices.openUrl(QUrl.fromLocalFile(job.output_folder)) def _retry(self) -> None: job = self._selected() if not job: return try: retried = ( self.jobs.safer_vram_retry(job.id) if job.status == JobStatus.FAILED and self.jobs._looks_like_vram_failure(job) else self.jobs.retry(job.id) ) except ValueError as exc: QMessageBox.information(self, "Retry not created", str(exc)) return self.selected_job_id = retried.id self.refresh() def _approve(self) -> None: job = self._selected() if job and job.status == JobStatus.AWAITING_CONFIRMATION: self.jobs.confirm(job.id) def _export_log(self) -> None: job = self._selected() if not job: return selected, _filter = QFileDialog.getSaveFileName( self, "Export job log", str(self.jobs.root / "logs" / f"job_{job.id}.txt"), "Text files (*.txt)", ) if not selected: return try: Path(selected).write_text( f"ADAM job {job.id}\n{job.plan.project_name}\n" f"{job.status.value}\n\n" + "\n".join(job.logs), encoding="utf-8", ) except OSError as exc: QMessageBox.warning(self, "Log not exported", str(exc)) def _export_all(self) -> None: selected, _filter = QFileDialog.getSaveFileName( self, "Export job history", str(self.jobs.root / "logs" / "job_history.json"), "JSON files (*.json)", ) if not selected: return try: Path(selected).write_text( json.dumps({"jobs": [job.to_dict() for job in self.jobs.jobs]}, indent=2), encoding="utf-8", ) except OSError as exc: QMessageBox.warning(self, "History not exported", str(exc)) def _remove_completed_or_failed(self) -> None: count = sum( job.status in {JobStatus.FINISHED, JobStatus.FAILED} for job in self.jobs.jobs ) if not count: QMessageBox.information( self, "Nothing to remove", "There are no completed or failed jobs in history." ) return answer = QMessageBox.question( self, "Remove completed / failed jobs", f"Remove {count} completed or failed job record(s)? This does not delete output files.", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if answer != QMessageBox.Yes: return self.jobs.remove_completed_or_failed() self.selected_job_id = None self.log_view.clear() self.detail_title.setText("Select a job") self.detail_status.setText("No job selected") self.refresh() @staticmethod def _format_time(value: str) -> str: try: return datetime.fromisoformat(value).astimezone().strftime("%b %d · %H:%M") except ValueError: return value[:16] def _tool_is_demo(self, tool_id: str) -> bool: try: return self.jobs.executor.registry.get(tool_id).demo except Exception: return False class ModelInspectionWorker(QThread): progress_changed = Signal(int, str) inspection_ready = Signal(object) failed = Signal(str) def __init__(self, path: str, architecture: str = "", settings: dict | None = None) -> None: super().__init__() self.path = path self.architecture = architecture self.settings = settings or {} self._cancelled = False def cancel(self) -> None: self._cancelled = True def run(self) -> None: try: summary = inspect_model( self.path, recorded_architecture=self.architecture, run_settings=self.settings, progress=self.progress_changed.emit, cancelled=lambda: self._cancelled, ) except Exception as exc: self.failed.emit(str(exc)) return self.inspection_ready.emit(summary) class ModelComparisonWorker(QThread): progress_changed = Signal(int, str) comparison_ready = Signal(object) failed = Signal(str) def __init__(self, path_a: str, path_b: str, run_a: ExperimentRun | None = None, run_b: ExperimentRun | None = None) -> None: super().__init__() self.path_a = path_a self.path_b = path_b self.run_a = run_a self.run_b = run_b self._cancelled = False def cancel(self) -> None: self._cancelled = True def run(self) -> None: try: comparison = compare_models( self.path_a, self.path_b, arch_a=self.run_a.model_architecture if self.run_a else "", arch_b=self.run_b.model_architecture if self.run_b else "", settings_a=self.run_a.settings if self.run_a else {}, settings_b=self.run_b.settings if self.run_b else {}, progress=self.progress_changed.emit, cancelled=lambda: self._cancelled, ) except Exception as exc: self.failed.emit(str(exc)) return self.comparison_ready.emit(comparison) class ModelInspectorPlot(QWidget): def __init__(self) -> None: super().__init__() self.layout = QVBoxLayout(self) self.layout.setContentsMargins(8, 8, 8, 8) self.placeholder = QLabel("Inspect a model to see parameter and weight-distribution charts.") self.placeholder.setProperty("muted", True) self.placeholder.setAlignment(Qt.AlignCenter) self.layout.addWidget(self.placeholder, 1) self.canvas = None self.figure = None try: from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg from matplotlib.figure import Figure self.figure = Figure(figsize=(9, 4.5), facecolor=COLORS["surface"]) self.canvas = FigureCanvasQTAgg(self.figure) self.layout.addWidget(self.canvas, 1) self.canvas.hide() except Exception: self.placeholder.setText("Charts are unavailable because matplotlib could not initialize.") def plot(self, summary: ModelInspection | None) -> None: if self.canvas is None or self.figure is None or summary is None: return self.placeholder.hide() self.canvas.show() self.figure.clear() axes = self.figure.subplots(1, 3) for axis in axes: axis.set_facecolor(COLORS["surface"]) axis.tick_params(colors=COLORS["muted"], labelsize=8) for spine in axis.spines.values(): spine.set_color(COLORS["border"]) components = sorted(summary.components.items(), key=lambda item: item[1], reverse=True)[:10] axes[0].set_title("Component Parameters", color=COLORS["text"], fontsize=10) if components: labels = [name[:18] for name, _ in components] values = [count / 1_000_000 for _, count in components] axes[0].barh(labels[::-1], values[::-1], color=COLORS["blue"]) axes[0].set_xlabel("Millions", color=COLORS["muted"], fontsize=8) sizes = summary.tensor_size_distribution[:20] axes[1].set_title("Largest Tensors", color=COLORS["text"], fontsize=10) if sizes: labels = [Path(name).name[:16] for name, _ in sizes[:10]] values = [count / 1_000_000 for _, count in sizes[:10]] axes[1].bar(range(len(values)), values, color=COLORS["purple"]) axes[1].set_xticks(range(len(labels))) axes[1].set_xticklabels(labels, rotation=70, ha="right") axes[1].set_ylabel("Millions", color=COLORS["muted"], fontsize=8) axes[2].set_title("Abs Mean Distribution", color=COLORS["text"], fontsize=10) bins = summary.histogram.get("abs_mean_bins", []) counts = summary.histogram.get("counts", []) if len(bins) > 1 and counts: axes[2].bar(range(len(counts)), counts, color=COLORS["green"]) axes[2].set_xticks([0, len(counts) - 1]) axes[2].set_xticklabels([f"{bins[0]:.2g}", f"{bins[-1]:.2g}"]) axes[2].set_ylabel("Tensors", color=COLORS["muted"], fontsize=8) self.figure.tight_layout() self.canvas.draw_idle() class ExperimentTrackerPage(QWidget): clone_requested = Signal(str) def __init__(self, store: ExperimentStore) -> None: super().__init__() self.store = store self.selected_run_id = "" self.current_inspection: ModelInspection | None = None self.current_model_path = "" self.inspection_cache: dict[tuple[str, float, int], ModelInspection] = {} self.inspection_worker: ModelInspectionWorker | None = None self.comparison_worker: ModelComparisonWorker | None = None root = QVBoxLayout(self) root.setContentsMargins(24, 20, 24, 17) root.setSpacing(12) header = QHBoxLayout() header.addWidget( _page_header( "Experiment tracker", "Search, score, clone, and compare local training runs recorded from ADAM jobs.", ), 1, ) refresh = QPushButton("Refresh") refresh.clicked.connect(self.refresh) header.addWidget(refresh, 0, Qt.AlignTop) root.addLayout(header) filters = QHBoxLayout() self.search = QLineEdit() self.search.setPlaceholderText("Search runs, datasets, or notes") self.architecture = QComboBox() self.architecture.addItem("All architectures", "") self.dataset = QLineEdit() self.dataset.setPlaceholderText("Dataset filter") filters.addWidget(self.search, 2) filters.addWidget(self.architecture) filters.addWidget(self.dataset, 1) root.addLayout(filters) body = QHBoxLayout() self.table = QTableWidget(0, 8) self.table.setHorizontalHeaderLabels( ["RUN", "MODEL", "ARCH", "DATASET", "EPOCHS", "LOSS", "TIME", "QUALITY"] ) self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setSelectionMode(QAbstractItemView.MultiSelection) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.table.setSortingEnabled(True) self.table.verticalHeader().hide() header_view = self.table.horizontalHeader() header_view.setSectionResizeMode(0, QHeaderView.ResizeToContents) header_view.setSectionResizeMode(1, QHeaderView.Stretch) header_view.setSectionResizeMode(2, QHeaderView.ResizeToContents) header_view.setSectionResizeMode(3, QHeaderView.Stretch) for column in range(4, 8): header_view.setSectionResizeMode(column, QHeaderView.ResizeToContents) body.addWidget(self.table, 3) details = _card() details.setMinimumWidth(390) details_layout = QVBoxLayout(details) details_layout.setContentsMargins(17, 16, 17, 16) details_layout.setSpacing(8) details_layout.addWidget(_card_title("RUN DETAILS")) self.detail_title = QLabel("Select a run") self.detail_title.setStyleSheet("font-size: 17px; font-weight: 650;") self.detail = QLabel("Recorded training settings will appear here.") self.detail.setWordWrap(True) self.detail.setProperty("muted", True) self.notes = QPlainTextEdit() self.notes.setPlaceholderText("Notes") self.notes.setMaximumHeight(110) rating_row = QHBoxLayout() rating_row.addWidget(QLabel("Quality")) self.quality = QSpinBox() self.quality.setRange(0, 100) self.quality.setSuffix(" / 100") rating_row.addWidget(self.quality) rating_row.addStretch() actions = QGridLayout() self.save_button = QPushButton("Save notes") self.clone_button = QPushButton("Clone settings") self.compare_button = QPushButton("Compare selected") self.output_button = QPushButton("Open output") self.inspect_button = QPushButton("Inspect model") self.manual_inspect_button = QPushButton("Choose checkpoint") self.cancel_inspect_button = QPushButton("Cancel scan") self.cancel_inspect_button.setEnabled(False) actions.addWidget(self.save_button, 0, 0) actions.addWidget(self.clone_button, 0, 1) actions.addWidget(self.compare_button, 1, 0) actions.addWidget(self.output_button, 1, 1) actions.addWidget(self.inspect_button, 2, 0) actions.addWidget(self.manual_inspect_button, 2, 1) actions.addWidget(self.cancel_inspect_button, 3, 0, 1, 2) self.inspect_status = QLabel("No model inspected yet.") self.inspect_status.setProperty("muted", True) self.inspect_status.setWordWrap(True) self.inspect_progress = QProgressBar() self.inspect_progress.setRange(0, 100) self.inspect_progress.setValue(0) details_layout.addWidget(self.detail_title) details_layout.addWidget(self.detail) details_layout.addLayout(rating_row) details_layout.addWidget(self.notes) details_layout.addLayout(actions) details_layout.addWidget(self.inspect_status) details_layout.addWidget(self.inspect_progress) details_layout.addStretch(1) body.addWidget(details, 2) root.addLayout(body, 3) self.analysis_tabs = QTabWidget() self.overview_view = QPlainTextEdit() self.overview_view.setReadOnly(True) self.overview_view.setPlaceholderText("Inspect a model to see architecture, parameters, configs, and major components.") weights_page = QWidget() weights_layout = QVBoxLayout(weights_page) weights_layout.setContentsMargins(10, 10, 10, 10) self.weight_search = QLineEdit() self.weight_search.setPlaceholderText("Search tensor names") self.weights_table = QTableWidget(0, 11) self.weights_table.setHorizontalHeaderLabels( ["TENSOR", "SHAPE", "DTYPE", "PARAMS", "MIN", "MAX", "MEAN", "STD", "ABS MEAN", "L2", "ZEROS"] ) self.weights_table.setSelectionBehavior(QAbstractItemView.SelectRows) self.weights_table.setEditTriggers(QAbstractItemView.NoEditTriggers) weights_header = self.weights_table.horizontalHeader() weights_header.setSectionResizeMode(0, QHeaderView.Stretch) for column in range(1, 11): weights_header.setSectionResizeMode(column, QHeaderView.ResizeToContents) weights_layout.addWidget(self.weight_search) weights_layout.addWidget(self.weights_table, 1) self.health_view = QPlainTextEdit() self.health_view.setReadOnly(True) self.plot_widget = ModelInspectorPlot() self.compare_view = QPlainTextEdit() self.compare_view.setReadOnly(True) self.compare_view.setPlaceholderText("Select 2 runs and choose Compare selected.") self.timeline_view = QPlainTextEdit() self.timeline_view.setReadOnly(True) self.timeline_view.setPlaceholderText("Checkpoint timeline appears when an inspected output has multiple checkpoints.") self.analysis_tabs.addTab(self.overview_view, "Overview") self.analysis_tabs.addTab(weights_page, "Weights") self.analysis_tabs.addTab(self.health_view, "Health") self.analysis_tabs.addTab(self.plot_widget, "Plots") self.analysis_tabs.addTab(self.compare_view, "Compare") self.analysis_tabs.addTab(self.timeline_view, "Timeline") root.addWidget(self.analysis_tabs, 2) self.search.textChanged.connect(self.refresh) self.architecture.currentIndexChanged.connect(self.refresh) self.dataset.textChanged.connect(self.refresh) self.table.itemSelectionChanged.connect(self._selection_changed) self.save_button.clicked.connect(self._save_notes) self.clone_button.clicked.connect(self._clone) self.compare_button.clicked.connect(self._compare) self.output_button.clicked.connect(self._open_output) self.inspect_button.clicked.connect(self._inspect_selected) self.manual_inspect_button.clicked.connect(self._inspect_manual) self.cancel_inspect_button.clicked.connect(self._cancel_inspection) self.weight_search.textChanged.connect(self._populate_weights) self.refresh() def refresh(self) -> None: current = self.selected_run_id runs = self.store.list_runs( self.search.text().strip(), str(self.architecture.currentData() or ""), self.dataset.text().strip(), ) known = sorted({run.model_architecture for run in self.store.list_runs(limit=1000) if run.model_architecture}) self.architecture.blockSignals(True) selected_arch = str(self.architecture.currentData() or "") self.architecture.clear() self.architecture.addItem("All architectures", "") for architecture in known: self.architecture.addItem(architecture.upper(), architecture) index = self.architecture.findData(selected_arch) self.architecture.setCurrentIndex(max(0, index)) self.architecture.blockSignals(False) self.table.setRowCount(len(runs)) self.table.setSortingEnabled(False) for row, run in enumerate(runs): values = [ run.id, run.model_name, run.model_architecture.upper(), run.dataset_name or Path(run.dataset_path).name, str(run.epochs), "—" if run.final_loss is None else f"{run.final_loss:.5f}", self._duration(run.training_time_seconds), "—" if run.quality_score is None else str(run.quality_score), ] for column, value in enumerate(values): item = QTableWidgetItem(value) item.setData(Qt.UserRole, run.id) if column in {0, 2, 4, 5, 6, 7}: item.setTextAlignment(Qt.AlignCenter) self.table.setItem(row, column, item) self.table.setSortingEnabled(True) if not runs: self.detail_title.setText("No experiments recorded") self.detail.setText("Training runs are recorded here when a registered trainer finishes, fails, or is cancelled.") self.notes.clear() self.compare_view.clear() self.inspect_button.setEnabled(False) self.output_button.setEnabled(False) if current: for row in range(self.table.rowCount()): if self.table.item(row, 0).text() == current: self.table.selectRow(row) break def _selection_changed(self) -> None: run = self._selected_run() if not run: return self.selected_run_id = run.id self.detail_title.setText(run.model_name) details = [ f"{run.model_architecture.upper()} · {run.status}", f"Epochs {run.epochs:,} · Batch {run.batch_size or '—'} · LR {run.learning_rate or '—'}", f"Resolution {run.resolution or '—'} · Loss {'—' if run.final_loss is None else f'{run.final_loss:.5f}'}", f"Dataset: {run.dataset_path or '—'}", f"Output: {run.output_folder or '—'}", ] if run.peak_vram_gb: details.append(f"VRAM at record time: {run.peak_vram_gb:.1f} GB") if run.preview_images: details.append(f"Previews recorded: {len(run.preview_images)}") self.detail.setText("\n".join(details)) self.notes.setPlainText(run.notes) self.quality.setValue(int(run.quality_score or 0)) model_path = self._default_model_path(run) self.output_button.setEnabled(bool(run.output_folder and Path(run.output_folder).exists())) self.inspect_button.setEnabled(bool(model_path)) if model_path: self.inspect_status.setText(f"Ready to inspect: {model_path}") def _selected_ids(self) -> list[str]: ids = [] for index in self.table.selectionModel().selectedRows(): item = self.table.item(index.row(), 0) if item: ids.append(item.text()) return ids def _selected_run(self) -> ExperimentRun | None: ids = self._selected_ids() return self.store.get(ids[0]) if ids else None def _save_notes(self) -> None: if not self.selected_run_id: return quality = self.quality.value() self.store.update_notes(self.selected_run_id, self.notes.toPlainText().strip(), quality if quality else None) self.refresh() def _clone(self) -> None: run = self._selected_run() if run: self.clone_requested.emit(self.store.clone_request(run.id)) def _compare(self) -> None: ids = self._selected_ids() if len(ids) < 2: self.compare_view.setPlainText("Select at least two experiment runs.") return runs = [self.store.get(run_id) for run_id in ids[:2]] paths = [self._default_model_path(run) if run else "" for run in runs] if len(paths) == 2 and paths[0] and paths[1]: self._start_comparison(paths[0], paths[1], runs[0], runs[1]) return rows = self.store.compare(ids) lines = ["Compare selected runs", "Run IDs: " + ", ".join(ids), ""] for row in rows: field = str(row.get("field", "")).replace("_", " ").title() marker = "Changed" if row.get("changed") else "Same" values = " | ".join(self._format_compare_value(field, row.get(run_id, "—")) for run_id in ids) lines.append(f"{marker:7} {field}: {values}") self.compare_view.setPlainText("\n".join(lines)) def _open_output(self) -> None: run = self._selected_run() if run and run.output_folder and Path(run.output_folder).exists(): QDesktopServices.openUrl(QUrl.fromLocalFile(run.output_folder)) def _default_model_path(self, run: ExperimentRun) -> str: candidates = [path for path in run.checkpoint_paths if path] candidates.append(run.output_folder) if run.model_architecture == "flow" and run.output_folder: candidates.insert(0, run.output_folder) for value in reversed(candidates): path = Path(value).expanduser() if path.exists(): return str(path) return "" def _cache_key(self, path: str) -> tuple[str, float, int] | None: target = Path(path).expanduser() if not target.exists(): return None try: if target.is_file(): stat = target.stat() return (str(target.resolve()), stat.st_mtime, stat.st_size) latest = 0.0 size = 0 for item in target.rglob("*"): if item.is_file(): stat = item.stat() latest = max(latest, stat.st_mtime) size += stat.st_size return (str(target.resolve()), latest, size) except OSError: return None def _inspect_selected(self) -> None: run = self._selected_run() if not run: self.inspect_status.setText("Select a run first.") return path = self._default_model_path(run) if not path: self.inspect_status.setText("No checkpoint or output folder was found for this run.") return self._start_inspection(path, run) def _inspect_manual(self) -> None: file_path, _ = QFileDialog.getOpenFileName( self, "Choose checkpoint", str(Path.cwd()), "Model checkpoints (*.safetensors *.pt *.pth *.bin *.ckpt);;All files (*)", ) selected = file_path if not selected: selected = QFileDialog.getExistingDirectory(self, "Choose model folder", str(Path.cwd())) if selected: self._start_inspection(selected, self._selected_run()) def _start_inspection(self, path: str, run: ExperimentRun | None = None) -> None: if self.inspection_worker and self.inspection_worker.isRunning(): self.inspect_status.setText("A model scan is already running.") return key = self._cache_key(path) if key and key in self.inspection_cache: self._show_inspection(self.inspection_cache[key]) self.inspect_status.setText(f"Loaded cached inspection: {Path(path).name}") return self.current_model_path = path self.inspect_progress.setValue(0) self.inspect_status.setText(f"Inspecting model: {path}") self.cancel_inspect_button.setEnabled(True) self.inspect_button.setEnabled(False) self.manual_inspect_button.setEnabled(False) self.inspection_worker = ModelInspectionWorker( path, run.model_architecture if run else "", run.settings if run else {}, ) self.inspection_worker.progress_changed.connect(self._inspection_progress) self.inspection_worker.inspection_ready.connect(lambda summary, cache_key=key: self._inspection_finished(summary, cache_key)) self.inspection_worker.failed.connect(self._inspection_failed) self.inspection_worker.finished.connect(self._inspection_worker_done) self.inspection_worker.start() def _cancel_inspection(self) -> None: if self.inspection_worker and self.inspection_worker.isRunning(): self.inspection_worker.cancel() self.inspect_status.setText("Cancelling model scan...") if self.comparison_worker and self.comparison_worker.isRunning(): self.comparison_worker.cancel() self.inspect_status.setText("Cancelling comparison...") def _inspection_progress(self, value: int, message: str) -> None: self.inspect_progress.setValue(value) self.inspect_status.setText(message) def _inspection_finished(self, summary: ModelInspection, cache_key: tuple[str, float, int] | None) -> None: if cache_key: self.inspection_cache[cache_key] = summary self._show_inspection(summary) self.inspect_status.setText(summary.messages[0] if summary.messages else "Inspection complete.") self.inspect_progress.setValue(100) def _inspection_failed(self, message: str) -> None: self.inspect_status.setText(message or "Model inspection failed.") self.health_view.setPlainText(message or "Model inspection failed.") def _inspection_worker_done(self) -> None: self.cancel_inspect_button.setEnabled(False) self.inspect_button.setEnabled(True) self.manual_inspect_button.setEnabled(True) def _show_inspection(self, summary: ModelInspection) -> None: self.current_inspection = summary self.overview_view.setPlainText(self._format_overview(summary)) self.health_view.setPlainText("\n".join(summary.health)) self._populate_weights() self.plot_widget.plot(summary) self.timeline_view.setPlainText(self._format_timeline(summary)) self.analysis_tabs.setCurrentWidget(self.overview_view) def _populate_weights(self) -> None: summary = self.current_inspection query = self.weight_search.text().strip().casefold() tensors = summary.tensors if summary else [] if query: tensors = [tensor for tensor in tensors if query in tensor.name.casefold()] tensors = sorted(tensors, key=lambda item: item.parameter_count, reverse=True)[:1000] self.weights_table.setRowCount(len(tensors)) for row, tensor in enumerate(tensors): values = [ tensor.name, shape_label(tensor.shape), tensor.dtype, f"{tensor.parameter_count:,}", self._metric(tensor.minimum), self._metric(tensor.maximum), self._metric(tensor.mean), self._metric(tensor.std), self._metric(tensor.abs_mean), self._metric(tensor.l2_norm), "-" if tensor.zero_percent is None else f"{tensor.zero_percent:.2f}%", ] for column, value in enumerate(values): item = QTableWidgetItem(value) if column > 0: item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) self.weights_table.setItem(row, column, item) def _format_overview(self, summary: ModelInspection) -> str: lines = [ f"Detected architecture: {summary.architecture} ({summary.confidence:.0%} confidence)", f"Model/checkpoint path: {summary.resolved_path}", f"Checkpoint size: {bytes_label(summary.size_bytes)}", f"Configuration files found: {len(summary.config_files)}", f"Resolution: {summary.resolution or '-'}", f"Epoch: {summary.epoch or '-'}", f"Step: {summary.step or '-'}", f"Number of tensors: {summary.tensor_count:,}", f"Total parameter count: {summary.total_parameters:,}", f"Trainable parameter count: {'-' if summary.trainable_parameters is None else f'{summary.trainable_parameters:,}'}", f"Parameter memory size: {bytes_label(summary.parameter_memory_bytes)}", "", "Tensor data types:", ] lines.extend(f" {dtype}: {count:,} parameters" for dtype, count in sorted(summary.dtypes.items())) lines.extend(["", "Major model components:"]) lines.extend(f" {name}: {count:,} parameters" for name, count in sorted(summary.components.items(), key=lambda item: item[1], reverse=True)[:20]) lines.extend(["", "Largest tensors/layers:"]) lines.extend(f" {tensor.name} {shape_label(tensor.shape)} {tensor.parameter_count:,}" for tensor in summary.largest_tensors[:15]) if summary.config_files: lines.extend(["", "Configuration files:"]) lines.extend(f" {path}" for path in summary.config_files[:20]) if summary.lora: lines.extend(["", "LoRA adapter details:"]) lines.extend(f" {key.replace('_', ' ').title()}: {value}" for key, value in summary.lora.items()) lines.append(" Tensor norms are adapter statistics; they do not directly tell visual strength.") if summary.messages: lines.extend(["", "Messages:"]) lines.extend(f" {message}" for message in summary.messages) return "\n".join(lines) def _format_timeline(self, summary: ModelInspection) -> str: if not summary.checkpoints: return "No timeline checkpoints were found near this model output." lines = [ "Checkpoint timeline", "Select several checkpoints with Compare selected runs, or inspect individual checkpoints from this list.", "", ] for path in summary.checkpoints[:80]: item = Path(path) try: size = bytes_label(sum(file.stat().st_size for file in item.rglob("*") if file.is_file()) if item.is_dir() else item.stat().st_size) except OSError: size = "-" lines.append(f"{item.name} | {size} | {path}") if len(summary.checkpoints) > 80: lines.append(f"... {len(summary.checkpoints) - 80} more checkpoints omitted.") return "\n".join(lines) def _start_comparison(self, path_a: str, path_b: str, run_a: ExperimentRun | None, run_b: ExperimentRun | None) -> None: if self.comparison_worker and self.comparison_worker.isRunning(): self.compare_view.setPlainText("A model comparison is already running.") return self.compare_view.setPlainText("Comparing checkpoints...") self.analysis_tabs.setCurrentWidget(self.compare_view) self.cancel_inspect_button.setEnabled(True) self.comparison_worker = ModelComparisonWorker(path_a, path_b, run_a, run_b) self.comparison_worker.progress_changed.connect(self._inspection_progress) self.comparison_worker.comparison_ready.connect(self._comparison_finished) self.comparison_worker.failed.connect(self._comparison_failed) self.comparison_worker.finished.connect(self._comparison_worker_done) self.comparison_worker.start() def _comparison_finished(self, comparison: ModelComparison) -> None: self.compare_view.setPlainText(self._format_model_comparison(comparison)) self.inspect_progress.setValue(100) self.inspect_status.setText("Model comparison complete.") def _comparison_failed(self, message: str) -> None: self.compare_view.setPlainText(message or "Model comparison failed.") self.inspect_status.setText(message or "Model comparison failed.") def _comparison_worker_done(self) -> None: self.cancel_inspect_button.setEnabled(False) def _format_model_comparison(self, comparison: ModelComparison) -> str: lines = [ "Compare checkpoints", f"A: {comparison.path_a}", f"B: {comparison.path_b}", f"Architecture: {comparison.architecture_a} vs {comparison.architecture_b} ({'match' if comparison.architecture_match else 'mismatch'})", f"Parameter-count difference: {comparison.parameter_count_difference:+,}", ] if comparison.resolution_difference: lines.append(f"Resolution difference: {comparison.resolution_difference[0]} vs {comparison.resolution_difference[1]}") lines.extend(["", "Messages:"]) lines.extend(f" {message}" for message in comparison.messages) if comparison.config_differences: lines.extend(["", "Config differences:"]) lines.extend(f" {item}" for item in comparison.config_differences[:30]) if comparison.only_a or comparison.only_b or comparison.shape_mismatches: lines.extend(["", "Tensor availability:"]) lines.append(f" Tensors only present in A: {len(comparison.only_a)}") lines.append(f" Tensors only present in B: {len(comparison.only_b)}") lines.append(f" Tensors with different shapes: {len(comparison.shape_mismatches)}") if comparison.group_comparisons: lines.extend(["", "Group summary:"]) for name, values in sorted(comparison.group_comparisons.items(), key=lambda item: item[1].get("mean_change_score", 0), reverse=True)[:20]: lines.append( f" {name}: {int(values['tensors'])} tensors, " f"change score {values['mean_change_score']:.6f}, " f"mean abs diff {values['mean_abs_difference']:.6f}" ) if comparison.tensor_comparisons: lines.extend(["", "Most changed tensors:"]) for item in comparison.tensor_comparisons[:40]: lines.append( f" {item.name} | score {self._metric(item.change_score)} | " f"mean abs {self._metric(item.mean_abs_difference)} | " f"relative {self._metric(item.relative_difference)} | " f"cosine {self._metric(item.cosine_similarity)} | L2 {self._metric(item.l2_distance)}" ) return "\n".join(lines) @staticmethod def _metric(value: float | None) -> str: if value is None: return "-" if abs(value) >= 1000 or (abs(value) < 0.001 and value != 0): return f"{value:.4e}" return f"{value:.6f}" @staticmethod def _duration(seconds: int) -> str: if seconds >= 3600: return f"{seconds // 3600}h {(seconds % 3600) // 60}m" return f"{seconds // 60}m {seconds % 60}s" @classmethod def _format_compare_value(cls, field: str, value) -> str: if value in (None, ""): return "—" if field == "Training Time Seconds": return cls._duration(int(value)) if field == "Learning Rate": try: return f"{float(value):.7f}" except (TypeError, ValueError): return str(value) if field in {"Final Loss", "Peak Vram Gb"}: try: return f"{float(value):.5f}" except (TypeError, ValueError): return str(value) return str(value) class DatasetLabPage(QWidget): def __init__(self, root_path: Path) -> None: super().__init__() self.root_path = root_path self.store = StudioStore(root_path) self.current_folder = "" self.selected_videos: list[str] = [] self.selected_item_path = "" self.cnn_scores: dict[str, object] = {} self._scan_worker: DatasetScanWorker | None = None self._transcript_worker: TranscriptExportWorker | None = None self._cnn_worker: CnnReviewerWorker | None = None root = QVBoxLayout(self) root.setContentsMargins(24, 20, 24, 17) root.setSpacing(12) root.addWidget( _page_header( "Dataset Lab", "Inspect local datasets, caption coverage, duplicates, dimensions, and transcript exports.", ) ) chooser = QHBoxLayout() self.folder = QLineEdit() self.folder.setPlaceholderText("Choose a dataset folder") browse = QPushButton("Browse") scan = QPushButton("Scan") browse.clicked.connect(self._browse_dataset) scan.clicked.connect(self._scan) chooser.addWidget(self.folder, 1) chooser.addWidget(browse) chooser.addWidget(scan) root.addLayout(chooser) self.summary = QLabel("No dataset scanned yet.") self.summary.setWordWrap(True) self.summary.setProperty("muted", True) root.addWidget(self.summary) body = QHBoxLayout() self.files = QTableWidget(0, 7) self.files.setHorizontalHeaderLabels(["FILE", "TYPE", "SIZE", "DIMENSIONS", "CAPTION", "DUPLICATE", "CNN REVIEW"]) self.files.setEditTriggers(QAbstractItemView.NoEditTriggers) self.files.setSelectionBehavior(QAbstractItemView.SelectRows) self.files.setSelectionMode(QAbstractItemView.SingleSelection) self.files.verticalHeader().hide() self.files.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) for column in range(1, 7): self.files.horizontalHeader().setSectionResizeMode(column, QHeaderView.ResizeToContents) self.files.itemSelectionChanged.connect(self._show_selected_file) body.addWidget(self.files, 3) side = QVBoxLayout() side.setSpacing(12) inspector = _card() inspector_layout = QVBoxLayout(inspector) inspector_layout.setContentsMargins(17, 16, 17, 16) inspector_layout.setSpacing(8) inspector_layout.addWidget(_card_title("DATASET ITEM")) self.preview = QLabel("Select an image or caption") self.preview.setAlignment(Qt.AlignCenter) self.preview.setFixedSize(260, 190) self.preview.setProperty("muted", True) self.preview.setStyleSheet( f"background: #050d14; border: 1px solid {COLORS['border_bright']}; border-radius: 8px;" ) self.caption = QPlainTextEdit() self.caption.setPlaceholderText("Caption text appears here.") self.caption.setMaximumHeight(105) decision_row = QHBoxLayout() decision_row.addWidget(QLabel("Decision")) self.decision = QComboBox() self.decision.addItem("Uncertain", "unreviewed") self.decision.addItem("Accept", "keep") self.decision.addItem("Reject", "reject") decision_row.addWidget(self.decision) save_caption = QPushButton("Save caption") open_file = QPushButton("Open file") inspector_layout.addWidget(self.preview, 0, Qt.AlignHCenter) inspector_layout.addWidget(self.caption) inspector_layout.addLayout(decision_row) inspector_layout.addWidget(save_caption) inspector_layout.addWidget(open_file) side.addWidget(inspector) cnn = _card() cnn_layout = QVBoxLayout(cnn) cnn_layout.setContentsMargins(17, 16, 17, 16) cnn_layout.setSpacing(8) cnn_layout.addWidget(_card_title("CNN FRAME REVIEWER")) self.cnn_hint = QLabel( "Teach a small local CNN from your Keep and Reject choices. It only ranks frames for review; it never changes Oasis or deletes data." ) self.cnn_hint.setWordWrap(True) self.cnn_hint.setProperty("muted", True) self.cnn_train = QPushButton("Train from my decisions") self.cnn_score = QPushButton("Score this dataset") self.cnn_status = QLabel("Review at least 8 Keep and 8 Reject frames to begin.") self.cnn_status.setWordWrap(True) self.cnn_status.setProperty("muted", True) cnn_layout.addWidget(self.cnn_hint) cnn_layout.addWidget(self.cnn_train) cnn_layout.addWidget(self.cnn_score) cnn_layout.addWidget(self.cnn_status) side.addWidget(cnn) transcript = _card() transcript.setMinimumWidth(360) transcript_layout = QVBoxLayout(transcript) transcript_layout.setContentsMargins(17, 16, 17, 16) transcript_layout.setSpacing(9) transcript_layout.addWidget(_card_title("TRANSCRIPT TO DATASET")) self.video_summary = QLabel("No videos selected.") self.video_summary.setWordWrap(True) self.video_summary.setProperty("muted", True) backend_status = ", ".join( f"{backend.name}: {'ready' if backend.available else 'missing'}" for backend in available_transcription_backends() ) self.transcript_backend = QLabel(backend_status) self.transcript_backend.setWordWrap(True) self.transcript_backend.setProperty("muted", True) choose_videos = QPushButton("Choose videos") export = QPushButton("Export TXT / JSONL") choose_videos.clicked.connect(self._choose_videos) export.clicked.connect(self._export_transcripts) self.transcript_status = QPlainTextEdit() self.transcript_status.setReadOnly(True) self.transcript_status.setMaximumHeight(230) transcript_layout.addWidget(self.video_summary) transcript_layout.addWidget(self.transcript_backend) transcript_layout.addWidget(choose_videos) transcript_layout.addWidget(export) transcript_layout.addWidget(self.transcript_status, 1) side.addWidget(transcript, 1) body.addLayout(side, 1) root.addLayout(body, 1) save_caption.clicked.connect(self._save_caption) open_file.clicked.connect(self._open_selected_file) self.decision.currentIndexChanged.connect(self._decision_changed) self.cnn_train.clicked.connect(self._train_cnn_reviewer) self.cnn_score.clicked.connect(self._score_with_cnn_reviewer) def _browse_dataset(self) -> None: selected = QFileDialog.getExistingDirectory(self, "Choose dataset", self.folder.text() or str(self.root_path / "ADAM_Datasets")) if selected: self.folder.setText(selected) self._scan() def _scan(self) -> None: if self._scan_worker and self._scan_worker.isRunning(): self.summary.setText("Dataset scan is already running.") return folder = self.folder.text().strip() if not folder: self.summary.setText("Choose a dataset folder first.") return self.summary.setText("Scanning dataset...") self.cnn_scores = {} self.files.setRowCount(0) self._scan_worker = DatasetScanWorker(folder) self._scan_worker.scanned.connect(self._scan_finished) self._scan_worker.failed.connect(self._scan_failed) self._scan_worker.finished.connect(self._scan_worker.deleteLater) self._scan_worker.finished.connect(lambda: setattr(self, "_scan_worker", None)) self._scan_worker.start() def _scan_finished(self, report) -> None: self.current_folder = report.path self.store.load() self.summary.setText( f"{report.image_count:,} images · {report.video_count:,} videos · {report.caption_count:,} captions · " f"{report.missing_caption_count:,} missing captions · {report.duplicate_groups:,} duplicate groups\n" + (" ".join(report.warnings) if report.warnings else "Dataset sample looks ready for review.") ) self.files.setRowCount(len(report.items)) for row, item in enumerate(report.items): values = [ Path(item.path).name, item.kind, f"{item.size_bytes / 1024:.1f} KB", f"{item.width}x{item.height}" if item.width and item.height else "—", "Yes" if item.caption_path else "—", item.duplicate_key[:8] if item.duplicate_key else "—", "—", ] for column, value in enumerate(values): table_item = QTableWidgetItem(value) table_item.setToolTip(item.path) if column: table_item.setTextAlignment(Qt.AlignCenter) table_item.setData(Qt.UserRole, item.path) self.files.setItem(row, column, table_item) if report.items: self.files.selectRow(0) else: self.preview.setPixmap(QPixmap()) self.preview.setText("No files to preview") self.caption.clear() self._update_cnn_status() def _scan_failed(self, message: str) -> None: self.summary.setText(f"Dataset scan failed: {message}") def _show_selected_file(self) -> None: rows = self.files.selectionModel().selectedRows() if not rows: return item = self.files.item(rows[0].row(), 0) path = Path(str(item.data(Qt.UserRole) if item else "")).expanduser() self.selected_item_path = str(path) pixmap = QPixmap(str(path)) if not pixmap.isNull(): self.preview.setPixmap(pixmap.scaled(self.preview.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)) self.preview.setText("") caption_path = next((path.with_suffix(ext) for ext in (".txt", ".caption") if path.with_suffix(ext).is_file()), path.with_suffix(".txt")) else: self.preview.setPixmap(QPixmap()) self.preview.setText(path.name or "No preview") caption_path = path if path.suffix.casefold() in {".txt", ".caption"} else path.with_suffix(".txt") try: text = caption_path.read_text(encoding="utf-8") if caption_path.is_file() else "" except OSError as exc: text = f"Caption could not be read: {exc}" self.caption.setPlainText(text) self.caption.setProperty("caption_path", str(caption_path)) decision = "unreviewed" if self.current_folder and pixmap.isNull() is False: decision = self.store.review(self.current_folder).decisions.get(str(path.resolve()), "unreviewed") index = self.decision.findData(decision) self.decision.blockSignals(True) self.decision.setCurrentIndex(max(0, index)) self.decision.blockSignals(False) def _save_caption(self) -> None: raw_path = str(self.caption.property("caption_path") or "") if not raw_path: return caption_path = Path(raw_path).expanduser() try: caption_path.write_text(self.caption.toPlainText().strip() + "\n", encoding="utf-8") self.summary.setText(f"Saved caption: {caption_path.name}") except OSError as exc: QMessageBox.warning(self, "Caption not saved", str(exc)) def _decision_changed(self) -> None: if not self.current_folder or not self.selected_item_path: return path = Path(self.selected_item_path) if path.suffix.casefold() not in {".jpg", ".jpeg", ".png", ".webp", ".bmp"}: return try: self.store.set_decision(self.current_folder, self.selected_item_path, str(self.decision.currentData())) self.summary.setText(f"Saved review decision for {path.name}.") self._update_cnn_status() except (OSError, ValueError) as exc: QMessageBox.warning(self, "Decision not saved", str(exc)) def _open_selected_file(self) -> None: if self.selected_item_path and Path(self.selected_item_path).exists(): QDesktopServices.openUrl(QUrl.fromLocalFile(self.selected_item_path)) def _update_cnn_status(self) -> None: if not self.current_folder: self.cnn_train.setEnabled(False) self.cnn_score.setEnabled(False) self.cnn_status.setText("Choose and scan a dataset first.") return decisions = self.store.review(self.current_folder).decisions keeps = sum(value == "keep" for value in decisions.values()) rejects = sum(value == "reject" for value in decisions.values()) checkpoint = reviewer_checkpoint(self.root_path, self.current_folder) working = bool(self._cnn_worker and self._cnn_worker.isRunning()) self.cnn_train.setEnabled(not working) self.cnn_score.setEnabled(checkpoint.is_file() and not working) trained = " Reviewer is ready." if checkpoint.is_file() else "" self.cnn_status.setText(f"{keeps} Keep · {rejects} Reject reviewed.{trained}") def _run_cnn_reviewer(self, mode: str) -> None: if self._cnn_worker and self._cnn_worker.isRunning(): return if not self.current_folder: self.cnn_status.setText("Choose and scan a dataset first.") return decisions = dict(self.store.review(self.current_folder).decisions) self.cnn_status.setText("Starting CNN reviewer…") self.cnn_train.setEnabled(False) self.cnn_score.setEnabled(False) self._cnn_worker = CnnReviewerWorker(mode, self.root_path, self.current_folder, decisions) self._cnn_worker.progress.connect(self.cnn_status.setText) self._cnn_worker.completed.connect(self._cnn_completed) self._cnn_worker.failed.connect(self._cnn_failed) self._cnn_worker.finished.connect(self._cnn_worker.deleteLater) self._cnn_worker.finished.connect(self._cnn_finished) self._cnn_worker.start() def _train_cnn_reviewer(self) -> None: self._run_cnn_reviewer("train") def _score_with_cnn_reviewer(self) -> None: self._run_cnn_reviewer("score") def _cnn_completed(self, mode: str, result: object) -> None: if mode == "train": self.cnn_status.setText( f"CNN reviewer trained with {result.kept_examples} Keep and {result.rejected_examples} Reject frames." ) self._update_cnn_status() return self.cnn_scores = {score.path: score for score in result} for row in range(self.files.rowCount()): item = self.files.item(row, 0) path = str(item.data(Qt.UserRole) if item else "") score = self.cnn_scores.get(str(Path(path).expanduser().resolve())) if path else None label = "—" if not score else f"{score.suggestion.title()} {score.keep_probability:.0%}" cell = QTableWidgetItem(label) cell.setTextAlignment(Qt.AlignCenter) self.files.setItem(row, 6, cell) self.cnn_status.setText(f"CNN suggested Keep, Reject, or Review for {len(self.cnn_scores):,} frames. Suggestions did not change your decisions.") self._update_cnn_status() def _cnn_failed(self, message: str) -> None: self.cnn_status.setText(f"CNN reviewer could not run: {message}") def _cnn_finished(self) -> None: self._cnn_worker = None self._update_cnn_status() def _choose_videos(self) -> None: files, _ = QFileDialog.getOpenFileNames( self, "Choose local videos", str(self.root_path), "Videos (*.mp4 *.mov *.mkv *.webm *.avi)", ) self.selected_videos = files self.video_summary.setText(f"{len(files)} video(s) selected." if files else "No videos selected.") def _export_transcripts(self) -> None: if self._transcript_worker and self._transcript_worker.isRunning(): self.transcript_status.setPlainText("Transcript export is already running.") return output = QFileDialog.getExistingDirectory( self, "Choose transcript dataset output", self.current_folder or str(self.root_path / "data" / "transcript_datasets"), ) if not output: return self.transcript_status.setPlainText("Preparing transcript dataset...") self._transcript_worker = TranscriptExportWorker(self.selected_videos, output) self._transcript_worker.completed.connect(self._transcript_finished) self._transcript_worker.finished.connect(self._transcript_worker.deleteLater) self._transcript_worker.finished.connect(lambda: setattr(self, "_transcript_worker", None)) self._transcript_worker.start() def _transcript_finished(self, result) -> None: self.transcript_status.setPlainText( result.message + (f"\nOutput: {result.output_folder}" if result.output_folder else "") ) class RemoteAccessPage(QWidget): def __init__(self, service: RemoteAccessService) -> None: super().__init__() self.service = service root = QVBoxLayout(self) root.setContentsMargins(24, 20, 24, 17) root.setSpacing(12) root.addWidget( _page_header( "Remote Access", "Optional authenticated local-network status API for a browser or phone. Disabled by default.", ) ) card = _card() layout = QGridLayout(card) layout.setContentsMargins(18, 17, 18, 17) layout.setHorizontalSpacing(12) layout.setVerticalSpacing(10) self.enabled = QCheckBox("Enable remote access service") self.remote_mode = QComboBox() self.remote_mode.addItem("Local / Wi-Fi Only", REMOTE_MODE_LOCAL) self.remote_mode.addItem("Private Tailscale", REMOTE_MODE_TAILSCALE) self.remote_mode.addItem("Disabled", REMOTE_MODE_DISABLED) self.bind = QLineEdit() self.port = QSpinBox() self.port.setRange(1024, 65535) self.token = QLineEdit() self.token.setEchoMode(QLineEdit.PasswordEchoOnEdit) self.allow_job_control = QCheckBox("Allow remote job controls and approval changes") self.allow_job_control.setToolTip("Allows approval, pause, resume, stop and retry from devices with your access token.") self.auto_approve_training = QCheckBox("Auto-approve remote training prompts") self.status = QLabel() self.status.setWordWrap(True) self.status.setProperty("muted", True) self.qr_code = QLabel("Use phone access to generate a QR code.") self.qr_code.setAlignment(Qt.AlignCenter) self.qr_code.setMinimumSize(220, 220) self.qr_code.setStyleSheet( f"background: #ffffff; color: #00101b; border: 1px solid {COLORS['border_bright']}; border-radius: 8px;" ) self.qr_caption = QLabel() self.qr_caption.setWordWrap(True) self.qr_caption.setProperty("muted", True) self.tailscale_status = QLabel() self.tailscale_status.setWordWrap(True) self.tailscale_status.setProperty("muted", True) save = QPushButton("Save") toggle = QPushButton("Start / Stop") open_url = QPushButton("Open local test URL") phone_access = QPushButton("Use phone access") copy_phone_url = QPushButton("Copy phone URL") start_tailscale = QPushButton("Start private Tailscale") stop_tailscale = QPushButton("Stop private Tailscale") regenerate = QPushButton("New token") layout.addWidget(self.enabled, 0, 0, 1, 2) layout.addWidget(QLabel("Remote mode"), 1, 0) layout.addWidget(self.remote_mode, 1, 1) layout.addWidget(QLabel("Bind address"), 2, 0) layout.addWidget(self.bind, 2, 1) layout.addWidget(QLabel("Port"), 3, 0) layout.addWidget(self.port, 3, 1) layout.addWidget(QLabel("Token"), 4, 0) layout.addWidget(self.token, 4, 1) layout.addWidget(self.allow_job_control, 5, 0, 1, 2) layout.addWidget(self.auto_approve_training, 6, 0, 1, 2) layout.addWidget(save, 7, 0) layout.addWidget(toggle, 7, 1) layout.addWidget(regenerate, 8, 0) layout.addWidget(open_url, 8, 1) layout.addWidget(phone_access, 9, 0) layout.addWidget(copy_phone_url, 9, 1) layout.addWidget(start_tailscale, 10, 0) layout.addWidget(stop_tailscale, 10, 1) layout.addWidget(self.tailscale_status, 11, 0, 1, 2) layout.addWidget(self.qr_code, 12, 0, 1, 2) layout.addWidget(self.qr_caption, 13, 0, 1, 2) layout.addWidget(self.status, 14, 0, 1, 2) root.addWidget(card) root.addStretch() save.clicked.connect(self._save) toggle.clicked.connect(self._toggle) open_url.clicked.connect(self._open_status_url) phone_access.clicked.connect(self._enable_phone_access) copy_phone_url.clicked.connect(self._copy_phone_url) start_tailscale.clicked.connect(self._start_tailscale) stop_tailscale.clicked.connect(self._stop_tailscale) regenerate.clicked.connect(self._regenerate) self.refresh() def refresh(self) -> None: settings = self.service.settings() self.enabled.setChecked(bool(settings["enabled"])) mode_index = self.remote_mode.findData(settings.get("remote_mode", REMOTE_MODE_LOCAL)) self.remote_mode.setCurrentIndex(max(0, mode_index)) self.bind.setText(str(settings["bind_address"])) self.port.setValue(int(settings["port"])) self.token.setText(str(settings["token"])) self.allow_job_control.setChecked(bool(settings["allow_job_control"])) self.auto_approve_training.setChecked(bool(settings.get("auto_approve_training", False))) if not hasattr(self, "_status_reader"): self._status_reader = BackgroundRead(self) self._status_reader.ready.connect(self._status_loaded) self._status_reader.failed.connect(lambda error: self.status.setText(f"Could not refresh connection status: {error}")) service = self.service self._status_reader.request(lambda: (settings, service.phone_test_url(), service.tailscale_status(), service.tailscale_url()), force=True) def _status_loaded(self, result: object) -> None: settings, phone_url, tailscale, tailscale_url = result if settings != self.service.settings(): self.refresh() return scope = remote_scope(str(settings["bind_address"])) mode = str(settings.get("remote_mode", REMOTE_MODE_LOCAL)) self.bind.setEnabled(mode != REMOTE_MODE_TAILSCALE) tailscale_lines = [ f"Tailscale: {'installed' if tailscale.installed else 'not installed'}", f"Status: {'connected' if tailscale.connected else 'disconnected'}", ] if tailscale.device_name: tailscale_lines.append(f"Device: {tailscale.device_name}") if tailscale.tailscale_ip: tailscale_lines.append(f"Private IP: {tailscale.tailscale_ip}") if tailscale_url: tailscale_lines.append(f"Private URL: {tailscale_url}") elif mode == REMOTE_MODE_TAILSCALE: tailscale_lines.append("Private URL appears after Tailscale is installed, connected, and Serve is started.") tailscale_lines.append(tailscale.message) self.tailscale_status.setText("\n".join(tailscale_lines)) phone_hint = ( f" Phone URL: {phone_url}" if phone_url else " For the phone dashboard and QR code, use the button below while your phone is on the same Wi-Fi." ) self.status.setText( ( f"Running at {self.service.url()} · Scope: {scope}. " "Use Open local test URL on this computer, scan the QR code on your phone, or send the token as a Bearer token from another device. " "Dangerous actions are unavailable remotely." + phone_hint ) if self.service.running else ( f"Stopped · Scope when started: {scope}. Keep 127.0.0.1 for this device only. " "Remote clients can view status, system usage, and queue state." + phone_hint ) ) self._refresh_qr(phone_url) def _save(self) -> bool: bind = self.bind.text().strip() or "127.0.0.1" was_running = self.service.running mode = str(self.remote_mode.currentData() or REMOTE_MODE_LOCAL) enabled = self.enabled.isChecked() and mode != REMOTE_MODE_DISABLED if mode == REMOTE_MODE_TAILSCALE: bind = "127.0.0.1" if enabled and mode == REMOTE_MODE_LOCAL and bind not in {"127.0.0.1", "localhost"}: answer = QMessageBox.question( self, "Enable local-network access", "This can expose ADAM status to other devices on your network. Continue only on a trusted network.", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if answer != QMessageBox.Yes: return False self.service.save_settings( { "enabled": enabled, "remote_mode": mode, "bind_address": bind, "port": self.port.value(), "token": self.token.text().strip(), "allow_job_control": self.allow_job_control.isChecked(), "auto_approve_training": self.auto_approve_training.isChecked(), } ) if was_running: self.service.stop() if enabled: try: self.service.start() except (OSError, RuntimeError) as exc: self.status.setText(f"Remote access settings were saved, but restart failed: {exc}") return False self.refresh() return True def _toggle(self) -> None: if self.service.running: self.service.stop() self.refresh() return self.enabled.setChecked(True) if not self._save(): return try: self.service.start() self.refresh() except (OSError, RuntimeError) as exc: self.status.setText(f"Remote access could not start: {exc}") def _open_status_url(self) -> None: QDesktopServices.openUrl(QUrl(self.service.local_test_url())) def _enable_phone_access(self) -> None: self.enabled.setChecked(True) self.remote_mode.setCurrentIndex(max(0, self.remote_mode.findData(REMOTE_MODE_LOCAL))) self.bind.setText("0.0.0.0") if self._save() and not self.service.running: try: self.service.start() except (OSError, RuntimeError) as exc: self.status.setText(f"Remote access could not start: {exc}") return self.refresh() def _copy_phone_url(self) -> None: url = self.service.phone_test_url() if not url: self.status.setText("No phone URL yet. Choose Use phone access, then keep your phone on the same Wi-Fi.") return QApplication.clipboard().setText(url) self.status.setText(f"Copied phone URL: {url}") def _start_tailscale(self) -> None: self.enabled.setChecked(True) self.remote_mode.setCurrentIndex(max(0, self.remote_mode.findData(REMOTE_MODE_TAILSCALE))) self.bind.setText("127.0.0.1") if not self._save(): return if not self.service.running: try: self.service.start() except (OSError, RuntimeError) as exc: self.status.setText(f"ADAM Remote could not start for Tailscale: {exc}") return ok, message = self.service.start_tailscale_serve() self.status.setText(message) self.refresh() def _stop_tailscale(self) -> None: answer = QMessageBox.question( self, "Stop Tailscale Serve", "This resets Tailscale Serve forwarding on this PC. ADAM will keep running locally. Continue?", QMessageBox.Yes | QMessageBox.No, QMessageBox.No, ) if answer != QMessageBox.Yes: return ok, message = self.service.stop_tailscale_serve() self.status.setText(message) self.refresh() def _refresh_qr(self, url: str) -> None: if not url: self.qr_code.setPixmap(QPixmap()) self.qr_code.setText("Use phone access to generate a QR code.") self.qr_caption.setText("The QR code appears here after ADAM is available to devices on your Wi-Fi.") return pixmap = self._qr_pixmap(url) if pixmap is None: self.qr_code.setPixmap(QPixmap()) self.qr_code.setText("QR package not installed") self.qr_caption.setText("Install ADAM requirements, then restart the app to generate phone QR codes.") return self.qr_code.setText("") self.qr_code.setPixmap(pixmap) self.qr_caption.setText("Scan this code to open the ADAM mobile dashboard on your phone.") @staticmethod def _qr_pixmap(url: str) -> QPixmap | None: try: import qrcode except ImportError: return None image = qrcode.make(url).convert("RGB") buffer = BytesIO() image.save(buffer, format="PNG") pixmap = QPixmap() if not pixmap.loadFromData(buffer.getvalue(), "PNG"): return None return pixmap.scaled(220, 220, Qt.KeepAspectRatio, Qt.FastTransformation) def _regenerate(self) -> None: from adam.remote_access import default_remote_settings self.token.setText(str(default_remote_settings()["token"])) self._save() class ToolsPage(QWidget): setup_requested = Signal() def __init__( self, registry: ToolRegistry, tool_folders: ToolFolderManager, ) -> None: super().__init__() self.registry = registry self.tool_folders = tool_folders root = QVBoxLayout(self) root.setContentsMargins(24, 20, 24, 17) root.setSpacing(12) title_row = QHBoxLayout() title_row.addWidget( _page_header( "Tool registry", "ADAM can execute only these registered backends. Disabled entries are safe placeholders.", ), 1, ) reload_button = QPushButton("Reload registry") reload_button.clicked.connect(self.reload) title_row.addWidget(reload_button, 0, Qt.AlignTop) root.addLayout(title_row) info = _card() info_layout = QHBoxLayout(info) info_layout.setContentsMargins(16, 12, 16, 12) info_icon = QLabel("i") info_icon.setAlignment(Qt.AlignCenter) info_icon.setFixedSize(25, 25) info_icon.setStyleSheet( f"border-radius: 12px; color: {COLORS['blue_2']}; " f"border: 1px solid {COLORS['blue']}; font-weight: 700;" ) info_text = QLabel( "Demo tools prove orchestration without downloading data or training. " "Connect a Python function or script in config/tools.json to replace a demo backend." ) info_text.setWordWrap(True) info_text.setProperty("muted", True) info_layout.addWidget(info_icon) info_layout.addWidget(info_text, 1) configure_demo = QPushButton("Configure demo tools") configure_demo.clicked.connect(self.setup_requested) info_layout.addWidget(configure_demo) root.addWidget(info) self.mode_summary = QLabel() self.mode_summary.setProperty("muted", True) root.addWidget(self.mode_summary) self.table = QTableWidget(0, 6) self.table.setHorizontalHeaderLabels( ["TOOL", "CATEGORY", "BACKEND", "MODE", "CONFIRM", "STATUS"] ) self.table.setAlternatingRowColors(True) self.table.setSelectionBehavior(QAbstractItemView.SelectRows) self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) self.table.verticalHeader().hide() header = self.table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.Stretch) for column in range(1, 6): header.setSectionResizeMode(column, QHeaderView.ResizeToContents) self.table.setToolTip( "Edit config/tools.json to point entries at your existing Python backends." ) root.addWidget(self.table, 1) self.reload() def reload(self) -> None: try: self.registry.load() except Exception as exc: QMessageBox.warning(self, "Registry error", str(exc)) return tools = self.registry.all() real_count = sum(tool.enabled and not tool.demo for tool in tools) demo_count = sum(tool.enabled and tool.demo for tool in tools) disabled_count = sum(not tool.enabled for tool in tools) self.mode_summary.setText( f"{real_count} real · {demo_count} demo · {disabled_count} not configured. " "Demo steps create transparent placeholders and never produce trained weights." ) folder_statuses = self.tool_folders.scan_all() self.table.setRowCount(len(tools)) for row, tool in enumerate(tools): folder_status = folder_statuses.get(tool.id) detected = bool(folder_status and folder_status.valid) backend_type = ( "External folder" if detected and not tool.enabled else str(tool.backend.get("type", "unconfigured")).title() ) if detected and tool.demo: status_text = "Demo active · folder detected" elif detected and not tool.enabled: status_text = "Detected · adapter pending" elif tool.enabled: status_text = "Ready" else: status_text = "Not configured" values = [ f"{tool.name}\n{tool.description}", tool.category, backend_type, "Demo" if tool.demo else "Real", "Required" if tool.requires_confirmation else "No", status_text, ] for column, value in enumerate(values): item = QTableWidgetItem(value) if column: item.setTextAlignment(Qt.AlignCenter) if column == 5: item.setForeground( Qt.green if tool.enabled else ( Qt.cyan if detected else Qt.gray ) ) if column == 3: item.setForeground(Qt.yellow if tool.demo else Qt.green) if folder_status and folder_status.path: item.setToolTip( f"{folder_status.path}\n{folder_status.message}" ) self.table.setItem(row, column, item) self.table.setRowHeight(row, 54) class SystemPage(QWidget): def __init__(self) -> None: super().__init__() root = QVBoxLayout(self) root.setContentsMargins(24, 20, 24, 17) root.setSpacing(12) root.addWidget( _page_header( "System monitor", "Live local telemetry for resource planning and training oversight.", ) ) metrics = QGridLayout() metrics.setHorizontalSpacing(12) metrics.setVerticalSpacing(12) self.cpu = MetricCard("CPU", COLORS["blue"]) self.ram = MetricCard("Memory", COLORS["purple"]) self.gpu = MetricCard("GPU", COLORS["green"]) self.vram = MetricCard("VRAM", COLORS["orange"]) metrics.addWidget(self.cpu, 0, 0) metrics.addWidget(self.ram, 0, 1) metrics.addWidget(self.gpu, 1, 0) metrics.addWidget(self.vram, 1, 1) root.addLayout(metrics) training = _card() training_layout = QVBoxLayout(training) training_layout.setContentsMargins(18, 17, 18, 17) training_layout.addWidget(_card_title("TRAINING MONITOR")) self.training_status = QLabel("No active training job") self.training_status.setStyleSheet("font-size: 18px; font-weight: 650;") self.training_progress = QProgressBar() self.training_progress.setRange(0, 100) self.resource_warning = QLabel() self.resource_warning.setWordWrap(True) self.training_log = QPlainTextEdit() self.training_log.setReadOnly(True) self.training_log.setMaximumHeight(145) training_hint = QLabel( "When a registered trainer runs, job progress, current step, logs, and " "output status are visible here and in Jobs." ) training_hint.setProperty("muted", True) training_hint.setWordWrap(True) training_layout.addWidget(self.training_status) training_layout.addWidget(self.training_progress) training_layout.addWidget(self.resource_warning) training_layout.addWidget(training_hint) training_layout.addWidget(self.training_log) training_layout.addStretch() root.addWidget(training, 1) def update_snapshot(self, snapshot: SystemSnapshot) -> None: self.cpu.update_metric( f"{snapshot.cpu_percent:.0f}%", "Current processor load", snapshot.cpu_percent, ) self.ram.update_metric( f"{snapshot.memory_percent:.0f}%", f"{snapshot.memory_used_gb:.1f} / {snapshot.memory_total_gb:.1f} GB", snapshot.memory_percent, ) temperature = ( f"{snapshot.gpu_temperature:.0f}°C" if snapshot.gpu_temperature is not None else "Temperature unavailable" ) self.gpu.update_metric( f"{snapshot.gpu_percent:.0f}%", f"{snapshot.gpu_name} · {temperature}", snapshot.gpu_percent, ) self.vram.update_metric( f"{snapshot.vram_percent:.0f}%", f"{snapshot.vram_used_gb:.1f} / {snapshot.vram_total_gb:.1f} GB", snapshot.vram_percent, ) warnings = [] if snapshot.vram_percent >= 92: warnings.append( "VRAM is nearly full; watch for an out-of-memory failure." ) if snapshot.gpu_temperature is not None and snapshot.gpu_temperature >= 85: warnings.append("GPU temperature is high; cooling may be needed.") self.resource_warning.setText(" · ".join(warnings)) self.resource_warning.setStyleSheet( f"color: {COLORS['orange'] if warnings else COLORS['muted']};" ) def set_active_job(self, job: Job | None) -> None: if job and any(step.tool_id.endswith("trainer") for step in job.plan.steps): self.training_status.setText( f"{job.plan.project_name} · {job.progress}% · {job.status.value}" ) self.training_progress.setValue(job.progress) self.training_log.setPlainText("\n".join(job.logs[-12:])) self.training_log.verticalScrollBar().setValue( self.training_log.verticalScrollBar().maximum() ) else: self.training_status.setText("No active training job") self.training_progress.setValue(0) self.training_log.clear() class ExternalToolDialog(QDialog): connector_saved = Signal() def __init__(self, root_path: Path, parent: QWidget | None = None) -> None: super().__init__(parent) self.root_path = root_path self.store = ExternalToolStore(root_path) self.analysis = ToolAnalysis("") self.scan_worker: ToolScanWorker | None = None self.setWindowTitle("Add External Tool") self.setMinimumSize(760, 680) root = QVBoxLayout(self) root.setSpacing(10) root.addWidget( _page_header( "External Tool Connector", "ADAM reads the selected files without running them, then explains compatibility and safety concerns.", ) ) folder_row = QHBoxLayout() self.folder = QLineEdit() self.folder.setPlaceholderText("Choose the external program folder…") browse = QPushButton("Browse") browse.clicked.connect(self._browse) self.scan_button = QPushButton("Scan safely") self.scan_button.setProperty("primary", True) self.scan_button.clicked.connect(self._scan) folder_row.addWidget(self.folder, 1) folder_row.addWidget(browse) folder_row.addWidget(self.scan_button) root.addLayout(folder_row) selection = QGridLayout() self.entry = QComboBox() self.config_files = QListWidget() self.config_files.setMaximumHeight(90) self.config_files.setSelectionMode(QAbstractItemView.MultiSelection) selection.addWidget(QLabel("Training entry script"), 0, 0) selection.addWidget(self.entry, 0, 1) selection.addWidget(QLabel("Important config files"), 1, 0, Qt.AlignTop) selection.addWidget(self.config_files, 1, 1) root.addLayout(selection) self.entry.currentTextChanged.connect(self._reanalyze) self.config_files.itemSelectionChanged.connect(self._reanalyze) identity = QGridLayout() self.tool_name = QLineEdit() self.tool_name.setPlaceholderText("Example: APVD Model Trainer") self.description = QLineEdit() self.description.setPlaceholderText("What this program trains or produces") self.arguments = QLineEdit() self.arguments.setPlaceholderText("Detected arguments, comma separated") self.required = QLineEdit() self.required.setPlaceholderText("Required arguments, comma separated") for row, (label, widget) in enumerate( ( ("Tool name", self.tool_name), ("Description", self.description), ("Command-line inputs", self.arguments), ("Required inputs", self.required), ) ): identity.addWidget(QLabel(label), row, 0) identity.addWidget(widget, row, 1) root.addLayout(identity) self.score = QLabel("Compatibility: not scanned") self.score.setStyleSheet("font-size: 20px; font-weight: 700;") root.addWidget(self.score) self.report = QPlainTextEdit() self.report.setReadOnly(True) self.report.setMinimumHeight(245) root.addWidget(self.report, 1) disclaimer = QLabel( "The rating is a static compatibility review, not a guarantee that third-party " "code is harmless. Every run remains approval-gated." ) disclaimer.setWordWrap(True) disclaimer.setProperty("muted", True) root.addWidget(disclaimer) self.buttons = QDialogButtonBox(QDialogButtonBox.Cancel | QDialogButtonBox.Save) self.buttons.button(QDialogButtonBox.Save).setText("Register external tool") self.buttons.button(QDialogButtonBox.Save).setEnabled(False) self.buttons.accepted.connect(self._save) self.buttons.rejected.connect(self.reject) root.addWidget(self.buttons) def _browse(self) -> None: selected = QFileDialog.getExistingDirectory( self, "Choose external tool folder", self.folder.text() or str(Path.home()) ) if selected: self.folder.setText(selected) self._scan() def _scan(self) -> None: folder = self.folder.text().strip() if self.scan_worker and self.scan_worker.isRunning(): return self.scan_button.setEnabled(False) self.scan_button.setText("Scanning…") self.report.setPlainText("Scanning the selected folder safely…") self.buttons.button(QDialogButtonBox.Save).setEnabled(False) self.buttons.button(QDialogButtonBox.Cancel).setEnabled(False) self.scan_worker = ToolScanWorker(folder) self.scan_worker.scanned.connect(self._scan_finished) self.scan_worker.failed.connect(self._scan_failed) self.scan_worker.finished.connect(self._scan_worker_finished) self.scan_worker.start() def _scan_finished(self, analysis: object) -> None: if not isinstance(analysis, ToolAnalysis): self._scan_failed("The folder scan returned an unexpected result.") return self.analysis = analysis self.entry.blockSignals(True) self.entry.clear() self.entry.addItems(self.analysis.entry_candidates) if self.analysis.selected_entry: self.entry.setCurrentText(self.analysis.selected_entry) self.entry.blockSignals(False) self.config_files.blockSignals(True) self.config_files.clear() self.config_files.addItems(self.analysis.config_files) self.config_files.blockSignals(False) if not self.tool_name.text().strip() and self.analysis.folder: self.tool_name.setText(Path(self.analysis.folder).name) self._reanalyze() def _scan_failed(self, message: str) -> None: self.report.setPlainText(f"The folder could not be scanned:\n\n{message}") def _scan_worker_finished(self) -> None: self.scan_button.setEnabled(True) self.scan_button.setText("Scan safely") self.buttons.button(QDialogButtonBox.Cancel).setEnabled(True) if self.scan_worker: self.scan_worker.deleteLater() self.scan_worker = None def closeEvent(self, event: QCloseEvent) -> None: if self.scan_worker and self.scan_worker.isRunning(): self.report.setPlainText( "Please wait for the safe folder scan to finish before closing this window." ) event.ignore() return super().closeEvent(event) def _reanalyze(self) -> None: if not self.analysis.folder: return configs = [item.text() for item in self.config_files.selectedItems()] self.analysis = analyze_selection(self.analysis, self.entry.currentText(), configs) self.arguments.setText(", ".join(self.analysis.arguments)) self.required.setText(", ".join(self.analysis.required_arguments)) color = ( COLORS["green"] if self.analysis.score >= 8 else COLORS["orange"] if self.analysis.score >= 5 else COLORS["red"] ) self.score.setText(f"Compatibility & safety rating: {self.analysis.score}/10") self.score.setStyleSheet(f"font-size: 20px; font-weight: 700; color: {color};") report = [ f"Entry script: {self.analysis.selected_entry or 'Not detected'}", f"Dataset format: {self.analysis.dataset_format}", f"Output: {self.analysis.output_behavior}", f"Checkpoints: {self.analysis.checkpoint_behavior}", f"Progress: {self.analysis.progress_behavior}", f"Resume training: {self.analysis.resume_behavior}", "", "Why ADAM gave this rating:", *[f" + {reason}" for reason in self.analysis.reasons], ] if self.analysis.warnings: report.extend(["", "Warnings:", *[f" ! {warning}" for warning in self.analysis.warnings]]) self.report.setPlainText("\n".join(report)) self.buttons.button(QDialogButtonBox.Save).setEnabled( bool(self.analysis.selected_entry and Path(self.analysis.folder).is_dir()) ) @staticmethod def _field_list(text: str) -> list[str]: values = [] for value in text.split(","): normalized = value.strip().lstrip("-").replace("-", "_") if normalized and normalized.replace("_", "").isalnum() and normalized not in values: values.append(normalized) return values def _save(self) -> None: name = self.tool_name.text().strip() if not name: QMessageBox.warning(self, "Tool name required", "Give this external tool a name.") return arguments = self._field_list(self.arguments.text()) required = [ value for value in self._field_list(self.required.text()) if value in arguments ] try: self.store.save_connector( name=name, description=self.description.text(), analysis=self.analysis, arguments=arguments, required_arguments=required, ) except (OSError, ValueError) as exc: QMessageBox.warning(self, "Could not register tool", str(exc)) return self.connector_saved.emit() self.accept() class SettingsPage(QWidget): saved = Signal() def __init__( self, config: ConfigManager, tool_folders: ToolFolderManager, ) -> None: super().__init__() self.config = config self.tool_folders = tool_folders self.folder_edits: dict[str, QLineEdit] = {} self.folder_statuses: dict[str, QLabel] = {} outer = QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) scroll = QScrollArea() scroll.setWidgetResizable(True) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) scroll.viewport().setStyleSheet(f"background: {COLORS['bg']};") content = QWidget() content.setStyleSheet(f"background: {COLORS['bg']};") root = QVBoxLayout(content) root.setContentsMargins(24, 20, 24, 17) root.setSpacing(12) scroll.setWidget(content) outer.addWidget(scroll) root.addWidget( _page_header( "Settings", "Local provider, safety gates, and notification preferences.", ) ) columns = QHBoxLayout() columns.setSpacing(12) provider_card = _card() provider_card.setMinimumHeight(300) provider_layout = QVBoxLayout(provider_card) provider_layout.setContentsMargins(18, 17, 18, 17) provider_layout.setSpacing(10) provider_layout.addWidget(_card_title("PLANNING MODEL")) provider_layout.addWidget(QLabel("Provider")) self.provider = QComboBox() self.provider.addItem("Ollama (local)", "ollama") self.provider.addItem("Manual safe planner", "manual") index = self.provider.findData(config.get("provider")) self.provider.setCurrentIndex(max(0, index)) provider_layout.addWidget(self.provider) provider_layout.addWidget(QLabel("Ollama URL")) self.ollama_url = QLineEdit(str(config.get("ollama_url"))) provider_layout.addWidget(self.ollama_url) provider_layout.addWidget(QLabel("Model")) self.ollama_model = QLineEdit(str(config.get("ollama_model"))) provider_layout.addWidget(self.ollama_model) provider_layout.addWidget(QLabel("Chat response detail")) self.ollama_chat_response_length = QComboBox() self.ollama_chat_response_length.addItem("Automatic", "automatic") self.ollama_chat_response_length.addItem("Short", "short") self.ollama_chat_response_length.addItem("Balanced", "balanced") self.ollama_chat_response_length.addItem("Detailed", "detailed") response_length_index = self.ollama_chat_response_length.findData( str(config.get("ollama_chat_response_length", "automatic")) ) self.ollama_chat_response_length.setCurrentIndex(max(0, response_length_index)) self.ollama_chat_response_length.setToolTip( "Automatic uses a small response for simple questions and more room for image reviews, explanations, and planning." ) provider_layout.addWidget(self.ollama_chat_response_length) provider_layout.addWidget(QLabel("Maximum response length")) self.ollama_chat_max_tokens = QSpinBox() self.ollama_chat_max_tokens.setRange(64, 4096) self.ollama_chat_max_tokens.setSingleStep(64) self.ollama_chat_max_tokens.setValue(int(config.get("ollama_chat_max_tokens", 1024))) self.ollama_chat_max_tokens.setSuffix(" tokens") self.ollama_chat_max_tokens.setToolTip( "Hard limit for a Chat Mode reply. Automatic detail stays within this value." ) provider_layout.addWidget(self.ollama_chat_max_tokens) self.web_search_enabled = QCheckBox("Enable web search in Chat Mode") self.web_search_enabled.setChecked(bool(config.get("web_search_enabled", True))) self.web_search_enabled.setToolTip( "ADAM searches only when you explicitly ask it to search or ask for current information." ) provider_layout.addWidget(self.web_search_enabled) self.web_link_reading_enabled = QCheckBox("Enable controlled link reading") self.web_link_reading_enabled.setChecked(bool(config.get("web_link_reading_enabled", True))) self.web_link_reading_enabled.setToolTip( "When explicitly asked, ADAM reads up to three public pages and gives Ollama short text extracts." ) provider_layout.addWidget(self.web_link_reading_enabled) test_row = QHBoxLayout() self.test_status = QLabel("Connection not tested") self.test_status.setProperty("muted", True) test_button = QPushButton("Test Ollama") test_button.clicked.connect(self.test_ollama) test_row.addWidget(self.test_status, 1) test_row.addWidget(test_button) provider_layout.addLayout(test_row) provider_layout.addStretch() safety_card = _card() safety_card.setMinimumHeight(300) safety_layout = QVBoxLayout(safety_card) safety_layout.setContentsMargins(18, 17, 18, 17) safety_layout.setSpacing(13) safety_layout.addWidget(_card_title("SAFETY & CONTROL")) safety_note = QLabel( "The execution allow-list and confirmation gates are architectural " "controls and cannot be disabled by an LLM." ) safety_note.setWordWrap(True) safety_note.setProperty("muted", True) safety_layout.addWidget(safety_note) self.long_tasks = QCheckBox("Ask before long tasks") self.long_tasks.setChecked(bool(config.get("ask_before_long_tasks"))) self.trusted_automation = QCheckBox( "Trusted mode: auto-start registered dataset + DDPM workflows" ) self.trusted_automation.setChecked( bool(config.get("trusted_dataset_ddpm_automation")) ) self.ollama_proposed_actions = QCheckBox( "Let the planning model propose registered actions" ) self.ollama_proposed_actions.setChecked( bool(config.get("ollama_proposed_actions", True)) ) self.ollama_proposed_actions.setToolTip( "The model can only propose a plan in Trainer Mode. ADAM validates every tool and setting, then still requires approval when needed." ) self.desktop_notifications = QCheckBox("Desktop notifications") self.desktop_notifications.setChecked(bool(config.get("desktop_notifications"))) self.sound_notifications = QCheckBox("Notification sounds") self.sound_notifications.setChecked(bool(config.get("sound_notifications"))) safety_layout.addWidget(self.long_tasks) safety_layout.addWidget(self.trusted_automation) safety_layout.addWidget(self.ollama_proposed_actions) safety_layout.addWidget(self.desktop_notifications) safety_layout.addWidget(self.sound_notifications) safety_layout.addWidget(QLabel("High-volume dataset threshold")) self.dataset_threshold = QSpinBox() self.dataset_threshold.setRange(1, 100_000) self.dataset_threshold.setValue( int(config.get("max_dataset_images_without_confirmation")) ) self.dataset_threshold.setSuffix(" images") safety_layout.addWidget(self.dataset_threshold) safety_layout.addStretch() columns.addWidget(provider_card, 1) columns.addWidget(safety_card, 1) root.addLayout(columns, 1) folders_card = _card() folders_card.setMinimumHeight(340) folders_layout = QGridLayout(folders_card) folders_layout.setContentsMargins(18, 15, 18, 15) folders_layout.setHorizontalSpacing(12) folders_layout.setVerticalSpacing(8) folders_title_row = QHBoxLayout() folders_title_row.addWidget(_card_title("TOOL FOLDERS")) folders_title_row.addStretch() scan_button = QPushButton("Scan folders") scan_button.clicked.connect(self.scan_tool_folders) folders_title_row.addWidget(scan_button) folders_layout.addLayout(folders_title_row, 0, 0, 1, 2) hint = QLabel( "Point ADAM at your existing programs. The code stays in its original " "folder; ADAM stores only the path and detected entry points." ) hint.setProperty("muted", True) hint.setWordWrap(True) folders_layout.addWidget(hint, 1, 0, 1, 2) for index, definition in enumerate(self.tool_folders.definitions.values()): column = index % 2 row = 2 + index // 2 folders_layout.addWidget( self._build_folder_field(definition.tool_id, definition.name), row, column, ) root.addWidget(folders_card) external_card = _card() external_layout = QVBoxLayout(external_card) external_layout.setContentsMargins(18, 15, 18, 15) external_header = QHBoxLayout() external_header.addWidget(_card_title("EXTERNAL TOOLS")) external_header.addStretch() add_external = QPushButton("Add external tool…") add_external.setProperty("primary", True) add_external.clicked.connect(self._open_external_tool) external_header.addWidget(add_external) external_layout.addLayout(external_header) external_hint = QLabel( "Connect Python training programs without changing ADAM's code. ADAM statically " "reviews the selected entry script, reports a 1–10 rating, and keeps every run approval-gated." ) external_hint.setWordWrap(True) external_hint.setProperty("muted", True) external_layout.addWidget(external_hint) self.external_tools_status = QLabel() self.external_tools_status.setWordWrap(True) external_layout.addWidget(self.external_tools_status) root.addWidget(external_card) self._refresh_external_tools() save = QPushButton("Save settings") save.setProperty("primary", True) save.clicked.connect(self.save) root.addWidget(save, 0, Qt.AlignRight) QTimer.singleShot(0, self.scan_tool_folders) def _open_external_tool(self) -> None: dialog = ExternalToolDialog(self.config.root, self) dialog.connector_saved.connect(self._external_tool_saved) dialog.exec() def _external_tool_saved(self) -> None: self._refresh_external_tools() self.saved.emit() def _refresh_external_tools(self) -> None: tools = ExternalToolStore(self.config.root).load() if not tools: self.external_tools_status.setText("No external tools registered yet.") return summaries = [] for tool in tools: analysis = tool.get("analysis", {}) score = analysis.get("score", "?") if isinstance(analysis, dict) else "?" summaries.append(f"{tool.get('name', 'Unnamed tool')} · {score}/10 · confirmation required") self.external_tools_status.setText("\n".join(summaries)) def save(self) -> None: folder_values = { tool_id: edit.text().strip() for tool_id, edit in self.folder_edits.items() } self.config.update( { "provider": self.provider.currentData(), "ollama_url": self.ollama_url.text().strip(), "ollama_model": self.ollama_model.text().strip(), "ollama_chat_max_tokens": self.ollama_chat_max_tokens.value(), "ollama_chat_response_length": self.ollama_chat_response_length.currentData(), "ollama_proposed_actions": self.ollama_proposed_actions.isChecked(), "web_search_enabled": self.web_search_enabled.isChecked(), "web_link_reading_enabled": self.web_link_reading_enabled.isChecked(), "ask_before_long_tasks": self.long_tasks.isChecked(), "trusted_dataset_ddpm_automation": self.trusted_automation.isChecked(), "desktop_notifications": self.desktop_notifications.isChecked(), "sound_notifications": self.sound_notifications.isChecked(), "max_dataset_images_without_confirmation": self.dataset_threshold.value(), } ) self.tool_folders.update(folder_values) self.scan_tool_folders() self.saved.emit() self.test_status.setText("Settings saved") self.test_status.setStyleSheet(f"color: {COLORS['green']};") def test_ollama(self) -> None: self.test_status.setText("Checking…") client = OllamaClient( self.ollama_url.text().strip(), self.ollama_model.text().strip(), ) models = client.list_models(timeout=2.0) configured_model = self.ollama_model.text().strip() if configured_model in models: self.test_status.setText(f"Ready · {configured_model} is installed") self.test_status.setStyleSheet(f"color: {COLORS['green']};") elif models: self.test_status.setText( f"Model not installed · available: {', '.join(models[:3])}" ) self.test_status.setStyleSheet(f"color: {COLORS['orange']};") else: self.test_status.setText( "Ollama service is not reachable · safe planner will be used" ) self.test_status.setStyleSheet(f"color: {COLORS['orange']};") def _build_folder_field(self, tool_id: str, name: str) -> QFrame: field = QFrame() field.setProperty("innerCard", True) layout = QVBoxLayout(field) layout.setContentsMargins(10, 8, 10, 8) layout.setSpacing(5) name_label = QLabel(name) name_label.setStyleSheet("font-size: 11px; font-weight: 650;") row = QHBoxLayout() edit = QLineEdit(self.tool_folders.get(tool_id)) edit.setPlaceholderText("Choose the program folder…") edit.setToolTip("The existing program is not copied or modified.") browse = QPushButton("Browse") browse.setFixedWidth(72) browse.clicked.connect( lambda _checked=False, key=tool_id: self._browse_folder(key) ) row.addWidget(edit, 1) row.addWidget(browse) status = QLabel("Not scanned") status.setProperty("muted", True) status.setStyleSheet("font-size: 10px;") self.folder_edits[tool_id] = edit self.folder_statuses[tool_id] = status layout.addWidget(name_label) layout.addLayout(row) layout.addWidget(status) return field def _browse_folder(self, tool_id: str) -> None: current = self.folder_edits[tool_id].text().strip() selected = QFileDialog.getExistingDirectory( self, f"Choose {self.tool_folders.definitions[tool_id].name} folder", current or str(Path.home()), ) if selected: self.folder_edits[tool_id].setText(selected) self.tool_folders.set(tool_id, selected) self._show_folder_status(self.tool_folders.scan(tool_id)) self.saved.emit() def scan_tool_folders(self) -> None: self.tool_folders.update( { tool_id: edit.text().strip() for tool_id, edit in self.folder_edits.items() } ) for status in self.tool_folders.scan_all().values(): self._show_folder_status(status) def refresh_tool_folders(self) -> None: for tool_id, edit in self.folder_edits.items(): edit.setText(self.tool_folders.get(tool_id)) self.scan_tool_folders() def _show_folder_status(self, status: ToolFolderStatus) -> None: label = self.folder_statuses.get(status.tool_id) if not label: return label.setText(status.message) color = ( COLORS["green"] if status.valid else COLORS["red"] if status.path else COLORS["muted"] ) label.setStyleSheet(f"font-size: 10px; color: {color};") class MainWindow(QMainWindow): PAGE_COMMAND = 0 PAGE_CHAT_HISTORY = 1 PAGE_STUDIO = 2 PAGE_DATASET_LAB = 3 PAGE_EXPERIMENTS = 4 PAGE_GENERATIONS = 5 PAGE_SHOWCASE = 6 PAGE_JOBS = 7 PAGE_TOOLS = 8 PAGE_SYSTEM = 9 PAGE_REMOTE = 10 PAGE_SETTINGS = 11 PAGE_VIDEO_LORA = 12 PAGE_OASIS_PLAYER = 13 PAGE_MODEL_INTELLIGENCE = 14 def __init__( self, root_path: Path, planner: Planner, registry: ToolRegistry, jobs: JobManager, config: ConfigManager, monitor: SystemMonitor, tool_folders: ToolFolderManager, ) -> None: super().__init__() self.root_path = root_path self.jobs = jobs self.config = config self.monitor = monitor self.remote_service = RemoteAccessService(config, jobs, monitor, planner) self.setWindowTitle("ADAM — AI Development and Automation Manager") self.resize(1480, 900) self.setMinimumSize(1120, 760) self.setStyleSheet(APP_STYLESHEET) self.tray_icon: QSystemTrayIcon | None = None logo_path = self.root_path / "assets" / "adam_atom.png" if logo_path.exists(): self.setWindowIcon(QIcon(str(logo_path))) if QSystemTrayIcon.isSystemTrayAvailable() and logo_path.exists(): self.tray_icon = QSystemTrayIcon(QIcon(str(logo_path)), self) self.tray_icon.setToolTip("ADAM · AI Workflow Manager") self.tray_icon.show() root = QWidget() root.setObjectName("Root") root_layout = QHBoxLayout(root) root_layout.setContentsMargins(0, 0, 0, 0) root_layout.setSpacing(0) root_layout.addWidget(self._build_sidebar()) content = QWidget() content_layout = QVBoxLayout(content) content_layout.setContentsMargins(0, 0, 0, 0) content_layout.setSpacing(0) self.stack = QStackedWidget() self.command_page = CommandCenterPage( planner, jobs, config, tool_folders, root_path, self.remote_service, ) self.command_scroll = QScrollArea() self.command_scroll.setWidgetResizable(True) self.command_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.command_scroll.setFrameShape(QFrame.NoFrame) self.command_scroll.setWidget(self.command_page) self.jobs_page = JobsPage(jobs) self.studio_page = StudioPage(root_path, jobs, jobs.assets, config) self.dataset_lab_page = DatasetLabPage(root_path) self.experiment_page = ExperimentTrackerPage(jobs.experiments) self.generations_page = GenerationsPage( root_path, registry, jobs, jobs.assets, config ) self.showcase_page = ShowcasePage( root_path, registry, jobs, jobs.assets, config ) self.video_lora_page = VideoLoraPage(root_path, registry, jobs, jobs.assets, config) self.video_lora_page.open_jobs_requested.connect(self._open_video_jobs) self.oasis_player_page = OasisPlayerPage(root_path, jobs.assets, config) self.model_intelligence_page = ModelIntelligencePage(root_path, jobs.experiments) self.tools_page = ToolsPage(registry, tool_folders) self.system_page = SystemPage() self.remote_page = RemoteAccessPage(self.remote_service) self.settings_page = SettingsPage(config, tool_folders) self.chat_history_page = ChatHistoryPage(self.command_page.history_store) for page in ( self.command_scroll, self.chat_history_page, self.studio_page, self.dataset_lab_page, self.experiment_page, self.generations_page, self.showcase_page, self.jobs_page, self.tools_page, self.system_page, self.remote_page, self.settings_page, self.video_lora_page, self.oasis_player_page, self.model_intelligence_page, ): self.stack.addWidget(page) content_layout.addWidget(self.stack, 1) content_layout.addWidget(self._build_status_bar()) root_layout.addWidget(content, 1) self.setCentralWidget(root) self.settings_page.saved.connect(self.command_page.refresh_provider_badge) self.settings_page.saved.connect(self.tools_page.reload) self.tools_page.setup_requested.connect(lambda: self._switch_page(self.PAGE_SETTINGS)) self.command_page.tool_folders_changed.connect( self.settings_page.refresh_tool_folders ) self.command_page.tool_folders_changed.connect(self.tools_page.reload) self.command_page.open_jobs_requested.connect(lambda: self._switch_page(self.PAGE_JOBS)) self.command_page.open_dataset_lab_requested.connect(lambda: self._switch_page(self.PAGE_DATASET_LAB)) self.command_page.open_experiments_requested.connect(lambda: self._switch_page(self.PAGE_EXPERIMENTS)) self.command_page.open_remote_requested.connect(lambda: self._switch_page(self.PAGE_REMOTE)) self.command_page.history_changed.connect(self.chat_history_page.refresh) self.chat_history_page.open_requested.connect(self._open_saved_conversation) self.experiment_page.clone_requested.connect(self._clone_experiment_request) self.model_intelligence_page.plan_requested.connect(self._plan_from_intelligence) self.model_intelligence_page.open_experiments_requested.connect( lambda: self._switch_page(self.PAGE_EXPERIMENTS) ) self.jobs.active_changed.connect(self.system_page.set_active_job) self.jobs.job_updated.connect(self._update_system_job) self.experiment_refresh_timer = QTimer(self) self.experiment_refresh_timer.setInterval(1000) self.experiment_refresh_timer.setSingleShot(True) self.experiment_refresh_timer.timeout.connect(self._refresh_visible_experiments) self.jobs.job_updated.connect(self._schedule_experiment_refresh) self.jobs.notification.connect(self._show_notification) self.studio_page.plan_requested.connect(self._plan_from_studio) self._page_refresh_timer = QTimer(self) self._page_refresh_timer.setSingleShot(True) self._page_refresh_timer.setInterval(30) self._page_refresh_timer.timeout.connect(self._refresh_current_page) self._switch_page(self.PAGE_COMMAND) self.monitor_timer = QTimer(self) self.monitor_timer.timeout.connect(self._refresh_monitor) self.monitor_timer.start(1500) self._refresh_monitor() if self.remote_service.settings().get("enabled"): QTimer.singleShot(500, self._start_saved_remote_access) if any(job.status == JobStatus.INTERRUPTED for job in self.jobs.jobs): QTimer.singleShot(350, self._offer_recovery) def _build_sidebar(self) -> QFrame: sidebar = QFrame() sidebar.setObjectName("Sidebar") sidebar.setFixedWidth(230) outer = QVBoxLayout(sidebar) outer.setContentsMargins(0, 0, 0, 0) scroll = QScrollArea() scroll.setObjectName("SidebarScroll") scroll.setWidgetResizable(True) scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) content = QWidget() layout = QVBoxLayout(content) layout.setSizeConstraint(QVBoxLayout.SetMinimumSize) scroll.setWidget(content) outer.addWidget(scroll) layout.setContentsMargins(0, 20, 0, 16) layout.setSpacing(3) brand = QWidget() brand_layout = QHBoxLayout(brand) brand_layout.setContentsMargins(0, 0, 0, 18) brand_layout.setSpacing(7) logo = QLabel() pixmap = QPixmap(str(self.root_path / "assets" / "adam_atom.png")) if not pixmap.isNull(): logo.setPixmap(pixmap) logo.setScaledContents(True) logo.setFixedSize(66, 66) logo.setAlignment(Qt.AlignCenter) names = QVBoxLayout() names.setSpacing(0) app_name = QLabel("ADAM") app_name.setObjectName("AppName") subtitle = QLabel("AI WORKFLOW\nMANAGER") subtitle.setStyleSheet( f"color: {COLORS['blue_2']}; font-size: 9px; font-weight: 700; " "letter-spacing: 1px;" ) names.addWidget(app_name) names.addWidget(subtitle) brand_layout.addWidget(logo) brand_layout.addLayout(names) layout.addWidget(brand) section = QLabel(" WORKSPACE") section.setStyleSheet( f"color: #557083; font-size: 9px; font-weight: 700; " "letter-spacing: 1.5px; padding: 8px 16px;" ) layout.addWidget(section) nav_items = [ ("COMMAND CENTER", self.PAGE_COMMAND), ("CHAT HISTORY", self.PAGE_CHAT_HISTORY), ("TRAINING STUDIO", self.PAGE_STUDIO), ("DATASET LAB", self.PAGE_DATASET_LAB), ("EXPERIMENT TRACKER", self.PAGE_EXPERIMENTS), ("GENERATIONS", self.PAGE_GENERATIONS), ("SHOWCASE VIDEO", self.PAGE_SHOWCASE), ("VIDEO LORA", self.PAGE_VIDEO_LORA), ("OASIS PLAYER", self.PAGE_OASIS_PLAYER), ("MODEL INTELLIGENCE", self.PAGE_MODEL_INTELLIGENCE), ("JOBS / HISTORY", self.PAGE_JOBS), ("TOOL REGISTRY", self.PAGE_TOOLS), ("SYSTEM MONITOR", self.PAGE_SYSTEM), ("REMOTE ACCESS", self.PAGE_REMOTE), ("SETTINGS", self.PAGE_SETTINGS), ] self.nav_buttons: list[QPushButton] = [] for text, index in nav_items: button = QPushButton(text) button.setProperty("nav", True) button.setProperty("pageIndex", index) button.clicked.connect(lambda _checked=False, value=index: self._switch_page(value)) self.nav_buttons.append(button) layout.addWidget(button) quick_title = QLabel(" QUICK ACCESS") quick_title.setStyleSheet( f"color: #557083; font-size: 9px; font-weight: 700; " "letter-spacing: 1.5px; padding: 10px 16px 3px 16px;" ) layout.addWidget(quick_title) for icon, title, action in ( ("▦", "Create Dataset", "dataset"), ("◉", "LoRA Trainer", "lora"), ("◎", "DDPM Trainer", "ddpm"), ("⌁", "Flow Matching", "flow"), ("▤", "Dataset Lab", "dataset_lab"), ("▥", "Compare Runs", "experiments"), ("▧", "Image Generator", "generations"), ("▣", "Video Generator", "video"), ): button = QPushButton(f"{icon} {title}") button.setProperty("quick", True) button.clicked.connect( lambda _checked=False, value=action: self._quick_access(value) ) layout.addWidget(button) layout.addStretch(1) safety = QFrame() safety.setProperty("innerCard", True) safety_layout = QVBoxLayout(safety) safety_layout.setContentsMargins(12, 11, 12, 11) safety_layout.setSpacing(4) safe_title = QLabel("● APPROVAL SETTINGS") safe_title.setStyleSheet( f"color: {COLORS['green']}; font-size: 10px; font-weight: 700;" ) safe_body = QLabel() self.safety_summary = safe_body safe_body.setWordWrap(True) safe_body.setProperty("muted", True) safe_body.setStyleSheet("font-size: 11px;") safety_layout.addWidget(safe_title) safety_layout.addWidget(safe_body) layout.addWidget(safety, 0) version = QLabel("ADAM 0.1.0 · LOCAL") version.setAlignment(Qt.AlignCenter) version.setStyleSheet("color: #40596a; font-size: 9px; padding-top: 10px;") layout.addWidget(version) layout.setContentsMargins(12, 20, 12, 16) return sidebar def _schedule_experiment_refresh(self, _job: Job) -> None: if ( (self.experiment_page.isVisible() or self.model_intelligence_page.isVisible()) and not self.experiment_refresh_timer.isActive() ): self.experiment_refresh_timer.start() def _refresh_visible_experiments(self) -> None: if self.experiment_page.isVisible(): self.experiment_page.refresh() if self.model_intelligence_page.isVisible(): self.model_intelligence_page.refresh_if_stale() def _build_status_bar(self) -> QFrame: bar = QFrame() bar.setObjectName("TopBar") bar.setFixedHeight(34) layout = QHBoxLayout(bar) layout.setContentsMargins(16, 0, 18, 0) layout.setSpacing(18) self.bottom_status = QLabel("● READY") self.bottom_status.setStyleSheet( f"color: {COLORS['green']}; font-size: 10px; font-weight: 700;" ) self.bottom_cpu = QLabel("CPU —") self.bottom_ram = QLabel("RAM —") self.bottom_gpu = QLabel("GPU —") self.bottom_vram = QLabel("VRAM —") for label in ( self.bottom_cpu, self.bottom_ram, self.bottom_gpu, self.bottom_vram, ): label.setProperty("muted", True) label.setStyleSheet("font-size: 10px;") layout.addWidget(self.bottom_status) layout.addStretch() layout.addWidget(self.bottom_cpu) layout.addWidget(self.bottom_ram) layout.addWidget(self.bottom_gpu) layout.addWidget(self.bottom_vram) return bar def _switch_page(self, index: int) -> None: if not hasattr(self, "stack"): return self.stack.setCurrentIndex(index) # Paint the selected workspace first, and discard refreshes for tabs # passed over during rapid navigation. self._page_refresh_timer.start() for button in self.nav_buttons: active = button.property("pageIndex") == index if button.property("navActive") != active: button.setProperty("navActive", active) button.style().unpolish(button) button.style().polish(button) def _refresh_current_page(self) -> None: index = self.stack.currentIndex() if index == self.PAGE_STUDIO: self.studio_page.refresh() elif index == self.PAGE_DATASET_LAB: self.dataset_lab_page.folder.setFocus() elif index == self.PAGE_EXPERIMENTS: self.experiment_page.refresh() elif index == self.PAGE_GENERATIONS: self.generations_page.refresh() elif index == self.PAGE_SHOWCASE: self.showcase_page.refresh() elif index == self.PAGE_VIDEO_LORA: self.video_lora_page.refresh() elif index == self.PAGE_OASIS_PLAYER: self.oasis_player_page.refresh() elif index == self.PAGE_MODEL_INTELLIGENCE: self.model_intelligence_page.refresh_if_stale() elif index == self.PAGE_REMOTE: self.remote_page.refresh() elif index == self.PAGE_COMMAND: self.command_page.refresh_remote_access() if index == self.PAGE_CHAT_HISTORY: self.chat_history_page.refresh() def _open_saved_conversation(self, conversation: dict) -> None: self.command_page.open_conversation(conversation) self._switch_page(self.PAGE_COMMAND) def _open_video_jobs(self) -> None: if self.video_lora_page.active_id: self.jobs_page.selected_job_id = self.video_lora_page.active_id self.jobs_page.refresh() self._switch_page(self.PAGE_JOBS) def _clone_experiment_request(self, request: str) -> None: if not request: return self._switch_page(self.PAGE_COMMAND) self.command_page.submit(request) def _plan_from_intelligence(self, request: str) -> None: """Send a coach suggestion through the normal reviewed planning path.""" if not request: return self._switch_page(self.PAGE_COMMAND) self.command_page.submit(request) def _quick_access(self, action: str) -> None: if action == "generations": self._switch_page(self.PAGE_GENERATIONS) return if action == "video": self._switch_page(self.PAGE_SHOWCASE) return if action == "dataset_lab": self._switch_page(self.PAGE_DATASET_LAB) return if action == "experiments": self._switch_page(self.PAGE_EXPERIMENTS) return self._switch_page(self.PAGE_COMMAND) if action == "dataset": self.command_page.submit("Adam, collect a dataset") elif action == "lora": self.command_page.submit("Adam, train a LoRA model") elif action == "ddpm": self.command_page.submit("Adam, train a DDPM model") elif action == "flow": self.command_page.submit("Adam, train a Flow Matching model") def _plan_from_studio(self, request: str) -> None: self._switch_page(self.PAGE_COMMAND) self.command_page.submit(request) def _refresh_monitor(self) -> None: remote = self.remote_service.settings() approval = "Remote auto-approval on" if remote.get("auto_approve_training") else "Remote approval required" local = "Long-task prompts on" if self.config.get("ask_before_long_tasks", True) else "Long-task prompts off" self.safety_summary.setText(f"{local}\n{approval}") snapshot = self.monitor.snapshot() self.jobs.supervise(snapshot) self.system_page.update_snapshot(snapshot) self.command_page.update_snapshot(snapshot) self.bottom_cpu.setText(f"CPU {snapshot.cpu_percent:.0f}%") self.bottom_ram.setText(f"RAM {snapshot.memory_percent:.0f}%") self.bottom_gpu.setText(f"GPU {snapshot.gpu_percent:.0f}%") self.bottom_vram.setText(f"VRAM {snapshot.vram_percent:.0f}%") active = self.jobs.active_job if active: self.bottom_status.setText( f"● {active.status.value.upper()} · {active.plan.project_name}" ) self.bottom_status.setStyleSheet( f"color: {COLORS['blue_2']}; font-size: 10px; font-weight: 700;" ) else: self.bottom_status.setText("● READY") self.bottom_status.setStyleSheet( f"color: {COLORS['green']}; font-size: 10px; font-weight: 700;" ) def _update_system_job(self, job: Job) -> None: if self.jobs.active_job and job.id == self.jobs.active_job.id: self.system_page.set_active_job(job) def _show_notification(self, title: str, message: str) -> None: self.statusBar().showMessage(f"{title}: {message}", 6000) if self.config.get("desktop_notifications") and self.tray_icon: self.tray_icon.showMessage( title, message, QSystemTrayIcon.Information, 6000, ) if self.config.get("sound_notifications"): QApplication.beep() def _start_saved_remote_access(self) -> None: try: self.remote_service.start() self.remote_page.refresh() except OSError as exc: self.remote_page.status.setText(f"Remote access is enabled, but it could not start: {exc}") def _offer_recovery(self) -> None: interrupted = [ job for job in self.jobs.jobs if job.status == JobStatus.INTERRUPTED ] if not interrupted: return choice = QMessageBox.question( self, "Interrupted work found", f"ADAM found {len(interrupted)} job(s) interrupted by a previous " "shutdown. Open Jobs to review logs and retry safely?", QMessageBox.Yes | QMessageBox.No, QMessageBox.Yes, ) if choice == QMessageBox.Yes: self._switch_page(4) def closeEvent(self, event: QCloseEvent) -> None: if self.tray_icon: self.tray_icon.hide() self.remote_service.stop() self.studio_page.shutdown() self.oasis_player_page.shutdown() super().closeEvent(event)