##gui.py##
import sys
import os
import json
import re
import time
import traceback
import threading
import psutil  # For system resource usage
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 
)
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_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_label = QLabel("AI Model")
        ai_model_label.setFont(QFont("CustomFont", 14))
        self.layout.addWidget(ai_model_label)

        self.model_combo = self.parent_app.model_combo
        self.layout.addWidget(self.model_combo)

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

        self.model_path = self.parent_app.model_path
        self.layout.addWidget(self.model_path)

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

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

        # GPU Settings
        gpu_settings_label = QLabel("GPU Settings")
        gpu_settings_label.setFont(QFont("CustomFont", 14))
        self.layout.addWidget(gpu_settings_label)

        self.enable_gpu_checkbox = QPushButton("Enable GPU Acceleration")
        self.enable_gpu_checkbox.setCheckable(True)
        self.enable_gpu_checkbox.toggled.connect(self.parent_app.toggle_gpu)
        self.layout.addWidget(self.enable_gpu_checkbox)

        self.gpu_layers_input = QSpinBox()
        self.gpu_layers_input.setRange(0, 1000)
        self.gpu_layers_input.setValue(self.parent_app.n_gpu_layers)
        self.gpu_layers_input.valueChanged.connect(self.parent_app.set_gpu_layers)
        self.layout.addWidget(QLabel("Number of GPU Layers"))
        self.layout.addWidget(self.gpu_layers_input)

        # Dataset Section
        dataset_label = QLabel("Dataset")
        dataset_label.setFont(QFont("CustomFont", 14))
        self.layout.addWidget(dataset_label)

        self.dataset_path = self.parent_app.dataset_path
        self.layout.addWidget(self.dataset_path)

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

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

        # 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)

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

    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.")

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
        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):
        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)

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

        # Custom Font
        self.custom_font = self.load_custom_font("C:/Users/HX/Documents/Projets/ChatHX/assets/HX_Sans.ttf")  # 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

        # 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 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()

        # 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)

        # 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)

        # Chat area
        self.chat_layout = QVBoxLayout()

        # 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)

        # Initialize model and dataset controls (hidden)
        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.dataset_path = QLineEdit()

    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 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;
                font-size: 12px;
            }
            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

        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)

            # 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.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()

            # Start resource monitoring
            self.monitor_resources()

        except Exception as e:
            QMessageBox.critical(self, "Error", f"An error occurred during processing: {str(e)}")
            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.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:
            # Display the raw response if parsing fails
            self.current_response_box.setText(self.full_response)
            QMessageBox.critical(self, "Error", f"Error processing response: {str(e)}")
        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 create_prompt(self, query):
        context = ""
        source_file = None
        if self.file_context:
            context = self.file_context
            source_file = self.file_name
            self.rag_llm.last_source_file = source_file
        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  # Store the source file
            else:
                self.rag_llm.last_source_file = None

        context = context.replace("{", "{{").replace("}", "}}")
        query = query.replace("{", "{{").replace("}", "}}")

        prompt = f"""
        You are a helpful and informative AI assistant.
        Context: '{context}'

        Question: {query}

        Answer the question based on the context and your prior 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 context and 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 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 monitor_resources(self):
        # Real-time monitoring is handled in the StatisticsWindow
        pass

    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()
        if model_path:
            try:
                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.")
            except Exception as e:
                QMessageBox.critical(self, "Error", f"Failed to load model: {str(e)}")

    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()

    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 = "Use Model Knowledge" if self.use_rag else "Use 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'.")

    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())

##openrag_s.py##
import os
import nltk
import torch
import pickle
import chardet
import numpy as np
import threading
from transformers import AutoTokenizer, AutoModel, logging
from sklearn.metrics.pairwise import cosine_similarity

# Download Punkt sentence tokenizer if not already downloaded
nltk.download('punkt', quiet=True)

# Suppress warnings from transformers
logging.set_verbosity_error()

# Set up cache directory for models and embeddings
cache_dir = os.path.join(os.path.dirname(__file__), "model_cache")
os.makedirs(cache_dir, exist_ok=True)

# ---- Embedding Model ----
def meanpooling(output, mask):
    embeddings = output[0]
    mask = mask.unsqueeze(-1).expand(embeddings.size()).float()
    return torch.sum(embeddings * mask, 1) / torch.clamp(mask.sum(1), min=1e-9)

def load_embedding_model(model_name="sentence-transformers/all-MiniLM-L6-v2"):
    """
    Loads the embedding model and tokenizer from cache or downloads if not present.
    """
    model_path = os.path.join(cache_dir, model_name.replace("/", "_"))
    try:
        if os.path.exists(os.path.join(model_path, "pytorch_model.bin")):
            print("Loading embedding model from cache...")
            tokenizer = AutoTokenizer.from_pretrained(model_path)
            model = AutoModel.from_pretrained(model_path)
        else:
            print("Downloading embedding model...")
            tokenizer = AutoTokenizer.from_pretrained(model_name, cache_dir=model_path)
            model = AutoModel.from_pretrained(model_name, cache_dir=model_path)
            tokenizer.save_pretrained(model_path)
            model.save_pretrained(model_path)
    except Exception as e:
        print(f"Error loading embedding model: {str(e)}")
        raise
    return tokenizer, model

# Load the embedding model and tokenizer
tokenizer, model = load_embedding_model()
device = torch.device("cpu")
model.to(device)

def get_embedding(texts, batch_size=32):
    """
    Calculates embeddings for a list of texts in batches.
    """
    all_embeddings = []
    try:
        for i in range(0, len(texts), batch_size):
            batch = texts[i : i + batch_size]
            inputs = tokenizer(batch, padding=True, truncation=True, return_tensors='pt').to(device)
            with torch.no_grad():
                output = model(**inputs)
            batch_embeddings = meanpooling(output, inputs['attention_mask']).cpu().numpy()
            all_embeddings.append(batch_embeddings)
    except Exception as e:
        print(f"Error generating embeddings: {str(e)}")
        raise
    return np.concatenate(all_embeddings, axis=0)

# ---- Search Engine ----
class LocalSemanticSearchEngine:
    def __init__(self, embeddings_file="embeddings.pkl"):
        self.corpus = {}
        self.embeddings = {}
        self.embeddings_file = embeddings_file
        self.lock = threading.Lock()  # For thread safety

    def load_documents(self, path):
        try:
            if os.path.isdir(path):
                self._load_embeddings_from_disk(path)
                files = [f for f in os.listdir(path) if f.lower().endswith((".txt", ".pdf", ".docx", ".pptx", ".rtf"))]
                total_files = len(files)
                for i, filename in enumerate(files):
                    file_path = os.path.join(path, filename)
                    if file_path not in self.embeddings:
                        try:
                            self.load_document(file_path)
                        except Exception as e:
                            print(f"Error loading document {filename}: {str(e)}")
                self._save_embeddings_to_disk(path)
            elif os.path.isfile(path) and path.lower().endswith((".txt", ".pdf", ".docx", ".pptx", ".rtf")):
                self.load_document(path)
                self._save_embeddings_to_disk(os.path.dirname(path))
            else:
                raise ValueError("Invalid path provided. Please provide a supported file or a directory.")
        except Exception as e:
            print(f"Error loading documents: {str(e)}")
            raise

    def load_document(self, file_path):
        try:
            _, ext = os.path.splitext(file_path)
            ext = ext.lower()

            if ext == ".txt":
                text = self.read_text_file(file_path)
            elif ext == ".pdf":
                text = self.extract_text_from_pdf(file_path)
            elif ext == ".docx":
                text = self.extract_text_from_docx(file_path)
            elif ext == ".pptx":
                text = self.extract_text_from_pptx(file_path)
            elif ext == ".rtf":
                text = self.read_text_file(file_path)  # RTF can be treated as text
            else:
                raise ValueError(f"Unsupported file type: {ext}")

            if not text.strip():
                print(f"Warning: No text content found in {file_path}")
                return

            sentences = nltk.sent_tokenize(text)
            if not sentences:
                print(f"Warning: No sentences extracted from {file_path}")
                return

            embeddings = get_embedding(sentences)
            if embeddings.size == 0:
                print(f"Warning: No embeddings generated for {file_path}")
                return

            self.corpus[file_path] = sentences
            self.embeddings[file_path] = embeddings
        except Exception as e:
            print(f"Error loading document '{file_path}': {str(e)}")

    def read_text_file(self, file_path):
        try:
            with open(file_path, 'rb') as file:
                raw_data = file.read()

            result = chardet.detect(raw_data)
            encoding = result['encoding'] or 'utf-8'

            with open(file_path, 'r', encoding=encoding, errors='ignore') as file:
                content = file.read()
            return content
        except Exception as e:
            print(f"Error reading file '{file_path}': {e}")
            return ""

    def extract_text_from_pdf(self, file_path):
        try:
            from PyPDF2 import PdfReader
            reader = PdfReader(file_path)
            text = ''
            for page in reader.pages:
                page_text = page.extract_text()
                if page_text:
                    text += page_text
            return text
        except Exception as e:
            print(f"Error extracting text from pdf '{file_path}': {str(e)}")
            raise

    def extract_text_from_docx(self, file_path):
        try:
            from docx import Document
            doc = Document(file_path)
            return "\n".join([paragraph.text for paragraph in doc.paragraphs])
        except Exception as e:
            print(f"Error extracting text from docx '{file_path}': {str(e)}")
            raise

    def extract_text_from_pptx(self, file_path):
        try:
            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)
            return "\n".join(text)
        except Exception as e:
            print(f"Error extracting text from pptx '{file_path}': {str(e)}")
            raise

    def _load_embeddings_from_disk(self, path):
        try:
            embeddings_path = os.path.join(path, self.embeddings_file)
            if os.path.exists(embeddings_path):
                with open(embeddings_path, "rb") as f:
                    self.embeddings = pickle.load(f)
                    self.corpus = {}
                    for file_path in self.embeddings.keys():
                        text = self.read_text_file(file_path)
                        if text.strip():
                            sentences = nltk.sent_tokenize(text)
                            self.corpus[file_path] = sentences
        except Exception as e:
            print(f"Error loading embeddings from disk: {str(e)}")
            # Continue without raising an exception to allow new embeddings to be created

    def _save_embeddings_to_disk(self, path):
        try:
            embeddings_path = os.path.join(path, self.embeddings_file)
            with self.lock:
                with open(embeddings_path, "wb") as f:
                    pickle.dump(self.embeddings, f)
        except Exception as e:
            print(f"Error saving embeddings to disk: {str(e)}")
            # Continue without raising an exception

    def search(self, query, similarity_threshold=0.4):
        try:
            if not self.embeddings:
                print("Embeddings are empty. Please load documents first.")
                return None, 0.0

            query_embedding = get_embedding([query])[0]
            max_similarity = -1
            most_similar_file = None

            for file_path, file_embeddings in self.embeddings.items():
                similarities = cosine_similarity(query_embedding.reshape(1, -1), file_embeddings)
                max_sim = np.max(similarities)
                if max_sim > max_similarity:
                    max_similarity = max_sim
                    most_similar_file = file_path

            if max_similarity < similarity_threshold:
                return None, max_similarity

            return most_similar_file, max_similarity
        except Exception as e:
            print(f"Error searching for similar documents: {str(e)}")
            raise

# ---- RAG Enhanced LLM Inference ----
class RAGEnabledLLM:
    def __init__(self, llm, search_engine, max_token_length):
        self.llm = llm
        self.search_engine = search_engine
        self.max_token_length = int(max_token_length)
        self.response = ""
        self.max_context_length = 1024
        self.last_source_file = None  # To store the source file used

    def generate_response(self, query, context_length, temperature=0.7, top_p=0.9, top_k=40, similarity_threshold=0.4):
        try:
            most_similar_file, similarity_score = self.search_engine.search(query, similarity_threshold=similarity_threshold)

            if most_similar_file:
                with open(most_similar_file, 'r', encoding='utf-8', errors='ignore') as file:
                    context = file.read()
                context = context[:min(
                    int(context_length), self.max_context_length - len(query) - 100)]
                self.last_source_file = most_similar_file
            else:
                context = ""
                self.last_source_file = None

            prompt = f"""
            You are a helpful and informative AI assistant.
            Context: '{context}'

            Question: {query}

            Answer the question based on the context and your prior 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 context and 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.
            """

            # Ensure context_length is an integer:
            if not isinstance(context_length, int):
                raise TypeError(f"context_length must be an integer, got {type(context_length)}")

            # Modify this part to yield tokens one by one
            params = {
                'max_tokens': self.max_token_length,
                'stop': ["
"],
                'echo': False,
                'stream': True,
                'temperature': temperature,
                'top_p': top_p,
                'top_k': top_k
            }
            for token in self.llm(prompt, **params):
                yield token['choices'][0]['text']
        except Exception as e:
            print(f"Error generating response with RAG: {str(e)}")
            raise

    def generate_response_without_rag(self, query, context_length, temperature=0.7, top_p=0.9, top_k=40):
        try:
            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.
            """

            # Ensure context_length is an integer:
            if not isinstance(context_length, int):
                raise TypeError(f"context_length must be an integer, got {type(context_length)}")

            # Yield tokens one by one
            params = {
                'max_tokens': self.max_token_length,
                'stop': ["
"],
                'echo': False,
                'stream': True,
                'temperature': temperature,
                'top_p': top_p,
                'top_k': top_k
            }
            for token in self.llm(prompt, **params):
                yield token['choices'][0]['text']
        except Exception as e:
            print(f"Error generating response without RAG: {str(e)}")
            raise