665 lines
26 KiB
Python
Executable File
665 lines
26 KiB
Python
Executable File
"""ServiceRow widget -- a single service row driven by registry metadata + flags.
|
|
|
|
Renders one tool (or skill:-prefixed behavior binding) inside the
|
|
Tools/Services page. Each row shows the service name, live status,
|
|
CPU load, port input, model selector (for engine/LLM-runtime services),
|
|
Ollama pull controls, launcher buttons (CLI/GUI/Web), and
|
|
Install/Update + Start/Stop action buttons. The install button runs
|
|
preflight first: missing tools install to their map-aligned root
|
|
(dashboards/ for web UIs, tools/ otherwise); already-installed tools
|
|
update in place (registry ``update_cmd`` when declared, per-type
|
|
default otherwise).
|
|
|
|
All process management is delegated to
|
|
:class:`~ai_lsc.runtime.executor.RuntimeExecutor` -- this widget
|
|
contains **zero** ``subprocess`` / ``psutil`` calls.
|
|
"""
|
|
|
|
import os
|
|
import threading
|
|
|
|
from ai_lsc.constants import SERVICE_LICENSES, STATUS_STYLES
|
|
from ai_lsc.utils.process import cpu_load_for_processes
|
|
|
|
try:
|
|
from PySide6.QtCore import Qt, QTimer
|
|
from PySide6.QtGui import QFont
|
|
from PySide6.QtWidgets import (
|
|
QComboBox,
|
|
QDialog,
|
|
QFrame,
|
|
QFormLayout,
|
|
QHBoxLayout,
|
|
QLabel,
|
|
QLineEdit,
|
|
QMessageBox,
|
|
QPushButton,
|
|
QVBoxLayout,
|
|
QWidget,
|
|
)
|
|
_HAS_QT = True
|
|
except ImportError:
|
|
_HAS_QT = False
|
|
|
|
if _HAS_QT:
|
|
|
|
class ServiceRow(QWidget):
|
|
"""A single service row, fully driven by registry metadata + flags.
|
|
Supports both regular tools and skill:-prefixed behavior bindings."""
|
|
|
|
def __init__(self, parent, tool_id: str, port, meta: dict):
|
|
super().__init__(parent)
|
|
self.parent = parent
|
|
self.tool_id = tool_id
|
|
self.port = port
|
|
self.meta = meta
|
|
self.is_skill = tool_id.startswith("skill:")
|
|
|
|
if self.is_skill:
|
|
self._build_skill_row()
|
|
return
|
|
|
|
self.installer = meta.get("installer", {})
|
|
self.launcher = meta.get("launcher", {})
|
|
self.flags = meta.get("flags", {})
|
|
self.is_ollama = self.flags.get("is_ollama", False)
|
|
self.has_models = meta.get("category") in (
|
|
"Engine", "LLM Runtime", "Development",
|
|
)
|
|
self.has_api = self.flags.get("has_web", False) or meta.get(
|
|
"category"
|
|
) in (
|
|
"Engine", "LLM Runtime", "Pipeline",
|
|
"Agent Framework", "Embedding",
|
|
)
|
|
self.search_term = self.installer.get("pkg", tool_id)
|
|
self._build_ui()
|
|
|
|
# -- property shortcuts to parent attributes -----------------------
|
|
|
|
@property
|
|
def _runtime(self):
|
|
"""Lazy accessor for the RuntimeExecutor on the main window."""
|
|
return self.parent.runtime
|
|
|
|
def _log(self, text: str, source: str = "System"):
|
|
self.parent.log(text, source)
|
|
|
|
# -- row builders --------------------------------------------------
|
|
|
|
def _build_skill_row(self):
|
|
layout = QHBoxLayout(self)
|
|
layout.setContentsMargins(10, 5, 10, 5)
|
|
skill_name = self.tool_id.split(":", 1)[1]
|
|
|
|
self.lbl_name = QLabel(f"[Skill] {skill_name}")
|
|
self.lbl_name.setFixedWidth(160)
|
|
self.lbl_name.setFont(QFont("Consolas", 11, QFont.Bold))
|
|
layout.addWidget(self.lbl_name)
|
|
|
|
self.lbl_status = QLabel("[ READY ]")
|
|
self.lbl_status.setFixedWidth(90)
|
|
self.lbl_status.setAlignment(Qt.AlignCenter)
|
|
self.lbl_status.setStyleSheet(
|
|
"color: #3498db; font-weight: bold;"
|
|
)
|
|
layout.addWidget(self.lbl_status)
|
|
|
|
desc = QLabel("Skill Behavior Binding (Active in Pipeline)")
|
|
desc.setStyleSheet("color: #7f8c8d; font-style: italic;")
|
|
layout.addWidget(desc)
|
|
layout.addStretch()
|
|
|
|
self.txt_port = None
|
|
self.cbo_model = None
|
|
|
|
def _build_ui(self):
|
|
layout = QHBoxLayout(self)
|
|
layout.setContentsMargins(10, 5, 10, 5)
|
|
|
|
level_tag = f"L{self.meta.get('level', 0)}"
|
|
self.lbl_name = QLabel(
|
|
f"[{level_tag}] {self.meta.get('name', self.tool_id)}"
|
|
)
|
|
self.lbl_name.setFixedWidth(160)
|
|
self.lbl_name.setFont(QFont("Consolas", 11, QFont.Bold))
|
|
layout.addWidget(self.lbl_name)
|
|
|
|
self.lbl_status = QLabel("[ CHECKING ]")
|
|
self.lbl_status.setFixedWidth(90)
|
|
self.lbl_status.setAlignment(Qt.AlignCenter)
|
|
layout.addWidget(self.lbl_status)
|
|
|
|
self.lbl_load = QLabel("---")
|
|
self.lbl_load.setFixedWidth(50)
|
|
self.lbl_load.setStyleSheet(
|
|
"color: #FFB000; font-family: 'Consolas'; font-weight: bold;"
|
|
)
|
|
layout.addWidget(self.lbl_load)
|
|
|
|
if self.port is not None:
|
|
layout.addWidget(QLabel("Port:"))
|
|
self.txt_port = QLineEdit(str(self.port))
|
|
self.txt_port.setFixedWidth(60)
|
|
self.txt_port.setAlignment(Qt.AlignCenter)
|
|
layout.addWidget(self.txt_port)
|
|
else:
|
|
self.txt_port = None
|
|
spacer = QFrame()
|
|
spacer.setFixedWidth(95)
|
|
layout.addWidget(spacer)
|
|
|
|
if self.has_models:
|
|
self.cbo_model = QComboBox()
|
|
self.cbo_model.setFixedWidth(180)
|
|
layout.addWidget(self.cbo_model)
|
|
else:
|
|
self.cbo_model = None
|
|
spacer = QFrame()
|
|
spacer.setFixedWidth(180)
|
|
layout.addWidget(spacer)
|
|
|
|
if self.is_ollama:
|
|
self.txt_pull = QLineEdit()
|
|
self.txt_pull.setPlaceholderText("model tag")
|
|
self.txt_pull.setFixedWidth(100)
|
|
layout.addWidget(self.txt_pull)
|
|
btn_pull = QPushButton("Pull")
|
|
btn_pull.setFixedWidth(50)
|
|
btn_pull.setStyleSheet(
|
|
"background-color: #d35400; color: white; font-weight: bold;"
|
|
)
|
|
btn_pull.clicked.connect(self.pull_model)
|
|
layout.addWidget(btn_pull)
|
|
|
|
layout.addStretch()
|
|
|
|
# -- Infer C/W/G interface buttons from flags + launcher type --
|
|
# Tools with explicit flags keep them; others are inferred so
|
|
# every service row gets the correct launcher buttons.
|
|
launcher_type = self.launcher.get("type", "")
|
|
has_port = self.port is not None
|
|
show_web = self.flags.get("has_web", False) or (
|
|
has_port and launcher_type in ("tmux", "systemd")
|
|
)
|
|
show_gui = self.flags.get("has_gui", False) or (
|
|
launcher_type == "desktop"
|
|
)
|
|
show_cli = self.flags.get("has_cli", False) or (
|
|
launcher_type in ("cli", "tmux")
|
|
)
|
|
|
|
launcher_style = (
|
|
"QPushButton { background-color: #34495e; color: white; "
|
|
"font-weight: bold; border-radius: 3px; } "
|
|
"QPushButton:hover { background-color: #415b76; }"
|
|
)
|
|
for visible, label, tip, handler in [
|
|
(show_cli, "C", "Launch CLI in terminal", self.launch_cli),
|
|
(show_gui, "G", "Launch Desktop/GUI app", self.launch_gui),
|
|
(show_web, "W", "Open Web UI in browser", self.launch_web),
|
|
]:
|
|
if visible:
|
|
btn = QPushButton(label)
|
|
btn.setFixedWidth(28)
|
|
btn.setToolTip(tip)
|
|
btn.setStyleSheet(launcher_style)
|
|
btn.clicked.connect(handler)
|
|
layout.addWidget(btn)
|
|
|
|
spacer = QFrame()
|
|
spacer.setFixedWidth(10)
|
|
layout.addWidget(spacer)
|
|
|
|
# API endpoint / key button for tools with external API support
|
|
if self.has_api:
|
|
btn_api = QPushButton(":set api")
|
|
btn_api.setToolTip(
|
|
"Set external API endpoint and key"
|
|
)
|
|
btn_api.setStyleSheet(
|
|
"QPushButton { background-color: #e67e22; color: white; "
|
|
"font-weight: bold; font-size: 11px; border-radius: 3px; }"
|
|
"QPushButton:hover { background-color: #f39c12; }"
|
|
)
|
|
btn_api.setFixedWidth(60)
|
|
btn_api.clicked.connect(self._open_api_dialog)
|
|
layout.addWidget(btn_api)
|
|
|
|
self.btn_update = QPushButton("Install / Update")
|
|
self.btn_update.setStyleSheet("background-color: #8e44ad;")
|
|
self.btn_update.clicked.connect(self.smart_install)
|
|
self._install_btn_original_text = self.btn_update.text()
|
|
layout.addWidget(self.btn_update)
|
|
|
|
start_labels = {
|
|
"systemd": "Enable (systemd)",
|
|
"desktop": "Launch App",
|
|
}
|
|
self.btn_start = QPushButton(
|
|
start_labels.get(self.launcher.get("type"), "Start Engine")
|
|
)
|
|
self.btn_start.setStyleSheet("background-color: #27ae60;")
|
|
self.btn_start.clicked.connect(self.start_service)
|
|
layout.addWidget(self.btn_start)
|
|
|
|
stop_labels = {"systemd": "Disable"}
|
|
self.btn_stop = QPushButton(
|
|
stop_labels.get(self.launcher.get("type"), "Kill Process")
|
|
)
|
|
self.btn_stop.setStyleSheet("background-color: #c0392b;")
|
|
self.btn_stop.clicked.connect(self.stop_service)
|
|
layout.addWidget(self.btn_stop)
|
|
|
|
# -- model hydration -----------------------------------------------
|
|
|
|
def hydrate_models(self, ollama_models: list, aider_models: list):
|
|
if not self.has_models or self.cbo_model is None:
|
|
return
|
|
self.cbo_model.clear()
|
|
pool = (
|
|
aider_models
|
|
if any(k in self.tool_id.lower()
|
|
for k in ("aider", "claude"))
|
|
else ollama_models
|
|
)
|
|
self.cbo_model.addItems(pool)
|
|
|
|
# -- launcher actions (delegate to runtime) ----------------------
|
|
|
|
def launch_cli(self):
|
|
desc = self._runtime.launch_cli(
|
|
tool_id=self.tool_id,
|
|
launcher_type=self.launcher.get("type", ""),
|
|
)
|
|
self._log(desc)
|
|
|
|
def launch_gui(self):
|
|
self._log(
|
|
f"GUI payload triggered for: {self.meta.get('name')}", "System"
|
|
)
|
|
|
|
def launch_web(self):
|
|
if self.txt_port is None:
|
|
return
|
|
url = self._runtime.open_web_url(self.txt_port.text().strip())
|
|
self._log(f"Browser navigated to {url}", "System")
|
|
|
|
# -- install (dispatched via runtime) -----------------------------
|
|
|
|
def smart_install(self):
|
|
inst_type = self.installer.get("type")
|
|
pkg = self.installer.get("pkg", self.tool_id)
|
|
cmd = self.installer.get("cmd", "")
|
|
|
|
# UX-03: disable button and show progress during install
|
|
self.btn_update.setEnabled(False)
|
|
self.btn_update.setText("Installing...")
|
|
self.btn_update.setStyleSheet("background-color: #555; color: #aaa;")
|
|
|
|
self._log(
|
|
f"Deploying {self.meta.get('name')} via {inst_type}...",
|
|
"Installer",
|
|
)
|
|
|
|
license_text = SERVICE_LICENSES.get(self.meta.get("name"))
|
|
if license_text:
|
|
msg = QMessageBox(self)
|
|
msg.setWindowTitle(f"Legal Notice: {self.meta.get('name')}")
|
|
msg.setText(
|
|
f"To install <b>{self.meta.get('name')}</b>, "
|
|
f"you must accept the license."
|
|
)
|
|
msg.setInformativeText(license_text)
|
|
msg.setTextInteractionFlags(Qt.TextBrowserInteraction)
|
|
msg.setStandardButtons(QMessageBox.Ok | QMessageBox.Cancel)
|
|
if msg.exec() != QMessageBox.Ok:
|
|
return
|
|
|
|
threading.Thread(
|
|
target=self._run_install,
|
|
args=(inst_type, pkg, cmd),
|
|
daemon=True,
|
|
).start()
|
|
|
|
def _run_install(self, inst_type: str, pkg: str, cmd: str):
|
|
ctx = self._runtime.format_context()
|
|
# Pull the tool's license SPDX from the registry metadata
|
|
# so the installer's license gate can check it.
|
|
license_spdx = self.meta.get("license")
|
|
try:
|
|
desc = self._runtime.install_tool(
|
|
inst_type=inst_type,
|
|
pkg=pkg,
|
|
cmd=cmd,
|
|
tool_id=self.tool_id,
|
|
ctx=ctx,
|
|
license_spdx=license_spdx,
|
|
registry_entry=self.meta,
|
|
)
|
|
QTimer.singleShot(
|
|
0, lambda d=desc, t=self.tool_id: self._on_install_done(d, t)
|
|
)
|
|
except (OSError, ValueError, subprocess.SubprocessError) as exc:
|
|
QTimer.singleShot(
|
|
0, lambda e=exc: self._on_install_error(
|
|
f"Deployment failed: {e}", "Installer Error"
|
|
)
|
|
)
|
|
except Exception as exc:
|
|
# Catch license-gate exceptions (LicenseBlocked /
|
|
# LicenseAcceptanceRequired) which are raised before
|
|
# any subprocess call. These need to be handled on the
|
|
# main thread (Qt dialogs must run on the main thread).
|
|
QTimer.singleShot(
|
|
0, lambda e=exc: self._handle_license_exception(e)
|
|
)
|
|
|
|
def _restore_install_btn(self) -> None:
|
|
"""UX-03: re-enable install button after install completes/fails."""
|
|
self.btn_update.setEnabled(True)
|
|
self.btn_update.setText(self._install_btn_original_text)
|
|
self.btn_update.setStyleSheet("background-color: #8e44ad;")
|
|
|
|
def _on_install_done(self, desc: str, tool_id: str) -> None:
|
|
self._log(desc, tool_id)
|
|
self._restore_install_btn()
|
|
|
|
def _on_install_error(self, msg: str, source: str) -> None:
|
|
self._log(msg, source)
|
|
self._restore_install_btn()
|
|
|
|
def _handle_license_exception(self, exc: Exception) -> None:
|
|
"""Handle LicenseBlocked / LicenseAcceptanceRequired on the
|
|
main thread (Qt dialogs must run there)."""
|
|
try:
|
|
from ai_lsc.registry.license_gate import (
|
|
LicenseAcceptanceRequired,
|
|
LicenseBlocked,
|
|
)
|
|
from ai_lsc.ui.dialogs.license_dialog import (
|
|
LicenseAcceptanceDialog,
|
|
LicenseBlockedDialog,
|
|
)
|
|
except ImportError:
|
|
# If the license modules aren't available (shouldn't
|
|
# happen in normal operation), fall back to logging.
|
|
self._log(f"License check failed: {exc}", "License")
|
|
return
|
|
|
|
if isinstance(exc, LicenseBlocked):
|
|
dlg = LicenseBlockedDialog(
|
|
tool_id=exc.tool_id,
|
|
tool_name=self.meta.get("name", exc.tool_id),
|
|
reason=exc.reason,
|
|
parent=self,
|
|
)
|
|
dlg.exec()
|
|
return
|
|
|
|
if isinstance(exc, LicenseAcceptanceRequired):
|
|
dlg = LicenseAcceptanceDialog(
|
|
tool_id=exc.tool_id,
|
|
tool_name=self.meta.get("name", exc.tool_id),
|
|
license_info=exc.license_info,
|
|
parent=self,
|
|
)
|
|
# Connect the dialog's signals to handlers that record
|
|
# the acceptance + retry the install.
|
|
dlg.accepted_individual.connect(
|
|
self._on_license_accepted_individual
|
|
)
|
|
dlg.accepted_all_of_type.connect(
|
|
self._on_license_accepted_all
|
|
)
|
|
dlg.exec()
|
|
return
|
|
|
|
# Unknown exception type — just log it.
|
|
self._log(f"Unexpected license error: {exc}", "License")
|
|
|
|
def _on_license_accepted_individual(self, tool_id: str, spdx: str) -> None:
|
|
"""Called when the user clicks 'Accept & Install' in the
|
|
license dialog. Records the acceptance and retries the
|
|
install on a background thread."""
|
|
try:
|
|
self.parent.license_gate.accept(tool_id, spdx, via="individual")
|
|
except Exception as exc:
|
|
self._log(f"Failed to record license acceptance: {exc}", "License")
|
|
return
|
|
self._log(
|
|
f"License {spdx} accepted for {tool_id}. Retrying install...",
|
|
"License",
|
|
)
|
|
# Retry the install
|
|
inst = self.meta.get("installer", {})
|
|
self._run_install(
|
|
inst.get("type", "pacman"),
|
|
inst.get("pkg", ""),
|
|
inst.get("cmd", ""),
|
|
)
|
|
|
|
def _on_license_accepted_all(self, tool_id: str, spdx: str) -> None:
|
|
"""Called when the user clicks 'Accept all <license>' in
|
|
the license dialog. Adds the SPDX to the auto-approval
|
|
registry, records the per-tool acceptance, and retries."""
|
|
try:
|
|
self.parent.license_gate.add_auto_approval(spdx)
|
|
self.parent.license_gate.accept(tool_id, spdx, via="auto-approved")
|
|
except ValueError as exc:
|
|
self._log(f"Cannot auto-approve {spdx}: {exc}", "License")
|
|
return
|
|
except Exception as exc:
|
|
self._log(f"Failed to record license acceptance: {exc}", "License")
|
|
return
|
|
self._log(
|
|
f"License {spdx} added to auto-approvals + accepted for {tool_id}. "
|
|
f"Retrying install...",
|
|
"License",
|
|
)
|
|
inst = self.meta.get("installer", {})
|
|
self._run_install(
|
|
inst.get("type", "pacman"),
|
|
inst.get("pkg", ""),
|
|
inst.get("cmd", ""),
|
|
)
|
|
|
|
# -- ollama model pull --------------------------------------------
|
|
|
|
def pull_model(self):
|
|
model_name = self.txt_pull.text().strip()
|
|
if not model_name:
|
|
self._log(
|
|
"Error: Specify a valid model target descriptor.", "Ollama"
|
|
)
|
|
return
|
|
self._log(f"Spawning pull for: {model_name}", "Ollama")
|
|
|
|
def _run():
|
|
try:
|
|
proc = self._runtime.pull_model(model_name)
|
|
# UX-10: Since pull_model now redirects to a log file,
|
|
# poll for completion and surface errors.
|
|
proc.wait(timeout=1800)
|
|
if proc.returncode == 0:
|
|
QTimer.singleShot(
|
|
0, lambda: self._log(
|
|
f"Model {model_name} pulled successfully.",
|
|
"Ollama",
|
|
)
|
|
)
|
|
QTimer.singleShot(
|
|
0, self.parent.refresh_all_models
|
|
)
|
|
else:
|
|
QTimer.singleShot(
|
|
0, lambda: self._log(
|
|
f"PULL FAILED for {model_name} "
|
|
f"(exit code {proc.returncode}). "
|
|
f"Check the pull log in logs/ for details.",
|
|
"Ollama Error",
|
|
)
|
|
)
|
|
except Exception as exc:
|
|
QTimer.singleShot(
|
|
0, lambda: self._log(f"PULL FAILED: {exc}", "Ollama Error")
|
|
)
|
|
|
|
threading.Thread(target=_run, daemon=True).start()
|
|
|
|
# -- start / stop / status (delegated to runtime) ---------------
|
|
|
|
def start_service(self):
|
|
port = self.txt_port.text() if self.txt_port else ""
|
|
model_arg = ""
|
|
if (self.has_models and self.cbo_model
|
|
and self.cbo_model.currentText()):
|
|
model_arg = self.cbo_model.currentText()
|
|
|
|
log_file = os.path.join(self.parent.logs_root, f"{self.tool_id}.log")
|
|
try:
|
|
desc = self._runtime.start_service(
|
|
tool_id=self.tool_id,
|
|
launcher_cmd=self.launcher.get("cmd", ""),
|
|
launcher_type=self.launcher.get("type", ""),
|
|
port=port,
|
|
model_arg=model_arg,
|
|
)
|
|
except ValueError as exc:
|
|
# H-19: port / tool_id validation surfaced from the runtime.
|
|
self._log(f"Cannot start: {exc}", "Validation")
|
|
return
|
|
self._log(desc, self.launcher.get("type", "Tmux").capitalize())
|
|
self.parent.verify_and_watch(log_file)
|
|
QTimer.singleShot(2000, self.update_status)
|
|
|
|
def stop_service(self):
|
|
desc = self._runtime.stop_service(
|
|
tool_id=self.tool_id,
|
|
launcher_type=self.launcher.get("type", ""),
|
|
launcher_cmd=self.launcher.get("cmd", ""),
|
|
search_term=self.search_term,
|
|
)
|
|
self._log(desc, "System")
|
|
QTimer.singleShot(1500, self.update_status)
|
|
|
|
def update_status(self):
|
|
if self.is_skill:
|
|
return
|
|
running = self._runtime.is_service_running(
|
|
launcher_type=self.launcher.get("type", ""),
|
|
tool_id=self.tool_id,
|
|
service_cmd=self.launcher.get("cmd", ""),
|
|
search_term=self.search_term,
|
|
)
|
|
self._last_running_state = running
|
|
cpu = (
|
|
cpu_load_for_processes(self.search_term)
|
|
if running
|
|
else 0.0
|
|
)
|
|
text, color = STATUS_STYLES[running]
|
|
self.lbl_status.setText(text)
|
|
self.lbl_status.setStyleSheet(f"color: {color}; font-weight: bold;")
|
|
self.lbl_load.setText(f"{int(cpu)}%" if running else "---")
|
|
|
|
def is_running_now(self) -> bool:
|
|
"""Return the most recent cached running state.
|
|
|
|
Returns False if update_status() has never been called.
|
|
Used by the PipelineTicker + WorkspaceTab to color/skip
|
|
tool pills without re-querying the runtime.
|
|
"""
|
|
return getattr(self, "_last_running_state", False)
|
|
|
|
# -- API configuration dialog ------------------------------------
|
|
|
|
def _open_api_dialog(self):
|
|
dlg = QDialog(self)
|
|
dlg.setWindowTitle(
|
|
f"API Configuration — {self.meta.get('name', self.tool_id)}"
|
|
)
|
|
dlg.setMinimumWidth(450)
|
|
dlg_layout = QVBoxLayout(dlg)
|
|
|
|
form = QFormLayout()
|
|
txt_endpoint = QLineEdit()
|
|
txt_endpoint.setPlaceholderText(
|
|
"e.g. https://api.openai.com/v1"
|
|
)
|
|
txt_key = QLineEdit()
|
|
txt_key.setPlaceholderText(
|
|
"e.g. sk-... (stored locally only)"
|
|
)
|
|
txt_key.setEchoMode(QLineEdit.Password)
|
|
txt_model_override = QLineEdit()
|
|
txt_model_override.setPlaceholderText(
|
|
"e.g. gpt-4o (optional model override)"
|
|
)
|
|
form.addRow("API Endpoint:", txt_endpoint)
|
|
form.addRow("API Key:", txt_key)
|
|
form.addRow("Model Override:", txt_model_override)
|
|
dlg_layout.addLayout(form)
|
|
|
|
# Restore saved values
|
|
cfg = self.parent.config_data.get("api_overrides", {})
|
|
tool_cfg = cfg.get(self.tool_id, {})
|
|
txt_endpoint.setText(tool_cfg.get("endpoint", ""))
|
|
txt_key.setText(tool_cfg.get("api_key", ""))
|
|
txt_model_override.setText(tool_cfg.get("model_override", ""))
|
|
|
|
btn_box = QHBoxLayout()
|
|
btn_save = QPushButton("Save")
|
|
btn_save.setStyleSheet(
|
|
"background-color: #27ae60; color: white; font-weight: bold;"
|
|
)
|
|
btn_clear = QPushButton("Clear")
|
|
btn_clear.setStyleSheet(
|
|
"background-color: #c0392b; color: white;"
|
|
)
|
|
btn_cancel = QPushButton("Cancel")
|
|
btn_box.addStretch()
|
|
btn_box.addWidget(btn_save)
|
|
btn_box.addWidget(btn_clear)
|
|
btn_box.addWidget(btn_cancel)
|
|
dlg_layout.addLayout(btn_box)
|
|
|
|
def _save():
|
|
api_overrides = self.parent.config_data.setdefault(
|
|
"api_overrides", {}
|
|
)
|
|
api_overrides[self.tool_id] = {
|
|
"endpoint": txt_endpoint.text().strip(),
|
|
"api_key": txt_key.text().strip(),
|
|
"model_override": txt_model_override.text().strip(),
|
|
}
|
|
self.parent.save_config()
|
|
self._log(
|
|
f"API configuration saved for {self.meta.get('name')}",
|
|
"API Config",
|
|
)
|
|
dlg.accept()
|
|
|
|
def _clear():
|
|
api_overrides = self.parent.config_data.get(
|
|
"api_overrides", {}
|
|
)
|
|
api_overrides.pop(self.tool_id, None)
|
|
self.parent.save_config()
|
|
txt_endpoint.clear()
|
|
txt_key.clear()
|
|
txt_model_override.clear()
|
|
self._log(
|
|
f"API configuration cleared for {self.meta.get('name')}",
|
|
"API Config",
|
|
)
|
|
|
|
btn_save.clicked.connect(_save)
|
|
btn_clear.clicked.connect(_clear)
|
|
btn_cancel.clicked.connect(dlg.reject)
|
|
|
|
dlg.exec()
|
|
|
|
else:
|
|
ServiceRow = None
|