diff --git a/images/chatbot.png b/images/chatbot.png new file mode 100644 index 000000000..4c36aee84 Binary files /dev/null and b/images/chatbot.png differ diff --git a/src/chatbot/chatbot_thread.py b/src/chatbot/chatbot_thread.py new file mode 100644 index 000000000..75a36bf57 --- /dev/null +++ b/src/chatbot/chatbot_thread.py @@ -0,0 +1,39 @@ +import subprocess +import os +import json +from PyQt5.QtCore import QThread, pyqtSignal + +os.environ["QT_QPA_PLATFORM"] = "xcb" + +class OllamaWorker(QThread): + response_signal = pyqtSignal(str) + + def __init__(self, user_text): + super().__init__() + self.user_text = user_text + + def run(self): + try: + # We explicitly tell the AI to prioritize the netlist context + messages = [ + { + "role": "system", + "content": ("You are a professional electronic engineer for eSim. " + "Use the provided netlist to analyze nodes, components, and connections. " + "Explain concisely in at MOST 30 words.") + }, + {"role": "user", "content": self.user_text} + ] + + # Using JSON dump for robust message passing + response = subprocess.run( + ["ollama", "run", "qwen2.5-coder:3b", json.dumps(messages)], + capture_output=True, text=True, check=True + ) + + bot_response = response.stdout.strip() or "No response received." + + except Exception as e: + bot_response = f"Error: {str(e)}" + + self.response_signal.emit(bot_response) \ No newline at end of file diff --git a/src/frontEnd/Application.py b/src/frontEnd/Application.py index 5d76bf9d7..464920499 100644 --- a/src/frontEnd/Application.py +++ b/src/frontEnd/Application.py @@ -40,7 +40,8 @@ from projManagement.Kicad import Kicad from projManagement.Validation import Validation from projManagement import Worker - +from frontEnd.Chatbot import ChatbotGUI +from PyQt5.QtCore import QTimer # Its our main window of application. @@ -48,7 +49,7 @@ class Application(QtWidgets.QMainWindow): """This class initializes all objects used in this file.""" global project_name simulationEndSignal = QtCore.pyqtSignal(QtCore.QProcess.ExitStatus, int) - + errorDetectedSignal = QtCore.pyqtSignal(str) def __init__(self, *args): """Initialize main Application window.""" @@ -57,16 +58,18 @@ def __init__(self, *args): # Set slot for simulation end signal to plot simulation data self.simulationEndSignal.connect(self.plotSimulationData) - + self.errorDetectedSignal.connect(self.handleError) # Creating require Object self.obj_workspace = Workspace.Workspace() self.obj_Mainview = MainView() self.obj_kicad = Kicad(self.obj_Mainview.obj_dockarea) self.obj_appconfig = Appconfig() self.obj_validation = Validation() + self.chatbot_window = ChatbotGUI() # Initialize all widget self.setCentralWidget(self.obj_Mainview) self.initToolBar() + self.initchatbot() self.setGeometry(self.obj_appconfig._app_xpos, self.obj_appconfig._app_ypos, @@ -82,6 +85,29 @@ def __init__(self, *args): self.systemTrayIcon.setIcon(QtGui.QIcon(init_path + 'images/logo.png')) self.systemTrayIcon.setVisible(True) + def initchatbot(self): + """ + This function initializes ChatbotIcon. + """ + self.chatboticon = QtWidgets.QPushButton(self, icon=QtGui.QIcon(init_path + 'images/chatbot.png')) + self.chatboticon.setIconSize(QtCore.QSize(30, 30)) + self.chatboticon.setStyleSheet("border-radius: 30px;") + self.chatboticon.clicked.connect(self.openChatbot) + + def openChatbot(self): + if not hasattr(self, 'chatbot_window') or not self.chatbot_window.isVisible(): + self.chatbot_window.setWindowModality(QtCore.Qt.WindowModal) + self.chatbot_window.setWindowFlags(QtCore.Qt.Dialog | QtCore.Qt.WindowStaysOnTopHint) + self.chatbot_window.show() + self.obj_appconfig.print_info('Chat Bot function is called') + + def resizeEvent(self, event): + """ + Adjust debug button position during window resize. + """ + super().resizeEvent(event) + self.chatboticon.move(self.width() - 100, self.height() - 60) + def initToolBar(self): """ This function initializes Tool Bars. @@ -293,6 +319,8 @@ def closeEvent(self, event): self.project.close() except BaseException: pass + if self.chatbot_window.isVisible(): + self.chatbot_window.close() event.accept() self.systemTrayIcon.showMessage('Exit', 'eSim is Closed.') @@ -416,6 +444,17 @@ def plotSimulationData(self, exitCode, exitStatus): self.obj_appconfig.print_error('Exception Message : ' + str(e)) + self.errorDetectedSignal.emit("Simulation failed.") + + def handleError(self): + self.projDir = self.obj_appconfig.current_project["ProjectName"] + self.output_file = os.path.join(self.projDir, "ngspice_error.log") + if self.chatbot_window.isVisible(): + self.delayed_function_call() + + def delayed_function_call(self): + QTimer.singleShot(2000, lambda: self.chatbot_window.debug_error(self.output_file)) + def open_ngspice(self): """This Function execute ngspice on current project.""" projDir = self.obj_appconfig.current_project["ProjectName"] @@ -438,7 +477,7 @@ def open_ngspice(self): return self.obj_Mainview.obj_dockarea.ngspiceEditor( - projName, ngspiceNetlist, self.simulationEndSignal) + projName, ngspiceNetlist, self.simulationEndSignal,self.chatbot_window) self.ngspice.setEnabled(False) self.conversion.setEnabled(False) diff --git a/src/frontEnd/Chatbot.py b/src/frontEnd/Chatbot.py new file mode 100644 index 000000000..c1e249e3e --- /dev/null +++ b/src/frontEnd/Chatbot.py @@ -0,0 +1,149 @@ +from chatbot.chatbot_thread import OllamaWorker +from PyQt5.QtWidgets import QWidget, QHBoxLayout, QTextEdit, QVBoxLayout, QLineEdit, QPushButton +from PyQt5.QtCore import QSize +from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import QApplication +from configuration.Appconfig import Appconfig +import os +if os.name == 'nt': + from frontEnd import pathmagic # noqa:F401 + init_path = '' +else: + import pathmagic # noqa:F401 + init_path = '../../' + +class ChatbotGUI(QWidget): + def __init__(self): + super().__init__() + self.setWindowTitle("AI Chatbot") + self.setFixedSize(400, 250) + self.chat_history = [] + + layout = QVBoxLayout(self) + self.chat_display = QTextEdit(self, readOnly=True) + layout.addWidget(self.chat_display) + + input_layout = QHBoxLayout() + self.user_input = QLineEdit(self, placeholderText="Type your query here...") + self.user_input.setStyleSheet("font-size: 14px;") + self.user_input.returnPressed.connect(self.ask_ollama) + input_layout.addWidget(self.user_input) + + self.clear_button = QPushButton(self, icon=QIcon(init_path + 'images/clear.png')) + self.clear_button.setIconSize(QSize(18, 18)) + self.clear_button.setStyleSheet("font-size: 14px; padding: 5px;") + self.clear_button.clicked.connect(self.clear_session) + input_layout.addWidget(self.clear_button) + + layout.addLayout(input_layout) + self.move_to_bottom_right() + + def get_netlist_content(self): + """Finds and reads the current project's .cir file.""" + try: + self.obj_appconfig = Appconfig() + proj_info = self.obj_appconfig.current_project + if proj_info and "ProjectName" in proj_info: + proj_dir = proj_info["ProjectName"] + proj_name = os.path.basename(proj_dir.rstrip(os.sep)) + netlist_path = os.path.join(proj_dir, f"{proj_name}.cir") + + if os.path.exists(netlist_path): + with open(netlist_path, "r") as f: + return f.read() + except Exception as e: + print(f"Error fetching netlist: {e}") + return None + + def ask_ollama(self): + user_text = self.user_input.text().strip() + if not user_text: + return + + # 1. Fetch Netlist Context (The Proposal Implementation) + netlist = self.get_netlist_content() + + # 2. Update History + self.chat_history = (self.chat_history + [f"User: {user_text}"])[-4:] + self.chat_display.append(f"You: {user_text}") + + # 3. Create a context-aware prompt + if netlist: + # We explicitly tell the AI to look at the netlist + context_prompt = ( + f"Analyze this eSim Netlist:\n{netlist}\n\n" + f"User Question: {user_text}" + ) + else: + context_prompt = user_text + + # 4. Pass the context_prompt to the worker + self.worker = OllamaWorker(context_prompt) + self.worker.response_signal.connect(self.display_response) + self.worker.start() + + self.user_input.clear() + def move_to_bottom_right(self): + """Move the chatbot window to the bottom-right corner of the screen.""" + screen = QApplication.desktop().screenGeometry() + widget = self.geometry() + x = screen.width() - widget.width() - 10 # 10px margin from the right + y = screen.height() - widget.height() - 50 # 50px margin from the bottom + self.move(x, y) + + def display_response(self, bot_response): + """Display the bot's response in the chat display.""" + self.chat_display.append(f"Bot: {bot_response}\n") + self.chat_history.append(f"Bot: {bot_response}\n") + + def clear_session(self): + """Clear the chat display.""" + self.chat_display.clear() + self.chat_history=[] + + def debug_ollama(self): + """Send log AND netlist to Ollama for failed simulation analysis.""" + self.chat_display.append(f"============ Simulation Failed =============\n") + error_log = self.user_input.text().strip() + + # Get the netlist to help the AI understand the context of the error + netlist = self.get_netlist_content() + + if netlist: + combined_query = ( + f"SIMULATION ERROR LOG:\n{error_log}\n\n" + f"CORRESPONDING NETLIST:\n{netlist}\n\n" + "Please analyze the error based on this netlist." + ) + else: + combined_query = error_log + + # Pass the combined data to the worker + self.worker = OllamaWorker(combined_query) + self.worker.response_signal.connect(self.display_response) + self.worker.start() + self.user_input.clear() + + def debug_error(self, log): + self.chat_history = [] + if os.path.exists(log): + with open(log, "r") as f: + lines = [line for line in f.readlines() if line.strip()] + + no_compat_index = next((i for i, line in enumerate(lines) if "No compatibility mode selected!" in line), None) + circuit_index = next((i for i, line in enumerate(lines) if "Circuit:" in line), None) + total_cpu_index = next((i for i, line in enumerate(lines) if "Total CPU time (seconds)" in line), None) + + before_no_compat = lines[:no_compat_index] if no_compat_index else [] + between_circuit_and_cpu = lines[circuit_index + 1:total_cpu_index] if circuit_index is not None and total_cpu_index is not None else [] + + filtered_lines = before_no_compat + between_circuit_and_cpu + combined_text = "".join(filtered_lines) + self.user_input.setText(combined_text) + self.obj_appconfig = Appconfig() + self.projDir = self.obj_appconfig.current_project["ProjectName"] + output_file = os.path.join(self.projDir, "erroroutput.txt") + with open(output_file, "w") as f: + f.writelines(filtered_lines) + self.debug_ollama() + diff --git a/src/frontEnd/DockArea.py b/src/frontEnd/DockArea.py index 01e0d715a..7f7116465 100755 --- a/src/frontEnd/DockArea.py +++ b/src/frontEnd/DockArea.py @@ -127,14 +127,14 @@ def plottingEditor(self): ) count = count + 1 - def ngspiceEditor(self, projName, netlist, simEndSignal): + def ngspiceEditor(self, projName, netlist, simEndSignal,chatbot): """ This function creates widget for Ngspice window.""" global count self.ngspiceWidget = QtWidgets.QWidget() self.ngspiceLayout = QtWidgets.QVBoxLayout() self.ngspiceLayout.addWidget( - NgspiceWidget(netlist, simEndSignal) + NgspiceWidget(netlist, simEndSignal,chatbot) ) # Adding to main Layout diff --git a/src/ngspiceSimulation/NgspiceWidget.py b/src/ngspiceSimulation/NgspiceWidget.py index 94368cddf..271a271a9 100644 --- a/src/ngspiceSimulation/NgspiceWidget.py +++ b/src/ngspiceSimulation/NgspiceWidget.py @@ -7,7 +7,7 @@ # This Class creates NgSpice Window class NgspiceWidget(QtWidgets.QWidget): - def __init__(self, netlist, simEndSignal): + def __init__(self, netlist, simEndSignal,chatbot): """ - Creates constructor for NgspiceWidget class. - Creates NgspiceWindow and runs the process @@ -27,12 +27,12 @@ def __init__(self, netlist, simEndSignal): self.projDir = self.obj_appconfig.current_project["ProjectName"] self.args = ['-b', '-r', netlist.replace(".cir.out", ".raw"), netlist] print("Argument to ngspice: ", self.args) - + self.chat=chatbot self.process = QtCore.QProcess(self) self.terminalUi = TerminalUi.TerminalUi(self.process, self.args) self.layout = QtWidgets.QVBoxLayout(self) self.layout.addWidget(self.terminalUi) - + self.output_file = os.path.join(self.projDir, "ngspice_error.log") self.process.setWorkingDirectory(self.projDir) self.process.setProcessChannelMode(QtCore.QProcess.MergedChannels) self.process.readyRead.connect(self.readyReadAll) @@ -69,7 +69,7 @@ def readyReadAll(self): stderror = str(self.process.readAllStandardError().data(), encoding='utf-8') - + print(stderror) # Suppressing the Ngspice PrinterOnly error that batch mode throws stderror = '\n'.join([errLine for errLine in stderror.split('\n') if ('PrinterOnly' not in errLine and @@ -135,8 +135,7 @@ def finishSimulation(self, exitCode, exitStatus, {} \ ' self.terminalUi.simulationConsole.append( - successFormat.format("Simulation Completed Successfully!")) - + successFormat.format("Simulation Completed Successfully!")) else: failedFormat = ' \ {} \ @@ -167,3 +166,10 @@ def finishSimulation(self, exitCode, exitStatus, ) simEndSignal.emit(exitStatus, exitCode) + console_output = self.terminalUi.simulationConsole.toPlainText() + # Save console output to a log file + error_log_path = os.path.join(self.projDir, "ngspice_error.log") + with open(error_log_path, "w", encoding="utf-8") as error_log: + error_log.write(console_output + "\n") + if self.chat.isVisible()and "Simulation Failed!" in console_output: + self.chat.debug_error(self.output_file) \ No newline at end of file