Is it a good idea to separate all the classes in this code into separate files ? code: import sys
import os
import json
import re
import time
import traceback
import logging
import threading
import psutil  # For system resource usage
from PyQt6.QtWidgets import QGraphicsOpacityEffect
from PyQt6.QtCore import QPropertyAnimation
from PyQt6.QtWidgets import (
    QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
    QLineEdit, QPushButton, QDialog, QLabel, QFrame,
    QFileDialog, QMessageBox, QScrollArea, QGraphicsDropShadowEffect,
    QMenu, QProgressDialog, QSlider, QDoubleSpinBox, QSpinBox,
    QSystemTrayIcon, QStyle, QComboBox, QWidgetAction, QGroupBox, QCheckBox
)
from PyQt6.QtGui import QFont, QColor, QIcon, QFontDatabase, QAction, QMovie
from PyQt6.QtCore import Qt, QSize, QThread, pyqtSignal, QTimer
from llama_cpp import Llama
import openrag_S

class LLMInferenceThread(QThread):
    token_ready = pyqtSignal(str)
    inference_finished = pyqtSignal(float, int)  # Pass latency and token count

    def __init__(self, rag_llm, query, context_length, temperature, top_p, top_k, use_rag=True):
        super().__init__()
        self.rag_llm = rag_llm
        self.query = query
        self.context_length = int(context_length)
        self.temperature = temperature
        self.top_p = top_p
        self.top_k = top_k
        self.use_rag = use_rag
        self.stop_flag = False  # Flag to stop inference

    def run(self):
        try:
            if not isinstance(self.context_length, int):
                raise TypeError(
                    f"context_length should be an integer, got {type(self.context_length)} instead."
                )

            start_time = time.time()
            tokens_generated = 0

            if self.use_rag:
                response_generator = self.rag_llm.generate_response(
                    self.query, context_length=self.context_length,
                    temperature=self.temperature, top_p=self.top_p, top_k=self.top_k)
            else:
                response_generator = self.rag_llm.generate_response_without_rag(
                    self.query, context_length=self.context_length,
                    temperature=self.temperature, top_p=self.top_p, top_k=self.top_k)

            for token in response_generator:
                if self.stop_flag:
                    break
                self.token_ready.emit(token)
                tokens_generated += 1

            latency = time.time() - start_time
            self.inference_finished.emit(latency, tokens_generated)

        except Exception as e:
            self.token_ready.emit(f"Error: {str(e)}")
            self.inference_finished.emit(0, 0)
            print(traceback.format_exc())

    def stop(self):
        self.stop_flag = True

class DatasetLoadingThread(QThread):
    progress_update = pyqtSignal(int)
    finished = pyqtSignal()
    error = pyqtSignal(str)

    def __init__(self, dataset_path, search_engine):
        super().__init__()
        self.dataset_path = dataset_path
        self.search_engine = search_engine

    def run(self):
        try:
            self.search_engine.load_documents(self.dataset_path)
        except Exception as e:
            self.error.emit(str(e))
        finally:
            self.finished.emit()

class StatisticsWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Performance Statistics")
        self.setGeometry(150, 150, 400, 300)
        self.init_ui()
        self.update_timer = QTimer()
        self.update_timer.timeout.connect(self.update_resource_usage)
        self.update_timer.start(1000)  # Update every 1 second

    def init_ui(self):
        self.central_widget = QWidget()
        self.layout = QVBoxLayout()

        self.token_speed_label = QLabel("Token Speed: N/A")
        self.memory_usage_label = QLabel("Memory Usage: N/A")
        self.cpu_usage_label = QLabel("CPU Usage: N/A")
        self.latency_label = QLabel("Inference Latency: N/A")
        self.tokens_generated_label = QLabel("Tokens Generated: N/A")

        labels = [
            self.token_speed_label,
            self.memory_usage_label,
            self.cpu_usage_label,
            self.latency_label,
            self.tokens_generated_label
        ]

        for label in labels:
            label.setFont(QFont("CustomFont", 12))
            self.layout.addWidget(label)

        self.central_widget.setLayout(self.layout)
        self.setCentralWidget(self.central_widget)

    def update_metrics(self, token_speed, memory_usage,
                       cpu_usage, latency, tokens_generated):
        self.token_speed_label.setText(f"Token Speed: {token_speed:.2f} tokens/sec")
        self.latency_label.setText(f"Inference Latency: {latency:.2f} sec")
        self.tokens_generated_label.setText(f"Tokens Generated: {tokens_generated}")

    def update_resource_usage(self):
        memory_usage = psutil.Process(os.getpid()).memory_info().rss / 1024 ** 2  # In MB
        cpu_usage = psutil.cpu_percent()
        self.memory_usage_label.setText(f"Memory Usage: {int(memory_usage)} MB")
        self.cpu_usage_label.setText(f"CPU Usage: {cpu_usage}%")

class SettingsWindow(QMainWindow):
    def __init__(self, parent_app):
        super().__init__()
        self.setWindowTitle("Settings")
        self.setGeometry(200, 200, 400, 700)
        self.parent_app = parent_app
        self.create_widgets()

    def create_widgets(self):
        self.central_widget = QWidget()
        self.layout = QVBoxLayout()

        # AI Model Section
        ai_model_group = QGroupBox("AI Model")
        ai_model_layout = QVBoxLayout()
        ai_model_layout.addWidget(self.parent_app.model_combo)
        ai_model_layout.addWidget(self.parent_app.add_model_button)
        ai_model_layout.addWidget(self.parent_app.model_path)
        ai_model_layout.addWidget(self.parent_app.load_model_button)
        ai_model_layout.addWidget(self.parent_app.eject_model_button)
        ai_model_group.setLayout(ai_model_layout)
        self.layout.addWidget(ai_model_group)

        # GPU Settings Section
        gpu_group = QGroupBox("GPU Settings")
        gpu_layout = QVBoxLayout()
        gpu_layout.addWidget(self.parent_app.enable_gpu_checkbox)
        gpu_layout.addWidget(QLabel("Number of GPU Layers"))
        gpu_layout.addWidget(self.parent_app.gpu_layers_input)
        gpu_group.setLayout(gpu_layout)
        self.layout.addWidget(gpu_group)

        # Dataset Section
        dataset_group = QGroupBox("Dataset")
        dataset_layout = QVBoxLayout()
        dataset_layout.addWidget(self.parent_app.dataset_path)
        dataset_layout.addWidget(self.parent_app.browse_dataset_button)
        dataset_layout.addWidget(self.parent_app.load_dataset_button)
        dataset_group.setLayout(dataset_layout)
        self.layout.addWidget(dataset_group)

        # Use Model Knowledge Toggle
        self.use_model_knowledge_button = QPushButton("Use Model Knowledge")
        self.use_model_knowledge_button.clicked.connect(self.parent_app.toggle_rag)
        self.layout.addWidget(self.use_model_knowledge_button)

        # Max Token Length
        self.max_token_length_button = QPushButton("Set Max Token Length")
        self.max_token_length_button.clicked.connect(self.parent_app.show_max_token_length_slider)
        self.layout.addWidget(self.max_token_length_button)

        # Context Length
        self.context_length_button = QPushButton("Set Context Length")
        self.context_length_button.clicked.connect(self.parent_app.show_context_length_slider)
        self.layout.addWidget(self.context_length_button)

        # Temperature Setting
        temperature_label = QLabel("Temperature")
        self.temperature_input = QDoubleSpinBox()
        self.temperature_input.setRange(0.1, 2.0)
        self.temperature_input.setSingleStep(0.1)
        self.temperature_input.setValue(self.parent_app.temperature)
        self.layout.addWidget(temperature_label)
        self.layout.addWidget(self.temperature_input)

        # Top_p Setting
        top_p_label = QLabel("Top_p")
        self.top_p_input = QDoubleSpinBox()
        self.top_p_input.setRange(0.0, 1.0)
        self.top_p_input.setSingleStep(0.05)
        self.top_p_input.setValue(self.parent_app.top_p)
        self.layout.addWidget(top_p_label)
        self.layout.addWidget(self.top_p_input)

        # Top_k Setting
        top_k_label = QLabel("Top_k")
        self.top_k_input = QSpinBox()
        self.top_k_input.setRange(0, 1000)
        self.top_k_input.setSingleStep(1)
        self.top_k_input.setValue(self.parent_app.top_k)
        self.layout.addWidget(top_k_label)
        self.layout.addWidget(self.top_k_input)

        # Set Parameters Button
        self.set_parameters_button = QPushButton("Set Parameters")
        self.set_parameters_button.clicked.connect(self.set_parameters)
        self.layout.addWidget(self.set_parameters_button)

        # GUI Scale Setting
        gui_scale_label = QLabel("GUI Scale")
        gui_scale_label.setFont(QFont("CustomFont", 14))
        self.layout.addWidget(gui_scale_label)

        self.gui_scale_slider = QSlider(Qt.Orientation.Horizontal)
        self.gui_scale_slider.setRange(50, 200)  # Scale from 50% to 200%
        self.gui_scale_slider.setValue(int(self.parent_app.gui_scale * 100))
        self.gui_scale_slider.setTickInterval(10)
        self.gui_scale_slider.setTickPosition(QSlider.TickPosition.TicksBelow)
        self.gui_scale_slider.valueChanged.connect(self.update_gui_scale)

        self.layout.addWidget(self.gui_scale_slider)

        # Logging Setting
        self.logging_checkbox = QCheckBox("Enable Logging")
        self.logging_checkbox.setChecked(self.parent_app.logging_enabled)
        self.logging_checkbox.stateChanged.connect(self.toggle_logging)
        self.layout.addWidget(self.logging_checkbox)

        self.central_widget.setLayout(self.layout)
        self.setCentralWidget(self.central_widget)

    def update_gui_scale(self, value):
        scale_factor = value / 100.0
        self.parent_app.set_gui_scale(scale_factor)
        self.parent_app.save_settings()  # Save the new GUI scale

    def set_parameters(self):
        self.parent_app.temperature = self.temperature_input.value()
        self.parent_app.top_p = self.top_p_input.value()
        self.parent_app.top_k = self.top_k_input.value()
        QMessageBox.information(self, "Parameters Set", "Parameters have been updated.")

    def toggle_logging(self, state):
        self.parent_app.logging_enabled = bool(state)
        self.parent_app.setup_logging()
        self.parent_app.save_settings()  # Save the logging setting

class ChatBubble(QWidget):
    def __init__(self, text, is_user, metadata=None, file_indicator=None, custom_font=None):
        super().__init__()
        self.is_user = is_user
        self.text = text
        self.metadata = metadata  # Dictionary to hold metadata like latency
        self.file_indicator = file_indicator  # File name if any
        self.custom_font = custom_font
        self.init_ui()

    def init_ui(self):
        layout = QHBoxLayout()
        if self.is_user:
            layout.addStretch()
            bubble_color = QColor(76, 175, 80)  # Green bubble
        else:
            bubble_color = QColor(66, 133, 244)  # Blue bubble

        # Create a vertical layout to include metadata
        bubble_layout = QVBoxLayout()

        bubble_text = self.text
        if self.file_indicator:
            bubble_text += f"\n[Attached File: {self.file_indicator}]"

        bubble = QLabel(bubble_text)
        bubble.setWordWrap(True)
        bubble.setFont(QFont(self.custom_font, 11))
        bubble.setContentsMargins(10, 10, 10, 10)
        bubble.setStyleSheet(f"background-color: {bubble_color.name()};"
                             "border-radius: 10px; color: white;")
        bubble_layout.addWidget(bubble)

        if self.metadata:
            # Display metadata like response time and source file
            metadata_text = ""
            if "latency" in self.metadata:
                latency = self.metadata["latency"]
                metadata_text += f"Time Taken: {latency:.2f} sec"
            if "source_file" in self.metadata and self.metadata["source_file"]:
                source_file = self.metadata["source_file"]
                metadata_text += f"\nSource: {os.path.basename(source_file)}"
            metadata_label = QLabel(metadata_text)
            metadata_label.setFont(QFont(self.custom_font, 8))
            metadata_label.setStyleSheet("color: gray;")
            bubble_layout.addWidget(metadata_label)

        bubble_widget = QWidget()
        bubble_widget.setLayout(bubble_layout)
        bubble_widget.setStyleSheet("background-color: transparent;")

        # Add shadow effect to bubble_widget
        shadow = QGraphicsDropShadowEffect()
        shadow.setBlurRadius(5)
        shadow.setOffset(0, 0)
        bubble_widget.setGraphicsEffect(shadow)

        # Wrap bubble_widget in a container to apply opacity effect
        bubble_container = QWidget()
        container_layout = QVBoxLayout(bubble_container)
        container_layout.setContentsMargins(0, 0, 0, 0)
        container_layout.addWidget(bubble_widget)

        # Add opacity effect to bubble_container
        self.opacity_effect = QGraphicsOpacityEffect()
        bubble_container.setGraphicsEffect(self.opacity_effect)
        self.opacity_effect.setOpacity(0)  # Start with fully transparent

        self.fade_in_animation()

        layout.addWidget(bubble_container)
        if not self.is_user:
            layout.addStretch()
        self.setLayout(layout)

    def fade_in_animation(self):
        self.animation = QPropertyAnimation(self.opacity_effect, b"opacity")
        self.animation.setDuration(500)  # Duration in milliseconds
        self.animation.setStartValue(0)
        self.animation.setEndValue(1)
        self.animation.start()

    def setText(self, text):
        self.text = text
        bubble_layout = self.layout().itemAt(0).widget().layout()
        bubble_label = bubble_layout.itemAt(0).widget()
        bubble_label.setText(self.text)

    def setMetadata(self, metadata):
        self.metadata = metadata
        # Update the metadata label
        bubble_layout = self.layout().itemAt(0).widget().layout()
        if self.metadata:
            metadata_text = ""
            if "latency" in self.metadata:
                latency = self.metadata["latency"]
                metadata_text += f"Time Taken: {latency:.2f} sec"
            if "source_file" in self.metadata and self.metadata["source_file"]:
                source_file = self.metadata["source_file"]
                metadata_text += f"\nSource: {os.path.basename(source_file)}"
            metadata_label = QLabel(metadata_text)
            metadata_label.setFont(QFont(self.custom_font, 8))
            metadata_label.setStyleSheet("color: gray;")
            if bubble_layout.count() >= 2:
                bubble_layout.itemAt(1).widget().deleteLater()
            bubble_layout.addWidget(metadata_label)

class ChatHXApp(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("ChatHX")
        self.setGeometry(100, 100, 800, 600)
        
        # Load settings first
        self.load_settings()

        # Logging
        self.logging_enabled = False  # Default to logging disabled
        self.setup_logging()

        # Default GUI settings
        self.gui_scale = 1.15
        self.dark_mode = True

        # Custom Font
        font_path = os.path.join(os.path.dirname(__file__), "assets", "HX_Sans.ttf")
        self.custom_font = self.load_custom_font(font_path)

        self.setStyleSheet(self.get_stylesheet())

        self.has_gpu_support = False
        self.n_gpu_layers = 0

        self.llm_model = None
        self.search_engine = openrag_S.LocalSemanticSearchEngine()
        self.model_config = {}
        self.loading_thread = None
        self.loading_progress = None
        self.is_inferencing = False
        self.max_token_length = 1024
        self.context_length = 2048
        self.max_context_length = self.context_length
        self.loaded_model_name = None
        self.last_model_path = None
        self.last_dataset_path = None
        self.current_response_box = None
        self.use_rag = True  # Flag to indicate whether to use RAG or not

        self.model_history = []
        self.load_history()
        self.create_widgets()

        self.llm_thread = None
        self.rag_llm = None

        # Auto-load the last used model
        if self.last_model_path:
            self.model_path.setText(self.last_model_path)
            self.load_model()

        # Performance metrics
        self.latency = 0
        self.tokens_generated = 0
        self.token_speed = 0
        self.statistics_window = StatisticsWindow()

        # Defaults
        self.temperature = 0.7
        self.top_p = 0.9
        self.top_k = 40

        self.stop_flag = False

        # File context
        self.file_context = None
        self.file_name = None

    def setup_logging(self):
        if self.logging_enabled:
            logging.basicConfig(filename='chathx.log', level=logging.INFO,
                                format='%(asctime)s - %(levelname)s - %(message)s')
        else:
            logging.disable(logging.CRITICAL)

    def set_gui_scale(self, scale_factor):
        self.gui_scale = scale_factor
        font = self.font()
        font.setPointSize(int(12 * self.gui_scale))  # Cast to int
        self.setFont(font)
        self.update_gui_elements()

    def update_gui_elements(self):
        # Adjust sizes of widgets if necessary
        scaling_factor = self.gui_scale
        self.input_area.setFixedHeight(int(40 * scaling_factor))
        self.send_button.setFixedHeight(int(40 * scaling_factor))
        self.add_file_button.setFixedHeight(int(40 * scaling_factor))
        self.stop_button.setFixedHeight(int(40 * scaling_factor))

        # Set font sizes directly
        font_size = int(12 * scaling_factor)
        font = QFont(self.custom_font, font_size)
        self.setFont(font)
        self.input_area.setFont(font)
        self.send_button.setFont(font)
        self.add_file_button.setFont(font)
        self.stop_button.setFont(font)
        self.info_widget.setFont(QFont(self.custom_font, int(10 * scaling_factor)))
        self.title_label.setFont(QFont(self.custom_font, int(24 * scaling_factor), QFont.Weight.Bold))

        # Update font sizes in other widgets if needed
        # self.model_combo.setFont(font)
        # ... update other widgets ...

        # Update the stylesheets if necessary
        self.setStyleSheet(self.get_stylesheet())

    def load_custom_font(self, font_path):
        font_id = QFontDatabase.addApplicationFont(font_path)
        if font_id == -1:
            print("Failed to load custom font.")
            return "Arial"
        font_family = QFontDatabase.applicationFontFamilies(font_id)[0]
        return font_family

    def create_widgets(self):
        self.central_widget = QWidget()
        self.main_layout = QVBoxLayout()

        # Initialize model and dataset controls (used in settings window)
        self.model_combo = QComboBox()
        self.model_combo.addItem("Select AI Model")
        if self.model_history:
            for model_data in self.model_history:
                model_name = model_data.get("model_name", "Unknown Model")
                self.model_combo.addItem(model_name)
        self.model_combo.currentIndexChanged.connect(self.on_model_selected)

        self.model_path = QLineEdit()

        self.add_model_button = QPushButton("Add New Model")
        self.add_model_button.clicked.connect(self.browse_model)

        self.load_model_button = QPushButton("Load Model")
        self.load_model_button.clicked.connect(self.load_model)

        self.eject_model_button = QPushButton("Eject Model")
        self.eject_model_button.clicked.connect(self.eject_model)

        self.enable_gpu_checkbox = QCheckBox("Enable GPU Acceleration")
        self.enable_gpu_checkbox.stateChanged.connect(self.toggle_gpu)

        self.gpu_layers_input = QSpinBox()
        self.gpu_layers_input.setRange(0, 1000)
        self.gpu_layers_input.setValue(self.n_gpu_layers)
        self.gpu_layers_input.valueChanged.connect(self.set_gpu_layers)

        self.dataset_path = QLineEdit()

        self.browse_dataset_button = QPushButton("Browse Dataset")
        self.browse_dataset_button.clicked.connect(self.browse_dataset)

        self.load_dataset_button = QPushButton("Load Dataset")
        self.load_dataset_button.clicked.connect(self.load_dataset)

        # Menu bar
        self.menu_bar = self.menuBar()

        # Settings action
        settings_action = QAction("Settings", self)
        settings_action.triggered.connect(self.open_settings_window)
        self.menu_bar.addAction(settings_action)

        # Statistics action
        stats_action = QAction("Statistics", self)
        stats_action.triggered.connect(self.show_statistics_window)
        self.menu_bar.addAction(stats_action)

        # Save Conversation action
        save_action = QAction("Save Conversation", self)
        save_action.triggered.connect(self.save_conversation)
        self.menu_bar.addAction(save_action)

        # Load Conversation action
        load_action = QAction("Load Conversation", self)
        load_action.triggered.connect(self.load_conversation)
        self.menu_bar.addAction(load_action)

        # Theme Toggle action
        theme_action = QAction("Toggle Theme", self)
        theme_action.triggered.connect(self.toggle_theme)
        self.menu_bar.addAction(theme_action)

        # Info action
        info_action = QAction("About", self)
        info_action.triggered.connect(self.show_about_dialog)
        self.menu_bar.addAction(info_action)

        # Add Stop button
        self.stop_button = QPushButton("Stop")
        self.stop_button.clicked.connect(self.stop_inference)
        self.stop_button.setEnabled(False)
        stop_action = QWidgetAction(self)
        stop_action.setDefaultWidget(self.stop_button)
        self.menu_bar.addAction(stop_action)

        # Add a spinner label (hidden by default)
        self.spinner_label = QLabel()
        spinner_path = os.path.join(os.path.dirname(__file__), "assets", "animate_loading.gif")
        if not os.path.exists(spinner_path):
            print("Spinner GIF not found. Please ensure 'animate_loading.gif' is in the 'assets' directory.")
        self.spinner_movie = QMovie(spinner_path)
        self.spinner_label.setMovie(self.spinner_movie)
        self.spinner_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.spinner_label.hide()
        self.main_layout.addWidget(self.spinner_label)

        # Info widget at the top
        self.info_widget = QLabel()
        self.info_widget.setFont(QFont(self.custom_font, 10))
        self.update_info_widget()
        self.main_layout.insertWidget(0, self.info_widget)

        # Chat area
        self.chat_layout = QVBoxLayout()

        # Title label
        self.title_label = QLabel("ChatHX")
        self.title_label.setFont(QFont(self.custom_font, 24, QFont.Weight.ExtraBold))
        self.title_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
        self.chat_layout.addWidget(self.title_label)

        # Make chat history scrollable
        self.scroll_area = QScrollArea()
        self.scroll_area.setWidgetResizable(True)

        self.chat_history_frame = QWidget()
        self.chat_history_layout = QVBoxLayout()
        self.chat_history_layout.addStretch(1)
        self.chat_history_frame.setLayout(self.chat_history_layout)

        self.scroll_area.setWidget(self.chat_history_frame)
        self.chat_layout.addWidget(self.scroll_area)

        self.main_layout.addLayout(self.chat_layout)

        # Suggested questions
        self.questions_layout = QHBoxLayout()
        self.suggested_questions = [
            "How to get the most out of AI?",
            "How many cores does the new Core Ultra 7 have?",
            "How many Xe2 cores are in the new Core Ultra 7?",
            "What are the benefits of AI for gaming?"
        ]
        self.update_suggested_questions()
        self.main_layout.addLayout(self.questions_layout)

        # Input area
        self.input_layout = QHBoxLayout()
        self.input_area = QLineEdit()
        self.input_area.setFont(QFont(self.custom_font, 12))
        self.input_area.setPlaceholderText("Type your message here...")
        self.input_area.returnPressed.connect(self.send_message)
        self.input_area.setFixedHeight(40)
        self.input_layout.addWidget(self.input_area)

        # Add File Button
        self.add_file_button = QPushButton("Add File")
        self.add_file_button.clicked.connect(self.add_file_to_context)
        self.add_file_button.setFixedHeight(40)
        self.input_layout.addWidget(self.add_file_button)

        self.send_button = QPushButton("Send")
        self.send_button.clicked.connect(self.send_message)
        self.send_button.setFixedHeight(40)
        self.input_layout.addWidget(self.send_button)

        self.main_layout.addLayout(self.input_layout)

        self.central_widget.setLayout(self.main_layout)
        self.setCentralWidget(self.central_widget)

    def update_info_widget(self):
        model_name = self.loaded_model_name or "No model loaded"
        dataset_path = os.path.basename(self.last_dataset_path) if self.last_dataset_path else "No dataset"
        rag_status = "On" if self.use_rag else "Off"
        info_text = f"Model: {model_name} | Dataset: {dataset_path} | RAG: {rag_status}"
        self.info_widget.setText(info_text)

    def on_model_selected(self, index):
        if index > 0:
            selected_model = self.model_history[index - 1]
            self.model_path.setText(selected_model.get("model_path", ""))

    def open_settings_window(self):
        self.settings_window = SettingsWindow(self)
        self.settings_window.show()

    def show_statistics_window(self):
        self.statistics_window.show()

    def show_about_dialog(self):
        QMessageBox.information(
        self,
        "About ChatHX",
        "ChatHX - Version 0.3\n\nLocal LLM chat with RAG Capabilities for Everyone to use."
    )

    def get_stylesheet(self):
        base_style = """
            QMainWindow, QWidget {
                background-color: #1e1e1e;
                color: #ffffff;
            }
            QLineEdit, QPushButton {
                background-color: #2d2d2d;
                color: #ffffff;
                border: 1px solid #3d3d3d;
                padding: 5px;
            }
            QPushButton {
                background-color: #3d3d3d;
            }
            QPushButton:hover {
                background-color: #4d4d4d;
            }
            QLabel {
                color: #ffffff;
            }
            QScrollArea {
                background-color: #1e1e1e;
                border: none;
            }
        """
        if not self.dark_mode:
            base_style = base_style.replace("#1e1e1e", "#ffffff").replace("#2d2d2d", "#f0f0f0").replace("#3d3d3d", "#d0d0d0").replace("#4d4d4d", "#c0c0c0").replace("#ffffff", "#000000")
        return base_style

    def update_suggested_questions(self):
        # Clear existing buttons
        for i in reversed(range(self.questions_layout.count())):
            widget = self.questions_layout.itemAt(i).widget()
            if widget is not None:
                widget.setParent(None)
                widget.deleteLater()

        # Add new buttons
        for question in self.suggested_questions:
            question_button = QPushButton(question)
            question_button.setFont(QFont(self.custom_font, 10))
            question_button.clicked.connect(lambda _, q=question: self.set_question(q))
            self.questions_layout.addWidget(question_button)

    def set_question(self, question):
        self.input_area.setText(question)

    def send_message(self):
        user_message = self.input_area.text().strip()
        if not user_message:
            return

        logging.info(f"User message: {user_message}")
        if not self.llm_model:
            QMessageBox.warning(self, "Error", "Please load an LLM model first.")
            return

        try:
            if not self.rag_llm:
                self.rag_llm = openrag_S.RAGEnabledLLM(
                    self.llm_model, self.search_engine,
                    self.max_token_length)
            
            # Hide the title label when a message is sent
            if self.title_label.isVisible():
                self.title_label.hide()

            # Display user message with file indicator if any
            user_message_box = ChatBubble(user_message, is_user=True, file_indicator=self.file_name, custom_font=self.custom_font)
            self.chat_history_layout.addWidget(user_message_box)

            # Create a new message box for the AI response
            self.current_response_box = ChatBubble("", is_user=False, custom_font=self.custom_font)
            self.chat_history_layout.addWidget(self.current_response_box)
            self.spinner_label.show()
            self.spinner_movie.start()

            self.input_area.clear()
            self.send_button.setEnabled(False)
            self.is_inferencing = True
            self.stop_button.setEnabled(True)

            # Start response processing
            self.full_response = ""
            prompt = self.create_prompt(user_message) if self.use_rag else self.create_prompt_without_rag(user_message)

            self.llm_thread = LLMInferenceThread(
                self.rag_llm, prompt, self.context_length,
                self.temperature, self.top_p, self.top_k,
                use_rag=self.use_rag)
            self.llm_thread.token_ready.connect(self.handle_token)
            self.llm_thread.inference_finished.connect(self.process_full_response)
            self.llm_thread.start()

        except Exception as e:
            logging.error("Error during processing:", exc_info=True)
            QMessageBox.critical(self, "Error", "An error occurred during processing. Please check the log file for details.")
            print(f"Detailed error: {e}")

    def stop_inference(self):
        if self.is_inferencing and self.llm_thread:
            self.llm_thread.stop()
            self.is_inferencing = False
            self.send_button.setEnabled(True)
            self.stop_button.setEnabled(False)
            QMessageBox.information(self, "Stopped", "Inference has been stopped.")

    def handle_token(self, token):
        self.full_response += token
        self.update_chat_history(token, from_user=False)

    def process_full_response(self, latency, tokens_generated):
        self.spinner_movie.stop()
        self.spinner_label.hide()
        logging.info(f"AI response: {self.full_response}")
        self.is_inferencing = False
        self.send_button.setEnabled(True)
        self.stop_button.setEnabled(False)
        self.llm_thread.inference_finished.disconnect(self.process_full_response)
        self.llm_thread = None

        # Update statistics
        elapsed_time = latency
        self.tokens_generated = tokens_generated
        self.token_speed = tokens_generated / elapsed_time if elapsed_time > 0 else 0

        self.statistics_window.update_metrics(
            self.token_speed, 0, 0, elapsed_time, self.tokens_generated)

        # Extract answer and follow-up questions
        try:
            answer, follow_up_questions = self.extract_answer_and_questions(self.full_response)
            self.current_response_box.setText(answer)
            # Set metadata including latency and source file
            metadata = {"latency": latency}
            if self.use_rag:
                # Get the source file
                source_file = self.rag_llm.last_source_file
                metadata["source_file"] = source_file
            self.current_response_box.setMetadata(metadata)

            self.suggested_questions = follow_up_questions
            self.update_suggested_questions()
        except Exception as e:
            logging.error("Error processing response:", exc_info=True)
            self.current_response_box.setText(self.full_response)
            QMessageBox.critical(self, "Error", "Error processing response. Please check the log file for details.")
        self.full_response = ""
        self.current_response_box = None

        # Clear file context after processing
        self.file_context = None
        self.file_name = None

    def update_chat_history(self, text, from_user=False):
        if not self.current_response_box or from_user:
            message_box = ChatBubble(text, is_user=from_user, custom_font=self.custom_font)
            self.chat_history_layout.addWidget(message_box)
        else:
            current_text = self.current_response_box.text
            self.current_response_box.setText(current_text + text)
        # Scroll to the bottom
        self.scroll_area.verticalScrollBar().setValue(
            self.scroll_area.verticalScrollBar().maximum())
        
    def get_conversation_history(self, max_turns=5):
        history = []
        # Iterate in reverse to get the latest messages
        for i in reversed(range(self.chat_history_layout.count())):
            widget = self.chat_history_layout.itemAt(i).widget()
            if isinstance(widget, ChatBubble):
                role = "User" if widget.is_user else "Assistant"
                history.append(f"{role}: {widget.text}")
                if len(history) >= max_turns * 2:  # Assuming each turn has a user and assistant message
                    break
        # Reverse to get the correct order
        history.reverse()
        return '\n'.join(history)

    def create_prompt(self, query):
        # Get conversation history
        conversation = self.get_conversation_history()

        # Get context
        context = ""
        if self.file_context:
            context = self.file_context
        else:
            most_similar_file, similarity_score = self.search_engine.search(query)
            if most_similar_file and similarity_score > 0.4:
                with open(most_similar_file, 'r', encoding='utf-8', errors='ignore') as file:
                    context = file.read()
                context = context[:min(int(self.context_length), self.max_context_length - len(query) - 100)]
                self.rag_llm.last_source_file = most_similar_file

        prompt = f"""You are ChatHX, an AI assistant. Below is a conversation between a user and you. Use the provided context to answer the user's question.

    ## Conversation
    {conversation}

    ## Context
    {context}

    ## Question
    {query}

    ## Instructions
    - Provide a clear and accurate answer.
    - If the context is insufficient, let the user know.
    - At the end of your response, include 3 follow-up questions related to the topic.

    ## Response Format
    Answer: <Your answer here>

    Follow-up Questions:
    1. <Question 1>
    2. <Question 2>
    3. <Question 3>
    """

        return prompt

    def create_prompt_without_rag(self, query):
        query = query.replace("{", "{{").replace("}", "}}")
        prompt = f"""
        You are a helpful and informative AI assistant.

        Question: {query}

        Answer the question based on your own knowledge.
        If you don't have enough information to answer the question, say that.
        After your answer, provide 3 follow-up questions that are relevant to the user's original question.

        You MUST format your response as a valid JSON object EXACTLY like this:

        {{
            "answer": "Your answer here.",
            "follow_up_questions": [
                "Question 1",
                "Question 2",
                "Question 3"
            ]
        }}


        Do NOT include anything else in your response.
        """
        return prompt

    def extract_answer_and_questions(self, response):
        answer = "Error: Could not process model output. Please try rephrasing."
        follow_up_questions = []

        try:
            # Use regex to find the JSON block
            json_match = re.search(r"\s*({[\s\S]*?})\s*", response)
            if not json_match:
                # Try to find JSON even without code blocks
                json_match = re.search(r"({[\s\S]})", response)
            if json_match:
                json_str = json_match.group(1).strip()
                # Clean up the JSON string
                json_str = json_str.replace('\n', ' ')
                json_str = json_str.replace('“', '"').replace('”', '"')
                json_str = json_str.replace("'", '"')
                # Use json.loads with error handling
                try:
                    response_json = json.loads(json_str)
                except json.JSONDecodeError:
                    # Attempt to fix common issues
                    json_str = re.sub(r',\s}', '}', json_str)
                    json_str = re.sub(r',\s*]', ']', json_str)
                    response_json = json.loads(json_str)
                answer = response_json.get("answer", answer)
                follow_up_questions = response_json.get("follow_up_questions", [])
            else:
                # If no JSON found, display the entire response
                answer = response.strip()
        except Exception as e:
            print(f"Error extracting information from response: {e}")
            answer = response.strip()

        return answer, follow_up_questions

    def browse_model(self):
        model_path = QFileDialog.getOpenFileName(self, "Select Model File")[0]
        if model_path:
            self.model_path.setText(model_path)

    def load_model(self):
        model_path = self.model_path.text()
        logging.info(f"Loading model from: {model_path}")
        if model_path:
            try:
                # Show progress dialog
                self.model_loading_progress = QProgressDialog("Loading Model...", None, 0, 0, self)
                self.model_loading_progress.setWindowModality(Qt.WindowModality.ApplicationModal)
                self.model_loading_progress.setCancelButton(None)
                self.model_loading_progress.setMinimumDuration(0)
                self.model_loading_progress.show()

                # Load the model asynchronously to avoid freezing the UI
                QTimer.singleShot(100, lambda: self._load_model_async(model_path))

            except Exception as e:
                QMessageBox.critical(self, "Error", f"Failed to load model: {str(e)}")
        self.update_info_widget()

    def _load_model_async(self, model_path):
        try:
            # Model loading code
            model_params = {
                'model_path': model_path,
                'n_ctx': self.context_length
            }
            if self.has_gpu_support:
                model_params['n_gpu_layers'] = self.n_gpu_layers

            self.llm_model = Llama(**model_params)
            self.loaded_model_name = os.path.basename(model_path)
            self.last_model_path = model_path

            # Update model history
            model_entry = {
                'model_name': self.loaded_model_name,
                'model_path': model_path
            }
            if model_entry not in self.model_history:
                self.model_history.append(model_entry)
                self.model_combo.addItem(self.loaded_model_name)
            self.save_history()

            QMessageBox.information(self, "Success", f"LLM model '{self.loaded_model_name}' loaded successfully.")

            # Update the info widget and save settings
            self.update_info_widget()
            self.save_settings()

        except Exception as e:
            QMessageBox.critical(self, "Error", f"Failed to load model: {str(e)}")
        finally:
            # Close the progress dialog
            if hasattr(self, 'model_loading_progress'):
                self.model_loading_progress.close()

    def eject_model(self):
        if self.llm_model:
            self.llm_model = None
            self.loaded_model_name = None
            self.model_path.clear()
            QMessageBox.information(self, "Success", "Model ejected successfully.")
        else:
            QMessageBox.warning(self, "Info", "No model loaded to eject.")

    def browse_dataset(self):
        folder_path = QFileDialog.getExistingDirectory(self, "Select Dataset Folder")
        if folder_path:
            self.dataset_path.setText(folder_path)

    def load_dataset(self):
        dataset_path = self.dataset_path.text()
        if not dataset_path:
            QMessageBox.warning(self, "Error", "Please select a dataset folder.")
            return

        self.loading_thread = DatasetLoadingThread(dataset_path, self.search_engine)
        self.loading_thread.finished.connect(self.loading_finished)
        self.loading_thread.progress_update.connect(self.update_loading_progress)
        self.loading_thread.error.connect(lambda err: QMessageBox.critical(self, "Error", f"Failed to load dataset: {err}"))
        self.loading_thread.start()

        self.loading_progress = QProgressDialog("Loading Dataset...", "Cancel", 0, 100, self)
        self.loading_progress.setWindowModality(Qt.WindowModality.WindowModal)
        self.loading_progress.show()
        self.last_dataset_path = self.dataset_path.text()
        self.update_info_widget()

    def update_loading_progress(self, value):
        if self.loading_progress:
            self.loading_progress.setValue(value)

    def loading_finished(self):
        self.loading_progress.close()
        self.last_dataset_path = self.dataset_path.text()
        self.save_history()
        QMessageBox.information(self, "Success", "Dataset loaded successfully.")

    def toggle_rag(self):
        self.use_rag = not self.use_rag
        button_text = "Switch to Model Knowledge" if self.use_rag else "Switch to Dataset"
        if hasattr(self, 'settings_window'):
            self.settings_window.use_model_knowledge_button.setText(button_text)
        if self.use_rag and not self.search_engine.corpus:
            QMessageBox.warning(self, "Info", "No dataset loaded. Please load a dataset or switch back to 'Use Model Knowledge'.")
        self.update_info_widget()
        self.save_settings()

    def save_settings(self):
        settings = {
            'gui_scale': self.gui_scale,
            'logging_enabled': self.logging_enabled,
            'last_model_path': self.last_model_path,
            'last_dataset_path': self.last_dataset_path,
            'use_rag': self.use_rag,
            # Include other settings as needed
        }
        settings_file = os.path.join(os.path.dirname(__file__), "settings.json")
        try:
            with open(settings_file, "w") as f:
                json.dump(settings, f, indent=4)
        except Exception as e:
            print(f"Error saving settings: {e}")

    def load_settings(self):
        settings_file = os.path.join(os.path.dirname(__file__), "settings.json")
        if os.path.exists(settings_file):
            try:
                with open(settings_file, "r") as f:
                    settings = json.load(f)
                    self.gui_scale = settings.get('gui_scale', 1.15)
                    self.logging_enabled = settings.get('logging_enabled', False)
                    self.last_model_path = settings.get('last_model_path', "")
                    self.last_dataset_path = settings.get('last_dataset_path', "")
                    self.use_rag = settings.get('use_rag', True)
            except Exception as e:
                print(f"Error loading settings: {e}")
        else:
            # Default settings
            self.gui_scale = 1.15
            self.logging_enabled = False
            self.use_rag = True

    def toggle_gpu(self, state):
        self.has_gpu_support = state

    def set_gpu_layers(self, value):
        self.n_gpu_layers = value

    def show_context_length_slider(self):
        slider_dialog = QDialog(self)
        slider_dialog.setWindowTitle("Set Context Length")
        layout = QVBoxLayout()

        slider = QSlider(Qt.Orientation.Horizontal)
        slider.setMinimum(128)
        slider.setMaximum(2048)
        slider.setValue(self.context_length)
        slider.setTickInterval(64)
        slider.setTickPosition(QSlider.TickPosition.TicksBelow)

        value_label = QLabel(str(self.context_length))

        def update_value(value):
            self.context_length = round(value / 64) * 64
            slider.setValue(self.context_length)
            value_label.setText(str(self.context_length))

        slider.valueChanged.connect(update_value)

        layout.addWidget(slider)
        layout.addWidget(value_label)

        slider_dialog.setLayout(layout)
        slider_dialog.exec()

    def show_max_token_length_slider(self):
        slider_dialog = QDialog(self)
        slider_dialog.setWindowTitle("Set Max Token Length")
        layout = QVBoxLayout()

        slider = QSlider(Qt.Orientation.Horizontal)
        slider.setMinimum(64)
        slider.setMaximum(4096)
        slider.setValue(self.max_token_length)
        slider.setTickInterval(64)
        slider.setTickPosition(QSlider.TickPosition.TicksBelow)

        value_label = QLabel(str(self.max_token_length))

        def update_value(value):
            self.max_token_length = round(value / 64) * 64
            slider.setValue(self.max_token_length)
            value_label.setText(str(self.max_token_length))

        slider.valueChanged.connect(update_value)

        layout.addWidget(slider)
        layout.addWidget(value_label)

        slider_dialog.setLayout(layout)
        slider_dialog.exec()

    def add_file_to_context(self):
        # Function to add file content as context
        file_path, _ = QFileDialog.getOpenFileName(self, "Select a File")
        if file_path:
            try:
                # Read the file content
                _, ext = os.path.splitext(file_path)
                ext = ext.lower()

                if ext == ".txt":
                    with open(file_path, 'r', encoding='utf-8', errors='ignore') as f:
                        content = f.read()
                elif ext == ".pdf":
                    from pdfminer.high_level import extract_text as pdf_extract_text
                    content = pdf_extract_text(file_path)
                elif ext == ".docx":
                    from docx import Document
                    doc = Document(file_path)
                    content = "\n".join([paragraph.text for paragraph in doc.paragraphs])
                elif ext == ".pptx":
                    from pptx import Presentation
                    prs = Presentation(file_path)
                    text = []
                    for slide in prs.slides:
                        for shape in slide.shapes:
                            if hasattr(shape, "text"):
                                text.append(shape.text)
                    content = "\n".join(text)
                else:
                    QMessageBox.warning(self, "Unsupported File", "The selected file format is not supported.")
                    return

                # Store the file content and name
                self.file_context = content
                self.file_name = os.path.basename(file_path)

            except Exception as e:
                QMessageBox.critical(self, "Error", f"An error occurred while reading the file: {str(e)}")

    def toggle_theme(self):
        self.dark_mode = not self.dark_mode
        self.setStyleSheet(self.get_stylesheet())

    def save_conversation(self):
        save_path, _ = QFileDialog.getSaveFileName(self, "Save Conversation", "", "JSON Files (.json)")
        if save_path:
            history = []
            for i in range(self.chat_history_layout.count()):
                widget = self.chat_history_layout.itemAt(i).widget()
                if isinstance(widget, ChatBubble):
                    history.append({
                        'text': widget.text,
                        'is_user': widget.is_user,
                        'metadata': widget.metadata
                    })
            with open(save_path, 'w', encoding='utf-8') as f:
                json.dump(history, f, indent=4)
            QMessageBox.information(self, "Saved", "Conversation saved successfully.")

    def load_conversation(self):
        load_path, _ = QFileDialog.getOpenFileName(self, "Load Conversation", "", "JSON Files (*.json)")
        if load_path:
            with open(load_path, 'r', encoding='utf-8') as f:
                history = json.load(f)
            # Clear current chat history
            for i in reversed(range(self.chat_history_layout.count())):
                widget = self.chat_history_layout.itemAt(i).widget()
                if widget is not None:
                    widget.setParent(None)
            # Load saved conversation
            for item in history:
                chat_bubble = ChatBubble(
                    item['text'],
                    is_user=item['is_user'],
                    metadata=item.get('metadata', {}),
                    custom_font=self.custom_font
                )
                self.chat_history_layout.addWidget(chat_bubble)
            QMessageBox.information(self, "Loaded", "Conversation loaded successfully.")

    def load_history(self):
        history_file = os.path.join(os.path.dirname(__file__), "history.json")
        if os.path.exists(history_file) and os.stat(history_file).st_size > 0:
            try:
                with open(history_file, "r") as f:
                    data = json.load(f)
                    if data:
                        self.model_history = [
                            {"model_name": item.get('model_name'), "model_path": item.get('model_path')} for item in data
                        ]
            except json.JSONDecodeError:
                print("Error decoding history.json. File might be corrupted.")
        else:
            print("No previous history found.")

    def save_history(self):
        history_file = os.path.join(os.path.dirname(__file__), "history.json")
        try:
            with open(history_file, "w") as f:
                json.dump(self.model_history, f, indent=4)
        except Exception as e:
            print(f"Error saving history: {e}")

if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = ChatHXApp()
    window.show()
    sys.exit(app.exec())