ai-lsc/src/ai_lsc/ui/pages/db_manager.py

1301 lines
57 KiB
Python
Executable File

"""DatabaseManager widget — full CRUD editor for the tool registry.
Presents a split-pane layout: a searchable / filterable tool table on the
left and a scrollable edit form on the right. Every field from the
registry schema is editable — categorisation (layer, role, category,
level), all 8 flags, installer spec, launcher spec, dependencies,
license, description, and filesystem paths.
**Categorisation cascade**: selecting a Category auto-populates Layer,
Level, and Role from the built-in category map (derived from the
registry). The user can still override any of them.
Changes are validated against the registry schema before being written
to ``ecosystem.json`` via atomic write.
"""
try:
from PySide6.QtCore import Qt, QTimer
from PySide6.QtGui import QBrush, QColor, QFont
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QDialog,
QDialogButtonBox,
QFormLayout,
QGroupBox,
QHBoxLayout,
QHeaderView,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QScrollArea,
QSizePolicy,
QSpinBox,
QSplitter,
QStyledItemDelegate,
QTableWidget,
QTableWidgetItem,
QTextEdit,
QVBoxLayout,
QWidget,
)
_HAS_QT = True
except ImportError:
_HAS_QT = False
# ── Category → default Layer / Level / Role mapping ──────────────────
# Derived from the canonical registry (11-layer taxonomy, v3.1.1b:
# Routing=L5, Orchestrators=L6, Security=L7, Observability=L8,
# User Interfaces=L9, DevOps=L10, Knowledge Management=L11).
# When the user picks a category these fields auto-fill; the user can
# still override afterwards.
CATEGORY_MAP: dict[str, dict[str, object]] = {
"AI Agent": {"layer": "Orchestrators", "level": 6, "role": "Sensory Bridge"},
"AI Assistant Platform": {"layer": "User Interfaces", "level": 9, "role": "Central Intelligence"},
"AI Augmentation": {"layer": "Orchestrators", "level": 6, "role": "Curation"},
"AI Coding Agent": {"layer": "DevOps", "level": 10, "role": "Coding Agent"},
"AI Monitoring": {"layer": "Observability", "level": 8, "role": "Health Monitor"},
"AI Observability": {"layer": "Observability", "level": 8, "role": "LLM Tracing"},
"AI Operating System": {"layer": "DevOps", "level": 10, "role": "OS Integration"},
"Academic References": {"layer": "Knowledge Management", "level": 11, "role": "Reference Manager"},
"Agent Framework": {"layer": "Orchestrators", "level": 6, "role": "Multi-Agent"},
"Agent Network": {"layer": "DevOps", "level": 10, "role": "Discovery"},
"Agent OS": {"layer": "Orchestrators", "level": 6, "role": "Hands"},
"Agent Toolkit": {"layer": "Orchestrators", "level": 6, "role": "Tool Integration"},
"Agent Workflow": {"layer": "Orchestrators", "level": 6, "role": "Reasoning"},
"Algorithm Toolkit": {"layer": "DevOps", "level": 10, "role": "Hands"},
"Analytical Database": {"layer": "Host Platform", "level": 1, "role": "Foundation"},
"Antivirus": {"layer": "Security", "level": 7, "role": "Scanner"},
"Build": {"layer": "Development Environment", "level": 2, "role": "Build Tool"},
"Audio Parsing": {"layer": "Knowledge Management", "level": 11, "role": "Memory"},
"Auth": {"layer": "Security", "level": 7, "role": "Identity"},
"Build Monitoring": {"layer": "Observability", "level": 8, "role": "Monitoring"},
"Cache": {"layer": "Host Platform", "level": 1, "role": "Foundation"},
"Chat": {"layer": "User Interfaces", "level": 9, "role": "Face"},
"Chat Agent Platform": {"layer": "User Interfaces", "level": 9, "role": "Face"},
"Chat Frontend": {"layer": "User Interfaces", "level": 9, "role": "Face"},
"Claude Code Skill": {"layer": "Orchestrators", "level": 6, "role": "Knowledge Graph Builder"},
"Cluster SSH": {"layer": "Orchestrators", "level": 6, "role": "Coordination"},
"Code Analysis": {"layer": "DevOps", "level": 10, "role": "Inspector"},
"Code Generation": {"layer": "DevOps", "level": 10, "role": "Hands"},
"Computer Vision": {"layer": "User Interfaces", "level": 9, "role": "Vision"},
"Config Management": {"layer": "DevOps", "level": 10, "role": "Configuration Management"},
"Container Ops": {"layer": "Orchestrators", "level": 6, "role": "Sandbox"},
"Container Security": {"layer": "Security", "level": 7, "role": "Scanner"},
"Containers": {"layer": "Host Platform", "level": 1, "role": "Container Runtime"},
"Context Manager": {"layer": "DevOps", "level": 10, "role": "Context"},
"Cortex Memory": {"layer": "Knowledge Management", "level": 11, "role": "Memory"},
"Dashboard": {"layer": "User Interfaces", "level": 9, "role": "Face"},
"Data Pipeline": {"layer": "Knowledge Management", "level": 11, "role": "Ingestion"},
"Data Sync": {"layer": "Knowledge Management", "level": 11, "role": "Integration"},
"Debugging": {"layer": "Development Environment", "level": 2, "role": "Profiling"},
"Database": {"layer": "Host Platform", "level": 1, "role": "Foundation"},
"Desktop Agent": {"layer": "User Interfaces", "level": 9, "role": "Face"},
"Dev Automation": {"layer": "DevOps", "level": 10, "role": "Hands"},
"Development": {"layer": "DevOps", "level": 10, "role": "Hands"},
"Distributed Compilation": {"layer": "Orchestrators", "level": 6, "role": "Distribution"},
"Distributed Compute": {"layer": "Orchestrators", "level": 6, "role": "Scaling"},
"Document Converter": {"layer": "Knowledge Management", "level": 11, "role": "File Parsing"},
"Document Management": {"layer": "Knowledge Management", "level": 11, "role": "Document Archive"},
"Document Understanding": {"layer": "Knowledge Management", "level": 11, "role": "Comprehension"},
"Ebook Library": {"layer": "Knowledge Management", "level": 11, "role": "Library Manager"},
"Ecosystem Dashboard": {"layer": "User Interfaces", "level": 9, "role": "Face"},
"Efficient LLM": {"layer": "Engines", "level": 4, "role": "Engine"},
"File Parsing": {"layer": "Knowledge Management", "level": 11, "role": "Memory"},
"Find Tool": {"layer": "Development Environment", "level": 2, "role": "Search"},
"GPU": {"layer": "GPU Runtimes", "level": 3, "role": "Acceleration"},
"GPU Computing": {"layer": "Development Environment", "level": 2, "role": "GPU Acceleration"},
"Graph Database": {"layer": "Knowledge Management", "level": 11, "role": "Memory"},
"Graph RAG": {"layer": "Knowledge Management", "level": 11, "role": "Knowledge Synthesis"},
"Homepage": {"layer": "User Interfaces", "level": 9, "role": "Face"},
"IaC": {"layer": "DevOps", "level": 10, "role": "Infrastructure as Code"},
"IaC Control Plane": {"layer": "DevOps", "level": 10, "role": "Infrastructure as Code"},
"IaC Wrapper": {"layer": "DevOps", "level": 10, "role": "Infrastructure as Code"},
"Image Generation": {"layer": "User Interfaces", "level": 9, "role": "Face"},
"Infrastructure": {"layer": "Orchestrators", "level": 6, "role": "Resource Manager"},
"Intrusion Prevention": {"layer": "Security", "level": 7, "role": "IDS"},
"Knowledge Graph": {"layer": "DevOps", "level": 10, "role": "Graph Builder"},
"Knowledge Graph Notes": {"layer": "User Interfaces", "level": 9, "role": "Face"},
"LLM Evaluation": {"layer": "Observability", "level": 8, "role": "Evaluation"},
"Memory System": {"layer": "Knowledge Management", "level": 11, "role": "Memory"},
"LLM Fine-tuning": {"layer": "GPU Runtimes", "level": 3, "role": "Abliteration"},
"LLM Framework": {"layer": "Orchestrators", "level": 6, "role": "Orchestration"},
"LLM GUI": {"layer": "User Interfaces", "level": 9, "role": "Face"},
"LLM Mesh": {"layer": "Routing", "level": 5, "role": "API Gateway"},
"LLM Router": {"layer": "Routing", "level": 5, "role": "API Gateway"},
"LLM Runtime": {"layer": "Engines", "level": 4, "role": "Engine"},
"LLM Serving": {"layer": "Engines", "level": 4, "role": "Engine"},
"MCP Server": {"layer": "Orchestrators", "level": 6, "role": "Code Audit"},
"Metrics": {"layer": "Observability", "level": 8, "role": "Metrics Collector"},
"Mesh Client": {"layer": "Routing", "level": 5, "role": "Coding Agent"},
"Mixed Precision": {"layer": "GPU Runtimes", "level": 3, "role": "Optimization"},
"Model Surgery": {"layer": "Engines", "level": 4, "role": "Abliteration"},
"Model Training": {"layer": "Development Environment", "level": 2, "role": "Training"},
"Multi-Agent": {"layer": "Orchestrators", "level": 6, "role": "Coordination"},
"Networking": {"layer": "Host Platform", "level": 1, "role": "Tunnel"},
"Notes": {"layer": "Knowledge Management", "level": 11, "role": "Note Taking"},
"OCI Export": {"layer": "DevOps", "level": 10, "role": "Runtime Packaging"},
"Observability": {"layer": "Observability", "level": 8, "role": "Monitoring"},
"Outliner": {"layer": "Knowledge Management", "level": 11, "role": "Knowledge Graph"},
"PDF Pipeline": {"layer": "Knowledge Management", "level": 11, "role": "Extraction"},
"Parser": {"layer": "Development Environment", "level": 2, "role": "Parsing"},
"Persistent Memory": {"layer": "Knowledge Management", "level": 11, "role": "Memory"},
"Pipeline": {"layer": "Routing", "level": 5, "role": "Pipeline Orchestrator"},
"Policy Engine": {"layer": "Security", "level": 7, "role": "Policy"},
"Procfile Runner": {"layer": "DevOps", "level": 10, "role": "Process Manager"},
"Project Management": {"layer": "DevOps", "level": 10, "role": "Management"},
"Prompt Tooling": {"layer": "DevOps", "level": 10, "role": "Prompt Management"},
"Provisioning": {"layer": "DevOps", "level": 10, "role": "Provisioning"},
"Proxy": {"layer": "Routing", "level": 5, "role": "API Gateway"},
"Reasoning Engine": {"layer": "Orchestrators", "level": 6, "role": "Brain"},
"Runtime": {"layer": "Development Environment", "level": 2, "role": "Build System"},
"Sandbox": {"layer": "DevOps", "level": 10, "role": "Isolation"},
"Search Engine": {"layer": "Knowledge Management", "level": 11, "role": "Memory"},
"Search Tool": {"layer": "Development Environment", "level": 2, "role": "Search"},
"Shell": {"layer": "Development Environment", "level": 2, "role": "Shell"},
"Secrets Management": {"layer": "Security", "level": 7, "role": "Secrets"},
"Serverless Framework": {"layer": "Development Environment", "level": 2, "role": "Full-Stack Framework"},
"Single-File LLM": {"layer": "Engines", "level": 4, "role": "Engine"},
"Skill Analysis": {"layer": "DevOps", "level": 10, "role": "Assessment"},
"Skill Inspection": {"layer": "DevOps", "level": 10, "role": "Analysis"},
"Spaced Repetition": {"layer": "Knowledge Management", "level": 11, "role": "Memory"},
"Spec Writer": {"layer": "DevOps", "level": 10, "role": "Documentation"},
"Speech Recognition": {"layer": "User Interfaces", "level": 9, "role": "Senses"},
"Task Runner": {"layer": "DevOps", "level": 10, "role": "Scheduler"},
"Telemetry": {"layer": "Observability", "level": 8, "role": "Collector"},
"Terminal": {"layer": "Host Platform", "level": 1, "role": "Multiplexer"},
"Text-to-Speech": {"layer": "User Interfaces", "level": 9, "role": "Voice"},
"Uncensored Models": {"layer": "Engines", "level": 4, "role": "Engine"},
"VCS": {"layer": "Host Platform", "level": 1, "role": "Version Control"},
"Vector Engine": {"layer": "Knowledge Management", "level": 11, "role": "Embedding"},
"Vector Store": {"layer": "Knowledge Management", "level": 11, "role": "Memory"},
"Visualization": {"layer": "Observability", "level": 8, "role": "Dashboard"},
"Virtualization": {"layer": "Host Platform", "level": 1, "role": "MicroVM"},
"Web Crawler": {"layer": "Knowledge Management", "level": 11, "role": "Data Harvesting"},
"Workflow": {"layer": "Orchestrators", "level": 6, "role": "Visual Builder"},
"Workflow Automation": {"layer": "Orchestrators", "level": 6, "role": "Workflow Orchestrator"},
}
# ── Field-constant lookups (populated once on first use) ─────────────
_LAYER_ORDER: list[str] = []
_ALL_CATEGORIES: list[str] = []
_ROLE_SET: set[str] = set()
_LICENSE_SPDX_IDS: list[str] = []
_INSTALLER_TYPES: list[str] = [
"ollama", "uv", "pipx", "pip", "npm",
"git", "git_node", "pacman", "dnf", "apt",
"script", "custom",
]
_LAUNCHER_TYPES: list[str] = ["systemd", "tmux", "desktop", "lxc"]
_FLAG_KEYS: list[str] = [
"has_cli", "has_gui", "has_web",
"is_ollama",
"is_passive", "is_mcp", "is_skills_collection",
]
_FLAG_LABELS: dict[str, str] = {
"has_cli": "CLI",
"has_gui": "GUI",
"has_web": "Web UI",
"is_ollama": "Ollama-native",
"is_passive": "Passive (library/collection)",
"is_mcp": "MCP API tool",
"is_skills_collection": "Skills collection",
}
def _populate_lookups(registry: dict) -> None:
"""Populate the layer / category / role dropdowns from live data."""
global _LAYER_ORDER, _ALL_CATEGORIES, _ROLE_SET, _LICENSE_SPDX_IDS
if _LAYER_ORDER:
return
from ai_lsc.constants import NAV_LAYER_ORDER
_LAYER_ORDER = list(NAV_LAYER_ORDER)
# Categories: start with the canonical CATEGORY_MAP keys, then
# append any extras found in live data.
seen_cats: set[str] = set(CATEGORY_MAP.keys())
for meta in registry.values():
if not isinstance(meta, dict):
continue
cat = meta.get("category", "")
if cat:
seen_cats.add(cat)
role = meta.get("role", "")
if role:
_ROLE_SET.add(role)
_ALL_CATEGORIES = sorted(seen_cats)
try:
from ai_lsc.registry.licenses import CATALOG
_LICENSE_SPDX_IDS = sorted(CATALOG.keys())
except ImportError:
_LICENSE_SPDX_IDS = ["MIT", "Apache-2.0", "GPL-3.0", "Proprietary"]
def _set_combo_text(combo: QComboBox, text: str) -> None:
"""Set a QComboBox to *text*, adding it if not present."""
idx = combo.findText(text)
if idx >= 0:
combo.setCurrentIndex(idx)
else:
combo.setEditText(text)
if _HAS_QT:
class _CategoryDelegate(QStyledItemDelegate):
"""Combo-box delegate for the Category column in the tool table.
When the user picks a new category the change is immediately
cascaded (layer / level / role) and auto-saved to ecosystem.json.
"""
def __init__(self, parent: DatabaseManager) -> None:
super().__init__(parent)
self._db: DatabaseManager = parent
def createEditor(self, parent, option, index):
combo = QComboBox(parent)
combo.addItem("") # blank = clear
for cat in _ALL_CATEGORIES:
combo.addItem(cat)
combo.setEditable(True)
# Pre-select current value
current = index.data(Qt.DisplayRole) or ""
idx = combo.findText(current)
if idx >= 0:
combo.setCurrentIndex(idx)
else:
combo.setEditText(current)
combo.currentTextChanged.connect(
lambda text, idx=index: self._on_category_picked(
idx, text
)
)
return combo
def setModelData(self, editor, model, index):
text = editor.currentText()
model.setData(index, text, Qt.DisplayRole)
def _on_category_picked(
self, table_index, new_category: str
) -> None:
row = table_index.row()
tool_id_item = self._db.tool_table.item(row, 0)
if not tool_id_item:
return
tool_id = tool_id_item.data(Qt.UserRole)
if not tool_id or not new_category:
return
# Cascade: update layer / level / role from CATEGORY_MAP
mapping = CATEGORY_MAP.get(new_category)
meta = self._db.main.registry_mgr.data.get(tool_id, {})
if mapping:
meta["layer"] = mapping["layer"]
meta["level"] = mapping["level"]
meta["role"] = mapping["role"]
meta["category"] = new_category
# Write back
self._db.main.registry_mgr.data[tool_id] = meta
from ai_lsc.ui.main_window import _atomic_write_json
_atomic_write_json(
self._db.main.registry_mgr.registry_file,
self._db.main.registry_mgr.data,
)
# Update table cells for this row
lvl_item = self._db.tool_table.item(row, 2)
if lvl_item:
lvl_item.setText(str(meta.get("level", "")))
cat_item = self._db.tool_table.item(row, 3)
if cat_item:
cat_item.setText(new_category)
# If this tool is currently loaded in the edit form,
# refresh it too.
if tool_id == self._db._current_tool_id:
self._db._load_tool(tool_id)
self._db._show_status(
"ok",
f"<b>{tool_id}</b> category → "
f"<b>{new_category}</b> (auto-saved).",
)
class DatabaseManager(QWidget):
"""Full CRUD editor for the ecosystem tool registry.
Layout
------
Left (40 %) — searchable table of all tools.
Right (60 %) — scrollable edit form for the selected tool.
The form groups fields into logical sections that match the
registry schema: Identity, Categorisation, Installer, Launcher,
Flags, License, Dependencies, Filesystem.
**Categorisation cascade**: changing Category auto-fills Layer,
Level, and Role from :data:`CATEGORY_MAP`.
"""
def __init__(self, main_window) -> None:
super().__init__()
self.main = main_window
self._current_tool_id: str | None = None
self._dirty = False
self._loading = False # guard to suppress dirty during programmatic set
_populate_lookups(main_window.registry_mgr.get_all_tools())
root = QVBoxLayout(self)
root.setContentsMargins(4, 4, 4, 4)
# ── Title bar ────────────────────────────────────────────
title = QLabel(
"<b>Tool Database Manager</b> "
"<span style='color:#7f8c8d;'>"
"— edit categorisation, flags, and metadata per tool"
"</span>"
)
title.setWordWrap(True)
root.addWidget(title)
# ── Status bar (validation results / save feedback) ──────
self.status_label = QLabel("")
self.status_label.setWordWrap(True)
self.status_label.setStyleSheet(
"padding: 4px 8px; border-radius: 4px;"
)
root.addWidget(self.status_label)
# ── Splitter ─────────────────────────────────────────────
splitter = QSplitter(Qt.Horizontal)
# ── Left panel: tool list ────────────────────────────────
left = QWidget()
left_layout = QVBoxLayout(left)
left_layout.setContentsMargins(0, 0, 0, 0)
# Filter row
filter_row = QHBoxLayout()
filter_row.addWidget(QLabel("Search:"))
self.search_edit = QLineEdit()
self.search_edit.setPlaceholderText(
"Filter by tool_id, name, category..."
)
self.search_edit.textChanged.connect(self._apply_filter)
filter_row.addWidget(self.search_edit)
self.layer_filter = QComboBox()
self.layer_filter.addItem("All Layers")
for layer in _LAYER_ORDER:
self.layer_filter.addItem(layer)
self.layer_filter.currentIndexChanged.connect(self._apply_filter)
filter_row.addWidget(QLabel("Layer:"))
filter_row.addWidget(self.layer_filter)
left_layout.addLayout(filter_row)
# Tool table
self.tool_table = QTableWidget()
self.tool_table.setColumnCount(5)
self.tool_table.setHorizontalHeaderLabels([
"Tool ID", "Name", "Lvl", "Category", "License",
])
self.tool_table.setSelectionBehavior(
QTableWidget.SelectRows
)
self.tool_table.setSelectionMode(
QTableWidget.SingleSelection
)
# Only the Category column (3) is editable via delegate;
# all other columns are read-only.
self.tool_table.setEditTriggers(
QTableWidget.SelectedClicked
)
self.tool_table.setItemDelegateForColumn(
3, _CategoryDelegate(self)
)
self.tool_table.setSortingEnabled(True)
self.tool_table.verticalHeader().setDefaultSectionSize(24)
self.tool_table.horizontalHeader().setStretchLastSection(
True
)
self.tool_table.setColumnWidth(0, 160)
self.tool_table.setColumnWidth(1, 200)
self.tool_table.setColumnWidth(2, 36)
self.tool_table.setColumnWidth(3, 150)
self.tool_table.setColumnWidth(4, 120)
self.tool_table.itemSelectionChanged.connect(
self._on_tool_selected
)
left_layout.addWidget(self.tool_table)
# Action buttons below table
btn_row = QHBoxLayout()
self.btn_add = QPushButton("+ Add Tool")
self.btn_add.clicked.connect(self._add_tool)
btn_row.addWidget(self.btn_add)
self.btn_delete = QPushButton("Delete Tool")
self.btn_delete.clicked.connect(self._delete_tool)
btn_row.addWidget(self.btn_delete)
self.btn_validate_all = QPushButton("Validate All")
self.btn_validate_all.clicked.connect(
self._validate_all
)
btn_row.addWidget(self.btn_validate_all)
left_layout.addLayout(btn_row)
splitter.addWidget(left)
# ── Right panel: edit form ───────────────────────────────
self.edit_scroll = QScrollArea()
self.edit_scroll.setWidgetResizable(True)
self.edit_container = QWidget()
self.edit_layout = QVBoxLayout(self.edit_container)
self.edit_layout.setSpacing(8)
self.edit_container.setSizePolicy(
QSizePolicy.Expanding, QSizePolicy.Expanding
)
self.edit_scroll.setWidget(self.edit_container)
# No-selection placeholder
self.placeholder = QLabel(
"<i>Select a tool from the table to edit its properties."
"</i>"
)
self.placeholder.setAlignment(Qt.AlignCenter)
self.placeholder.setSizePolicy(
QSizePolicy.Expanding, QSizePolicy.Expanding
)
self.placeholder.setStyleSheet("color: #7f8c8d; padding: 40px;")
self.edit_layout.addWidget(self.placeholder)
splitter.addWidget(self.edit_scroll)
splitter.setStretchFactor(0, 4)
splitter.setStretchFactor(1, 6)
root.addWidget(splitter, 1)
# Populate the table
self._populate_table()
# ── Table population ──────────────────────────────────────────
def _populate_table(self) -> None:
registry = self.main.registry_mgr.get_all_tools()
self.tool_table.setRowCount(0)
self._all_rows: list[tuple[str, dict]] = []
for t_id, meta in sorted(registry.items()):
if not isinstance(meta, dict):
continue
self._all_rows.append((t_id, meta))
self._apply_filter()
def _apply_filter(self) -> None:
search = self.search_edit.text().lower().strip()
layer_idx = self.layer_filter.currentIndex()
layer_filter = (
_LAYER_ORDER[layer_idx - 1]
if layer_idx > 0
else ""
)
self.tool_table.setRowCount(0)
row_idx = 0
for t_id, meta in self._all_rows:
name = meta.get("name", t_id)
cat = meta.get("category", "")
layer = meta.get("layer", "")
lic = meta.get("license", "")
if layer_filter and layer != layer_filter:
continue
if search:
haystack = f"{t_id} {name} {cat} {layer} {lic}".lower()
if search not in haystack:
continue
self.tool_table.insertRow(row_idx)
items = [
t_id,
name,
str(meta.get("level", "")),
cat,
lic,
]
for col, text in enumerate(items):
item = QTableWidgetItem(text)
item.setData(Qt.UserRole, t_id)
if col != 3:
# Only Category column is editable
item.setFlags(
item.flags() & ~Qt.ItemIsEditable
)
if col == 0:
item.setFont(
QFont("Segoe UI", 9, QFont.Bold)
)
self.tool_table.setItem(row_idx, col, item)
row_idx += 1
def _on_tool_selected(self) -> None:
rows = self.tool_table.selectionModel().selectedRows()
if not rows:
return
row = rows[0].row()
tool_id = (
self.tool_table.item(row, 0)
.data(Qt.UserRole)
)
if tool_id == self._current_tool_id:
return
self._save_current_if_dirty()
self._load_tool(tool_id)
# ── Edit form builders ────────────────────────────────────────
def _clear_edit_form(self) -> None:
"""Remove all edit widgets from the form."""
while self.edit_layout.count():
child = self.edit_layout.takeAt(0)
w = child.widget()
if w:
w.deleteLater()
def _load_tool(self, tool_id: str) -> None:
self._current_tool_id = tool_id
self._dirty = False
self._loading = True
self._clear_edit_form()
meta = self.main.registry_mgr.get_tool(tool_id)
if not meta:
self.edit_layout.addWidget(QLabel(
f"<b>{tool_id}</b> not found in registry."
))
self._loading = False
return
# ── Identity ────────────────────────────────────────────
grp_id = QGroupBox("Identity")
form_id = QFormLayout(grp_id)
self.f_tool_id = QLabel(tool_id)
self.f_tool_id.setTextInteractionFlags(
Qt.TextSelectableByMouse
)
self.f_tool_id.setStyleSheet(
"font-family: monospace; color: #3498db;"
)
form_id.addRow("Tool ID:", self.f_tool_id)
self.f_name = QLineEdit(meta.get("name", tool_id))
form_id.addRow("Name:", self.f_name)
self.f_description = QTextEdit(
meta.get("description", "")
)
self.f_description.setMaximumHeight(60)
form_id.addRow("Description:", self.f_description)
self.edit_layout.addWidget(grp_id)
# ── Categorisation ─────────────────────────────────────
grp_cat = QGroupBox("Categorisation")
form_cat = QFormLayout(grp_cat)
# Category — fixed dropdown, NOT editable.
# This is the primary selector; Layer / Level / Role
# auto-fill when it changes.
self.f_category = QComboBox()
self.f_category.setEditable(False)
self.f_category.addItem("") # blank = custom / unknown
for cat in _ALL_CATEGORIES:
self.f_category.addItem(cat)
idx = self.f_category.findText(meta.get("category", ""))
if idx >= 0:
self.f_category.setCurrentIndex(idx)
else:
self.f_category.setCurrentIndex(0)
form_cat.addRow("Category:", self.f_category)
# Level — auto-determined from category.
self.f_level = QSpinBox()
self.f_level.setRange(1, 10)
self.f_level.setValue(meta.get("level", 1))
form_cat.addRow("Level:", self.f_level)
# Layer — auto-determined from category.
self.f_layer = QComboBox()
self.f_layer.setEditable(True)
for layer in _LAYER_ORDER:
self.f_layer.addItem(layer)
idx = self.f_layer.findText(meta.get("layer", ""))
if idx >= 0:
self.f_layer.setCurrentIndex(idx)
else:
self.f_layer.setEditText(meta.get("layer", ""))
form_cat.addRow("Layer:", self.f_layer)
# Role — auto-selected from category default.
self.f_role = QComboBox()
self.f_role.setEditable(True)
for role in sorted(_ROLE_SET):
self.f_role.addItem(role)
idx = self.f_role.findText(meta.get("role", ""))
if idx >= 0:
self.f_role.setCurrentIndex(idx)
else:
self.f_role.setEditText(meta.get("role", ""))
form_cat.addRow("Role:", self.f_role)
# Cascade signal: category change → auto-fill level/layer/role
self.f_category.currentTextChanged.connect(
self._on_category_changed
)
self.edit_layout.addWidget(grp_cat)
# ── Installer ──────────────────────────────────────────
inst = meta.get("installer", {})
grp_inst = QGroupBox("Installer")
form_inst = QFormLayout(grp_inst)
self.f_inst_type = QComboBox()
self.f_inst_type.addItems(_INSTALLER_TYPES)
idx = self.f_inst_type.findText(
inst.get("type", "pacman")
)
if idx >= 0:
self.f_inst_type.setCurrentIndex(idx)
form_inst.addRow("Type:", self.f_inst_type)
self.f_inst_pkg = QLineEdit(inst.get("pkg", ""))
form_inst.addRow("Package / URL:", self.f_inst_pkg)
self.f_inst_cmd = QLineEdit(inst.get("cmd", "") or "")
self.f_inst_cmd.setPlaceholderText(
"(script type only)"
)
form_inst.addRow("Command:", self.f_inst_cmd)
self.f_inst_post = QLineEdit(
inst.get("post_install", "") or ""
)
form_inst.addRow("Post-install:", self.f_inst_post)
self.f_inst_update = QLineEdit(
inst.get("update_cmd", "") or ""
)
form_inst.addRow("Update cmd:", self.f_inst_update)
self.edit_layout.addWidget(grp_inst)
# ── Launcher ──────────────────────────────────────────
launch = meta.get("launcher", {})
grp_launch = QGroupBox("Launcher")
form_launch = QFormLayout(grp_launch)
self.f_launch_type = QComboBox()
self.f_launch_type.addItems(_LAUNCHER_TYPES)
idx = self.f_launch_type.findText(
launch.get("type", "desktop")
)
if idx >= 0:
self.f_launch_type.setCurrentIndex(idx)
form_launch.addRow("Type:", self.f_launch_type)
self.f_launch_cmd = QLineEdit(launch.get("cmd", ""))
form_launch.addRow("Command:", self.f_launch_cmd)
self.f_launch_port = QSpinBox()
self.f_launch_port.setRange(0, 65535)
port_val = launch.get("default_port")
self.f_launch_port.setValue(port_val if port_val else 0)
self.f_launch_port.setSpecialValueText("None")
form_launch.addRow("Default Port:", self.f_launch_port)
self.edit_layout.addWidget(grp_launch)
# ── Flags ──────────────────────────────────────────────
grp_flags = QGroupBox("Flags")
flags_layout = QVBoxLayout(grp_flags)
flags = meta.get("flags", {})
self._flag_widgets: dict[str, QCheckBox] = {}
row_flags = QHBoxLayout()
col_count = 0
for key in _FLAG_KEYS:
cb = QCheckBox(_FLAG_LABELS.get(key, key))
cb.setChecked(bool(flags.get(key, False)))
self._flag_widgets[key] = cb
row_flags.addWidget(cb)
col_count += 1
if col_count == 4:
flags_layout.addLayout(row_flags)
row_flags = QHBoxLayout()
col_count = 0
if col_count > 0:
flags_layout.addLayout(row_flags)
# Quick presets
preset_row = QHBoxLayout()
preset_row.addWidget(QLabel("Presets:"))
for label, preset_flags in [
("Web Service", {
"has_cli": True, "has_web": True,
"has_gui": False, "is_ollama": False,
"is_passive": False,
"is_mcp": False, "is_skills_collection": False,
}),
("CLI Tool", {
"has_cli": True, "has_web": False,
"has_gui": False, "is_ollama": False,
"is_passive": False,
"is_mcp": False, "is_skills_collection": False,
}),
("Passive Library", {
"has_cli": False, "has_web": False,
"has_gui": False, "is_ollama": False,
"is_passive": True,
"is_mcp": False, "is_skills_collection": False,
}),
("MCP API", {
"has_cli": False, "has_web": False,
"has_gui": False, "is_ollama": False,
"is_passive": False,
"is_mcp": True, "is_skills_collection": False,
}),
("Skills Pack", {
"has_cli": False, "has_web": False,
"has_gui": False, "is_ollama": False,
"is_passive": False,
"is_mcp": False, "is_skills_collection": True,
}),
]:
btn = QPushButton(label)
btn.setMaximumWidth(120)
btn.clicked.connect(
lambda _, pf=preset_flags: self._apply_flag_preset(pf)
)
preset_row.addWidget(btn)
preset_row.addStretch()
flags_layout.addLayout(preset_row)
self.edit_layout.addWidget(grp_flags)
# ── License ────────────────────────────────────────────
grp_lic = QGroupBox("License")
form_lic = QFormLayout(grp_lic)
self.f_license = QComboBox()
self.f_license.setEditable(True)
self.f_license.addItems(_LICENSE_SPDX_IDS)
idx = self.f_license.findText(
meta.get("license", "Proprietary")
)
if idx >= 0:
self.f_license.setCurrentIndex(idx)
else:
self.f_license.setEditText(
meta.get("license", "Proprietary")
)
form_lic.addRow("SPDX ID:", self.f_license)
self.edit_layout.addWidget(grp_lic)
# ── Dependencies ───────────────────────────────────────
grp_deps = QGroupBox("Dependencies")
deps_layout = QVBoxLayout(grp_deps)
self.f_deps = QLineEdit(
", ".join(meta.get("deps", []))
)
self.f_deps.setPlaceholderText(
"Comma-separated tool IDs (e.g. redis, qdrant)"
)
deps_layout.addWidget(self.f_deps)
self.edit_layout.addWidget(grp_deps)
# ── Filesystem ─────────────────────────────────────────
fs = meta.get("filesystem", {})
grp_fs = QGroupBox("Filesystem Paths (relative to base)")
form_fs = QFormLayout(grp_fs)
self._fs_fields: dict[str, QLineEdit] = {}
for fs_key in (
"install", "config", "cache", "data",
"logs", "runtime", "models",
):
le = QLineEdit(fs.get(fs_key, "") or "")
le.setPlaceholderText(f"e.g. tools/{tool_id}")
self._fs_fields[fs_key] = le
form_fs.addRow(f"{fs_key}:", le)
self.edit_layout.addWidget(grp_fs)
# ── Action buttons ─────────────────────────────────────
btn_bar = QHBoxLayout()
self.btn_save = QPushButton("Save Changes")
self.btn_save.setStyleSheet(
"background-color: #27ae60; font-weight: bold;"
)
self.btn_save.clicked.connect(self._save_current)
btn_bar.addWidget(self.btn_save)
self.btn_revert = QPushButton("Revert")
self.btn_revert.clicked.connect(
lambda: self._load_tool(self._current_tool_id)
)
btn_bar.addWidget(self.btn_revert)
self.btn_validate = QPushButton("Validate Entry")
self.btn_validate.clicked.connect(self._validate_current)
btn_bar.addWidget(self.btn_validate)
btn_bar.addStretch()
self.edit_layout.addLayout(btn_bar)
# ── Raw JSON preview ───────────────────────────────────
grp_raw = QGroupBox("Raw JSON Preview")
raw_layout = QVBoxLayout(grp_raw)
self.raw_preview = QTextEdit()
self.raw_preview.setReadOnly(True)
self.raw_preview.setMaximumHeight(180)
self.raw_preview.setStyleSheet(
"font-family: monospace; font-size: 11px;"
)
raw_layout.addWidget(self.raw_preview)
self._refresh_raw_preview()
self.edit_layout.addWidget(grp_raw)
# Mark dirty on any edit
for w in (
self.f_name, self.f_description,
self.f_inst_pkg, self.f_inst_cmd,
self.f_inst_post, self.f_inst_update,
self.f_launch_cmd, self.f_deps,
):
if isinstance(w, QLineEdit):
w.textChanged.connect(self._mark_dirty)
elif isinstance(w, QTextEdit):
w.textChanged.connect(self._mark_dirty)
for w in (
self.f_level, self.f_layer, self.f_role,
self.f_inst_type,
self.f_launch_type, self.f_launch_port,
self.f_license,
):
if isinstance(w, QComboBox):
w.currentIndexChanged.connect(self._mark_dirty)
elif isinstance(w, QSpinBox):
w.valueChanged.connect(self._mark_dirty)
for cb in self._flag_widgets.values():
cb.stateChanged.connect(self._mark_dirty)
for le in self._fs_fields.values():
le.textChanged.connect(self._mark_dirty)
self.edit_layout.addStretch()
self._loading = False
# ── Category cascade ──────────────────────────────────────────
def _on_category_changed(self, category: str) -> None:
"""Auto-fill Level, Layer, and Role when Category changes."""
if self._loading:
return
mapping = CATEGORY_MAP.get(category)
if mapping is None:
return
self._loading = True
self.f_level.setValue(mapping["level"])
_set_combo_text(self.f_layer, mapping["layer"])
_set_combo_text(self.f_role, mapping["role"])
self._loading = False
self._mark_dirty()
# ── Flag presets ──────────────────────────────────────────────
def _apply_flag_preset(
self, preset: dict[str, bool]
) -> None:
for key, val in preset.items():
cb = self._flag_widgets.get(key)
if cb:
cb.setChecked(val)
self._mark_dirty()
# ── Dirty tracking ────────────────────────────────────────────
def _mark_dirty(self) -> None:
if self._loading:
return
self._dirty = True
self.btn_save.setStyleSheet(
"background-color: #e67e22; font-weight: bold;"
)
self._refresh_raw_preview()
def _refresh_raw_preview(self) -> None:
if not self._current_tool_id:
return
data = self._collect_form_data()
import json
self.raw_preview.setPlainText(
json.dumps(data, indent=2, ensure_ascii=False)
)
# ── Collect form data back into a dict ────────────────────────
def _collect_form_data(self) -> dict:
port_val = self.f_launch_port.value()
deps_text = self.f_deps.text().strip()
deps_list = [
d.strip()
for d in deps_text.split(",")
if d.strip()
]
flags = {}
for key, cb in self._flag_widgets.items():
flags[key] = cb.isChecked()
fs = {}
for key, le in self._fs_fields.items():
val = le.text().strip()
fs[key] = val
return {
"name": self.f_name.text().strip(),
"level": self.f_level.value(),
"layer": self.f_layer.currentText(),
"role": self.f_role.currentText(),
"category": self.f_category.currentText(),
"description": self.f_description.toPlainText().strip(),
"license": self.f_license.currentText().strip(),
"installer": {
"type": self.f_inst_type.currentText(),
"pkg": self.f_inst_pkg.text().strip(),
"cmd": self.f_inst_cmd.text().strip() or None,
"post_install": (
self.f_inst_post.text().strip() or None
),
"update_cmd": (
self.f_inst_update.text().strip() or None
),
},
"launcher": {
"type": self.f_launch_type.currentText(),
"cmd": self.f_launch_cmd.text().strip(),
"default_port": port_val if port_val > 0 else None,
},
"deps": deps_list,
"flags": flags,
"filesystem": fs,
}
# ── Save / Revert / Validate ──────────────────────────────────
def _save_current(self) -> None:
if not self._current_tool_id:
return
data = self._collect_form_data()
tool_id = self._current_tool_id
# Validate before writing
from ai_lsc.registry.validator import validate_registry
test_registry = dict(self.main.registry_mgr.data)
test_registry[tool_id] = data
errors = validate_registry(test_registry)
tool_errors = [
e for e in errors
if e.startswith(f"{tool_id}:")
]
if tool_errors:
self._show_status(
"error",
f"Validation failed for {tool_id}:<br>"
+ "<br>".join(
f"&bull; {e}" for e in tool_errors
),
)
return
# Write via atomic write
self.main.registry_mgr.data[tool_id] = data
registry_path = (
self.main.registry_mgr.registry_file
)
from ai_lsc.ui.main_window import _atomic_write_json
_atomic_write_json(registry_path, self.main.registry_mgr.data)
self._dirty = False
self.btn_save.setStyleSheet(
"background-color: #27ae60; font-weight: bold;"
)
self._show_status(
"ok",
f"<b>{tool_id}</b> saved successfully.",
)
self._populate_table()
self._reselect_tool(tool_id)
def _save_current_if_dirty(self) -> None:
if self._dirty and self._current_tool_id:
reply = QMessageBox.question(
self,
"Unsaved changes",
f"Save changes to <b>{self._current_tool_id}</b> "
f"before switching?",
QMessageBox.Save | QMessageBox.Discard,
QMessageBox.Save,
)
if reply == QMessageBox.Save:
self._save_current()
def _validate_current(self) -> None:
if not self._current_tool_id:
return
data = self._collect_form_data()
tool_id = self._current_tool_id
from ai_lsc.registry.validator import validate_registry
test_registry = dict(self.main.registry_mgr.data)
test_registry[tool_id] = data
errors = validate_registry(test_registry)
tool_errors = [
e for e in errors
if e.startswith(f"{tool_id}:")
]
other_count = len(errors) - len(tool_errors)
if not tool_errors:
self._show_status(
"ok",
f"<b>{tool_id}</b> — no validation errors."
+ (
f" ({other_count} warning(s) in other tools)"
if other_count
else ""
),
)
else:
self._show_status(
"error",
f"<b>{tool_id}</b> — {len(tool_errors)} error(s):"
"<br>"
+ "<br>".join(
f"&bull; {e}" for e in tool_errors
),
)
def _validate_all(self) -> None:
# Re-read from disk in case user edited outside
self.main.registry_mgr._bootstrap()
from ai_lsc.registry.validator import validate_registry
errors = validate_registry(self.main.registry_mgr.data)
total_tools = len(self.main.registry_mgr.data)
if not errors:
self._show_status(
"ok",
f"All {total_tools} tools pass validation.",
)
else:
self._show_status(
"error",
f"<b>{len(errors)} error(s)</b> across "
f"{total_tools} tools:<br>"
+ "<br>".join(
f"&bull; {e}" for e in errors[:30]
)
+ (
f"<br>... and {len(errors) - 30} more"
if len(errors) > 30
else ""
),
)
# ── Add / Delete tool ─────────────────────────────────────────
def _add_tool(self) -> None:
new_id, ok = _NewToolDialog.get_tool_id(self)
if not ok or not new_id:
return
registry = self.main.registry_mgr.data
if new_id in registry:
self._show_status(
"error",
f"<b>{new_id}</b> already exists in the registry.",
)
return
# Template with sensible defaults
registry[new_id] = {
"name": new_id.replace("_", " ").title(),
"level": 1,
"layer": _LAYER_ORDER[0] if _LAYER_ORDER else "",
"role": "",
"category": "",
"description": "",
"license": "Proprietary",
"installer": {
"type": "pacman",
"pkg": new_id,
},
"launcher": {
"type": "desktop",
"cmd": new_id,
"default_port": None,
},
"deps": [],
"flags": {
k: False for k in _FLAG_KEYS
},
"filesystem": {},
}
from ai_lsc.ui.main_window import _atomic_write_json
_atomic_write_json(
self.main.registry_mgr.registry_file, registry
)
self._populate_table()
self._reselect_tool(new_id)
self._show_status(
"ok",
f"<b>{new_id}</b> added with defaults. "
f"Edit and save to finalise.",
)
def _delete_tool(self) -> None:
if not self._current_tool_id:
return
tool_id = self._current_tool_id
reply = QMessageBox.warning(
self,
"Delete tool?",
f"Permanently remove <b>{tool_id}</b> from the "
f"registry? This cannot be undone.",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No,
)
if reply != QMessageBox.Yes:
return
self.main.registry_mgr.data.pop(tool_id, None)
from ai_lsc.ui.main_window import _atomic_write_json
_atomic_write_json(
self.main.registry_mgr.registry_file,
self.main.registry_mgr.data,
)
self._current_tool_id = None
self._dirty = False
self._clear_edit_form()
self.edit_layout.addWidget(QLabel(
"<i>Tool deleted. Select another to edit.</i>"
))
self._populate_table()
self._show_status(
"ok",
f"<b>{tool_id}</b> removed from registry.",
)
# ── Helpers ──────────────────────────────────────────────────
def _reselect_tool(self, tool_id: str) -> None:
"""Re-select the given tool_id in the table after refresh."""
for row in range(self.tool_table.rowCount()):
item = self.tool_table.item(row, 0)
if item and item.data(Qt.UserRole) == tool_id:
self.tool_table.selectRow(row)
self.tool_table.scrollTo(
self.tool_table.model().index(row, 0)
)
return
def _show_status(
self, level: str, html: str
) -> None:
colors = {
"ok": ("#27ae60", "#1a3a2a"),
"error": ("#e74c3c", "#3a1a1a"),
"info": ("#3498db", "#1a2a3a"),
}
fg, bg = colors.get(level, ("#bdc3c7", "#1a1a1a"))
self.status_label.setStyleSheet(
f"padding: 6px 10px; border-radius: 4px; "
f"color: {fg}; background-color: {bg};"
)
self.status_label.setText(html)
def refresh(self) -> None:
"""Called by the main window when the nav tab is activated."""
_populate_lookups(
self.main.registry_mgr.get_all_tools()
)
self._populate_table()
# ── Dialogs ────────────────────────────────────────────────────────
class _NewToolDialog(QDialog):
"""Modal dialog that asks the user for a new tool_id."""
@staticmethod
def get_tool_id(parent: QWidget) -> tuple[str, bool]:
dlg = _NewToolDialog(parent)
result = dlg.exec()
return dlg.input_field.text().strip(), (
result == QDialog.DialogCode.Accepted
)
def __init__(self, parent: QWidget) -> None:
super().__init__(parent)
self.setWindowTitle("Add New Tool")
self.setMinimumWidth(400)
layout = QVBoxLayout(self)
layout.addWidget(QLabel(
"Enter the new tool identifier (lowercase, "
"alphanumeric + underscores):"
))
self.input_field = QLineEdit()
self.input_field.setPlaceholderText("e.g. my_new_tool")
layout.addWidget(self.input_field)
buttons = QDialogButtonBox(
QDialogButtonBox.Ok | QDialogButtonBox.Cancel
)
buttons.accepted.connect(self._validate_and_accept)
buttons.rejected.connect(self.reject)
layout.addWidget(buttons)
def _validate_and_accept(self) -> None:
import re
text = self.input_field.text().strip()
if not text:
self.input_field.setStyleSheet(
"border: 2px solid #e74c3c;"
)
return
if not re.match(r"^[a-z0-9_]+$", text):
self.input_field.setStyleSheet(
"border: 2px solid #e74c3c;"
)
return
self.accept()
else:
DatabaseManager = None