Asset-Frameworker/gui/llm_editor_widget.py

315 lines
14 KiB
Python

# gui/llm_editor_widget.py
import json
import logging
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QTabWidget, QPlainTextEdit, QGroupBox,
QHBoxLayout, QPushButton, QFormLayout, QLineEdit, QDoubleSpinBox,
QSpinBox, QMessageBox, QTextEdit
)
from PySide6.QtCore import Slot as pyqtSlot, Signal as pyqtSignal # Use PySide6 equivalents
# Assuming configuration module exists and has relevant functions later
from configuration import save_llm_config, ConfigurationError
# For now, define path directly for initial structure
LLM_CONFIG_PATH = "config/llm_settings.json"
logger = logging.getLogger(__name__)
class LLMEditorWidget(QWidget):
"""
Widget for editing LLM settings stored in config/llm_settings.json.
"""
settings_saved = pyqtSignal() # Signal emitted when settings are successfully saved
def __init__(self, parent=None):
super().__init__(parent)
self._unsaved_changes = False
self._init_ui()
self._connect_signals()
self.save_button.setEnabled(False) # Initially disabled
def _init_ui(self):
"""Initialize the user interface components."""
main_layout = QVBoxLayout(self)
# --- Main Tab Widget ---
self.tab_widget = QTabWidget()
main_layout.addWidget(self.tab_widget)
# --- Tab 1: Prompt Settings ---
self.tab_prompt = QWidget()
prompt_layout = QVBoxLayout(self.tab_prompt)
self.tab_widget.addTab(self.tab_prompt, "Prompt Settings")
self.prompt_editor = QPlainTextEdit()
self.prompt_editor.setPlaceholderText("Enter the main LLM predictor prompt here...")
prompt_layout.addWidget(self.prompt_editor)
# Examples GroupBox
examples_groupbox = QGroupBox("Examples")
examples_layout = QVBoxLayout(examples_groupbox)
prompt_layout.addWidget(examples_groupbox)
self.examples_tab_widget = QTabWidget()
self.examples_tab_widget.setTabsClosable(True)
examples_layout.addWidget(self.examples_tab_widget)
example_button_layout = QHBoxLayout()
examples_layout.addLayout(example_button_layout)
self.add_example_button = QPushButton("Add Example")
example_button_layout.addWidget(self.add_example_button)
self.delete_example_button = QPushButton("Delete Current Example")
example_button_layout.addWidget(self.delete_example_button)
example_button_layout.addStretch()
# --- Tab 2: API Settings ---
self.tab_api = QWidget()
api_layout = QFormLayout(self.tab_api)
self.tab_widget.addTab(self.tab_api, "API Settings")
self.endpoint_url_edit = QLineEdit()
api_layout.addRow("Endpoint URL:", self.endpoint_url_edit)
self.api_key_edit = QLineEdit()
self.api_key_edit.setEchoMode(QLineEdit.Password)
api_layout.addRow("API Key:", self.api_key_edit)
self.model_name_edit = QLineEdit()
api_layout.addRow("Model Name:", self.model_name_edit)
self.temperature_spinbox = QDoubleSpinBox()
self.temperature_spinbox.setRange(0.0, 2.0)
self.temperature_spinbox.setSingleStep(0.1)
self.temperature_spinbox.setDecimals(2)
api_layout.addRow("Temperature:", self.temperature_spinbox)
self.timeout_spinbox = QSpinBox()
self.timeout_spinbox.setRange(1, 600)
self.timeout_spinbox.setSuffix(" s")
api_layout.addRow("Request Timeout:", self.timeout_spinbox)
# --- Save Button ---
save_button_layout = QHBoxLayout()
main_layout.addLayout(save_button_layout)
save_button_layout.addStretch()
self.save_button = QPushButton("Save LLM Settings")
save_button_layout.addWidget(self.save_button)
self.setLayout(main_layout)
def _connect_signals(self):
"""Connect signals to slots."""
self.save_button.clicked.connect(self._save_settings)
self.prompt_editor.textChanged.connect(self._mark_unsaved)
self.endpoint_url_edit.textChanged.connect(self._mark_unsaved)
self.api_key_edit.textChanged.connect(self._mark_unsaved)
self.model_name_edit.textChanged.connect(self._mark_unsaved)
self.temperature_spinbox.valueChanged.connect(self._mark_unsaved)
self.timeout_spinbox.valueChanged.connect(self._mark_unsaved)
self.add_example_button.clicked.connect(self._add_example_tab)
self.delete_example_button.clicked.connect(self._delete_current_example_tab)
self.examples_tab_widget.tabCloseRequested.connect(self._remove_example_tab)
# Note: Connecting textChanged for example editors needs to happen
# when the tabs/editors are created (in load_settings and _add_example_tab)
@pyqtSlot()
def load_settings(self):
"""Load settings from the JSON file and populate the UI."""
logger.info(f"Attempting to load LLM settings from {LLM_CONFIG_PATH}")
self.setEnabled(True) # Enable widget before trying to load
# Clear previous examples
while self.examples_tab_widget.count() > 0:
self.examples_tab_widget.removeTab(0)
try:
with open(LLM_CONFIG_PATH, 'r', encoding='utf-8') as f:
settings = json.load(f)
# Populate Prompt Settings
self.prompt_editor.setPlainText(settings.get("llm_predictor_prompt", ""))
# Populate Examples
examples = settings.get("llm_predictor_examples", [])
for i, example in enumerate(examples):
try:
example_text = json.dumps(example, indent=4)
example_editor = QTextEdit()
example_editor.setPlainText(example_text)
example_editor.textChanged.connect(self._mark_unsaved)
self.examples_tab_widget.addTab(example_editor, f"Example {i+1}")
except TypeError as e:
logger.error(f"Error formatting example {i+1}: {e}. Skipping.")
QMessageBox.warning(self, "Load Error", f"Could not format example {i+1}. It might be invalid.\nError: {e}")
# Populate API Settings
self.endpoint_url_edit.setText(settings.get("llm_endpoint_url", ""))
self.api_key_edit.setText(settings.get("llm_api_key", "")) # Consider security implications
self.model_name_edit.setText(settings.get("llm_model_name", ""))
self.temperature_spinbox.setValue(settings.get("llm_temperature", 0.7))
self.timeout_spinbox.setValue(settings.get("llm_request_timeout", 120))
logger.info("LLM settings loaded successfully.")
except FileNotFoundError:
logger.warning(f"LLM settings file not found: {LLM_CONFIG_PATH}. Using defaults and disabling editor.")
QMessageBox.warning(self, "Load Error",
f"LLM settings file not found:\n{LLM_CONFIG_PATH}\n\nPlease ensure the file exists. Using default values.")
# Reset to defaults (optional, or leave fields empty)
self.prompt_editor.clear()
self.endpoint_url_edit.clear()
self.api_key_edit.clear()
self.model_name_edit.clear()
self.temperature_spinbox.setValue(0.7)
self.timeout_spinbox.setValue(120)
# self.setEnabled(False) # Disabling might be too harsh if user wants to create settings
except json.JSONDecodeError as e:
logger.error(f"Error decoding JSON from {LLM_CONFIG_PATH}: {e}")
QMessageBox.critical(self, "Load Error",
f"Failed to parse LLM settings file:\n{LLM_CONFIG_PATH}\n\nError: {e}\n\nPlease check the file for syntax errors. Editor will be disabled.")
self.setEnabled(False) # Disable editor on critical load error
except Exception as e: # Catch other potential errors during loading/populating
logger.error(f"An unexpected error occurred loading LLM settings: {e}", exc_info=True)
QMessageBox.critical(self, "Load Error",
f"An unexpected error occurred while loading settings:\n{e}\n\nEditor will be disabled.")
self.setEnabled(False)
# Reset unsaved changes flag and disable save button after loading
self.save_button.setEnabled(False)
self._unsaved_changes = False
@pyqtSlot()
def _mark_unsaved(self):
"""Mark settings as having unsaved changes and enable the save button."""
if not self._unsaved_changes:
self._unsaved_changes = True
self.save_button.setEnabled(True)
logger.debug("Unsaved changes marked.")
@pyqtSlot()
def _save_settings(self):
"""Gather data from UI, save to JSON file, and handle errors."""
logger.info("Attempting to save LLM settings...")
settings_dict = {}
parsed_examples = []
has_errors = False
# Gather API Settings
settings_dict["llm_endpoint_url"] = self.endpoint_url_edit.text().strip()
settings_dict["llm_api_key"] = self.api_key_edit.text() # Keep as is, don't strip
settings_dict["llm_model_name"] = self.model_name_edit.text().strip()
settings_dict["llm_temperature"] = self.temperature_spinbox.value()
settings_dict["llm_request_timeout"] = self.timeout_spinbox.value()
# Gather Prompt Settings
settings_dict["llm_predictor_prompt"] = self.prompt_editor.toPlainText().strip()
# Gather and Parse Examples
for i in range(self.examples_tab_widget.count()):
example_editor = self.examples_tab_widget.widget(i)
if isinstance(example_editor, QTextEdit):
example_text = example_editor.toPlainText().strip()
if not example_text: # Skip empty examples silently
continue
try:
parsed_example = json.loads(example_text)
parsed_examples.append(parsed_example)
except json.JSONDecodeError as e:
has_errors = True
tab_name = self.examples_tab_widget.tabText(i)
logger.warning(f"Invalid JSON in '{tab_name}': {e}. Skipping example.")
QMessageBox.warning(self, "Invalid Example",
f"The content in '{tab_name}' is not valid JSON and will not be saved.\n\nError: {e}\n\nPlease correct it or remove the tab.")
# Optionally switch to the tab with the error:
# self.examples_tab_widget.setCurrentIndex(i)
else:
logger.warning(f"Widget at index {i} in examples tab is not a QTextEdit. Skipping.")
if has_errors:
logger.warning("LLM settings not saved due to invalid JSON in examples.")
# Keep save button enabled if there were errors, allowing user to fix and retry
# self.save_button.setEnabled(True)
# self._unsaved_changes = True
return # Stop saving process
settings_dict["llm_predictor_examples"] = parsed_examples
# Save the dictionary to file
try:
save_llm_config(settings_dict)
QMessageBox.information(self, "Save Successful", f"LLM settings saved to:\n{LLM_CONFIG_PATH}")
self.save_button.setEnabled(False)
self._unsaved_changes = False
self.settings_saved.emit() # Notify MainWindow or others
logger.info("LLM settings saved successfully.")
except ConfigurationError as e:
logger.error(f"Failed to save LLM settings: {e}")
QMessageBox.critical(self, "Save Error", f"Could not save LLM settings.\n\nError: {e}")
# Keep save button enabled as save failed
self.save_button.setEnabled(True)
self._unsaved_changes = True
except Exception as e: # Catch unexpected errors during save
logger.error(f"An unexpected error occurred during LLM settings save: {e}", exc_info=True)
QMessageBox.critical(self, "Save Error", f"An unexpected error occurred while saving settings:\n{e}")
self.save_button.setEnabled(True)
self._unsaved_changes = True
# --- Example Management Slots ---
@pyqtSlot()
def _add_example_tab(self):
"""Add a new, empty tab for an LLM example."""
logger.debug("Adding new example tab.")
new_example_editor = QTextEdit()
new_example_editor.setPlaceholderText("Enter example JSON here...")
new_example_editor.textChanged.connect(self._mark_unsaved)
# Determine the next example number
next_example_num = self.examples_tab_widget.count() + 1
index = self.examples_tab_widget.addTab(new_example_editor, f"Example {next_example_num}")
self.examples_tab_widget.setCurrentIndex(index) # Focus the new tab
new_example_editor.setFocus() # Focus the editor within the tab
self._mark_unsaved() # Mark changes since we added a tab
@pyqtSlot()
def _delete_current_example_tab(self):
"""Delete the currently selected example tab."""
current_index = self.examples_tab_widget.currentIndex()
if current_index != -1: # Check if a tab is selected
logger.debug(f"Deleting current example tab at index {current_index}.")
self._remove_example_tab(current_index) # Reuse the remove logic
else:
logger.debug("Delete current example tab called, but no tab is selected.")
@pyqtSlot(int)
def _remove_example_tab(self, index):
"""Remove the example tab at the given index."""
if 0 <= index < self.examples_tab_widget.count():
widget_to_remove = self.examples_tab_widget.widget(index)
self.examples_tab_widget.removeTab(index)
if widget_to_remove:
# Disconnect signals if necessary, though Python's GC should handle it
# widget_to_remove.textChanged.disconnect(self._mark_unsaved) # Optional cleanup
widget_to_remove.deleteLater() # Ensure proper cleanup of the widget
logger.debug(f"Removed example tab at index {index}.")
# Renumber subsequent tabs
for i in range(index, self.examples_tab_widget.count()):
self.examples_tab_widget.setTabText(i, f"Example {i+1}")
self._mark_unsaved() # Mark changes since we removed a tab
else:
logger.warning(f"Attempted to remove example tab at invalid index {index}.")