356 lines
17 KiB
Python
356 lines
17 KiB
Python
# gui/llm_editor_widget.py
|
|
import json
|
|
import logging
|
|
import copy # Added for deepcopy
|
|
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 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.original_llm_settings = {} # Initialize original_llm_settings
|
|
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)
|
|
self.original_llm_settings = copy.deepcopy(settings) # Store a deep copy
|
|
|
|
# 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.")
|
|
QMessageBox.warning(self, "Load Error",
|
|
f"LLM settings file not found:\n{LLM_CONFIG_PATH}\n\nNew settings will be created if you save.")
|
|
# 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.original_llm_settings = {} # Start with empty original settings if file not found
|
|
|
|
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
|
|
self.original_llm_settings = {} # Reset original settings on JSON 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)
|
|
self.original_llm_settings = {} # Reset original settings on other errors
|
|
|
|
|
|
# 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...")
|
|
|
|
# 1.a. Load Current Target File
|
|
target_file_content = {}
|
|
try:
|
|
with open(LLM_CONFIG_PATH, 'r', encoding='utf-8') as f:
|
|
target_file_content = json.load(f)
|
|
except FileNotFoundError:
|
|
logger.info(f"{LLM_CONFIG_PATH} not found. Will create a new one.")
|
|
target_file_content = {} # Start with an empty dict if file doesn't exist
|
|
except json.JSONDecodeError as e:
|
|
logger.error(f"Error decoding existing {LLM_CONFIG_PATH}: {e}. Starting with an empty config for save.")
|
|
QMessageBox.warning(self, "Warning",
|
|
f"Could not parse existing LLM settings file ({LLM_CONFIG_PATH}).\n"
|
|
f"Any pre-existing settings in that file might be overwritten if you save now.\nError: {e}")
|
|
target_file_content = {} # Start fresh if current file is corrupt
|
|
|
|
# 1.b. Gather current UI settings into current_llm_settings
|
|
current_llm_settings = {}
|
|
parsed_examples = []
|
|
has_errors = False # For example parsing
|
|
|
|
current_llm_settings["llm_endpoint_url"] = self.endpoint_url_edit.text().strip()
|
|
current_llm_settings["llm_api_key"] = self.api_key_edit.text() # Keep as is
|
|
current_llm_settings["llm_model_name"] = self.model_name_edit.text().strip()
|
|
current_llm_settings["llm_temperature"] = self.temperature_spinbox.value()
|
|
current_llm_settings["llm_request_timeout"] = self.timeout_spinbox.value()
|
|
current_llm_settings["llm_predictor_prompt"] = self.prompt_editor.toPlainText().strip()
|
|
|
|
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:
|
|
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.")
|
|
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.")
|
|
return
|
|
|
|
current_llm_settings["llm_predictor_examples"] = parsed_examples
|
|
|
|
# 1.c. Identify Changes and Update Target File Content
|
|
changed_settings_count = 0
|
|
for key, current_value in current_llm_settings.items():
|
|
original_value = self.original_llm_settings.get(key)
|
|
|
|
# Special handling for lists (e.g., examples) - direct comparison works
|
|
# For other types, direct comparison also works.
|
|
# This includes new keys present in current_llm_settings but not in original_llm_settings
|
|
if key not in self.original_llm_settings or current_value != original_value:
|
|
target_file_content[key] = current_value
|
|
logger.debug(f"Setting '{key}' changed or added. Old: '{original_value}', New: '{current_value}'")
|
|
changed_settings_count +=1
|
|
|
|
if changed_settings_count == 0 and self._unsaved_changes:
|
|
logger.info("Save called, but no actual changes detected compared to original loaded settings.")
|
|
# If _unsaved_changes was true, it means UI interaction happened,
|
|
# but values might have been reverted to original.
|
|
# We still proceed to save target_file_content as it might contain
|
|
# values from a file that was modified externally since last load.
|
|
# Or, if the file didn't exist, it will now be created with current UI values.
|
|
|
|
# 1.d. Save Updated Content
|
|
try:
|
|
# Ensure the directory exists before saving
|
|
import os
|
|
os.makedirs(os.path.dirname(LLM_CONFIG_PATH), exist_ok=True)
|
|
|
|
with open(LLM_CONFIG_PATH, 'w', encoding='utf-8') as f:
|
|
json.dump(target_file_content, f, indent=4)
|
|
|
|
QMessageBox.information(self, "Save Successful", f"LLM settings saved to:\n{LLM_CONFIG_PATH}")
|
|
|
|
# Update original_llm_settings to reflect the newly saved state
|
|
self.original_llm_settings = copy.deepcopy(target_file_content)
|
|
|
|
self.save_button.setEnabled(False)
|
|
self._unsaved_changes = False
|
|
self.settings_saved.emit()
|
|
logger.info("LLM settings saved successfully.")
|
|
|
|
except (IOError, OSError) as e:
|
|
logger.error(f"Failed to write LLM settings file {LLM_CONFIG_PATH}: {e}")
|
|
QMessageBox.critical(self, "Save Error", f"Could not write LLM settings file.\n\nError: {e}")
|
|
self.save_button.setEnabled(True) # Keep save enabled
|
|
self._unsaved_changes = True
|
|
except Exception as e:
|
|
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) # Keep save enabled
|
|
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}.") |