SyntheticMDProductions's picture
Some of Adams structure
e0265b9 verified
Raw
History Blame Contribute Delete
199 kB
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)
# The shell owns the card border and heading. Hide the panel's original
# heading while preserving every existing control and signal.
for label in content.findChildren(QLabel):
if label.objectName() == "CardTitle" and label.text() == title:
label.hide()
break
content.setProperty("card", False)
content.style().unpolish(content)
content.style().polish(content)
root.addWidget(content, 1)
self.set_collapsed(bool(config.get(setting_key, False)), persist=False)
def _toggle(self) -> None:
self.set_collapsed(not self.collapsed, persist=True)
def set_collapsed(self, collapsed: bool, *, persist: bool = True) -> None:
self.collapsed = collapsed
self.content.setVisible(not collapsed)
self.toggle.setText("+" if collapsed else "−")
self.toggle.setToolTip(("Show " if collapsed else "Hide ") + self.panel_title)
self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Fixed if collapsed else QSizePolicy.Preferred)
horizontal = self.collapse_direction == "right"
self.title_label.setVisible(not (collapsed and horizontal))
if collapsed and horizontal:
self.setFixedWidth(45)
self.setMinimumHeight(45)
self.setMaximumHeight(45)
elif collapsed:
self.setMinimumWidth(0)
self.setMaximumWidth(16777215)
self.setMinimumHeight(45)
self.setMaximumHeight(45)
else:
if horizontal:
self.setMinimumWidth(330)
self.setMaximumWidth(390)
else:
self.setMinimumWidth(0)
self.setMaximumWidth(16777215)
self.setMinimumHeight(0)
self.setMaximumHeight(16777215)
if persist:
self.config.update({self.setting_key: collapsed})
self.collapsed_changed.emit(collapsed)
class ChatHistoryStore:
"""Small, local JSON store for archived Command Center conversations."""
def __init__(self, root: Path) -> None:
self.path = root / "data" / "chat_history.json"
self.path.parent.mkdir(parents=True, exist_ok=True)
def load(self) -> list[dict]:
try:
value = json.loads(self.path.read_text(encoding="utf-8"))
return value if isinstance(value, list) else []
except (OSError, json.JSONDecodeError):
return []
def save_conversation(self, entries: list[dict[str, str]], mode: str) -> dict | None:
useful = [entry for entry in entries if entry.get("text", "").strip()]
if not useful:
return None
first_user = next((entry["text"] for entry in useful if entry.get("user")), useful[0]["text"])
now = datetime.now()
conversation = {
"id": now.strftime("%Y%m%d%H%M%S%f"),
"title": first_user.replace("\n", " ").strip()[:72] or "Untitled conversation",
"created_at": now.isoformat(timespec="seconds"),
"mode": mode,
"entries": useful,
}
history = self.load()
history.insert(0, conversation)
temporary = self.path.with_suffix(".tmp")
temporary.write_text(json.dumps(history[:100], indent=2), encoding="utf-8")
temporary.replace(self.path)
return conversation
class ChatHistoryPage(QWidget):
open_requested = Signal(object)
def __init__(self, store: ChatHistoryStore) -> None:
super().__init__()
self.store = store
root = QVBoxLayout(self)
root.setContentsMargins(28, 24, 28, 24)
root.setSpacing(14)
root.addWidget(_page_header("Chat history", "Reopen earlier prompts and conversations in Command Center."))
self.list = QListWidget()
self.list.setSpacing(6)
self.list.itemDoubleClicked.connect(self._open_item)
root.addWidget(self.list, 1)
self.open_button = QPushButton("Open selected chat →")
self.open_button.setProperty("primary", True)
self.open_button.clicked.connect(self._open_selected)
root.addWidget(self.open_button, 0, Qt.AlignRight)
self.refresh()
def refresh(self) -> None:
self.list.clear()
for conversation in self.store.load():
stamp = str(conversation.get("created_at", "")).replace("T", " ")
item = QListWidgetItem(f"{conversation.get('title', 'Untitled conversation')}\n{stamp} · {conversation.get('mode', 'trainer').title()} Mode")
item.setData(Qt.UserRole, conversation)
self.list.addItem(item)
self.open_button.setEnabled(self.list.count() > 0)
def _open_selected(self) -> None:
item = self.list.currentItem() or (self.list.item(0) if self.list.count() else None)
if item:
self.open_requested.emit(item.data(Qt.UserRole))
def _open_item(self, item: QListWidgetItem) -> None:
self.open_requested.emit(item.data(Qt.UserRole))
class PlanningWorker(QThread):
chunk = Signal(str)
planned = Signal(object)
failed = Signal(str)
def __init__(self, planner: Planner, request: str) -> None:
super().__init__()
self.planner = planner
self.request = request
def run(self) -> None:
try:
self.planned.emit(self.planner.plan(self.request, self.chunk.emit))
except Exception as exc:
self.failed.emit(str(exc))
class BatchPlanningWorker(QThread):
"""Plans every requested model, then returns one ordered execution plan."""
chunk = Signal(str)
planned = Signal(object)
failed = Signal(str)
def __init__(self, planner: Planner, requests: list[str]) -> None:
super().__init__()
self.planner = planner
self.requests = requests
def run(self) -> None:
try:
plans = []
total = len(self.requests)
for index, request in enumerate(self.requests, 1):
self.chunk.emit(f"Planning model {index} of {total}…\n")
plan = self.planner.plan(request)
if not plan.steps:
raise PlanningError(
f"Model {index} could not be turned into an actionable plan: {plan.summary}"
)
plans.append(plan)
self.planned.emit(combine_training_plans(plans))
except Exception as exc:
self.failed.emit(str(exc))
class ToolScanWorker(QThread):
scanned = Signal(object)
failed = Signal(str)
def __init__(self, folder: str) -> None:
super().__init__()
self.folder = folder
def run(self) -> None:
try:
self.scanned.emit(scan_folder(self.folder))
except Exception as exc:
self.failed.emit(str(exc))
class 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
)
# Positive Prompt is the explicit Stable Diffusion signal. A named LoRA
# still remains active when a base checkpoint is supplied alongside it.
base_only = (
stable_diffusion_request
and parsed.provider_hint != "lora"
and not parsed.model_query
)
preferred_id = {
"ddpm": "ddpm_generator", "flow": "flow_generator", "lora": "lora_generator"
}.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:
# If there is no matching unconditional model, the subject can still
# be rendered as a normal Stable Diffusion prompt.
base_only = True
preferred_tool = next(
(item for item in tools if item.id == "lora_generator"), None
)
model_query = ""
if model is None and not base_only:
detail = f' matching “{model_query}”' if model_query else ""
examples: list[str] = []
for asset in candidates:
if asset.name not in examples:
examples.append(asset.name)
if len(examples) == 4:
break
example_text = f" Available examples: {', '.join(examples)}." if examples else ""
self.add_message(
f"I couldn’t find a completed image model{detail}, so I did not substitute the model selected in Generations.{example_text} Try: Generate an image using model “Model Name”.",
label="ADAM · GENERATION NEEDS MODEL",
)
return
tool = next(
(
item for item in ([preferred_tool] if preferred_tool else tools)
if item is not None and (base_only or model.trainer in item.model_trainers)
),
None,
)
if tool is None:
self.add_message(
"The matching model does not have an available image generator.",
label="ADAM · GENERATION UNAVAILABLE",
)
return
if parsed.reference_image and "reference_image" not in tool.capabilities:
self.add_message(
f"{tool.name} does not support reference-image conditioning. Remove the attachment or choose LoRA/Stable Diffusion or DDPM.",
label="ADAM · REFERENCE IMAGE UNSUPPORTED",
)
return
options = tool.generation_options
saved_generation = self.config.get("generation_settings", {})
saved_generation = saved_generation if isinstance(saved_generation, dict) else {}
sampler_options = [str(value) for value in options.get("samplers", [])]
sampler = parsed.sampler or (str(saved_generation.get("sampler", "")) if tool.id == "lora_generator" else "")
if sampler not in sampler_options:
sampler = sampler_options[0] if sampler_options else sampler or "DDIM"
aspect_options = [str(value) for value in options.get("aspect_ratios", [])]
aspect = parsed.aspect_ratio or (str(saved_generation.get("aspect", "")) if tool.id == "lora_generator" else "")
if aspect and aspect not in aspect_options:
aspect = next((value for value in aspect_options if value.startswith(f"{aspect} ") or value == aspect), "")
if not aspect:
aspect = aspect_options[0] if aspect_options else "1:1 (Square)"
step_min = int(options.get("step_min", 1) or 1)
step_max = int(options.get("step_max", 500) or 500)
default_steps = saved_generation.get("steps", options.get("step_default", 50)) if tool.id == "lora_generator" else options.get("step_default", 50)
steps = parsed.steps if parsed.steps is not None else int(default_steps or 50)
steps = max(step_min, min(steps, step_max))
count_limit = 8 if tool.id == "lora_generator" else 32
default_count = int(saved_generation.get("images", 1) or 1) if tool.id == "lora_generator" else 1
count = max(1, min(parsed.image_count or default_count, count_limit))
seed = parsed.seed if parsed.seed is not None else 0
extra_arguments = {}
if tool.id == "ddpm_generator":
extra_arguments = {
"reference_image": parsed.reference_image,
"reference_strength": max(0, min(parsed.reference_strength if parsed.reference_strength is not None else 65, 100)),
"width": 0, "height": 0,
}
elif tool.id == "lora_generator":
base_assets = [
asset for asset in self.planner.assets.assets
if asset.kind == "base_model" and Path(asset.path).exists()
]
base_model_path = ""
if parsed.base_model_query:
scored_bases = sorted(
(
(generation_model_match_score(parsed.base_model_query, asset.name), asset)
for asset in base_assets
),
key=lambda item: item[0],
reverse=True,
)
if scored_bases and scored_bases[0][0] > 0:
base_model_path = scored_bases[0][1].path
if not base_model_path:
self.add_message(
f"I couldn’t find a Stable Diffusion base model matching “{parsed.base_model_query}”.",
label="ADAM · LORA NEEDS BASE MODEL",
)
return
# A fresh Command Center image request should have a dependable SDXL
# fallback instead of relying on whichever model the external trainer
# happened to use last.
if not base_model_path:
preferred_base = next(
(
asset for asset in base_assets
if "waiillustrious" in "".join(
character for character in asset.name.casefold() if character.isalnum()
)
or "wallilustrious" in "".join(
character for character in asset.name.casefold() if character.isalnum()
)
),
None,
)
if preferred_base is not None:
base_model_path = preferred_base.path
if not base_model_path:
selected_base = str(saved_generation.get("base_model_path", ""))
if selected_base and Path(selected_base).expanduser().exists():
base_model_path = selected_base
if not base_model_path:
trainer_root = Path(str(self.config.get("tool_folders", {}).get("lora_trainer", "")))
try:
trainer_settings = json.loads(
(trainer_root / "config" / "app_settings.json").read_text(encoding="utf-8")
)
configured_base = str(
trainer_settings.get("generate_model")
or trainer_settings.get("last_model")
or ""
)
configured_path = Path(configured_base).expanduser()
if configured_base and not configured_path.is_absolute():
configured_path = trainer_root / configured_path
if configured_base and configured_path.exists():
base_model_path = str(configured_path.resolve())
except (OSError, ValueError, TypeError, json.JSONDecodeError):
pass
if not base_model_path and len(base_assets) == 1:
base_model_path = base_assets[0].path
if not base_model_path:
names = ", ".join(asset.name for asset in base_assets[:4])
available = f" Available base models: {names}." if names else ""
self.add_message(
"LoRA generation also needs a Stable Diffusion base model. Put one in “LoRA StableDiffusionModels Here”, or select one in the Generations tab."
+ available,
label="ADAM · LORA NEEDS BASE MODEL",
)
return
extra_arguments = {
"negative_prompt": parsed.negative_prompt or str(saved_generation.get("negative_prompt", "")),
"base_model_path": base_model_path,
"width": 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:
# Trusted dataset-to-DDPM automation is only for ordinary reviewed plans.
# ORION warnings deliberately restore the human approval gate.
if getattr(plan, "orion_review", {}).get("level") == "warning":
return False
return bool(
plan.requires_confirmation
and self.config.get("trusted_dataset_ddpm_automation")
and plan.steps
and {step.tool_id for step in plan.steps}.issubset({"dataset_collector", "ddpm_trainer"})
)
def refresh_provider_badge(self) -> None:
provider = str(self.config.get("provider", "ollama")).upper()
if provider == "OLLAMA":
self.provider_badge.setText(f"● OLLAMA · {self.config.get('ollama_model')}")
else:
self.provider_badge.setText("● SAFE PLANNER · MANUAL")
def _select_recent_job(self, job_id: str) -> None:
try:
job = self.jobs.get(job_id)
except KeyError:
return
self.selected_job = job
self.plan_panel.set_job(job)
def 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)
# Two rows keep every action available when the Jobs panel is narrow.
actions = QGridLayout()
actions.setHorizontalSpacing(7)
actions.setVerticalSpacing(7)
self.pause_button = QPushButton("Pause")
self.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)