| from __future__ import annotations |
|
|
| from datetime import datetime |
| from dataclasses import replace |
| import json |
| from pathlib import Path |
| import re |
| import shutil |
| import sys |
|
|
| from PySide6.QtCore import QThread, QTimer, Qt, QUrl, Signal |
| from PySide6.QtGui import QCloseEvent, QDesktopServices, QIcon, QPixmap |
| from PySide6.QtWidgets import ( |
| QAbstractItemView, |
| QApplication, |
| QCheckBox, |
| QComboBox, |
| QDialog, |
| QDialogButtonBox, |
| QDoubleSpinBox, |
| QFileDialog, |
| QFrame, |
| QGridLayout, |
| QGroupBox, |
| QHBoxLayout, |
| QHeaderView, |
| QLabel, |
| QLineEdit, |
| QListWidget, |
| QListWidgetItem, |
| QMainWindow, |
| QMessageBox, |
| QPlainTextEdit, |
| QProgressBar, |
| QPushButton, |
| QScrollArea, |
| QSizePolicy, |
| QSpinBox, |
| QStackedWidget, |
| QSystemTrayIcon, |
| QTabBar, |
| QTableWidget, |
| QTableWidgetItem, |
| QVBoxLayout, |
| QWidget, |
| ) |
|
|
| from adam.config import ConfigManager |
| from adam.generations import ( |
| ChatGenerationRequest, |
| build_generation_plan, |
| generation_model_match_score, |
| generation_tools, |
| parse_chat_generation_request, |
| ) |
| 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.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.registry import ToolRegistry |
| from adam.tool_folders import ToolFolderManager, ToolFolderStatus |
| 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.widgets import ( |
| ActiveJobPanel, |
| ChatBubble, |
| GenerationChatCard, |
| MetricCard, |
| PlanPanel, |
| PromptEdit, |
| SparklineWidget, |
| ) |
|
|
|
|
| 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) |
|
|
| |
| |
| 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 ChatWorker(QThread): |
| chunk = Signal(str) |
| answered = Signal(str) |
| failed = Signal(str) |
|
|
| def __init__( |
| self, |
| planner: Planner, |
| request: str, |
| history: list[dict[str, str]], |
| ) -> None: |
| super().__init__() |
| self.planner = planner |
| self.request = request |
| self.history = history |
|
|
| def run(self) -> None: |
| try: |
| response = self.planner.chat( |
| self.request, self.history, self.chunk.emit |
| ) |
| self.answered.emit(response) |
| except Exception as exc: |
| self.failed.emit(str(exc)) |
|
|
|
|
| class ModelCreationDialog(QDialog): |
| """Collects training choices in plain language and produces a planner request.""" |
|
|
| 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._model_states: list[dict[str, object]] = [] |
| self._current_model_index = 0 |
| self.setWindowTitle("Model Creation Assistant") |
| self.setMinimumWidth(560) |
| outer = QVBoxLayout(self) |
| outer.setContentsMargins(0, 0, 0, 0) |
| scroll = QScrollArea() |
| scroll.setWidgetResizable(True) |
| scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) |
| content = QWidget() |
| root = QVBoxLayout(content) |
| 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.", |
| ) |
| ) |
| journey = QLabel( |
| "1 GOAL → 2 DATASET → 3 TRAINING RECIPE → 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) |
|
|
| 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 = QHBoxLayout() |
| self.bulk_add_button = QPushButton("Paste model list…") |
| 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 button in ( |
| self.bulk_add_button, self.apply_many_button, self.save_draft_button, |
| self.load_draft_button, self.match_existing_button, self.refresh_datasets_button, |
| ): |
| button.setProperty("chip", True) |
| batch_tools.addWidget(button) |
| batch_tools.addStretch() |
| root.addLayout(batch_tools) |
|
|
| form = QGridLayout() |
| form.setHorizontalSpacing(12) |
| form.setVerticalSpacing(9) |
| self.preset = QComboBox() |
| self.presets = presets_from_config(config) |
| self.preset.addItems(self.presets) |
| self.trainer = QComboBox() |
| self.trainer.addItem("LoRA", "lora") |
| self.trainer.addItem("DDPM", "ddpm") |
| self.trainer.addItem("Flow Matching", "flow") |
| self.source = QComboBox() |
| self.source.addItem("Create a new dataset", "new") |
| self.source.addItem("Use an existing dataset", "existing") |
| 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.epochs = QSpinBox() |
| self.epochs.setRange(1, 100_000) |
| 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), |
| ("Dataset choice", self.source), |
| ("What should it learn?", self.subject), |
| ("Existing dataset", self.dataset), |
| ("Model name", self.model_name), |
| ("Training length", self.epochs), |
| ("Internet image collection", self.collection_mode), |
| ("New dataset size", self.images), |
| ] |
| for row, (label, widget) in enumerate(rows): |
| form.addWidget(QLabel(label), row, 0) |
| form.addWidget(widget, row, 1) |
| root.addLayout(form) |
| self.options_group = QGroupBox("Training options") |
| options = QGridLayout(self.options_group) |
| self.resolution = QComboBox(); self.resolution.addItems(["64", "128", "256", "384", "512"]) |
| self.batch_size = QSpinBox(); self.batch_size.setRange(1, 64) |
| self.learning_rate = QDoubleSpinBox(); self.learning_rate.setDecimals(7); self.learning_rate.setRange(0.0000001, 0.1); self.learning_rate.setSingleStep(0.00005) |
| self.gradient_accumulation = QSpinBox(); self.gradient_accumulation.setRange(1, 64) |
| self.workers = QSpinBox(); self.workers.setRange(0, 16) |
| self.precision = QComboBox(); self.precision.addItem("FP16 (faster / less VRAM)", "fp16"); self.precision.addItem("Full precision (more stable / slower)", "no") |
| self.save_every = QSpinBox(); self.save_every.setRange(1, 1000) |
| self.preview_steps = QSpinBox(); self.preview_steps.setRange(1, 500) |
| self.intensity = QSpinBox(); self.intensity.setRange(10, 100); self.intensity.setSuffix("%") |
| self.gradient_checkpointing = QCheckBox("Gradient checkpointing (uses less VRAM)") |
| self.options_hint = QLabel(); self.options_hint.setProperty("muted", True); self.options_hint.setWordWrap(True) |
| fields = [("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), ("DDPM training intensity", self.intensity)] |
| for row, (label, widget) in enumerate(fields): options.addWidget(QLabel(label), row, 0); options.addWidget(widget, row, 1) |
| 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(self.orion_settings_button, len(fields), 0, 1, 2) |
| options.addWidget(self.gradient_checkpointing, len(fields) + 1, 0, 1, 2) |
| options.addWidget(self.options_hint, len(fields) + 2, 0, 1, 2) |
| 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) |
|
|
| 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) |
| root.addWidget(buttons) |
|
|
| self.preset.currentTextChanged.connect(self._apply_preset) |
| self.trainer.currentIndexChanged.connect(self._trainer_changed) |
| 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.epochs.valueChanged.connect(self._update_review) |
| 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) |
| 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, self.gradient_checkpointing): |
| signal = getattr(widget, "valueChanged", None) or getattr(widget, "currentIndexChanged", None) or getattr(widget, "stateChanged", None) |
| if signal: signal.connect(self._update_review) |
| 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._apply_preset(self.preset.currentText()) |
| self._set_training_defaults() |
| 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.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) |
|
|
| def _capture_state(self) -> dict[str, object]: |
| return { |
| "preset": self.preset.currentText(), "trainer": self.trainer.currentData(), |
| "source": self.source.currentData(), "subject": self.subject.text(), |
| "dataset": self.dataset.currentText(), "model_name": self.model_name.text(), |
| "epochs": self.epochs.value(), "images": self.images.value(), |
| "collection_mode": self.collection_mode.currentData(), |
| "dataset_reviewed": self.dataset_reviewed.isChecked(), |
| "training_options": self._training_options(), |
| } |
|
|
| def _load_state(self, state: dict[str, object]) -> None: |
| preset_index = self.preset.findText(str(state.get("preset", ""))) |
| if preset_index >= 0: |
| self.preset.setCurrentIndex(preset_index) |
| trainer_index = self.trainer.findData(state.get("trainer", "lora")) |
| self.trainer.setCurrentIndex(max(0, trainer_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.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))) |
| options = state.get("training_options", {}) |
| if isinstance(options, dict): |
| self.resolution.setCurrentText(str(options.get("resolution", self.resolution.currentText()))) |
| self.batch_size.setValue(int(options.get("batch_size", self.batch_size.value()))) |
| self.learning_rate.setValue(float(options.get("learning_rate", self.learning_rate.value()))) |
| self.gradient_accumulation.setValue(int(options.get("gradient_accumulation_steps", options.get("gradient_accumulation", self.gradient_accumulation.value())))) |
| self.workers.setValue(int(options.get("dataloader_num_workers", options.get("workers", self.workers.value())))) |
| precision_index = self.precision.findData(options.get("mixed_precision", self.precision.currentData())) |
| self.precision.setCurrentIndex(max(0, precision_index)) |
| self.save_every.setValue(int(options.get("save_every", self.save_every.value()))) |
| self.preview_steps.setValue(int(options.get("preview_steps", self.preview_steps.value()))) |
| self.intensity.setValue(int(options.get("training_intensity", self.intensity.value()))) |
| self.gradient_checkpointing.setChecked(bool(options.get("gradient_checkpointing", False))) |
| 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._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": ""}) |
| 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 _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", "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 _apply_preset(self, name: str) -> None: |
| values = self.presets.get(name, {}) |
| index = self.trainer.findData(values.get("trainer", "lora")) |
| self.trainer.setCurrentIndex(max(0, index)) |
| self.epochs.setValue(int(values.get("epochs", 100))) |
| self.images.setValue(int(values.get("image_count", 60))) |
| self.preset_hint.setText(str(values.get("description", ""))) |
|
|
| def _update_source(self) -> None: |
| 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) |
| 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 _trainer_changed(self) -> None: |
| flow = self.trainer.currentData() == "flow" |
| if flow: |
| self.source.setCurrentIndex(self.source.findData("existing")) |
| self.source.model().item(self.source.findData("new")).setEnabled(not flow) |
| 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." |
| ) |
| self._set_training_defaults() |
|
|
| def _set_training_defaults(self) -> None: |
| trainer = self.trainer.currentData() |
| enabled = trainer in {"ddpm", "flow"} |
| self.options_group.setEnabled(enabled) |
| if trainer == "flow": |
| values = ("256", 8, 0.0002, 1, 4, 10, 10, 30) |
| self.intensity.hide(); self.gradient_checkpointing.show() |
| self.options_hint.setText("Flow: higher resolution and batch size need substantially more VRAM. Heun/preview settings remain in the Flow app.") |
| elif trainer == "ddpm": |
| values = ("128", 1, 0.0001, 1, 4, 10, 50, 100) |
| self.intensity.show(); self.gradient_checkpointing.hide() |
| self.options_hint.setText("DDPM: resolution has the biggest speed and VRAM impact. Keep batch size at 1 if you are unsure.") |
| else: |
| self.options_hint.setText("LoRA uses its connected trainer's saved settings for now.") |
| return |
| self.resolution.setCurrentText(values[0]); self.batch_size.setValue(values[1]); self.learning_rate.setValue(values[2]); self.gradient_accumulation.setValue(values[3]); self.workers.setValue(values[4]); self.save_every.setValue(values[5]); self.preview_steps.setValue(values[6]); self.intensity.setValue(values[7]); self.gradient_checkpointing.setChecked(False) |
|
|
| 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 _apply_orion_settings(self) -> None: |
| trainer = str(self.trainer.currentData()) |
| images = self._orion_image_count() |
| recommendation = recommend_training_settings( |
| trainer, images, int(self.resolution.currentText()) |
| ) |
| self.epochs.setValue(int(recommendation["epochs"])) |
| settings = recommendation["settings"] |
| if trainer in {"ddpm", "flow"}: |
| self.batch_size.setValue(int(settings["batch_size"])) |
| self.learning_rate.setValue(float(settings["learning_rate"])) |
| self.gradient_accumulation.setValue(int(settings["gradient_accumulation_steps"])) |
| self.workers.setValue(int(settings["dataloader_num_workers"])) |
| precision = self.precision.findData(settings["mixed_precision"]) |
| self.precision.setCurrentIndex(max(0, precision)) |
| self.save_every.setValue(int(settings["save_every"])) |
| self.preview_steps.setValue(int(settings["preview_steps"])) |
| self.preview_every.setValue(int(settings["preview_every"])) |
| self.intensity.setValue(int(settings["training_intensity"])) |
| self.gradient_checkpointing.setChecked(bool(settings["gradient_checkpointing"])) |
| self.preset_hint.setText(str(recommendation["summary"])) |
| self.validation.setText("ORION applied a reviewable starting recipe. Nothing has been queued or started.") |
| self._update_review() |
|
|
| def _training_options(self) -> dict[str, object]: |
| trainer = self.trainer.currentData() |
| common = { |
| "preview_enabled": self.preview_enabled.isChecked(), |
| "preview_every": self.preview_every.value(), |
| "preview_prompt": self.preview_prompt.text().strip(), |
| "preview_seed": self.preview_seed.value(), |
| } |
| 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(), **common} |
| 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_steps": self.preview_steps.value(), "gradient_checkpointing": self.gradient_checkpointing.isChecked(), **common} |
| return common |
|
|
| 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: |
| 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 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" |
| self.review_summary.setText( |
| f"Review: {dataset}; train {model} with " |
| f"{self.trainer.currentText()} for {self.epochs.value():,} epochs. " |
| + (f"{self.resolution.currentText()}px · batch {self.batch_size.value()} · lr {self.learning_rate.value():.7f}. " if self.trainer.currentData() in {"ddpm", "flow"} else "") |
| + (f"Live preview every {self.preview_every.value()} epochs. " if self.preview_enabled.isChecked() else "Live previews off. ") |
| + "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 _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] |
| if self.preset.findText(name) < 0: |
| self.preset.addItem(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): |
| creating = state.get("source") == "new" |
| subject = str(state.get("subject", "")).strip() |
| dataset = str(state.get("dataset", "")).strip() |
| 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 |
| options = state.get("training_options", {}) |
| 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=options if isinstance(options, dict) else {}, |
| )) |
| self.requests = requests |
| self.request = requests[0] |
| 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.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("Continue training"), 5, 0) |
| form.addWidget(self.epochs, 5, 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.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"} |
| models = [asset for asset in self.planner.assets.assets if asset.kind == "model"] |
| for trainer in ("ddpm", "flow", "lora"): |
| 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() |
| ) |
| 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) |
| 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" |
| self.summary.setText( |
| f"Review: continue {asset.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 "" |
| 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"}) |
| 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.") |
| 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, |
| } |
| 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, |
| 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.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.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.setMaximumHeight(124) |
| root = QVBoxLayout(self) |
| root.setContentsMargins(16, 11, 16, 11) |
| root.setSpacing(6) |
| root.addWidget(_card_title("SYSTEM MONITOR")) |
| row = QHBoxLayout() |
| row.setSpacing(0) |
| self.gpu = self._metric("GPU", "Waiting for GPU") |
| self.cpu = self._metric("CPU", "Waiting for CPU") |
| self.storage = self._metric("STORAGE", "Checking drive") |
| row.addLayout(self.gpu[0], 2) |
| row.addWidget(self._divider()) |
| row.addLayout(self.cpu[0], 2) |
| row.addWidget(self._divider()) |
| row.addLayout(self.storage[0], 2) |
| row.addWidget(self._divider()) |
|
|
| activity_box = QVBoxLayout() |
| activity_box.setContentsMargins(14, 0, 14, 0) |
| activity_title = QLabel("ACTIVITY") |
| activity_title.setProperty("muted", True) |
| activity_title.setStyleSheet("font-size: 9px; font-weight: 700;") |
| self.activity = SparklineWidget("Collecting activity…") |
| self.activity.setMinimumHeight(43) |
| self.activity.setMaximumHeight(43) |
| activity_box.addWidget(activity_title) |
| activity_box.addWidget(self.activity) |
| row.addLayout(activity_box, 2) |
| row.addWidget(self._divider()) |
|
|
| tools_box = QVBoxLayout() |
| tools_box.setContentsMargins(14, 0, 0, 0) |
| tools_title = QLabel("TOOLS STATUS") |
| tools_title.setProperty("muted", True) |
| tools_title.setStyleSheet("font-size: 9px; font-weight: 700;") |
| tools_box.addWidget(tools_title) |
| tool_grid = QGridLayout() |
| tool_grid.setHorizontalSpacing(13) |
| tool_grid.setVerticalSpacing(3) |
| checks = ( |
| ("Python", bool(sys.executable)), |
| ("Git", bool(shutil.which("git"))), |
| ("Ollama", bool(shutil.which("ollama"))), |
| ("FFmpeg", bool(shutil.which("ffmpeg"))), |
| ) |
| for index, (name, available) in enumerate(checks): |
| label = QLabel(f"{'✓' if available else '○'} {name}") |
| label.setStyleSheet( |
| f"font-size: 10px; color: {COLORS['green'] if available else COLORS['muted']};" |
| ) |
| tool_grid.addWidget(label, index // 2, index % 2) |
| tools_box.addLayout(tool_grid) |
| row.addLayout(tools_box, 2) |
| root.addLayout(row) |
| self._activity_values: list[float] = [] |
|
|
| @staticmethod |
| def _divider() -> QFrame: |
| divider = QFrame() |
| divider.setFrameShape(QFrame.VLine) |
| divider.setStyleSheet(f"color: {COLORS['border']};") |
| return divider |
|
|
| @staticmethod |
| def _metric(title: str, initial: str) -> tuple[QVBoxLayout, QLabel, QLabel, QProgressBar]: |
| layout = QVBoxLayout() |
| layout.setContentsMargins(14, 0, 14, 0) |
| layout.setSpacing(3) |
| heading = QLabel(title) |
| heading.setProperty("muted", True) |
| heading.setStyleSheet("font-size: 9px; font-weight: 700;") |
| value = QLabel(initial) |
| value.setStyleSheet("font-size: 11px; font-weight: 600;") |
| detail = QLabel("—") |
| detail.setProperty("muted", True) |
| detail.setStyleSheet("font-size: 9px;") |
| progress = QProgressBar() |
| progress.setRange(0, 100) |
| layout.addWidget(heading) |
| layout.addWidget(value) |
| layout.addWidget(detail) |
| layout.addWidget(progress) |
| return layout, value, detail, progress |
|
|
| def update_snapshot(self, snapshot: SystemSnapshot) -> None: |
| self.gpu[1].setText(snapshot.gpu_name) |
| temp = f" · {snapshot.gpu_temperature:.0f}°C" if snapshot.gpu_temperature is not None else "" |
| self.gpu[2].setText( |
| f"VRAM {snapshot.vram_used_gb:.1f} / {snapshot.vram_total_gb:.1f} GB{temp}" |
| ) |
| self.gpu[3].setValue(int(snapshot.vram_percent)) |
| self.cpu[1].setText(f"Usage {snapshot.cpu_percent:.0f}%") |
| self.cpu[2].setText( |
| f"RAM {snapshot.memory_used_gb:.1f} / {snapshot.memory_total_gb:.1f} GB" |
| ) |
| self.cpu[3].setValue(int(snapshot.memory_percent)) |
| if snapshot.disk_total_gb >= 1024: |
| storage_text = ( |
| f"{snapshot.disk_used_gb / 1024:.2f} / " |
| f"{snapshot.disk_total_gb / 1024:.2f} TB" |
| ) |
| else: |
| storage_text = f"{snapshot.disk_used_gb:.0f} / {snapshot.disk_total_gb:.0f} GB" |
| self.storage[1].setText(storage_text) |
| self.storage[2].setText(f"{snapshot.disk_percent:.0f}% used") |
| self.storage[3].setValue(int(snapshot.disk_percent)) |
| self._activity_values.append(max(snapshot.cpu_percent, snapshot.gpu_percent)) |
| self._activity_values = self._activity_values[-50:] |
| self.activity.set_values(self._activity_values) |
|
|
|
|
| class CommandCenterPage(QWidget): |
| provider_changed = Signal(str) |
| tool_folders_changed = Signal() |
| open_jobs_requested = Signal() |
| history_changed = Signal() |
|
|
| def __init__( |
| self, |
| planner: Planner, |
| jobs: JobManager, |
| config: ConfigManager, |
| tool_folders: ToolFolderManager, |
| root_path: Path, |
| ) -> 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.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 = "" |
|
|
| 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) |
| 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_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_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 = ( |
| ("Collect a dataset", "Gather captioned images\nwith filters.", "Adam, collect a dataset of liminal spaces"), |
| ("Train a LoRA", "Fine-tune Stable Diffusion\nwith your dataset.", "Adam, train a LoRA of Hatsune Miku"), |
| ("Train DDPM", "Train a diffusion model\nfrom scratch.", "Adam, train a DDPM model"), |
| ("Train Flow Matching", "Build an image or video\nflow model.", "Adam, train a Flow Matching model"), |
| ("Generate previews", "Review model outputs\nbefore export.", "Adam, generate 4 previews"), |
| ("Inspect GPU", "Check VRAM, utilization,\ndrivers and heat.", "Adam, check GPU status"), |
| ("Generate an image", "Create it here from a\ncompleted model.", 'Generate a DDPM image of "A new sample" for 100 steps on DDIM sampler with aspect ratio 16:9'), |
| ) |
| for title, description, command 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, text=command: self.submit(text) |
| ) |
| 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.setFixedHeight(66) |
| self.prompt.send_requested.connect(self._submit_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.addWidget(self.prompt, 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) |
| 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: |
| 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.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")) |
| self.planner.assets.discover(self.config) |
| 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 |
| self.add_message(request, user=True) |
| if self.mode_selector.currentData() == "chat": |
| self._start_chat(request) |
| return |
| generation_request = parse_chat_generation_request(request) |
| 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._streamed_text = "" |
| self._planning_bubble = self.add_message("Thinking…", 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: |
| self.planner.assets.discover(self.config) |
| 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 |
| ) |
| |
| |
| base_only = ( |
| stable_diffusion_request |
| and parsed.provider_hint != "lora" |
| and not parsed.model_query |
| ) |
| preferred_id = { |
| "ddpm": "ddpm_generator", "flow": "flow_generator", "lora": "lora_generator" |
| }.get(parsed.provider_hint, "") |
| if stable_diffusion_request and parsed.provider_hint not in {"ddpm", "flow"}: |
| preferred_id = "lora_generator" |
| preferred_tool = next((item for item in tools if item.id == preferred_id), None) |
| if parsed.provider_hint and preferred_tool is None: |
| 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 = scored[0][1] if scored and scored[0][0] > 0 else None |
| if model is None and not model_query and len(candidates) == 1: |
| model = candidates[0] |
| if model is None and plain_model_search: |
| |
| |
| base_only = True |
| preferred_tool = next( |
| (item for item in tools if item.id == "lora_generator"), None |
| ) |
| 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.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 |
| |
| |
| |
| 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": 0, |
| "height": 0, |
| "cfg_scale": parsed.cfg_scale if parsed.cfg_scale is not None else float(saved_generation.get("cfg_scale", 0) or 0), |
| "lora_strength": 0.0 if base_only else (parsed.lora_strength if parsed.lora_strength is not None else float(saved_generation.get("lora_strength", 0) or 0)), |
| "reference_image": parsed.reference_image, |
| "denoise_strength": parsed.denoise_strength if parsed.denoise_strength is not None else float(saved_generation.get("denoise_strength", 0) or 0), |
| "prompt_weighting": bool(saved_generation.get("prompt_weighting", True)), |
| } |
| 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: |
| 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) -> 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) |
| ) |
| 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… (Chat Mode cannot run tools)" |
| if chat_mode |
| else "Tell ADAM what you want to accomplish… (Shift+Enter for a new line)" |
| ) |
| if announce: |
| self.add_message( |
| ( |
| "Chat Mode is active. I can answer questions and discuss your models " |
| "and workflows through Ollama, but I won’t launch tools or jobs here." |
| 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: |
| 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) |
| job = self.jobs.submit(plan) |
| 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 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: |
| |
| |
| 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 update_snapshot(self, snapshot: SystemSnapshot) -> None: |
| self.system_summary.update_snapshot(snapshot) |
|
|
| def _job_updated(self, job: Job) -> None: |
| self.recent_panel.refresh() |
| generation_card = self._generation_cards.get(job.id) |
| if generation_card: |
| generation_card.update_job(job) |
| if self.selected_job and job.id == self.selected_job.id: |
| self.selected_job = job |
| self.plan_panel.set_job(job) |
| if self.jobs.active_job and job.id == self.jobs.active_job.id: |
| self.active_panel.set_job(job) |
| terminal = { |
| JobStatus.FINISHED, |
| JobStatus.FAILED, |
| JobStatus.CANCELLED, |
| JobStatus.INTERRUPTED, |
| } |
| marker = (job.id, job.status) |
| if generation_card and job.status in terminal: |
| self._announced.add(marker) |
| return |
| if job.status in terminal and marker not in self._announced: |
| self._announced.add(marker) |
| if job.status == JobStatus.FINISHED: |
| demo_tools = [] |
| real_tools = [] |
| for step in job.plan.steps: |
| try: |
| if self.jobs.executor.registry.get(step.tool_id).demo: |
| demo_tools.append(step.tool_id) |
| else: |
| real_tools.append(step.tool_id) |
| except Exception: |
| pass |
| if demo_tools and real_tools: |
| message = ( |
| f"{job.plan.project_name} completed with a mixture of real and " |
| "demo steps. The real collector output is available in Jobs; " |
| "simulated preparation/training steps did not create a model." |
| ) |
| label = "ADAM · PARTIAL REAL WORKFLOW" |
| elif demo_tools: |
| message = ( |
| f"{job.plan.project_name} demo simulation completed. No real " |
| "images were downloaded and no model was trained. Review the " |
| "generated manifests in Jobs." |
| ) |
| label = "ADAM · DEMO COMPLETE" |
| else: |
| message = ( |
| f"{job.plan.project_name} completed successfully. " |
| "The job record and full logs are available in Jobs." |
| ) |
| label = "ADAM · COMPLETE" |
| recommendation = completion_recommendation(job.plan) |
| if recommendation: |
| message += "\n\n" + recommendation |
| elif job.status == JobStatus.FAILED: |
| message = f"{job.plan.project_name} failed safely: {job.error}" |
| label = "ADAM · ERROR" |
| else: |
| message = f"{job.plan.project_name} was cancelled." |
| label = "ADAM · STOPPED" |
| self.add_message(message, label=label) |
|
|
| @staticmethod |
| def open_output(path: str) -> None: |
| output = Path(path) |
| if output.exists(): |
| QDesktopServices.openUrl(QUrl.fromLocalFile(str(output))) |
|
|
|
|
| class JobsPage(QWidget): |
| def __init__(self, jobs: JobManager) -> None: |
| super().__init__() |
| self.jobs = jobs |
| self.selected_job_id: str | None = None |
| self._refresh_index = 0 |
| self._refresh_token = 0 |
| self._show_full_log = False |
| self._log_job_id: str | None = None |
| root = QVBoxLayout(self) |
| root.setContentsMargins(24, 20, 24, 17) |
| root.setSpacing(12) |
| root.addWidget( |
| _page_header( |
| "Jobs & history", |
| "Every workflow has durable state, timestamps, logs, progress, and an output location.", |
| ) |
| ) |
|
|
| body = QHBoxLayout() |
| body.setSpacing(12) |
| self.table = QTableWidget(0, 6) |
| self.table.setHorizontalHeaderLabels( |
| ["JOB", "PROJECT", "STATUS", "PROGRESS", "CREATED", "OUTPUT"] |
| ) |
| self.table.setAlternatingRowColors(True) |
| self.table.setSelectionBehavior(QAbstractItemView.SelectRows) |
| self.table.setSelectionMode(QAbstractItemView.SingleSelection) |
| self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) |
| self.table.verticalHeader().hide() |
| header = self.table.horizontalHeader() |
| header.setSectionResizeMode(0, QHeaderView.ResizeToContents) |
| header.setSectionResizeMode(1, QHeaderView.Stretch) |
| header.setSectionResizeMode(2, QHeaderView.ResizeToContents) |
| header.setSectionResizeMode(3, QHeaderView.ResizeToContents) |
| header.setSectionResizeMode(4, QHeaderView.ResizeToContents) |
| header.setSectionResizeMode(5, QHeaderView.ResizeToContents) |
| self.table.itemSelectionChanged.connect(self._selection_changed) |
| body.addWidget(self.table, 3) |
|
|
| details = _card() |
| details.setMinimumWidth(350) |
| details_layout = QVBoxLayout(details) |
| details_layout.setContentsMargins(17, 16, 17, 16) |
| self.detail_title = QLabel("Select a job") |
| self.detail_title.setStyleSheet("font-size: 17px; font-weight: 650;") |
| self.detail_status = QLabel("No job selected") |
| self.detail_status.setProperty("muted", True) |
| self.agent_status = QLabel("ORION, ATLAS, and NOVA reports will appear here.") |
| self.agent_status.setWordWrap(True) |
| self.agent_status.setProperty("muted", True) |
| self.agent_status.setStyleSheet("font-size: 10px;") |
| self.log_view = QPlainTextEdit() |
| self.log_view.setReadOnly(True) |
| self.log_view.setPlaceholderText("Job logs will appear here.") |
| details_layout.addWidget(_card_title("JOB DETAILS")) |
| details_layout.addWidget(self.detail_title) |
| details_layout.addWidget(self.detail_status) |
| details_layout.addWidget(self.agent_status) |
| details_layout.addWidget(self.log_view, 1) |
| |
| actions = QGridLayout() |
| actions.setHorizontalSpacing(7) |
| actions.setVerticalSpacing(7) |
| self.pause_button = QPushButton("Pause") |
| 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.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.stop_button, 0, 1) |
| actions.addWidget(self.end_task_button, 0, 2) |
| actions.addWidget(self.retry_button, 0, 3) |
| 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) |
| details_layout.addLayout(actions) |
| body.addWidget(details, 2) |
| root.addLayout(body, 1) |
|
|
| self.pause_button.clicked.connect(self._pause_or_resume) |
| 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.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 |
| self.table.setRowCount(len(self.jobs.jobs)) |
| self.table.setUpdatesEnabled(False) |
| 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): |
| self.table.setUpdatesEnabled(True) |
| 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}" |
| ) |
| 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) |
| self.pause_button.setText("Resume" if job.status == JobStatus.PAUSED else "Pause") |
| self.stop_button.setEnabled( |
| running |
| or job.status in { |
| 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 |
| self.retry_button.setText("Approve and run" if awaiting_confirmation else "Retry plan") |
| self.retry_button.setEnabled( |
| awaiting_confirmation or job.status in { |
| JobStatus.FINISHED, |
| JobStatus.FAILED, |
| JobStatus.CANCELLED, |
| JobStatus.INTERRUPTED, |
| } |
| ) |
| 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 _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 |
| if job.status == JobStatus.AWAITING_CONFIRMATION: |
| self.jobs.confirm(job.id) |
| return |
| retried = self.jobs.retry(job.id) |
| self.selected_job_id = retried.id |
| self.refresh() |
|
|
| def _export_log(self) -> None: |
| job = self._selected() |
| if not job: |
| return |
| selected, _filter = QFileDialog.getSaveFileName( |
| self, |
| "Export job log", |
| str(self.jobs.root / "logs" / f"job_{job.id}.txt"), |
| "Text files (*.txt)", |
| ) |
| if not selected: |
| return |
| try: |
| Path(selected).write_text( |
| f"ADAM job {job.id}\n{job.plan.project_name}\n" |
| f"{job.status.value}\n\n" + "\n".join(job.logs), |
| encoding="utf-8", |
| ) |
| except OSError as exc: |
| QMessageBox.warning(self, "Log not exported", str(exc)) |
|
|
| def _export_all(self) -> None: |
| selected, _filter = QFileDialog.getSaveFileName( |
| self, |
| "Export job history", |
| str(self.jobs.root / "logs" / "job_history.json"), |
| "JSON files (*.json)", |
| ) |
| if not selected: |
| return |
| try: |
| Path(selected).write_text( |
| json.dumps({"jobs": [job.to_dict() for job in self.jobs.jobs]}, indent=2), |
| encoding="utf-8", |
| ) |
| except OSError as exc: |
| QMessageBox.warning(self, "History not exported", str(exc)) |
|
|
| def _remove_completed_or_failed(self) -> None: |
| count = sum( |
| job.status in {JobStatus.FINISHED, JobStatus.FAILED} |
| for job in self.jobs.jobs |
| ) |
| if not count: |
| QMessageBox.information( |
| self, "Nothing to remove", "There are no completed or failed jobs in history." |
| ) |
| return |
| answer = QMessageBox.question( |
| self, |
| "Remove completed / failed jobs", |
| f"Remove {count} completed or failed job record(s)? This does not delete output files.", |
| QMessageBox.Yes | QMessageBox.No, |
| QMessageBox.No, |
| ) |
| if answer != QMessageBox.Yes: |
| return |
| self.jobs.remove_completed_or_failed() |
| self.selected_job_id = None |
| self.log_view.clear() |
| self.detail_title.setText("Select a job") |
| self.detail_status.setText("No job selected") |
| self.refresh() |
|
|
| @staticmethod |
| def _format_time(value: str) -> str: |
| try: |
| return datetime.fromisoformat(value).astimezone().strftime("%b %d · %H:%M") |
| except ValueError: |
| return value[:16] |
|
|
| def _tool_is_demo(self, tool_id: str) -> bool: |
| try: |
| return self.jobs.executor.registry.get(tool_id).demo |
| except Exception: |
| return False |
|
|
|
|
| class ToolsPage(QWidget): |
| setup_requested = Signal() |
|
|
| def __init__( |
| self, |
| registry: ToolRegistry, |
| tool_folders: ToolFolderManager, |
| ) -> None: |
| super().__init__() |
| self.registry = registry |
| self.tool_folders = tool_folders |
| root = QVBoxLayout(self) |
| root.setContentsMargins(24, 20, 24, 17) |
| root.setSpacing(12) |
| title_row = QHBoxLayout() |
| title_row.addWidget( |
| _page_header( |
| "Tool registry", |
| "ADAM can execute only these registered backends. Disabled entries are safe placeholders.", |
| ), |
| 1, |
| ) |
| reload_button = QPushButton("Reload registry") |
| reload_button.clicked.connect(self.reload) |
| title_row.addWidget(reload_button, 0, Qt.AlignTop) |
| root.addLayout(title_row) |
|
|
| info = _card() |
| info_layout = QHBoxLayout(info) |
| info_layout.setContentsMargins(16, 12, 16, 12) |
| info_icon = QLabel("i") |
| info_icon.setAlignment(Qt.AlignCenter) |
| info_icon.setFixedSize(25, 25) |
| info_icon.setStyleSheet( |
| f"border-radius: 12px; color: {COLORS['blue_2']}; " |
| f"border: 1px solid {COLORS['blue']}; font-weight: 700;" |
| ) |
| info_text = QLabel( |
| "Demo tools prove orchestration without downloading data or training. " |
| "Connect a Python function or script in config/tools.json to replace a demo backend." |
| ) |
| info_text.setWordWrap(True) |
| info_text.setProperty("muted", True) |
| info_layout.addWidget(info_icon) |
| info_layout.addWidget(info_text, 1) |
| configure_demo = QPushButton("Configure demo tools") |
| configure_demo.clicked.connect(self.setup_requested) |
| info_layout.addWidget(configure_demo) |
| root.addWidget(info) |
| self.mode_summary = QLabel() |
| self.mode_summary.setProperty("muted", True) |
| root.addWidget(self.mode_summary) |
|
|
| self.table = QTableWidget(0, 6) |
| self.table.setHorizontalHeaderLabels( |
| ["TOOL", "CATEGORY", "BACKEND", "MODE", "CONFIRM", "STATUS"] |
| ) |
| self.table.setAlternatingRowColors(True) |
| self.table.setSelectionBehavior(QAbstractItemView.SelectRows) |
| self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) |
| self.table.verticalHeader().hide() |
| header = self.table.horizontalHeader() |
| header.setSectionResizeMode(0, QHeaderView.Stretch) |
| for column in range(1, 6): |
| header.setSectionResizeMode(column, QHeaderView.ResizeToContents) |
| self.table.setToolTip( |
| "Edit config/tools.json to point entries at your existing Python backends." |
| ) |
| root.addWidget(self.table, 1) |
| self.reload() |
|
|
| def reload(self) -> None: |
| try: |
| self.registry.load() |
| except Exception as exc: |
| QMessageBox.warning(self, "Registry error", str(exc)) |
| return |
| tools = self.registry.all() |
| real_count = sum(tool.enabled and not tool.demo for tool in tools) |
| demo_count = sum(tool.enabled and tool.demo for tool in tools) |
| disabled_count = sum(not tool.enabled for tool in tools) |
| self.mode_summary.setText( |
| f"{real_count} real · {demo_count} demo · {disabled_count} not configured. " |
| "Demo steps create transparent placeholders and never produce trained weights." |
| ) |
| folder_statuses = self.tool_folders.scan_all() |
| self.table.setRowCount(len(tools)) |
| for row, tool in enumerate(tools): |
| folder_status = folder_statuses.get(tool.id) |
| detected = bool(folder_status and folder_status.valid) |
| backend_type = ( |
| "External folder" |
| if detected and not tool.enabled |
| else str(tool.backend.get("type", "unconfigured")).title() |
| ) |
| if detected and tool.demo: |
| status_text = "Demo active · folder detected" |
| elif detected and not tool.enabled: |
| status_text = "Detected · adapter pending" |
| elif tool.enabled: |
| status_text = "Ready" |
| else: |
| status_text = "Not configured" |
| values = [ |
| f"{tool.name}\n{tool.description}", |
| tool.category, |
| backend_type, |
| "Demo" if tool.demo else "Real", |
| "Required" if tool.requires_confirmation else "No", |
| status_text, |
| ] |
| for column, value in enumerate(values): |
| item = QTableWidgetItem(value) |
| if column: |
| item.setTextAlignment(Qt.AlignCenter) |
| if column == 5: |
| item.setForeground( |
| Qt.green if tool.enabled else ( |
| Qt.cyan if detected else Qt.gray |
| ) |
| ) |
| if column == 3: |
| item.setForeground(Qt.yellow if tool.demo else Qt.green) |
| if folder_status and folder_status.path: |
| item.setToolTip( |
| f"{folder_status.path}\n{folder_status.message}" |
| ) |
| self.table.setItem(row, column, item) |
| self.table.setRowHeight(row, 54) |
|
|
|
|
| class SystemPage(QWidget): |
| def __init__(self) -> None: |
| super().__init__() |
| root = QVBoxLayout(self) |
| root.setContentsMargins(24, 20, 24, 17) |
| root.setSpacing(12) |
| root.addWidget( |
| _page_header( |
| "System monitor", |
| "Live local telemetry for resource planning and training oversight.", |
| ) |
| ) |
| metrics = QGridLayout() |
| metrics.setHorizontalSpacing(12) |
| metrics.setVerticalSpacing(12) |
| self.cpu = MetricCard("CPU", COLORS["blue"]) |
| self.ram = MetricCard("Memory", COLORS["purple"]) |
| self.gpu = MetricCard("GPU", COLORS["green"]) |
| self.vram = MetricCard("VRAM", COLORS["orange"]) |
| metrics.addWidget(self.cpu, 0, 0) |
| metrics.addWidget(self.ram, 0, 1) |
| metrics.addWidget(self.gpu, 1, 0) |
| metrics.addWidget(self.vram, 1, 1) |
| root.addLayout(metrics) |
|
|
| training = _card() |
| training_layout = QVBoxLayout(training) |
| training_layout.setContentsMargins(18, 17, 18, 17) |
| training_layout.addWidget(_card_title("TRAINING MONITOR")) |
| self.training_status = QLabel("No active training job") |
| self.training_status.setStyleSheet("font-size: 18px; font-weight: 650;") |
| self.training_progress = QProgressBar() |
| self.training_progress.setRange(0, 100) |
| self.resource_warning = QLabel() |
| self.resource_warning.setWordWrap(True) |
| self.training_log = QPlainTextEdit() |
| self.training_log.setReadOnly(True) |
| self.training_log.setMaximumHeight(145) |
| training_hint = QLabel( |
| "When a registered trainer runs, job progress, current step, logs, and " |
| "output status are visible here and in Jobs." |
| ) |
| training_hint.setProperty("muted", True) |
| training_hint.setWordWrap(True) |
| training_layout.addWidget(self.training_status) |
| training_layout.addWidget(self.training_progress) |
| training_layout.addWidget(self.resource_warning) |
| training_layout.addWidget(training_hint) |
| training_layout.addWidget(self.training_log) |
| training_layout.addStretch() |
| root.addWidget(training, 1) |
|
|
| def update_snapshot(self, snapshot: SystemSnapshot) -> None: |
| self.cpu.update_metric( |
| f"{snapshot.cpu_percent:.0f}%", |
| "Current processor load", |
| snapshot.cpu_percent, |
| ) |
| self.ram.update_metric( |
| f"{snapshot.memory_percent:.0f}%", |
| f"{snapshot.memory_used_gb:.1f} / {snapshot.memory_total_gb:.1f} GB", |
| snapshot.memory_percent, |
| ) |
| temperature = ( |
| f"{snapshot.gpu_temperature:.0f}°C" |
| if snapshot.gpu_temperature is not None |
| else "Temperature unavailable" |
| ) |
| self.gpu.update_metric( |
| f"{snapshot.gpu_percent:.0f}%", |
| f"{snapshot.gpu_name} · {temperature}", |
| snapshot.gpu_percent, |
| ) |
| self.vram.update_metric( |
| f"{snapshot.vram_percent:.0f}%", |
| f"{snapshot.vram_used_gb:.1f} / {snapshot.vram_total_gb:.1f} GB", |
| snapshot.vram_percent, |
| ) |
| warnings = [] |
| if snapshot.vram_percent >= 92: |
| warnings.append( |
| "VRAM is nearly full; watch for an out-of-memory failure." |
| ) |
| if snapshot.gpu_temperature is not None and snapshot.gpu_temperature >= 85: |
| warnings.append("GPU temperature is high; cooling may be needed.") |
| self.resource_warning.setText(" · ".join(warnings)) |
| self.resource_warning.setStyleSheet( |
| f"color: {COLORS['orange'] if warnings else COLORS['muted']};" |
| ) |
|
|
| def set_active_job(self, job: Job | None) -> None: |
| if job and any(step.tool_id.endswith("trainer") for step in job.plan.steps): |
| self.training_status.setText( |
| f"{job.plan.project_name} · {job.progress}% · {job.status.value}" |
| ) |
| self.training_progress.setValue(job.progress) |
| self.training_log.setPlainText("\n".join(job.logs[-12:])) |
| self.training_log.verticalScrollBar().setValue( |
| self.training_log.verticalScrollBar().maximum() |
| ) |
| else: |
| self.training_status.setText("No active training job") |
| self.training_progress.setValue(0) |
| self.training_log.clear() |
|
|
|
|
| class ExternalToolDialog(QDialog): |
| connector_saved = Signal() |
|
|
| def __init__(self, root_path: Path, parent: QWidget | None = None) -> None: |
| super().__init__(parent) |
| self.root_path = root_path |
| self.store = ExternalToolStore(root_path) |
| self.analysis = ToolAnalysis("") |
| self.scan_worker: ToolScanWorker | None = None |
| self.setWindowTitle("Add External Tool") |
| self.setMinimumSize(760, 680) |
| root = QVBoxLayout(self) |
| root.setSpacing(10) |
| root.addWidget( |
| _page_header( |
| "External Tool Connector", |
| "ADAM reads the selected files without running them, then explains compatibility and safety concerns.", |
| ) |
| ) |
|
|
| folder_row = QHBoxLayout() |
| self.folder = QLineEdit() |
| self.folder.setPlaceholderText("Choose the external program folder…") |
| browse = QPushButton("Browse") |
| browse.clicked.connect(self._browse) |
| self.scan_button = QPushButton("Scan safely") |
| self.scan_button.setProperty("primary", True) |
| self.scan_button.clicked.connect(self._scan) |
| folder_row.addWidget(self.folder, 1) |
| folder_row.addWidget(browse) |
| folder_row.addWidget(self.scan_button) |
| root.addLayout(folder_row) |
|
|
| selection = QGridLayout() |
| self.entry = QComboBox() |
| self.config_files = QListWidget() |
| self.config_files.setMaximumHeight(90) |
| self.config_files.setSelectionMode(QAbstractItemView.MultiSelection) |
| selection.addWidget(QLabel("Training entry script"), 0, 0) |
| selection.addWidget(self.entry, 0, 1) |
| selection.addWidget(QLabel("Important config files"), 1, 0, Qt.AlignTop) |
| selection.addWidget(self.config_files, 1, 1) |
| root.addLayout(selection) |
| self.entry.currentTextChanged.connect(self._reanalyze) |
| self.config_files.itemSelectionChanged.connect(self._reanalyze) |
|
|
| identity = QGridLayout() |
| self.tool_name = QLineEdit() |
| self.tool_name.setPlaceholderText("Example: APVD Model Trainer") |
| self.description = QLineEdit() |
| self.description.setPlaceholderText("What this program trains or produces") |
| self.arguments = QLineEdit() |
| self.arguments.setPlaceholderText("Detected arguments, comma separated") |
| self.required = QLineEdit() |
| self.required.setPlaceholderText("Required arguments, comma separated") |
| for row, (label, widget) in enumerate( |
| ( |
| ("Tool name", self.tool_name), |
| ("Description", self.description), |
| ("Command-line inputs", self.arguments), |
| ("Required inputs", self.required), |
| ) |
| ): |
| identity.addWidget(QLabel(label), row, 0) |
| identity.addWidget(widget, row, 1) |
| root.addLayout(identity) |
|
|
| self.score = QLabel("Compatibility: not scanned") |
| self.score.setStyleSheet("font-size: 20px; font-weight: 700;") |
| root.addWidget(self.score) |
| self.report = QPlainTextEdit() |
| self.report.setReadOnly(True) |
| self.report.setMinimumHeight(245) |
| root.addWidget(self.report, 1) |
| disclaimer = QLabel( |
| "The rating is a static compatibility review, not a guarantee that third-party " |
| "code is harmless. Every run remains approval-gated." |
| ) |
| disclaimer.setWordWrap(True) |
| disclaimer.setProperty("muted", True) |
| root.addWidget(disclaimer) |
|
|
| self.buttons = QDialogButtonBox(QDialogButtonBox.Cancel | QDialogButtonBox.Save) |
| self.buttons.button(QDialogButtonBox.Save).setText("Register external tool") |
| self.buttons.button(QDialogButtonBox.Save).setEnabled(False) |
| self.buttons.accepted.connect(self._save) |
| self.buttons.rejected.connect(self.reject) |
| root.addWidget(self.buttons) |
|
|
| def _browse(self) -> None: |
| selected = QFileDialog.getExistingDirectory( |
| self, "Choose external tool folder", self.folder.text() or str(Path.home()) |
| ) |
| if selected: |
| self.folder.setText(selected) |
| self._scan() |
|
|
| def _scan(self) -> None: |
| folder = self.folder.text().strip() |
| if self.scan_worker and self.scan_worker.isRunning(): |
| return |
| self.scan_button.setEnabled(False) |
| self.scan_button.setText("Scanning…") |
| self.report.setPlainText("Scanning the selected folder safely…") |
| self.buttons.button(QDialogButtonBox.Save).setEnabled(False) |
| self.buttons.button(QDialogButtonBox.Cancel).setEnabled(False) |
| self.scan_worker = ToolScanWorker(folder) |
| self.scan_worker.scanned.connect(self._scan_finished) |
| self.scan_worker.failed.connect(self._scan_failed) |
| self.scan_worker.finished.connect(self._scan_worker_finished) |
| self.scan_worker.start() |
|
|
| def _scan_finished(self, analysis: object) -> None: |
| if not isinstance(analysis, ToolAnalysis): |
| self._scan_failed("The folder scan returned an unexpected result.") |
| return |
| self.analysis = analysis |
| self.entry.blockSignals(True) |
| self.entry.clear() |
| self.entry.addItems(self.analysis.entry_candidates) |
| if self.analysis.selected_entry: |
| self.entry.setCurrentText(self.analysis.selected_entry) |
| self.entry.blockSignals(False) |
| self.config_files.blockSignals(True) |
| self.config_files.clear() |
| self.config_files.addItems(self.analysis.config_files) |
| self.config_files.blockSignals(False) |
| if not self.tool_name.text().strip() and self.analysis.folder: |
| self.tool_name.setText(Path(self.analysis.folder).name) |
| self._reanalyze() |
|
|
| def _scan_failed(self, message: str) -> None: |
| self.report.setPlainText(f"The folder could not be scanned:\n\n{message}") |
|
|
| def _scan_worker_finished(self) -> None: |
| self.scan_button.setEnabled(True) |
| self.scan_button.setText("Scan safely") |
| self.buttons.button(QDialogButtonBox.Cancel).setEnabled(True) |
| if self.scan_worker: |
| self.scan_worker.deleteLater() |
| self.scan_worker = None |
|
|
| def closeEvent(self, event: QCloseEvent) -> None: |
| if self.scan_worker and self.scan_worker.isRunning(): |
| self.report.setPlainText( |
| "Please wait for the safe folder scan to finish before closing this window." |
| ) |
| event.ignore() |
| return |
| super().closeEvent(event) |
|
|
| def _reanalyze(self) -> None: |
| if not self.analysis.folder: |
| return |
| configs = [item.text() for item in self.config_files.selectedItems()] |
| self.analysis = analyze_selection(self.analysis, self.entry.currentText(), configs) |
| self.arguments.setText(", ".join(self.analysis.arguments)) |
| self.required.setText(", ".join(self.analysis.required_arguments)) |
| color = ( |
| COLORS["green"] if self.analysis.score >= 8 |
| else COLORS["orange"] if self.analysis.score >= 5 |
| else COLORS["red"] |
| ) |
| self.score.setText(f"Compatibility & safety rating: {self.analysis.score}/10") |
| self.score.setStyleSheet(f"font-size: 20px; font-weight: 700; color: {color};") |
| report = [ |
| f"Entry script: {self.analysis.selected_entry or 'Not detected'}", |
| f"Dataset format: {self.analysis.dataset_format}", |
| f"Output: {self.analysis.output_behavior}", |
| f"Checkpoints: {self.analysis.checkpoint_behavior}", |
| f"Progress: {self.analysis.progress_behavior}", |
| f"Resume training: {self.analysis.resume_behavior}", |
| "", |
| "Why ADAM gave this rating:", |
| *[f" + {reason}" for reason in self.analysis.reasons], |
| ] |
| if self.analysis.warnings: |
| report.extend(["", "Warnings:", *[f" ! {warning}" for warning in self.analysis.warnings]]) |
| self.report.setPlainText("\n".join(report)) |
| self.buttons.button(QDialogButtonBox.Save).setEnabled( |
| bool(self.analysis.selected_entry and Path(self.analysis.folder).is_dir()) |
| ) |
|
|
| @staticmethod |
| def _field_list(text: str) -> list[str]: |
| values = [] |
| for value in text.split(","): |
| normalized = value.strip().lstrip("-").replace("-", "_") |
| if normalized and normalized.replace("_", "").isalnum() and normalized not in values: |
| values.append(normalized) |
| return values |
|
|
| def _save(self) -> None: |
| name = self.tool_name.text().strip() |
| if not name: |
| QMessageBox.warning(self, "Tool name required", "Give this external tool a name.") |
| return |
| arguments = self._field_list(self.arguments.text()) |
| required = [ |
| value for value in self._field_list(self.required.text()) |
| if value in arguments |
| ] |
| try: |
| self.store.save_connector( |
| name=name, |
| description=self.description.text(), |
| analysis=self.analysis, |
| arguments=arguments, |
| required_arguments=required, |
| ) |
| except (OSError, ValueError) as exc: |
| QMessageBox.warning(self, "Could not register tool", str(exc)) |
| return |
| self.connector_saved.emit() |
| self.accept() |
|
|
|
|
| class SettingsPage(QWidget): |
| saved = Signal() |
|
|
| def __init__( |
| self, |
| config: ConfigManager, |
| tool_folders: ToolFolderManager, |
| ) -> None: |
| super().__init__() |
| self.config = config |
| self.tool_folders = tool_folders |
| self.folder_edits: dict[str, QLineEdit] = {} |
| self.folder_statuses: dict[str, QLabel] = {} |
| outer = QVBoxLayout(self) |
| outer.setContentsMargins(0, 0, 0, 0) |
| scroll = QScrollArea() |
| scroll.setWidgetResizable(True) |
| scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) |
| scroll.viewport().setStyleSheet(f"background: {COLORS['bg']};") |
| content = QWidget() |
| content.setStyleSheet(f"background: {COLORS['bg']};") |
| root = QVBoxLayout(content) |
| root.setContentsMargins(24, 20, 24, 17) |
| root.setSpacing(12) |
| scroll.setWidget(content) |
| outer.addWidget(scroll) |
| root.addWidget( |
| _page_header( |
| "Settings", |
| "Local provider, safety gates, and notification preferences.", |
| ) |
| ) |
| columns = QHBoxLayout() |
| columns.setSpacing(12) |
| provider_card = _card() |
| provider_card.setMinimumHeight(300) |
| provider_layout = QVBoxLayout(provider_card) |
| provider_layout.setContentsMargins(18, 17, 18, 17) |
| provider_layout.setSpacing(10) |
| provider_layout.addWidget(_card_title("PLANNING MODEL")) |
| provider_layout.addWidget(QLabel("Provider")) |
| self.provider = QComboBox() |
| self.provider.addItem("Ollama (local)", "ollama") |
| self.provider.addItem("Manual safe planner", "manual") |
| index = self.provider.findData(config.get("provider")) |
| self.provider.setCurrentIndex(max(0, index)) |
| provider_layout.addWidget(self.provider) |
| provider_layout.addWidget(QLabel("Ollama URL")) |
| self.ollama_url = QLineEdit(str(config.get("ollama_url"))) |
| provider_layout.addWidget(self.ollama_url) |
| provider_layout.addWidget(QLabel("Model")) |
| self.ollama_model = QLineEdit(str(config.get("ollama_model"))) |
| provider_layout.addWidget(self.ollama_model) |
| provider_layout.addWidget(QLabel("Chat response 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( |
| "Maximum tokens for each Chat Mode reply. Higher values allow longer answers but can take longer." |
| ) |
| 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.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.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(), |
| "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): |
| 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.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.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.generations_page = GenerationsPage( |
| root_path, registry, jobs, jobs.assets, config |
| ) |
| self.showcase_page = ShowcasePage( |
| root_path, registry, jobs, jobs.assets, config |
| ) |
| self.tools_page = ToolsPage(registry, tool_folders) |
| self.system_page = SystemPage() |
| self.settings_page = SettingsPage(config, tool_folders) |
| self.chat_history_page = ChatHistoryPage(self.command_page.history_store) |
| for page in ( |
| self.command_scroll, |
| self.studio_page, |
| self.generations_page, |
| self.showcase_page, |
| self.jobs_page, |
| self.tools_page, |
| self.system_page, |
| self.settings_page, |
| self.chat_history_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(7)) |
| 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(4)) |
| self.command_page.history_changed.connect(self.chat_history_page.refresh) |
| self.chat_history_page.open_requested.connect(self._open_saved_conversation) |
| self.jobs.active_changed.connect(self.system_page.set_active_job) |
| self.jobs.job_updated.connect(self._update_system_job) |
| self.jobs.notification.connect(self._show_notification) |
| self.studio_page.plan_requested.connect(self._plan_from_studio) |
| self._switch_page(0) |
|
|
| self.monitor_timer = QTimer(self) |
| self.monitor_timer.timeout.connect(self._refresh_monitor) |
| self.monitor_timer.start(1500) |
| self._refresh_monitor() |
| 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) |
| layout = QVBoxLayout(sidebar) |
| layout.setContentsMargins(0, 20, 0, 16) |
| layout.setSpacing(3) |
|
|
| brand = QWidget() |
| brand_layout = QHBoxLayout(brand) |
| brand_layout.setContentsMargins(16, 0, 12, 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", 0), |
| ("CHAT HISTORY", 8), |
| ("TRAINING STUDIO", 1), |
| ("GENERATIONS", 2), |
| ("SHOWCASE VIDEO", 3), |
| ("JOBS / HISTORY", 4), |
| ("TOOL REGISTRY", 5), |
| ("SYSTEM MONITOR", 6), |
| ("SETTINGS", 7), |
| ] |
| 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"), |
| ("▧", "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("● SAFE MODE") |
| safe_title.setStyleSheet( |
| f"color: {COLORS['green']}; font-size: 10px; font-weight: 700;" |
| ) |
| safe_body = QLabel("Approval gates on\nFull action logging") |
| 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 _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) |
| if index == 1: |
| self.studio_page.refresh() |
| elif index == 2: |
| self.generations_page.refresh() |
| elif index == 3: |
| self.showcase_page.refresh() |
| if index == 8: |
| self.chat_history_page.refresh() |
| for button in self.nav_buttons: |
| button.setProperty("navActive", button.property("pageIndex") == index) |
| button.style().unpolish(button) |
| button.style().polish(button) |
|
|
| def _open_saved_conversation(self, conversation: dict) -> None: |
| self.command_page.open_conversation(conversation) |
| self._switch_page(0) |
|
|
| def _quick_access(self, action: str) -> None: |
| if action == "generations": |
| self._switch_page(2) |
| return |
| if action == "video": |
| self._switch_page(3) |
| return |
| self._switch_page(0) |
| 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(0) |
| self.command_page.submit(request) |
|
|
| def _refresh_monitor(self) -> None: |
| 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 _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.studio_page.shutdown() |
| super().closeEvent(event) |
|
|