We want to add the text tools tab to our chatHX app, here is the code for the full chathx app : 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
from PyQt6.QtCore import Qt, QSize, QThread, pyqtSignal, QTimer
from llama_cpp import Llama
import OpenRAG_02

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
        logging.debug("LLMInferenceThread initialized with query: %s", self.query)

    def run(self):
        logging.info("LLMInferenceThread started.")
        try:
            if not isinstance(self.context_length, int):
                raise TypeError(
                    f"context_length should be an integer, got {type(self.context_length)} instead."
                )
            logging.debug("Context length verified: %d", self.context_length)

            start_time = time.time()
            tokens_generated = 0

            if self.use_rag:
                logging.debug("Using RAG for response generation.")
                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:
                logging.debug("Not using RAG for response generation.")
                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:
                    logging.info("LLMInferenceThread received stop flag.")
                    break
                self.token_ready.emit(token)
                tokens_generated += 1
                logging.debug("Token generated: %s", token)

            latency = time.time() - start_time
            logging.info("LLMInferenceThread finished. Latency: %.2f sec, Tokens: %d", latency, tokens_generated)
            self.inference_finished.emit(latency, tokens_generated)

        except Exception as e:
            logging.error("Exception in LLMInferenceThread: %s", str(e), exc_info=True)
            self.token_ready.emit(f"Error: {str(e)}")
            self.inference_finished.emit(0, 0)
            logging.debug("Traceback: %s", traceback.format_exc())

    def stop(self):
        logging.info("LLMInferenceThread stop requested.")
        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
        logging.debug("DatasetLoadingThread initialized with path: %s", self.dataset_path)

    def run(self):
        logging.info("DatasetLoadingThread started.")
        try:
            logging.debug("Loading documents from dataset path.")
            self.search_engine.load_documents(self.dataset_path)
            logging.info("DatasetLoadingThread completed successfully.")
        except Exception as e:
            logging.error("Exception in DatasetLoadingThread: %s", str(e), exc_info=True)
            self.error.emit(str(e))
        finally:
            self.finished.emit()
            logging.debug("DatasetLoadingThread signals finished.")

class StatisticsWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        logging.debug("Initializing StatisticsWindow.")
        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
        logging.debug("StatisticsWindow initialized.")

    def init_ui(self):
        logging.debug("StatisticsWindow UI setup started.")
        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)
        logging.debug("StatisticsWindow UI setup completed.")

    def update_metrics(self, token_speed, memory_usage, cpu_usage, latency, tokens_generated):
        logging.debug("Updating metrics: Speed=%.2f, Memory=%.2f, CPU=%.2f, Latency=%.2f, Tokens=%d",
                      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()
        logging.debug("Resource usage - Memory: %.2f MB, CPU: %.2f%%", memory_usage, cpu_usage)
        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.parent_app = parent_app
        logging.debug("Initializing SettingsWindow.")
        self.setWindowTitle("Settings")
        self.setGeometry(200, 200, 400, 700)
        self.create_widgets()
        logging.debug("SettingsWindow initialized.")

    def create_widgets(self):
        logging.debug("SettingsWindow UI setup started.")
        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)
        logging.debug("AI Model section added to SettingsWindow.")

        # 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)
        logging.debug("GPU Settings section added to SettingsWindow.")

        # 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)
        logging.debug("Dataset section added to SettingsWindow.")

        # 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)
        logging.debug("Use Model Knowledge toggle added to SettingsWindow.")

        # 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)
        logging.debug("Max Token Length setting added to SettingsWindow.")

        # 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)
        logging.debug("Context Length setting added to SettingsWindow.")

        # 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)
        logging.debug("Temperature setting added to SettingsWindow.")

        # 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)
        logging.debug("Top_p setting added to SettingsWindow.")

        # 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)
        logging.debug("Top_k setting added to SettingsWindow.")

        # 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)
        logging.debug("Set Parameters button added to SettingsWindow.")

        # 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.debug("GUI Scale slider added to SettingsWindow.")

        # 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)
        logging.debug("Logging toggle added to SettingsWindow.")

        self.central_widget.setLayout(self.layout)
        self.setCentralWidget(self.central_widget)
        logging.debug("SettingsWindow UI setup completed.")

    def update_gui_scale(self, value):
        logging.debug("GUI scale slider changed to %d%%", 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):
        logging.info("Setting parameters: Temperature=%.2f, Top_p=%.2f, Top_k=%d",
                     self.temperature_input.value(),
                     self.top_p_input.value(),
                     self.top_k_input.value())
        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):
        logging.info("Logging toggle changed to: %s", "Enabled" if state else "Disabled")
        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()
        logging.debug("ChatBubble created. User: %s, Text: %s", self.is_user, self.text)

    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)

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

    def setText(self, text):
        logging.debug("Updating ChatBubble text from '%s' to '%s'.", self.text, text)
        self.text = text
        bubble_layout = self.layout().itemAt(0).widget().layout()
        bubble_label = bubble_layout.itemAt(0).widget()

        if isinstance(bubble_label, QLabel):
            bubble_label.setText(self.text)
        else:
            logging.warning("Expected QLabel, but got %s", type(bubble_label))

    def setMetadata(self, metadata):
        logging.debug("Updating ChatBubble 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__()
        logging.info("Initializing ChatHXApp.")
        self.setWindowTitle("ChatHX")
        self.setGeometry(100, 100, 800, 600)
        
        # Load settings first
        self.load_settings()

        # Logging
        self.logging_enabled = self.settings.get('logging_enabled', False)
        self.setup_logging()
        logging.debug("Logging setup completed with enabled=%s", self.logging_enabled)

        # Default GUI settings
        self.gui_scale = self.settings.get('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())
        logging.debug("Custom font set to: %s", self.custom_font)

        self.has_gpu_support = False
        self.n_gpu_layers = 0

        self.llm_model = None
        self.search_engine = OpenRAG_02.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 = 8192
        self.loaded_model_name = None
        self.last_model_path = self.settings.get('last_model_path', "")
        self.last_dataset_path = self.settings.get('last_dataset_path', "")
        self.current_response_box = None
        self.use_rag = self.settings.get('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:
            logging.info("Auto-loading last used model from: %s", 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

        logging.info("ChatHXApp initialized successfully.")

    def setup_logging(self):
        if self.logging_enabled:
            logging.basicConfig(
                filename='chathx.log',
                level=logging.DEBUG,  # Set to DEBUG to capture all levels of logs
                format='%(asctime)s - %(levelname)s - %(threadName)s - %(message)s',
                datefmt='%Y-%m-%d %H:%M:%S'
            )
            logging.info("Logging has been enabled.")
        else:
            logging.disable(logging.CRITICAL)
            logging.info("Logging has been disabled.")

    def set_gui_scale(self, scale_factor):
        logging.debug("Setting GUI scale to %.2f", 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()
        logging.info("GUI scale set to %.2f", self.gui_scale)

    def update_gui_elements(self):
        logging.debug("Updating GUI elements based on scale factor.")
        # 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())
        logging.debug("GUI elements updated with new scaling factor.")

    def load_custom_font(self, font_path):
        logging.debug("Loading custom font from: %s", font_path)
        if not os.path.exists(font_path):
            logging.warning("Custom font file not found at: %s. Falling back to Arial.", font_path)
            return "Arial"
        font_id = QFontDatabase.addApplicationFont(font_path)
        if font_id == -1:
            logging.error("Failed to load custom font from: %s. Falling back to Arial.", font_path)
            return "Arial"
        font_family = QFontDatabase.applicationFontFamilies(font_id)[0]
        logging.debug("Custom font loaded: %s", font_family)
        return font_family

    def create_widgets(self):
        logging.debug("Creating main widgets for ChatHXApp.")
        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)

        # Remove spinner_label and spinner_movie as per user request
        # Previously, spinner_label and spinner_movie were added here

        # 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)
        logging.debug("Main widgets created and layout set.")

    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)
        logging.debug("Info widget updated with: %s", info_text)

    def on_model_selected(self, index):
        logging.debug("Model selected from combo box. Index: %d", index)
        if index > 0:
            selected_model = self.model_history[index - 1]
            self.model_path.setText(selected_model.get("model_path", ""))
            logging.info("Model selected: %s", selected_model.get("model_name", "Unknown Model"))

    def open_settings_window(self):
        logging.info("Opening SettingsWindow.")
        self.settings_window = SettingsWindow(self)
        self.settings_window.show()

    def show_statistics_window(self):
        logging.info("Opening StatisticsWindow.")
        self.statistics_window.show()

    def show_about_dialog(self):
        logging.info("Displaying About dialog.")
        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):
        logging.debug("Updating suggested questions.")
        # 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)
        logging.info("Suggested questions updated.")

    def set_question(self, question):
        logging.info("Setting input area with suggested question: %s", question)
        self.input_area.setText(question)

    def send_message(self):
        user_message = self.input_area.text().strip()
        if not user_message:
            logging.debug("Send message called with empty input.")
            return

        logging.info("User message sent: %s", user_message)
        if not self.llm_model:
            logging.warning("Send message failed: No LLM model loaded.")
            QMessageBox.warning(self, "Error", "Please load an LLM model first.")
            return

        try:
            if not self.rag_llm:
                logging.debug("Initializing RAGEnabledLLM.")
                self.rag_llm = OpenRAG_02.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)
            logging.debug("User message added to chat history.")

            # 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)
            logging.debug("AI response box created.")

            # Since spinner is removed, we won't show it
            # Previously: 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)
            logging.info("Inference started.")

            # 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()
            logging.debug("LLMInferenceThread started.")

        except Exception as e:
            logging.error("Error during processing send_message: %s", str(e), exc_info=True)
            QMessageBox.critical(self, "Error", "An error occurred during processing. Please check the log file for details.")
            logging.debug("Detailed error: %s", e)

    def stop_inference(self):
        if self.is_inferencing and self.llm_thread:
            logging.info("Stopping inference as per user request.")
            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.")
        else:
            logging.debug("Stop inference called, but no inference was ongoing.")

    def handle_token(self, token):
        logging.debug("Handling token: %s", token)
        self.full_response += token
        self.update_chat_history(token, from_user=False)

    def process_full_response(self, latency, tokens_generated):
        logging.info("Processing full response. Latency: %.2f sec, Tokens: %d", latency, tokens_generated)
        # Since spinner is removed, no need to stop it
        # Previously: 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)
        logging.debug("Statistics updated with new metrics.")

        # 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)
            logging.debug("AI response set in chat history.")

            # 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)
            logging.debug("Metadata set for AI response.")

            self.suggested_questions = follow_up_questions
            self.update_suggested_questions()
            logging.debug("Follow-up questions updated.")

        except Exception as e:
            logging.error("Error processing response: %s", str(e), 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
        logging.debug("File context cleared after processing response.")

    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)
            logging.debug("Chat history updated with new message.")
        else:
            current_text = self.current_response_box.text
            self.current_response_box.setText(current_text + text)
            logging.debug("Chat history updated with token.")

        # Scroll to the bottom
        self.scroll_area.verticalScrollBar().setValue(
            self.scroll_area.verticalScrollBar().maximum())
        logging.debug("Scrolled chat history to the bottom.")

    def get_conversation_history(self, max_turns=5):
        history = []
        logging.debug("Retrieving conversation 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()
        conversation = '\n'.join(history)
        logging.debug("Conversation history retrieved.")
        return conversation

    def create_prompt(self, query):
        logging.debug("Creating prompt with RAG.")
        # Get conversation history
        conversation = self.get_conversation_history()

        # Get context
        context = ""
        if self.file_context:
            context = self.file_context
            logging.debug("Using file context for prompt.")
        else:
            most_similar_file, similarity_score = self.search_engine.search(query)
            logging.debug("Most similar file: %s with similarity: %.2f", most_similar_file, similarity_score)
            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
                logging.debug("Context loaded from file: %s", 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>
"""

        logging.debug("Prompt created with RAG.")
        return prompt

    def create_prompt_without_rag(self, query):
        logging.debug("Creating prompt without RAG.")
        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.
"""
        logging.debug("Prompt created without RAG.")
        return prompt

    def extract_answer_and_questions(self, response):
        logging.debug("Extracting answer and follow-up questions from 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("'", '"')
                logging.debug("JSON string extracted: %s", json_str)
                # 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", [])
                logging.debug("Answer extracted: %s", answer)
                logging.debug("Follow-up questions extracted: %s", follow_up_questions)
            else:
                # If no JSON found, display the entire response
                answer = response.strip()
                logging.debug("No JSON found. Using entire response as answer.")
        except Exception as e:
            logging.error("Error extracting information from response: %s", str(e), exc_info=True)
            answer = response.strip()

        return answer, follow_up_questions

    def browse_model(self):
        logging.info("Browsing for model file.")
        model_path = QFileDialog.getOpenFileName(self, "Select Model File")[0]
        if model_path:
            self.model_path.setText(model_path)
            logging.info("Model file selected: %s", model_path)
        else:
            logging.debug("No model file selected.")

    def load_model(self):
        model_path = self.model_path.text()
        logging.info("Attempting to load model from: %s", 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()
                logging.debug("Model loading progress dialog shown.")

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

            except Exception as e:
                logging.error("Failed to load model: %s", str(e), exc_info=True)
                QMessageBox.critical(self, "Error", f"Failed to load model: {str(e)}")
        else:
            logging.warning("Load model called without specifying a model path.")

        self.update_info_widget()

    def _load_model_async(self, model_path):
        logging.debug("Asynchronous model loading started.")
        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
            logging.info("LLM model '%s' loaded successfully.", self.loaded_model_name)

            # 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)
                logging.debug("Model history updated with: %s", 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:
            logging.error("Exception during model loading: %s", str(e), exc_info=True)
            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()
                logging.debug("Model loading progress dialog closed.")

    def eject_model(self):
        logging.info("Ejecting current model.")
        if self.llm_model:
            self.llm_model = None
            self.loaded_model_name = None
            self.model_path.clear()
            QMessageBox.information(self, "Success", "Model ejected successfully.")
            logging.info("Model ejected successfully.")
        else:
            logging.warning("Eject model called, but no model was loaded.")
            QMessageBox.warning(self, "Info", "No model loaded to eject.")

    def browse_dataset(self):
        logging.info("Browsing for dataset folder.")
        folder_path = QFileDialog.getExistingDirectory(self, "Select Dataset Folder")
        if folder_path:
            self.dataset_path.setText(folder_path)
            logging.info("Dataset folder selected: %s", folder_path)
        else:
            logging.debug("No dataset folder selected.")

    def load_dataset(self):
        dataset_path = self.dataset_path.text()
        logging.info("Attempting to load dataset from: %s", dataset_path)
        if not dataset_path:
            logging.warning("Load dataset called without specifying a 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()
        logging.debug("DatasetLoadingThread started.")

        self.loading_progress = QProgressDialog("Loading Dataset...", "Cancel", 0, 100, self)
        self.loading_progress.setWindowModality(Qt.WindowModality.WindowModal)
        self.loading_progress.show()
        logging.debug("Dataset loading progress dialog shown.")

        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)
            logging.debug("Dataset loading progress updated to: %d%%", value)

    def loading_finished(self):
        if self.loading_progress:
            self.loading_progress.close()
            logging.debug("Dataset loading progress dialog closed.")
        self.last_dataset_path = self.dataset_path.text()
        self.save_history()
        QMessageBox.information(self, "Success", "Dataset loaded successfully.")
        logging.info("Dataset loaded successfully.")

    def toggle_rag(self):
        logging.info("Toggling RAG usage to: %s", not self.use_rag)
        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') and self.settings_window.use_model_knowledge_button:
            self.settings_window.use_model_knowledge_button.setText(button_text)
        if self.use_rag and not self.search_engine.corpus:
            logging.warning("RAG enabled but no dataset loaded.")
            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):
        logging.debug("Saving settings to settings.json.")
        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)
            logging.info("Settings saved successfully.")
        except Exception as e:
            logging.error("Error saving settings: %s", str(e), exc_info=True)

    def load_settings(self):
        logging.debug("Loading settings from settings.json.")
        settings_file = os.path.join(os.path.dirname(__file__), "settings.json")
        self.settings = {}
        if os.path.exists(settings_file):
            try:
                with open(settings_file, "r") as f:
                    self.settings = json.load(f)
                logging.info("Settings loaded successfully.")
            except Exception as e:
                logging.error("Error loading settings: %s", str(e), exc_info=True)
        else:
            logging.info("No settings.json found. Using default settings.")

        # Default settings if not loaded
        self.gui_scale = self.settings.get('gui_scale', 1.15)
        self.logging_enabled = self.settings.get('logging_enabled', False)
        self.last_model_path = self.settings.get('last_model_path', "")
        self.last_dataset_path = self.settings.get('last_dataset_path', "")
        self.use_rag = self.settings.get('use_rag', True)

    def toggle_gpu(self, state):
        logging.info("GPU acceleration toggled to: %s", "Enabled" if state else "Disabled")
        self.has_gpu_support = state

    def set_gpu_layers(self, value):
        logging.info("Setting number of GPU layers to: %d", value)
        self.n_gpu_layers = value

    def show_context_length_slider(self):
        logging.info("Opening context length slider dialog.")
        slider_dialog = QDialog(self)
        slider_dialog.setWindowTitle("Set Context Length")
        layout = QVBoxLayout()

        slider = QSlider(Qt.Orientation.Horizontal)
        slider.setMinimum(128)
        slider.setMaximum(8192)
        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))
            logging.debug("Context length slider updated to: %d", self.context_length)

        slider.valueChanged.connect(update_value)

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

        slider_dialog.setLayout(layout)
        slider_dialog.exec()
        logging.debug("Context length slider dialog closed.")

    def show_max_token_length_slider(self):
        logging.info("Opening max token length slider dialog.")
        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))
            logging.debug("Max token length slider updated to: %d", self.max_token_length)

        slider.valueChanged.connect(update_value)

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

        slider_dialog.setLayout(layout)
        slider_dialog.exec()
        logging.debug("Max token length slider dialog closed.")

    def add_file_to_context(self):
        logging.info("Adding file to context.")
        # 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()
                    logging.debug("Text file loaded: %s", file_path)
                elif ext == ".pdf":
                    from pdfminer.high_level import extract_text as pdf_extract_text
                    content = pdf_extract_text(file_path)
                    logging.debug("PDF file loaded: %s", file_path)
                elif ext == ".docx":
                    from docx import Document
                    doc = Document(file_path)
                    content = "\n".join([paragraph.text for paragraph in doc.paragraphs])
                    logging.debug("DOCX file loaded: %s", file_path)
                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)
                    logging.debug("PPTX file loaded: %s", file_path)
                else:
                    logging.warning("Unsupported file format attempted to be added: %s", ext)
                    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)
                logging.info("File added to context: %s", self.file_name)

            except Exception as e:
                logging.error("Error reading file for context: %s", str(e), exc_info=True)
                QMessageBox.critical(self, "Error", f"An error occurred while reading the file: {str(e)}")
        else:
            logging.debug("Add file to context canceled or no file selected.")

    def toggle_theme(self):
        logging.info("Toggling theme to: %s", "Light Mode" if self.dark_mode else "Dark Mode")
        self.dark_mode = not self.dark_mode
        self.setStyleSheet(self.get_stylesheet())
        logging.debug("Theme toggled.")
        # Optionally, you might want to save the theme preference in settings

    def save_conversation(self):
        logging.info("Saving conversation.")
        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
                    })
            try:
                with open(save_path, 'w', encoding='utf-8') as f:
                    json.dump(history, f, indent=4)
                logging.info("Conversation saved successfully to: %s", save_path)
                QMessageBox.information(self, "Saved", "Conversation saved successfully.")
            except Exception as e:
                logging.error("Error saving conversation: %s", str(e), exc_info=True)
                QMessageBox.critical(self, "Error", f"Failed to save conversation: {str(e)}")
        else:
            logging.debug("Save conversation canceled or no path selected.")

    def load_conversation(self):
        logging.info("Loading conversation.")
        load_path, _ = QFileDialog.getOpenFileName(self, "Load Conversation", "", "JSON Files (*.json)")
        if load_path:
            try:
                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)
                logging.info("Conversation loaded successfully from: %s", load_path)
                QMessageBox.information(self, "Loaded", "Conversation loaded successfully.")
            except Exception as e:
                logging.error("Error loading conversation: %s", str(e), exc_info=True)
                QMessageBox.critical(self, "Error", f"Failed to load conversation: {str(e)}")
        else:
            logging.debug("Load conversation canceled or no file selected.")

    def load_history(self):
        logging.debug("Loading model history from history.json.")
        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
                        ]
                        logging.info("Model history loaded from history.json.")
            except json.JSONDecodeError:
                logging.error("Error decoding history.json. File might be corrupted.", exc_info=True)
            except Exception as e:
                logging.error("Unexpected error loading history.json: %s", str(e), exc_info=True)
        else:
            logging.info("No previous history.json found or file is empty.")

    def save_history(self):
        logging.debug("Saving model history to history.json.")
        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)
            logging.info("Model history saved successfully to history.json.")
        except Exception as e:
            logging.error("Error saving history: %s", str(e), exc_info=True)

if __name__ == "__main__":
    # Initialize logging before creating the application
    logging.basicConfig(
        filename='chathx.log',
        level=logging.DEBUG,  # Set to DEBUG to capture all levels of logs
        format='%(asctime)s - %(levelname)s - %(threadName)s - %(message)s',
        datefmt='%Y-%m-%d %H:%M:%S'
    )
    logging.info("Application started.")

    app = QApplication(sys.argv)
    window = ChatHXApp()
    window.show()
    try:
        sys.exit(app.exec())
    except Exception as e:
        logging.critical("Unhandled exception: %s", str(e), exc_info=True)  ; here is the code for text tools, we know the implementation is not complete, so we want to integrate it into our app and complete its functionality: class TextToolsTab(QWidget):
    def __init__(self, parent_app):
        super().__init__()
        self.parent_app = parent_app
        self.init_ui()
        self.current_loaded_file = None
        self.model = None

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

        # Top Layout with Load Button and Model Selection
        top_layout = QHBoxLayout()

        # Load File Button
        self.load_file_button = QPushButton("Load File")
        self.load_file_button.setStyleSheet("background-color: #28a745; color: white;")
        self.load_file_button.clicked.connect(self.load_file)
        top_layout.addWidget(self.load_file_button)

        # Spacer
        top_layout.addStretch()

        # Model Selection Dropdown
        self.model_combo = QComboBox()
        self.model_combo.addItem("Select Model")
        for model in self.parent_app.model_history:
            self.model_combo.addItem(model.get("model_name", "Unknown Model"))
        self.model_combo.currentIndexChanged.connect(self.select_model)
        top_layout.addWidget(self.model_combo)

        self.layout.addLayout(top_layout)

        # Model Path Input
        model_layout = QHBoxLayout()
        self.model_path = QLineEdit()
        self.model_path.setPlaceholderText("Path to LLM Model")
        self.model_path.setText(self.parent_app.last_model_path)
        model_layout.addWidget(self.model_path)

        self.browse_model_button = QPushButton("Browse Model")
        self.browse_model_button.clicked.connect(self.parent_app.browse_model)
        model_layout.addWidget(self.browse_model_button)

        self.layout.addLayout(model_layout)

        # Dataset Path Input
        dataset_layout = QHBoxLayout()
        self.dataset_path = QLineEdit()
        self.dataset_path.setPlaceholderText("Path to Dataset")
        self.dataset_path.setText(self.parent_app.last_dataset_path)
        dataset_layout.addWidget(self.dataset_path)

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

        self.layout.addLayout(dataset_layout)

        # Central Area
        self.central_widget = QWidget()
        self.central_layout = QHBoxLayout()
        self.central_widget.setLayout(self.central_layout)

        # Initial Empty Text Box
        self.text_input = QLineEdit()
        self.text_input.setPlaceholderText("Paste or type your text here...")
        self.text_input.setFont(QFont("Arial", 12))
        self.text_input.setMinimumHeight(100)
        self.text_input.setAlignment(Qt.AlignmentFlag.AlignTop)
        self.central_layout.addWidget(self.text_input)

        self.layout.addWidget(self.central_widget)

        # Text Tools Buttons
        tools_layout = QHBoxLayout()

        # First Category Tools
        self.modify_tools_group = QGroupBox("Modify and Save")
        modify_layout = QVBoxLayout()
        modify_actions = ["Summarize", "Shorten", "Expand", "Proofread"]

        for action in modify_actions:
            btn = GradientButton(action)
            btn.clicked.connect(lambda checked, a=action: self.modify_and_save(a))
            modify_layout.addWidget(btn)

        self.modify_tools_group.setLayout(modify_layout)
        tools_layout.addWidget(self.modify_tools_group)

        # Second Category Tools
        self.generate_tools_group = QGroupBox("Generate New Text")
        generate_layout = QVBoxLayout()
        generate_actions = ["Explain More", "Translate To"]

        for action in generate_actions:
            btn = GradientButton(action)
            btn.clicked.connect(lambda checked, a=action: self.generate_new_text(a))
            generate_layout.addWidget(btn)

        self.generate_tools_group.setLayout(generate_layout)
        tools_layout.addWidget(self.generate_tools_group)

        self.layout.addLayout(tools_layout)

        # Dynamic Area for File Thumbnail and Messages
        self.dynamic_area = QHBoxLayout()
        self.layout.addLayout(self.dynamic_area)

        self.setLayout(self.layout)

    def load_file(self):
        logging.info("TextToolsTab: Load File button clicked.")
        file_path, _ = QFileDialog.getOpenFileName(
            self, "Select File", "",
            "Text Files (*.txt);;PDF Files (*.pdf);;Word Documents (*.docx);;PowerPoint Presentations (*.pptx);;Images (*.png *.xpm *.jpg)"
        )
        if file_path:
            self.current_loaded_file = file_path
            self.display_file_info(file_path)

    def display_file_info(self, file_path):
        logging.info(f"TextToolsTab: Displaying file info for {file_path}.")
        # Clear previous dynamic widgets
        while self.dynamic_area.count():
            child = self.dynamic_area.takeAt(0).widget()
            if child:
                child.deleteLater()

        # Thumbnail/Icon
        file_ext = os.path.splitext(file_path)[1].lower()
        if file_ext in [".png", ".jpg", ".jpeg", ".bmp", ".gif"]:
            pixmap = QPixmap(file_path).scaled(100, 100, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
        else:
            # Use generic icons for non-image files
            icon_path = os.path.join(os.path.dirname(__file__), "assets", "file_icon.png")
            if not os.path.exists(icon_path):
                icon_path = ""
            pixmap = QPixmap(icon_path if os.path.exists(icon_path) else "").scaled(100, 100, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
            if pixmap.isNull():
                # Create a placeholder pixmap
                pixmap = QPixmap(100, 100)
                pixmap.fill(QColor(200, 200, 200))
                painter = QPainter(pixmap)
                painter.setPen(QColor(0, 0, 0))
                painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, file_ext.upper())
                painter.end()

        self.thumbnail_label = QLabel()
        self.thumbnail_label.setPixmap(pixmap)
        self.thumbnail_label.setFixedSize(100, 100)
        self.dynamic_area.addWidget(self.thumbnail_label)

        # File Name
        self.file_name_label = QLabel(os.path.basename(file_path))
        self.file_name_label.setFont(QFont("Arial", 10))
        self.dynamic_area.addWidget(self.file_name_label)

        # Spacer
        self.dynamic_area.addStretch()

        # Message Label
        self.message_label = QLabel()
        self.dynamic_area.addWidget(self.message_label)

    def select_model(self, index):
        if index == 0:
            logging.info("TextToolsTab: No model selected.")
            return
        model_name = self.model_combo.currentText()
        model_entry = next((m for m in self.parent_app.model_history if m.get("model_name") == model_name), None)
        if model_entry:
            model_path = model_entry.get("model_path")
            if model_path:
                logging.info(f"TextToolsTab: Loading model '{model_name}' from {model_path}.")
                try:
                    self.model = Llama(model_path=model_path, n_ctx=4096)  # Reasonable context length
                    logging.info(f"TextToolsTab: Model '{model_name}' loaded successfully with context length=4096.")
                except Exception as e:
                    logging.error(f"TextToolsTab: Failed to load model '{model_name}': {str(e)}", exc_info=True)
                    QMessageBox.critical(self, "Error", f"Failed to load model '{model_name}': {str(e)}")
            else:
                logging.warning("TextToolsTab: Selected model does not have a valid path.")
                QMessageBox.warning(self, "Error", "Selected model does not have a valid path.")
        else:
            logging.warning(f"TextToolsTab: Model '{model_name}' not found in history.")
            QMessageBox.warning(self, "Error", f"Model '{model_name}' not found.")

    def modify_and_save(self, action):
        if not self.parent_app.llm_model:
            logging.warning(f"TextToolsTab: Model not loaded. Cannot perform action '{action}'.")
            QMessageBox.warning(self, "Error", "Please select and load a model first.")
            return
        if not self.current_loaded_file:
            logging.warning(f"TextToolsTab: No file loaded. Cannot perform action '{action}'.")
            QMessageBox.warning(self, "Error", "Please load a file first.")
            return
        logging.info(f"TextToolsTab: Performing '{action}' on the loaded file.")
        try:
            with open(self.current_loaded_file, 'r', encoding='utf-8', errors='ignore') as f:
                content = f.read()
            prompt = self.generate_prompt(action, content)
            response = self.parent_app.llm_model.generate(prompt)
            # Here, assuming response is a single string. Adjust if model returns differently.
            modified_content = response.strip()

            with open(self.current_loaded_file, 'w', encoding='utf-8', errors='ignore') as f:
                f.write(modified_content)

            # Display message
            self.message_label.setText(f"{action} has been applied to the content of this file.")
            logging.info(f"TextToolsTab: Action '{action}' performed and file '{self.current_loaded_file}' updated.")
        except Exception as e:
            logging.error(f"TextToolsTab: Error performing '{action}': {str(e)}", exc_info=True)
            QMessageBox.critical(self, "Error", f"An error occurred while performing '{action}': {str(e)}")

    def generate_new_text_prompt(self, action, content):
        return f"Action: {action}\n\nContent:\n{content}\n\nPlease perform the action above on the content."

    def modify_new_text_prompt(self, action, content):
        return f"Action: {action}\n\nContent:\n{content}\n\nPlease perform the action above on the content."

    def generate_prompt(self, action, content):
        # Customize the prompt based on action
        prompts = {
            "Summarize": f"Please provide a concise summary of the following text:\n\n{content}",
            "Shorten": f"Please shorten the following text while retaining its original meaning:\n\n{content}",
            "Expand": f"Please expand on the following text, providing more details and explanations:\n\n{content}",
            "Proofread": f"Please proofread and correct any grammatical errors in the following text:\n\n{content}",
            "Explain More": f"Please provide a more detailed explanation of the following text:\n\n{content}",
            "Translate To": f"Please translate the following text to English:\n\n{content}"
        }
        return prompts.get(action, f"Please perform the action '{action}' on the following text:\n\n{content}")

    def generate_new_text(self, action):
        if not self.parent_app.llm_model:
            logging.warning(f"TextToolsTab: Model not loaded. Cannot perform action '{action}'.")
            QMessageBox.warning(self, "Error", "Please select and load a model first.")
            return
        if not self.current_loaded_file and not self.text_input.text().strip():
            logging.warning(f"TextToolsTab: No text provided. Cannot perform action '{action}'.")
            QMessageBox.warning(self, "Error", "Please load a file or enter text to perform actions.")
            return
        logging.info(f"TextToolsTab: Performing '{action}' to generate new text.")
        try:
            if self.current_loaded_file:
                with open(self.current_loaded_file, 'r', encoding='utf-8', errors='ignore') as f:
                    content = f.read()
            else:
                content = self.text_input.text().strip()

            prompt = self.generate_new_text_prompt(action, content)
            
            # Create and start the inference thread
            self.llm_thread = LLMInferenceThread(
                rag_llm=self.parent_app.rag_llm,  # Ensure rag_llm is properly initialized
                query=prompt,
                context_length=self.parent_app.context_length,
                temperature=self.parent_app.temperature,
                top_p=self.parent_app.top_p,
                top_k=self.parent_app.top_k,
                use_rag=self.parent_app.use_rag
            )
            self.llm_thread.token_ready.connect(self.update_generated_text)
            self.llm_thread.inference_finished.connect(self.inference_finished_handler)
            self.llm_thread.start()

            # Display a message or loading indicator if desired
            self.message_label.setText(f"Generating '{action}'...")

        except Exception as e:
            logging.error(f"TextToolsTab: Error performing '{action}': {str(e)}", exc_info=True)
            QMessageBox.critical(self, "Error", f"An error occurred while performing '{action}': {str(e)}")

    def update_generated_text(self, token):
        # Update the generated text incrementally
        if not hasattr(self, 'generated_text'):
            self.generated_text = ""
        self.generated_text += token
        self.message_label.setText(self.generated_text)

    def inference_finished_handler(self, latency, tokens_generated):
        # Finalize the generated text
        self.message_label.setText(self.generated_text.strip())
        logging.info(f"Generated text completed. Latency: {latency} sec, Tokens: {tokens_generated}")
        self.send_button.setEnabled(True)
        self.is_inferencing = False
        self.generated_text = ""