ADAM October 2026 source release: PixelRow, INRFlow, Wan Video, Oasis player and field guide
f8c73f9 verified Download adam/ui/main_window.py from SyntheticMDProductions/AI_Development_Automation_Manager: direct link, hf CLI and curl.
- Browser
- Download file 316 kB
-
https://huggingface.co/SyntheticMDProductions/AI_Development_Automation_Manager/resolve/main/adam/ui/main_window.py
- Command line
-
hf download hf://SyntheticMDProductions/AI_Development_Automation_Manager/adam/ui/main_window.py
-
curl -L -o main_window.py https://huggingface.co/SyntheticMDProductions/AI_Development_Automation_Manager/resolve/main/adam/ui/main_window.py
316 kB
| 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] = [] | |
| def _divider() -> QFrame: | |
| divider = QFrame() | |
| divider.setFrameShape(QFrame.VLine) | |
| divider.setStyleSheet(f"color: {COLORS['border']};") | |
| return divider | |
| 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) | |
| 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() | |
| 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) | |
| 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}" | |
| 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" | |
| 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.") | |
| 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()) | |
| ) | |
| 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) | |