From b77d4ce3ef43d541e409a6bee7f34922bf575931 Mon Sep 17 00:00:00 2001 From: Poojamallavarapu Date: Thu, 28 May 2026 18:29:12 +0530 Subject: [PATCH] Added chatbot enhancements --- installation_guide.md | 170 ++ src/chatbot/image_handler.py | 1 - src/chatbot/ollama_runner.py | 126 +- src/frontEnd/Application.py | 1749 +++++++------ src/frontEnd/Chatbot.py | 4807 +++++++++++++--------------------- src/frontEnd/tts_handler.py | 61 + 6 files changed, 3071 insertions(+), 3843 deletions(-) create mode 100644 installation_guide.md create mode 100644 src/frontEnd/tts_handler.py diff --git a/installation_guide.md b/installation_guide.md new file mode 100644 index 000000000..4df1b9930 --- /dev/null +++ b/installation_guide.md @@ -0,0 +1,170 @@ +# eSim AI Chatbot - Generalized Installation and Setup Guide + +## 1. Overview + +Before you begin, ensure you have: + +- **OS:** Windows 10/11 with WSL2, macOS, or Ubuntu Linux +- **RAM:** Minimum 8 GB +- **Disk Space:** At least 15 GB free +- **Internet:** Required for initial downloads + +--- + +## 2. Install Required Dependencies (OS-Specific) + +### Linux (Ubuntu/Debian) + +```bash +sudo apt update && sudo apt upgrade -y +sudo apt install -y python3 python3-venv python3-pip git curl \ +portaudio19-dev python3-pyaudio \ +libgl1 libglib2.0-0 \ +libxcb-xinerama0 libxkbcommon-x11-0 libgl1-mesa-glx +``` + +### macOS + +```bash +brew install python3 portaudio git +``` + +### Windows (WSL2) + +Open **PowerShell as Administrator** and run: + +```powershell +wsl --install +``` + +Then restart your system and follow the Linux steps above inside WSL. + +--- + +## 3. Install Ollama + +Download and install Ollama from: + +https://ollama.com + +Start Ollama: + +```bash +ollama serve +``` + +--- + +## 4. Download AI Models + +Run the following commands: + +```bash +ollama pull qwen2.5-coder:3b +ollama pull qwen2.5-coder:1.5b +ollama pull nomic-embed-text +ollama pull minicpm-v +ollama pull tinyllama:1.1b +``` + +Verify installation: + +```bash +ollama list +``` + +--- + +## 5. Clone the Repository + +```bash +git clone https://github.com/username/Repository_name + +cd Repository_name +``` + +--- + +## 6. Set Up Python Environment + +### Linux/macOS + +```bash +python3 -m venv venv + +source venv/bin/activate +``` + +### Windows + +```powershell +python -m venv venv + +venv\Scripts\activate +``` + +--- + +## 7. Install Python Packages + +```bash +pip install --upgrade pip + +pip install -r requirements.txt +``` + +--- + +## 8. Run the Chatbot + +```bash +PYTHONPATH=src python3 -m frontEnd.Application +``` + +--- + +## 9. Daily Usage + +```bash +cd esim-chatbot-pr34 + +source venv/bin/activate + +ollama serve & + +PYTHONPATH=src python3 -m frontEnd.Application +``` + +--- +## 10. Voice output + +```bash + sudo apt install -y espeak +``` + +## 11. Troubleshooting + +### GUI Won't Open + +- Restart WSL +- Check display settings + +### Missing Models + +Run: + +```bash +ollama pull +``` + +### Microphone Issues + +```bash +pip install pyaudio +``` + +### Qt Plugin Errors + +- Verify Qt libraries are properly installed + +--- \ No newline at end of file diff --git a/src/chatbot/image_handler.py b/src/chatbot/image_handler.py index cd8744791..3938ec307 100644 --- a/src/chatbot/image_handler.py +++ b/src/chatbot/image_handler.py @@ -28,7 +28,6 @@ except Exception as e: HAS_PADDLE = False print(f"[INIT] PaddleOCR init failed: {e}") - print("[INIT] Vision analysis unavailable. Text and netlist analysis still work.") def encode_image(image_path: str) -> str: diff --git a/src/chatbot/ollama_runner.py b/src/chatbot/ollama_runner.py index ae754bd0b..dd84041d4 100644 --- a/src/chatbot/ollama_runner.py +++ b/src/chatbot/ollama_runner.py @@ -1,90 +1,40 @@ import os import ollama -import json -import time +import json,time -# ==================== CLIENT ==================== +# Model configuration +VISION_MODELS = {"primary": "minicpm-v:latest"} +TEXT_MODELS = {"default": "qwen2.5:3b"} +EMBED_MODEL = "nomic-embed-text" ollama_client = ollama.Client( host="http://localhost:11434", - timeout=300.0, + timeout=300.0, ) -# ==================== SETTINGS ==================== - -_SETTINGS_DIR = os.path.join( - os.path.expanduser("~"), ".local", "share", "esim-copilot" -) -_SETTINGS_PATH = os.path.join(_SETTINGS_DIR, "settings.json") - -_DEFAULT_TEXT_MODEL = "qwen2.5:3b" -_DEFAULT_VISION_MODEL = "minicpm-v:latest" -EMBED_MODEL = "nomic-embed-text" - - -def load_model_settings() -> dict: - """Load persisted model preferences from disk.""" - try: - with open(_SETTINGS_PATH, "r", encoding="utf-8") as f: - return json.load(f) - except Exception: - return {} - - -def save_model_settings(text_model: str, vision_model: str) -> None: - """Persist model preferences to disk.""" - os.makedirs(_SETTINGS_DIR, exist_ok=True) - try: - with open(_SETTINGS_PATH, "w", encoding="utf-8") as f: - json.dump({"text_model": text_model, "vision_model": vision_model}, f, indent=2) - except Exception as e: - print(f"[SETTINGS] Failed to save: {e}") - - -def list_available_models() -> list: - """Query Ollama for installed models. Returns list of model name strings.""" - try: - resp = ollama_client.list() - names = [m["name"] for m in resp.get("models", [])] - return names if names else [_DEFAULT_TEXT_MODEL, _DEFAULT_VISION_MODEL] - except Exception: - return [_DEFAULT_TEXT_MODEL, _DEFAULT_VISION_MODEL, EMBED_MODEL] - - -# Load settings and initialise model dicts -_settings = load_model_settings() - -VISION_MODELS = {"primary": _settings.get("vision_model", _DEFAULT_VISION_MODEL)} -TEXT_MODELS = {"default": _settings.get("text_model", _DEFAULT_TEXT_MODEL)} - - -def reload_model_settings() -> None: - """Re-read settings from disk and update running dicts (called after save).""" - s = load_model_settings() - VISION_MODELS["primary"] = s.get("vision_model", _DEFAULT_VISION_MODEL) - TEXT_MODELS["default"] = s.get("text_model", _DEFAULT_TEXT_MODEL) - - -# ==================== VISION ==================== - -def run_ollama_vision(prompt: str, image_input) -> str: - """Call vision model with Chain-of-Thought for better accuracy.""" +def run_ollama_vision(prompt: str, image_input: str | bytes) -> str: + """Call minicpm-v:latest with Chain-of-Thought for better accuracy.""" model = VISION_MODELS["primary"] - + try: import base64 - + image_b64 = "" + + if isinstance(image_input, bytes): image_b64 = base64.b64encode(image_input).decode("utf-8") - elif isinstance(image_input, str) and os.path.isfile(image_input): + + elif os.path.isfile(image_input): with open(image_input, "rb") as f: image_b64 = base64.b64encode(f.read()).decode("utf-8") + elif isinstance(image_input, str) and len(image_input) > 100: - image_b64 = image_input + image_b64 = image_input else: - raise ValueError("Invalid image input format") + raise ValueError("Invalid image input format") + # === CHAIN OF THOUGHT === system_prompt = ( "You are an expert Electronics Engineer using eSim.\n" "Analyze the schematic image carefully.\n\n" @@ -115,7 +65,7 @@ def run_ollama_vision(prompt: str, image_input) -> str: { "role": "user", "content": prompt, - "images": [image_b64], + "images": [image_b64], # <--- MUST BE LIST OF BASE64 STRINGS }, ], options={ @@ -126,17 +76,18 @@ def run_ollama_vision(prompt: str, image_input) -> str: ) content = resp["message"]["content"] - + + # === PARSE JSON FROM MIXED OUTPUT === import re json_match = re.search(r'```json\s*(\{.*?\})\s*```', content, re.DOTALL) if json_match: return json_match.group(1) - + start = content.find('{') end = content.rfind('}') + 1 if start != -1 and end != -1: return content[start:end] - + return "{}" except Exception as e: @@ -145,45 +96,44 @@ def run_ollama_vision(prompt: str, image_input) -> str: "vision_summary": f"Vision failed: {str(e)[:50]}", "component_counts": {}, "circuit_analysis": {"circuit_type": "Error", "design_errors": [], "design_warnings": []}, - "components": [], - "values": {}, + "components": [], "values": {} }) - -# ==================== TEXT ==================== - def run_ollama(prompt: str, mode: str = "default") -> str: - """Run text model with focused parameters.""" + """ + OPTIMIZED: Run text model with focused parameters. + """ model = TEXT_MODELS.get(mode, TEXT_MODELS["default"]) - + try: resp = ollama_client.chat( model=model, messages=[ { "role": "system", - "content": "You are an eSim and electronics expert. Be concise, accurate, and practical.", + "content": "You are an eSim and electronics expert. Be concise, accurate, and practical." }, {"role": "user", "content": prompt}, ], options={ - "temperature": 0.05, - "num_ctx": 2048, - "num_predict": 400, + "temperature": 0.05, + "num_ctx": 2048, + "num_predict": 400, "top_p": 0.9, - "repeat_penalty": 1.1, + "repeat_penalty": 1.1, }, ) + return resp["message"]["content"].strip() - + except Exception as e: return f"[Error] {str(e)}" -# ==================== EMBEDDINGS ==================== - def get_embedding(text: str): - """Get text embeddings for RAG.""" + """ + OPTIMIZED: Get text embeddings for RAG. + """ try: r = ollama_client.embeddings(model=EMBED_MODEL, prompt=text) return r["embedding"] diff --git a/src/frontEnd/Application.py b/src/frontEnd/Application.py index 20a529731..6e7a9fcc5 100644 --- a/src/frontEnd/Application.py +++ b/src/frontEnd/Application.py @@ -1,840 +1,909 @@ -# ========================================================================= -# FILE: Application.py -# -# USAGE: --- -# -# DESCRIPTION: This main file use to start the Application -# -# OPTIONS: --- -# REQUIREMENTS: --- -# BUGS: --- -# NOTES: --- -# AUTHOR: Fahim Khan, fahim.elex@gmail.com -# MAINTAINED: Rahul Paknikar, rahulp@iitb.ac.in -# Sumanto Kar, sumantokar@iitb.ac.in -# Pranav P, pranavsdreams@gmail.com -# ORGANIZATION: eSim Team at FOSSEE, IIT Bombay -# CREATED: Tuesday 24 February 2015 -# REVISION: Wednesday 07 June 2023 -# ========================================================================= - -import os -import sys -import traceback - -if os.name == 'nt': - from frontEnd import pathmagic # noqa:F401 - init_path = '' -else: - import pathmagic # noqa:F401 - init_path = '../../' - -from PyQt5 import QtGui, QtCore, QtWidgets -from PyQt5.Qt import QSize -from configuration.Appconfig import Appconfig -from frontEnd import ProjectExplorer -from frontEnd import Workspace -from frontEnd import DockArea -from projManagement.openProject import OpenProjectInfo -from projManagement.newProject import NewProjectInfo -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. - - -class Application(QtWidgets.QMainWindow): - """This class initializes all objects used in this file.""" - - # FIX #12: Removed the `global project_name` declaration that was here. - # A `global` statement at class scope has no effect in Python — class bodies - # do not create a new scope the way functions do — and the variable was - # never assigned or read anywhere in the class, making it pure dead code. - - simulationEndSignal = QtCore.pyqtSignal(QtCore.QProcess.ExitStatus, int) - errorDetectedSignal = QtCore.pyqtSignal(str) - - def __init__(self, *args): - """Initialize main Application window.""" - - # Calling __init__ of super class - QtWidgets.QMainWindow.__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, - self.obj_appconfig._app_width, - self.obj_appconfig._app_heigth) - self.setWindowTitle( - self.obj_appconfig._APPLICATION + "-" + self.obj_appconfig._VERSION - ) - self.showMaximized() - self.setWindowIcon(QtGui.QIcon(init_path + 'images/logo.png')) - - self.systemTrayIcon = QtWidgets.QSystemTrayIcon(self) - self.systemTrayIcon.setIcon(QtGui.QIcon(init_path + 'images/logo.png')) - self.systemTrayIcon.setVisible(True) - - def initchatbot(self): - """ - This function initializes the ChatbotIcon and embeds the ChatbotGUI - as a dockable panel on the right side of the main window. - Clicking the icon toggles the panel open/closed. - """ - # ── Dock widget (embedded in main window) ────────────────────── - self.chatbot_dock = QtWidgets.QDockWidget("🤖 eSim AI Assistant", self) - self.chatbot_dock.setWidget(self.chatbot_window) - self.chatbot_dock.setMinimumWidth(360) - self.chatbot_dock.setFeatures( - QtWidgets.QDockWidget.DockWidgetClosable | - QtWidgets.QDockWidget.DockWidgetMovable | - QtWidgets.QDockWidget.DockWidgetFloatable - ) - # Style the dock title bar - self.chatbot_dock.setStyleSheet(""" - QDockWidget { - font-weight: bold; - font-size: 13px; - color: #0055a5; - } - QDockWidget::title { - background: qlineargradient( - x1:0, y1:0, x2:1, y2:0, - stop:0 #e8f4fd, stop:1 #d0e8f8 - ); - padding: 6px 10px; - border-bottom: 2px solid #0078d4; - border-top-left-radius: 6px; - border-top-right-radius: 6px; - } - """) - self.addDockWidget(QtCore.Qt.RightDockWidgetArea, self.chatbot_dock) - self.chatbot_dock.hide() # Hidden by default; toggled by the icon button - # When user closes dock via the X button, reposition the floating icon - self.chatbot_dock.visibilityChanged.connect( - lambda _: self._reposition_chatbot_icon() - ) - - # ── Floating icon button (bottom-right corner) ────────────────── - self.chatboticon = QtWidgets.QPushButton( - self, icon=QtGui.QIcon(init_path + 'images/chatbot.png') - ) - self.chatboticon.setIconSize(QtCore.QSize(30, 30)) - self.chatboticon.setToolTip("Toggle AI Assistant") - self.chatboticon.setStyleSheet(""" - QPushButton { - border-radius: 26px; - background-color: transparent; - border: none; - } - QPushButton:hover { - background-color: rgba(0, 120, 212, 0.12); - border: 2px solid #0078d4; - } - QPushButton:pressed { - background-color: rgba(0, 63, 110, 0.18); - border: 2px solid #003f6e; - } - """) - self.chatboticon.setFixedSize(52, 52) - self.chatboticon.clicked.connect(self.openChatbot) - - def openChatbot(self): - """Toggle the chatbot dock panel open or closed.""" - if self.chatbot_dock.isVisible(): - self.chatbot_dock.hide() - else: - self.chatbot_dock.show() - self.chatbot_dock.raise_() - self.obj_appconfig.print_info('Chat Bot function is called') - # Reposition icon after dock visibility changes - self._reposition_chatbot_icon() - - def _reposition_chatbot_icon(self): - """ - Keep the icon in the bottom-right corner of the visible area. - When the dock is open, shift the icon left so it sits just - outside the dock panel instead of on top of it. - - FIX #13: Added a max(0, ...) guard so that if the dock is very wide - (e.g. undocked and then re-docked at a large size), the computed x - coordinate cannot go negative and push the button off-screen. - """ - margin = 12 - btn_w = self.chatboticon.width() - btn_h = self.chatboticon.height() - bottom_y = self.height() - btn_h - margin - - if self.chatbot_dock.isVisible(): - dock_w = self.chatbot_dock.width() - # FIX #13: Clamp x to 0 so the icon never goes off-screen - x = max(0, self.width() - dock_w - btn_w - margin) - else: - x = self.width() - btn_w - margin - - self.chatboticon.move(x, bottom_y) - self.chatboticon.raise_() # Always keep on top - - def resizeEvent(self, event): - """ - Adjust chatbot icon button position during window resize. - """ - super().resizeEvent(event) - self._reposition_chatbot_icon() - - def initToolBar(self): - """ - This function initializes Tool Bars. - It setups the icons, short-cuts and defining functonality for: - - - Top-tool-bar (New project, Open project, Close project, \ - Mode switch, Help option) - - Left-tool-bar (Open Schematic, Convert KiCad to Ngspice, \ - Simuation, Model Editor, Subcircuit, NGHDL, Modelica \ - Converter, OM Optimisation) - """ - # Top Tool bar - self.newproj = QtWidgets.QAction( - QtGui.QIcon(init_path + 'images/newProject.png'), - 'New Project', self - ) - self.newproj.setShortcut('Ctrl+N') - self.newproj.triggered.connect(self.new_project) - - self.openproj = QtWidgets.QAction( - QtGui.QIcon(init_path + 'images/openProject.png'), - 'Open Project', self - ) - self.openproj.setShortcut('Ctrl+O') - self.openproj.triggered.connect(self.open_project) - - self.closeproj = QtWidgets.QAction( - QtGui.QIcon(init_path + 'images/closeProject.png'), - 'Close Project', self - ) - self.closeproj.setShortcut('Ctrl+X') - self.closeproj.triggered.connect(self.close_project) - - self.wrkspce = QtWidgets.QAction( - QtGui.QIcon(init_path + 'images/workspace.ico'), - 'Change Workspace', self - ) - self.wrkspce.setShortcut('Ctrl+W') - self.wrkspce.triggered.connect(self.change_workspace) - - self.helpfile = QtWidgets.QAction( - QtGui.QIcon(init_path + 'images/helpProject.png'), - 'Help', self - ) - self.helpfile.setShortcut('Ctrl+H') - self.helpfile.triggered.connect(self.help_project) - - self.topToolbar = self.addToolBar('Top Tool Bar') - self.topToolbar.addAction(self.newproj) - self.topToolbar.addAction(self.openproj) - self.topToolbar.addAction(self.closeproj) - self.topToolbar.addAction(self.wrkspce) - self.topToolbar.addAction(self.helpfile) - - # ## This part is meant for SoC Generation which is currently ## - # ## under development and will be will be required in future. ## - # self.soc = QtWidgets.QToolButton(self) - # self.soc.setText('Generate SoC') - # ... - - # This part is setting fossee logo to the right - # corner in the application window. - self.spacer = QtWidgets.QWidget() - self.spacer.setSizePolicy( - QtWidgets.QSizePolicy.Expanding, - QtWidgets.QSizePolicy.Expanding) - self.topToolbar.addWidget(self.spacer) - self.logo = QtWidgets.QLabel() - self.logopic = QtGui.QPixmap( - os.path.join( - os.path.abspath(''), init_path + 'images', 'fosseeLogo.png' - )) - self.logopic = self.logopic.scaled( - QSize(150, 150), QtCore.Qt.KeepAspectRatio) - self.logo.setPixmap(self.logopic) - self.logo.setStyleSheet("padding:0 15px 0 0;") - self.topToolbar.addWidget(self.logo) - - # Left Tool bar Action Widget - self.kicad = QtWidgets.QAction( - QtGui.QIcon(init_path + 'images/kicad.png'), - 'Open Schematic', self - ) - self.kicad.triggered.connect(self.obj_kicad.openSchematic) - - self.conversion = QtWidgets.QAction( - QtGui.QIcon(init_path + 'images/ki-ng.png'), - 'Convert KiCad to Ngspice', self - ) - self.conversion.triggered.connect(self.obj_kicad.openKicadToNgspice) - - self.ngspice = QtWidgets.QAction( - QtGui.QIcon(init_path + 'images/ngspice.png'), - 'Simulate', self - ) - self.ngspice.triggered.connect(self.open_ngspice) - - self.model = QtWidgets.QAction( - QtGui.QIcon(init_path + 'images/model.png'), - 'Model Editor', self - ) - self.model.triggered.connect(self.open_modelEditor) - - self.subcircuit = QtWidgets.QAction( - QtGui.QIcon(init_path + 'images/subckt.png'), - 'Subcircuit', self - ) - self.subcircuit.triggered.connect(self.open_subcircuit) - - self.nghdl = QtWidgets.QAction( - QtGui.QIcon(init_path + 'images/nghdl.png'), 'NGHDL', self - ) - self.nghdl.triggered.connect(self.open_nghdl) - - self.makerchip = QtWidgets.QAction( - QtGui.QIcon(init_path + 'images/makerchip.png'), - 'Makerchip-NgVeri', self - ) - self.makerchip.triggered.connect(self.open_makerchip) - - self.omedit = QtWidgets.QAction( - QtGui.QIcon(init_path + 'images/omedit.png'), - 'Modelica Converter', self - ) - self.omedit.triggered.connect(self.open_OMedit) - - self.omoptim = QtWidgets.QAction( - QtGui.QIcon(init_path + 'images/omoptim.png'), - 'OM Optimisation', self - ) - self.omoptim.triggered.connect(self.open_OMoptim) - - self.conToeSim = QtWidgets.QAction( - QtGui.QIcon(init_path + 'images/icon.png'), - 'Schematics converter', self - ) - self.conToeSim.triggered.connect(self.open_conToeSim) - - # Adding Action Widget to tool bar - self.lefttoolbar = QtWidgets.QToolBar('Left ToolBar') - self.addToolBar(QtCore.Qt.LeftToolBarArea, self.lefttoolbar) - self.lefttoolbar.addAction(self.kicad) - self.lefttoolbar.addAction(self.conversion) - self.lefttoolbar.addAction(self.ngspice) - self.lefttoolbar.addAction(self.model) - self.lefttoolbar.addAction(self.subcircuit) - self.lefttoolbar.addAction(self.makerchip) - self.lefttoolbar.addAction(self.nghdl) - self.lefttoolbar.addAction(self.omedit) - self.lefttoolbar.addAction(self.omoptim) - self.lefttoolbar.addAction(self.conToeSim) - self.lefttoolbar.setOrientation(QtCore.Qt.Vertical) - self.lefttoolbar.setIconSize(QSize(40, 40)) - - def closeEvent(self, event): - ''' - This function closes the ongoing program (process). - When exit button is pressed a Message box pops out with \ - exit message and buttons 'Yes', 'No'. - - 1. If 'Yes' is pressed: - - check that program (process) in procThread_list \ - (a list made in Appconfig.py): - - - if available it terminates that program. - - if the program (process) is not available, \ - then check it in process_obj (a list made in \ - Appconfig.py) and if found, it closes the program. - - 2. If 'No' is pressed: - - the program just continues as it was doing earlier. - ''' - exit_msg = "Are you sure you want to exit the program?" - exit_msg += " All unsaved data will be lost." - reply = QtWidgets.QMessageBox.question( - self, 'Message', exit_msg, QtWidgets.QMessageBox.Yes, - QtWidgets.QMessageBox.No - ) - - if reply == QtWidgets.QMessageBox.Yes: - for proc in self.obj_appconfig.procThread_list: - try: - proc.terminate() - except BaseException: - pass - try: - for process_object in self.obj_appconfig.process_obj: - try: - process_object.close() - except BaseException: - pass - except BaseException: - pass - - # Check if "Open project" and "New project" window is open. - # If yes, just close it when application is closed. - try: - self.project.close() - except BaseException: - pass - if self.chatbot_dock.isVisible(): - self.chatbot_dock.close() - event.accept() - self.systemTrayIcon.showMessage('Exit', 'eSim is Closed.') - - elif reply == QtWidgets.QMessageBox.No: - event.ignore() - - def new_project(self): - """This function call New Project Info class.""" - text, ok = QtWidgets.QInputDialog.getText( - self, 'New Project Info', 'Enter Project Name:' - ) - updated = False - - if ok: - self.projname = (str(text)) - self.project = NewProjectInfo() - directory, filelist = self.project.createProject(self.projname) - - if directory and filelist: - self.obj_Mainview.obj_projectExplorer.addTreeNode( - directory, filelist - ) - updated = True - - if not updated: - print("No new project created") - self.obj_appconfig.print_info('No new project created') - try: - self.obj_appconfig.print_info( - 'Current project is : ' + - self.obj_appconfig.current_project["ProjectName"] - ) - except BaseException: - pass - - def open_project(self): - """This project call Open Project Info class.""" - print("Function : Open Project") - self.project = OpenProjectInfo() - try: - directory, filelist = self.project.body() - self.obj_Mainview.obj_projectExplorer.addTreeNode( - directory, filelist) - except BaseException: - pass - - def close_project(self): - """ - This function closes the saved project. - It first checks whether project (file) is present in list. - - - If present: - - it first kills that process-id. - - closes that file. - - Shows message "Current project is closed" - - - If not present: pass - """ - print("Function : Close Project") - current_project = self.obj_appconfig.current_project['ProjectName'] - if current_project is None: - pass - else: - temp = self.obj_appconfig.current_project['ProjectName'] - for pid in self.obj_appconfig.proc_dict[temp]: - try: - os.kill(pid, 9) - except BaseException: - pass - self.obj_Mainview.obj_dockarea.closeDock() - self.obj_appconfig.current_project['ProjectName'] = None - self.systemTrayIcon.showMessage( - 'Close', 'Current project ' + - os.path.basename(current_project) + ' is Closed.' - ) - - def change_workspace(self): - """ - This function call changes Workspace - """ - print("Function : Change Workspace") - self.obj_workspace.returnWhetherClickedOrNot(self) - self.hide() - self.obj_workspace.show() - - def help_project(self): - """ - This function opens usermanual in dockarea. - - It prints the message ""Function : Help"" - - Uses print_info() method of class Appconfig - from Configuration/Appconfig.py file. - - Call method usermanual() from ./DockArea.py. - """ - print("Function : Help") - self.obj_appconfig.print_info('Help is called') - print("Current Project is : ", self.obj_appconfig.current_project) - self.obj_Mainview.obj_dockarea.usermanual() - - @QtCore.pyqtSlot(QtCore.QProcess.ExitStatus, int) - def plotSimulationData(self, exitCode, exitStatus): - """Enables interaction for new simulation and - displays the plotter dock where graphs can be plotted. - """ - self.ngspice.setEnabled(True) - self.conversion.setEnabled(True) - self.closeproj.setEnabled(True) - self.wrkspce.setEnabled(True) - - if exitStatus == QtCore.QProcess.NormalExit and exitCode == 0: - try: - self.obj_Mainview.obj_dockarea.plottingEditor() - except Exception as e: - self.msg = QtWidgets.QErrorMessage() - self.msg.setModal(True) - self.msg.setWindowTitle("Error Message") - self.msg.showMessage( - 'Data could not be plotted. Please try again.' - ) - self.msg.exec_() - print("Exception Message:", str(e), traceback.format_exc()) - 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") - # Auto-open the chatbot dock so the user sees the error analysis - if not self.chatbot_dock.isVisible(): - self.chatbot_dock.show() - 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"] - - if projDir is not None: - projName = os.path.basename(projDir) - ngspiceNetlist = os.path.join(projDir, projName + ".cir.out") - - if not os.path.isfile(ngspiceNetlist): - print( - "Netlist file (*.cir.out) not found." - ) - self.msg = QtWidgets.QErrorMessage() - self.msg.setModal(True) - self.msg.setWindowTitle("Error Message") - self.msg.showMessage( - 'Netlist (*.cir.out) not found.' - ) - self.msg.exec_() - return - - self.obj_Mainview.obj_dockarea.ngspiceEditor( - projName, ngspiceNetlist, self.simulationEndSignal, self.chatbot_window) - - self.ngspice.setEnabled(False) - self.conversion.setEnabled(False) - self.closeproj.setEnabled(False) - self.wrkspce.setEnabled(False) - - else: - self.msg = QtWidgets.QErrorMessage() - self.msg.setModal(True) - self.msg.setWindowTitle("Error Message") - self.msg.showMessage( - 'Please select the project first.' - ' You can either create new project or open existing project' - ) - self.msg.exec_() - - def open_subcircuit(self): - """ - This function opens 'subcircuit' option in left-tool-bar. - When 'subcircuit' icon is clicked wich is present in - left-tool-bar of main page: - - - Meassge shown on screen "Subcircuit editor is called". - - 'subcircuiteditor()' function is called using object - 'obj_dockarea' of class 'Mainview'. - """ - print("Function : Subcircuit editor") - self.obj_appconfig.print_info('Subcircuit editor is called') - self.obj_Mainview.obj_dockarea.subcircuiteditor() - - def open_nghdl(self): - """ - This function calls NGHDL option in left-tool-bar. - It uses validateTool() method from Validation.py: - - - If 'nghdl' is present in executables list then - it passes command 'nghdl -e' to WorkerThread class of - Worker.py. - - If 'nghdl' is not present, then it shows error message. - """ - print("Function : NGHDL") - self.obj_appconfig.print_info('NGHDL is called') - - if self.obj_validation.validateTool('nghdl'): - self.cmd = 'nghdl -e' - self.obj_workThread = Worker.WorkerThread(self.cmd) - self.obj_workThread.start() - else: - self.msg = QtWidgets.QErrorMessage() - self.msg.setModal(True) - self.msg.setWindowTitle('NGHDL Error') - self.msg.showMessage('Error while opening NGHDL. ' + - 'Please make sure it is installed') - self.obj_appconfig.print_error('Error while opening NGHDL. ' + - 'Please make sure it is installed') - self.msg.exec_() - - def open_makerchip(self): - """ - This function opens 'subcircuit' option in left-tool-bar. - When 'subcircuit' icon is clicked wich is present in - left-tool-bar of main page: - - - Meassge shown on screen "Subcircuit editor is called". - - 'subcircuiteditor()' function is called using object - 'obj_dockarea' of class 'Mainview'. - """ - print("Function : Makerchip and Verilator to Ngspice Converter") - self.obj_appconfig.print_info('Makerchip is called') - self.obj_Mainview.obj_dockarea.makerchip() - - def open_modelEditor(self): - """ - This function opens model editor option in left-tool-bar. - When model editor icon is clicked which is present in - left-tool-bar of main page: - - - Meassge shown on screen "Model editor is called". - - 'modeleditor()' function is called using object - 'obj_dockarea' of class 'Mainview'. - """ - print("Function : Model editor") - self.obj_appconfig.print_info('Model editor is called') - self.obj_Mainview.obj_dockarea.modelEditor() - - def open_OMedit(self): - """ - This function calls ngspice to OMEdit converter and then launch OMEdit. - """ - self.obj_appconfig.print_info('OMEdit is called') - self.projDir = self.obj_appconfig.current_project["ProjectName"] - - if self.projDir is not None: - if self.obj_validation.validateCirOut(self.projDir): - self.projName = os.path.basename(self.projDir) - self.ngspiceNetlist = os.path.join( - self.projDir, self.projName + ".cir.out" - ) - self.modelicaNetlist = os.path.join( - self.projDir, self.projName + ".mo" - ) - self.obj_Mainview.obj_dockarea.modelicaEditor(self.projDir) - - else: - self.msg = QtWidgets.QErrorMessage() - self.msg.setModal(True) - self.msg.setWindowTitle("Missing Ngspice Netlist") - self.msg.showMessage( - 'Current project does not contain any Ngspice file. ' + - 'Please create Ngspice file with extension .cir.out' - ) - self.msg.exec_() - else: - self.msg = QtWidgets.QErrorMessage() - self.msg.setModal(True) - self.msg.setWindowTitle("Error Message") - self.msg.showMessage( - 'Please select the project first. You can either ' + - 'create a new project or open an existing project' - ) - self.msg.exec_() - - def open_OMoptim(self): - """ - This function uses validateTool() method from Validation.py: - - - If 'OMOptim' is present in executables list then - it passes command 'OMOptim' to WorkerThread class of Worker.py - - If 'OMOptim' is not present, then it shows error message with - link to download it on Linux and Windows. - """ - print("Function : OMOptim") - self.obj_appconfig.print_info('OMOptim is called') - # Check if OMOptim is installed - if self.obj_validation.validateTool("OMOptim"): - # Creating a command to run - self.cmd = "OMOptim" - self.obj_workThread = Worker.WorkerThread(self.cmd) - self.obj_workThread.start() - else: - self.msg = QtWidgets.QMessageBox() - self.msgContent = ( - "There was an error while opening OMOptim.
" - "Please make sure OpenModelica is installed in your" - " system.
" - "To install it on Linux : Go to OpenModelica Linux and install nightly build" - " release.
" - "To install it on Windows : Go to OpenModelica Windows and install latest version.
" - ) - self.msg.setTextFormat(QtCore.Qt.RichText) - self.msg.setText(self.msgContent) - self.msg.setWindowTitle("Error Message") - self.obj_appconfig.print_info(self.msgContent) - self.msg.exec_() - - def open_conToeSim(self): - print("Function : Schematics converter") - self.obj_appconfig.print_info('Schematics converter is called') - self.obj_Mainview.obj_dockarea.eSimConverter() - - -# This class initialize the Main View of Application -class MainView(QtWidgets.QWidget): - """ - This class defines whole view and style of main page: - - - Position of tool bars: - - Top tool bar. - - Left tool bar. - - Project explorer Area. - - Dock area. - - Console area. - """ - - def __init__(self, *args): - # call init method of superclass - QtWidgets.QWidget.__init__(self, *args) - - self.obj_appconfig = Appconfig() - - self.leftSplit = QtWidgets.QSplitter() - self.middleSplit = QtWidgets.QSplitter() - - self.mainLayout = QtWidgets.QVBoxLayout() - # Intermediate Widget - self.middleContainer = QtWidgets.QWidget() - self.middleContainerLayout = QtWidgets.QVBoxLayout() - - # Area to be included in MainView - self.noteArea = QtWidgets.QTextEdit() - self.noteArea.setReadOnly(True) - self.obj_appconfig.noteArea['Note'] = self.noteArea - self.obj_appconfig.noteArea['Note'].append( - ' eSim Started......') - self.obj_appconfig.noteArea['Note'].append('Project Selected : None') - self.obj_appconfig.noteArea['Note'].append('\n') - # CSS - self.noteArea.setStyleSheet(" \ - QWidget { border-radius: 15px; border: 1px \ - solid gray; padding: 5px; } \ - ") - - self.obj_dockarea = DockArea.DockArea() - self.obj_projectExplorer = ProjectExplorer.ProjectExplorer() - - # Adding content to vertical middle Split. - self.middleSplit.setOrientation(QtCore.Qt.Vertical) - self.middleSplit.addWidget(self.obj_dockarea) - self.middleSplit.addWidget(self.noteArea) - - # Adding middle split to Middle Container Widget - self.middleContainerLayout.addWidget(self.middleSplit) - self.middleContainer.setLayout(self.middleContainerLayout) - - # Adding content of left split - self.leftSplit.addWidget(self.obj_projectExplorer) - self.leftSplit.addWidget(self.middleContainer) - - # Adding to main Layout - self.mainLayout.addWidget(self.leftSplit) - self.leftSplit.setSizes([int(self.width() / 4.5), self.height()]) - self.middleSplit.setSizes([self.width(), int(self.height() / 2)]) - self.setLayout(self.mainLayout) - - -# It is main function of the module and starts the application -def main(args): - """ - The splash screen opened at the starting of screen is performed - by this function. - """ - print("Starting eSim......") - app = QtWidgets.QApplication(args) - app.setApplicationName("eSim") - - appView = Application() - appView.hide() - - splash_pix = QtGui.QPixmap(init_path + 'images/splash_screen_esim.png') - splash = QtWidgets.QSplashScreen( - appView, splash_pix, QtCore.Qt.WindowStaysOnTopHint - ) - splash.setMask(splash_pix.mask()) - splash.setDisabled(True) - splash.show() - - appView.splash = splash - appView.obj_workspace.returnWhetherClickedOrNot(appView) - - try: - # FIX #11: Both branches of the original if/else did exactly the same - # thing (os.path.expanduser('~')), making the conditional dead code. - # Simplified to a single unconditional assignment. - user_home = os.path.expanduser('~') - file = open(os.path.join(user_home, ".esim/workspace.txt"), 'r') - work = int(file.read(1)) - file.close() - except IOError: - work = 0 - - if work != 0: - appView.obj_workspace.defaultWorkspace() - else: - appView.obj_workspace.show() - - sys.exit(app.exec_()) - - -# Call main function -if __name__ == '__main__': - # Create and display the splash screen - try: - main(sys.argv) - except Exception as err: - print("Error: ", err) \ No newline at end of file +# ========================================================================= +# FILE: Application.py +# +# USAGE: --- +# +# DESCRIPTION: This main file use to start the Application +# +# OPTIONS: --- +# REQUIREMENTS: --- +# BUGS: --- +# NOTES: --- +# AUTHOR: Fahim Khan, fahim.elex@gmail.com +# MAINTAINED: Rahul Paknikar, rahulp@iitb.ac.in +# Sumanto Kar, sumantokar@iitb.ac.in +# Pranav P, pranavsdreams@gmail.com +# ORGANIZATION: eSim Team at FOSSEE, IIT Bombay +# CREATED: Tuesday 24 February 2015 +# REVISION: Wednesday 07 June 2023 +# ========================================================================= + +import os +import sys +import traceback + +current_dir = os.path.dirname(os.path.abspath(__file__)) +if current_dir not in sys.path: + sys.path.insert(0, current_dir) + +# ================= GLOBAL STATE ================= +CHATBOT_AVAILABLE = False + + +try: + if os.name == 'nt': + from frontEnd import pathmagic + else: + import pathmagic + init_path = pathmagic.init_path + print(f"[BOOT] pathmagic imported successfully, init_path='{init_path}'") +except ImportError as e: + print(f"[BOOT WARNING] Could not import pathmagic: {e}") + print("[BOOT WARNING] Using fallback path settings") + import os as _os + _repo_root = _os.path.dirname(_os.path.dirname(_os.path.dirname(_os.path.abspath(__file__)))) + init_path = _repo_root + '/' + + +os.environ['DISABLE_MODEL_SOURCE_CHECK'] = 'True' +print("[BOOT] DISABLE_MODEL_SOURCE_CHECK set to True") + + +from PyQt5 import QtGui, QtCore, QtWidgets +from PyQt5.Qt import QSize +from configuration.Appconfig import Appconfig +from frontEnd import ProjectExplorer +from frontEnd import Workspace +from frontEnd import DockArea +from projManagement.openProject import OpenProjectInfo +from projManagement.newProject import NewProjectInfo +from projManagement.Kicad import Kicad +from projManagement.Validation import Validation +from projManagement import Worker +from PyQt5.QtCore import QTimer + +try: + from frontEnd.Chatbot import ChatbotGUI + CHATBOT_AVAILABLE = True +except ImportError: + CHATBOT_AVAILABLE = False + print("Chatbot module not available. Chatbot features will be disabled.") + + +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.""" + + QtWidgets.QMainWindow.__init__(self, *args) + + # Set slot for simulation end signal + self.simulationEndSignal.connect(self.plotSimulationData) + self.errorDetectedSignal.connect(self.handleError) + + 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.setCentralWidget(self.obj_Mainview) + self.initToolBar() + + self.setGeometry(self.obj_appconfig._app_xpos, + self.obj_appconfig._app_ypos, + self.obj_appconfig._app_width, + self.obj_appconfig._app_heigth) + self.setWindowTitle( + self.obj_appconfig._APPLICATION + "-" + self.obj_appconfig._VERSION + ) + self.showMaximized() + self.setWindowIcon(QtGui.QIcon(init_path + 'images/logo.png')) + + self.systemTrayIcon = QtWidgets.QSystemTrayIcon(self) + self.systemTrayIcon.setIcon(QtGui.QIcon(init_path + 'images/logo.png')) + self.systemTrayIcon.setVisible(True) + + def initChatbot(self): + """Initialize chatbot with proper context.""" + if not CHATBOT_AVAILABLE: + return + + try: + self.chatbot_window = ChatbotGUI(self) + + self.errorDetectedSignal.connect(self.auto_debug_error) + + except Exception as e: + print(f"Failed to initialize chatbot: {e}") + + def auto_debug_error(self, error_message): + """Automatically send simulation errors to chatbot.""" + if not CHATBOT_AVAILABLE or not hasattr(self, 'chatbot_window'): + return + + self.projDir = self.obj_appconfig.current_project["ProjectName"] + if not self.projDir: + return + + # Look for error logs + error_log_path = os.path.join(self.projDir, "ngspice_error.log") + if os.path.exists(error_log_path): + + if (hasattr(self, 'chatbot_window') and + self.chatbot_window.isVisible()): + + QTimer.singleShot(1000, lambda: self.send_error_to_chatbot(error_log_path)) + + def initToolBar(self): + """ + This function initializes Tool Bars. + It setups the icons, short-cuts and defining functonality for: + + - Top-tool-bar (New project, Open project, Close project, \ + Mode switch, Help option) + - Left-tool-bar (Open Schematic, Convert KiCad to Ngspice, \ + Simuation, Model Editor, Subcircuit, NGHDL, Modelica \ + Converter, OM Optimisation) + """ + # Top Tool bar + self.newproj = QtWidgets.QAction( + QtGui.QIcon(init_path + 'images/newProject.png'), + 'New Project', self + ) + self.newproj.setShortcut('Ctrl+N') + self.newproj.triggered.connect(self.new_project) + + self.openproj = QtWidgets.QAction( + QtGui.QIcon(init_path + 'images/openProject.png'), + 'Open Project', self + ) + self.openproj.setShortcut('Ctrl+O') + self.openproj.triggered.connect(self.open_project) + + self.closeproj = QtWidgets.QAction( + QtGui.QIcon(init_path + 'images/closeProject.png'), + 'Close Project', self + ) + self.closeproj.setShortcut('Ctrl+X') + self.closeproj.triggered.connect(self.close_project) + + self.wrkspce = QtWidgets.QAction( + QtGui.QIcon(init_path + 'images/workspace.ico'), + 'Change Workspace', self + ) + self.wrkspce.setShortcut('Ctrl+W') + self.wrkspce.triggered.connect(self.change_workspace) + + self.helpfile = QtWidgets.QAction( + QtGui.QIcon(init_path + 'images/helpProject.png'), + 'Help', self + ) + self.helpfile.setShortcut('Ctrl+H') + self.helpfile.triggered.connect(self.help_project) + + self.topToolbar = self.addToolBar('Top Tool Bar') + self.topToolbar.addAction(self.newproj) + self.topToolbar.addAction(self.openproj) + self.topToolbar.addAction(self.closeproj) + self.topToolbar.addAction(self.wrkspce) + self.topToolbar.addAction(self.helpfile) + + # ## This part is meant for SoC Generation which is currently ## + # ## under development and will be will be required in future. ## + # self.soc = QtWidgets.QToolButton(self) + # self.soc.setText('Generate SoC') + # self.soc.setToolTip( + # 'SPICE to Verilog Conversion
' + \ + # '
The feature is under development.' + \ + # '
It will be released soon.' + \ + # '

Thank you for your patience!!!' + # ) + # self.soc.setStyleSheet(" \ + # QWidget { border-radius: 15px; border: 1px \ + # solid gray; padding: 10px; margin-left: 20px; } \ + # ") + # self.soc.clicked.connect(self.showSoCRelease) + # self.topToolbar.addWidget(self.soc) + + # This part is setting fossee logo to the right + # corner in the application window. + self.spacer = QtWidgets.QWidget() + self.spacer.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding) + self.topToolbar.addWidget(self.spacer) + self.logo = QtWidgets.QLabel() + self.logopic = QtGui.QPixmap( + os.path.join( + os.path.abspath(''), init_path + 'images', 'fosseeLogo.png' + )) + self.logopic = self.logopic.scaled( + QSize(150, 150), QtCore.Qt.KeepAspectRatio) + self.logo.setPixmap(self.logopic) + self.logo.setStyleSheet("padding:0 15px 0 0;") + self.topToolbar.addWidget(self.logo) + + # Left Tool bar Action Widget + self.kicad = QtWidgets.QAction( + QtGui.QIcon(init_path + 'images/kicad.png'), + 'Open Schematic', self + ) + self.kicad.triggered.connect(self.obj_kicad.openSchematic) + + self.conversion = QtWidgets.QAction( + QtGui.QIcon(init_path + 'images/ki-ng.png'), + 'Convert KiCad to Ngspice', self + ) + self.conversion.triggered.connect(self.obj_kicad.openKicadToNgspice) + + self.ngspice = QtWidgets.QAction( + QtGui.QIcon(init_path + 'images/ngspice.png'), + 'Simulate', self + ) + self.ngspice.triggered.connect(self.open_ngspice) + + self.model = QtWidgets.QAction( + QtGui.QIcon(init_path + 'images/model.png'), + 'Model Editor', self + ) + self.model.triggered.connect(self.open_modelEditor) + + self.subcircuit = QtWidgets.QAction( + QtGui.QIcon(init_path + 'images/subckt.png'), + 'Subcircuit', self + ) + self.subcircuit.triggered.connect(self.open_subcircuit) + + self.nghdl = QtWidgets.QAction( + QtGui.QIcon(init_path + 'images/nghdl.png'), 'NGHDL', self + ) + self.nghdl.triggered.connect(self.open_nghdl) + + self.makerchip = QtWidgets.QAction( + QtGui.QIcon(init_path + 'images/makerchip.png'), + 'Makerchip-NgVeri', self + ) + self.makerchip.triggered.connect(self.open_makerchip) + + self.omedit = QtWidgets.QAction( + QtGui.QIcon(init_path + 'images/omedit.png'), + 'Modelica Converter', self + ) + self.omedit.triggered.connect(self.open_OMedit) + + self.omoptim = QtWidgets.QAction( + QtGui.QIcon(init_path + 'images/omoptim.png'), + 'OM Optimisation', self + ) + self.omoptim.triggered.connect(self.open_OMoptim) + + self.conToeSim = QtWidgets.QAction( + QtGui.QIcon(init_path + 'images/icon.png'), + 'Schematics converter', self + ) + self.conToeSim.triggered.connect(self.open_conToeSim) + # ... existing actions ... + + self.copilot_action = QtWidgets.QAction( + QtGui.QIcon(init_path + 'images/chatbot.png'), # Ensure this icon exists or use fallback + 'eSim Copilot', self + ) + self.copilot_action.setToolTip("AI Circuit Assistant") + self.copilot_action.triggered.connect(self.openChatbot) + + # Adding Action Widget to tool bar + self.lefttoolbar = QtWidgets.QToolBar('Left ToolBar') + self.addToolBar(QtCore.Qt.LeftToolBarArea, self.lefttoolbar) + self.lefttoolbar.addAction(self.kicad) + self.lefttoolbar.addAction(self.conversion) + self.lefttoolbar.addAction(self.ngspice) + self.lefttoolbar.addAction(self.model) + self.lefttoolbar.addAction(self.subcircuit) + self.lefttoolbar.addAction(self.makerchip) + self.lefttoolbar.addAction(self.nghdl) + self.lefttoolbar.addAction(self.omedit) + self.lefttoolbar.addAction(self.omoptim) + self.lefttoolbar.addAction(self.conToeSim) + self.lefttoolbar.addSeparator() + self.lefttoolbar.addAction(self.copilot_action) + self.lefttoolbar.setOrientation(QtCore.Qt.Vertical) + self.lefttoolbar.setIconSize(QSize(40, 40)) + + def closeEvent(self, event): + ''' + This function closes the ongoing program (process). + When exit button is pressed a Message box pops out with \ + exit message and buttons 'Yes', 'No'. + + 1. If 'Yes' is pressed: + - check that program (process) in procThread_list \ + (a list made in Appconfig.py): + + - if available it terminates that program. + - if the program (process) is not available, \ + then check it in process_obj (a list made in \ + Appconfig.py) and if found, it closes the program. + + 2. If 'No' is pressed: + - the program just continues as it was doing earlier. + ''' + exit_msg = "Are you sure you want to exit the program?" + exit_msg += " All unsaved data will be lost." + reply = QtWidgets.QMessageBox.question( + self, 'Message', exit_msg, QtWidgets.QMessageBox.Yes, + QtWidgets.QMessageBox.No + ) + + if reply == QtWidgets.QMessageBox.Yes: + for proc in self.obj_appconfig.procThread_list: + try: + proc.terminate() + except BaseException: + pass + try: + for process_object in self.obj_appconfig.process_obj: + try: + process_object.close() + except BaseException: + pass + except BaseException: + pass + + # Check if "Open project" and "New project" window is open. + # If yes, just close it when application is closed. + try: + self.project.close() + except BaseException: + pass + + # Close chatbot if open + if CHATBOT_AVAILABLE and hasattr(self, 'chatbot_window') and self.chatbot_window.isVisible(): + self.chatbot_window.close() + + event.accept() + self.systemTrayIcon.showMessage('Exit', 'eSim is Closed.') + + elif reply == QtWidgets.QMessageBox.No: + event.ignore() + + def new_project(self): + """This function call New Project Info class.""" + text, ok = QtWidgets.QInputDialog.getText( + self, 'New Project Info', 'Enter Project Name:' + ) + updated = False + + if ok: + self.projname = (str(text)) + self.project = NewProjectInfo() + directory, filelist = self.project.createProject(self.projname) + + if directory and filelist: + self.obj_Mainview.obj_projectExplorer.addTreeNode( + directory, filelist + ) + updated = True + + if not updated: + print("No new project created") + self.obj_appconfig.print_info('No new project created') + try: + self.obj_appconfig.print_info( + 'Current project is : ' + + self.obj_appconfig.current_project["ProjectName"] + ) + except BaseException: + pass + + + def openChatbot(self): + if not CHATBOT_AVAILABLE: + QtWidgets.QMessageBox.warning( + self, "Error", + "Chatbot unavailable. Please check backend dependencies." + ) + return + try: + if not hasattr(self, "chatbot_standalone") or self.chatbot_standalone is None: + from frontEnd.Chatbot import ChatbotGUI + self.chatbot_standalone = ChatbotGUI() + self.chatbot_standalone.setWindowTitle("eSim AI Assistant") + g = self.geometry() + self.chatbot_standalone.resize(500, 650) + self.chatbot_standalone.move(g.x() + g.width() - 520, g.y() + 50) + self.chatbot_standalone.show() + self.chatbot_standalone.raise_() + self.chatbot_standalone.activateWindow() + if hasattr(self.chatbot_standalone, "input_field"): + self.chatbot_standalone.input_field.setEnabled(True) + self.chatbot_standalone.input_field.setFocus() + self.chatbot_window = self.chatbot_standalone + except Exception as e: + print("Error opening chatbot:", e) + QtWidgets.QMessageBox.warning( + self, "Error", f"Could not open chatbot: {str(e)}" + ) + def open_project(self): + """This project call Open Project Info class.""" + print("Function : Open Project") + self.project = OpenProjectInfo() + try: + directory, filelist = self.project.body() + self.obj_Mainview.obj_projectExplorer.addTreeNode( + directory, filelist) + except BaseException: + pass + + def close_project(self): + """ + This function closes the saved project. + It first checks whether project (file) is present in list. + + - If present: + - it first kills that process-id. + - closes that file. + - Shows message "Current project is closed" + + - If not present: pass + """ + print("Function : Close Project") + current_project = self.obj_appconfig.current_project['ProjectName'] + if current_project is None: + pass + else: + temp = self.obj_appconfig.current_project['ProjectName'] + for pid in self.obj_appconfig.proc_dict[temp]: + try: + os.kill(pid, 9) + except BaseException: + pass + self.obj_Mainview.obj_dockarea.closeDock() + self.obj_appconfig.current_project['ProjectName'] = None + self.systemTrayIcon.showMessage( + 'Close', 'Current project ' + + os.path.basename(current_project) + ' is Closed.' + ) + + def change_workspace(self): + """ + This function call changes Workspace + """ + print("Function : Change Workspace") + self.obj_workspace.returnWhetherClickedOrNot(self) + self.hide() + self.obj_workspace.show() + + def help_project(self): + """ + This function opens usermanual in dockarea. + - It prints the message ""Function : Help"" + - Uses print_info() method of class Appconfig + from Configuration/Appconfig.py file. + - Call method usermanual() from ./DockArea.py. + """ + print("Function : Help") + self.obj_appconfig.print_info('Help is called') + print("Current Project is : ", self.obj_appconfig.current_project) + self.obj_Mainview.obj_dockarea.usermanual() + + @QtCore.pyqtSlot(QtCore.QProcess.ExitStatus, int) + def plotSimulationData(self, exitCode, exitStatus): + """Enables interaction for new simulation and + displays the plotter dock where graphs can be plotted. + """ + self.ngspice.setEnabled(True) + self.conversion.setEnabled(True) + self.closeproj.setEnabled(True) + self.wrkspce.setEnabled(True) + + if exitStatus == QtCore.QProcess.NormalExit and exitCode == 0: + try: + self.obj_Mainview.obj_dockarea.plottingEditor() + except Exception as e: + self.msg = QtWidgets.QErrorMessage() + self.msg.setModal(True) + self.msg.setWindowTitle("Error Message") + self.msg.showMessage( + 'Data could not be plotted. Please try again.' + ) + self.msg.exec_() + print("Exception Message:", str(e), traceback.format_exc()) + self.obj_appconfig.print_error('Exception Message : ' + + str(e)) + + self.errorDetectedSignal.emit("Simulation failed.") + + def handleError(self): + """Slot called when a simulation error happens.""" + if not CHATBOT_AVAILABLE: + return + + self.projDir = self.obj_appconfig.current_project["ProjectName"] + if not self.projDir: + return + + error_log_path = os.path.join(self.projDir, "ngspice_error.log") + + # Only try to send if chatbot is visible and has debug_error() + if (hasattr(self, 'chatbot_window') and + self.chatbot_window.isVisible() and + hasattr(self.chatbot_window, 'debug_error')): + # Use a small delay to ensure the error log is written + QTimer.singleShot( + 1000, + lambda: self.send_error_to_chatbot(error_log_path) + ) + + def send_error_to_chatbot(self, error_log_path: str): + """Send ngspice error log to chatbot for debugging.""" + try: + if os.path.exists(error_log_path): + with open(error_log_path, 'r') as f: + error_content = f.read() + if error_content.strip(): + self.chatbot_window.debug_error(error_log_path) + except Exception as e: + print(f"Error sending to chatbot: {e}") + + def open_ngspice(self): + """This Function execute ngspice on current project.""" + projDir = self.obj_appconfig.current_project["ProjectName"] + + if projDir is not None: + projName = os.path.basename(projDir) + ngspiceNetlist = os.path.join(projDir, projName + ".cir.out") + + if not os.path.isfile(ngspiceNetlist): + print("Netlist file (*.cir.out) not found.") + self.msg = QtWidgets.QErrorMessage() + self.msg.setModal(True) + self.msg.setWindowTitle("Error Message") + self.msg.showMessage('Netlist (*.cir.out) not found.') + self.msg.exec_() + return + + # Pass chatbot reference into ngspiceEditor + chatbot_ref = ( + self.chatbot_window + if CHATBOT_AVAILABLE and hasattr(self, "chatbot_window") + else None + ) + + self.obj_Mainview.obj_dockarea.ngspiceEditor( + projName, ngspiceNetlist, self.simulationEndSignal, chatbot_ref + ) + + self.ngspice.setEnabled(False) + self.conversion.setEnabled(False) + self.closeproj.setEnabled(False) + self.wrkspce.setEnabled(False) + + else: + self.msg = QtWidgets.QErrorMessage() + self.msg.setModal(True) + self.msg.setWindowTitle("Error Message") + self.msg.showMessage( + 'Please select the project first.' + ' You can either create new project or open existing project' + ) + self.msg.exec_() + + def open_subcircuit(self): + """ + This function opens 'subcircuit' option in left-tool-bar. + When 'subcircuit' icon is clicked wich is present in + left-tool-bar of main page: + + - Meassge shown on screen "Subcircuit editor is called". + - 'subcircuiteditor()' function is called using object + 'obj_dockarea' of class 'Mainview'. + """ + print("Function : Subcircuit editor") + self.obj_appconfig.print_info('Subcircuit editor is called') + self.obj_Mainview.obj_dockarea.subcircuiteditor() + + def open_nghdl(self): + """ + This function calls NGHDL option in left-tool-bar. + It uses validateTool() method from Validation.py: + + - If 'nghdl' is present in executables list then + it passes command 'nghdl -e' to WorkerThread class of + Worker.py. + - If 'nghdl' is not present, then it shows error message. + """ + print("Function : NGHDL") + self.obj_appconfig.print_info('NGHDL is called') + + if self.obj_validation.validateTool('nghdl'): + self.cmd = 'nghdl -e' + self.obj_workThread = Worker.WorkerThread(self.cmd) + self.obj_workThread.start() + else: + self.msg = QtWidgets.QErrorMessage() + self.msg.setModal(True) + self.msg.setWindowTitle('NGHDL Error') + self.msg.showMessage('Error while opening NGHDL. ' + + 'Please make sure it is installed') + self.obj_appconfig.print_error('Error while opening NGHDL. ' + + 'Please make sure it is installed') + self.msg.exec_() + + def open_makerchip(self): + """ + This function opens 'subcircuit' option in left-tool-bar. + When 'subcircuit' icon is clicked wich is present in + left-tool-bar of main page: + + - Meassge shown on screen "Subcircuit editor is called". + - 'subcircuiteditor()' function is called using object + 'obj_dockarea' of class 'Mainview'. + """ + print("Function : Makerchip and Verilator to Ngspice Converter") + self.obj_appconfig.print_info('Makerchip is called') + self.obj_Mainview.obj_dockarea.makerchip() + + def open_modelEditor(self): + """ + This function opens model editor option in left-tool-bar. + When model editor icon is clicked which is present in + left-tool-bar of main page: + + - Meassge shown on screen "Model editor is called". + - 'modeleditor()' function is called using object + 'obj_dockarea' of class 'Mainview'. + """ + print("Function : Model editor") + self.obj_appconfig.print_info('Model editor is called') + self.obj_Mainview.obj_dockarea.modelEditor() + + def open_OMedit(self): + """ + This function calls ngspice to OMEdit converter and then launch OMEdit. + """ + self.obj_appconfig.print_info('OMEdit is called') + self.projDir = self.obj_appconfig.current_project["ProjectName"] + + if self.projDir is not None: + if self.obj_validation.validateCirOut(self.projDir): + self.projName = os.path.basename(self.projDir) + self.ngspiceNetlist = os.path.join( + self.projDir, self.projName + ".cir.out" + ) + self.modelicaNetlist = os.path.join( + self.projDir, self.projName + ".mo" + ) + + """ + try: + # Creating a command for Ngspice to Modelica converter + self.cmd1 = " + python3 ../ngspicetoModelica/NgspicetoModelica.py "\ + + self.ngspiceNetlist + self.obj_workThread1 = Worker.WorkerThread(self.cmd1) + self.obj_workThread1.start() + if self.obj_validation.validateTool("OMEdit"): + # Creating command to run OMEdit + self.cmd2 = "OMEdit "+self.modelicaNetlist + self.obj_workThread2 = Worker.WorkerThread(self.cmd2) + self.obj_workThread2.start() + else: + self.msg = QtWidgets.QMessageBox() + self.msgContent = "There was an error while + opening OMEdit.
\ + Please make sure OpenModelica is installed in your\ + system.
\ + To install it on Linux : Go to\ + OpenModelica Linux and \ + install nigthly build release.
\ + To install it on Windows : Go to\ + OpenModelica Windows\ + and install latest version.
" + self.msg.setTextFormat(QtCore.Qt.RichText) + self.msg.setText(self.msgContent) + self.msg.setWindowTitle("Missing OpenModelica") + self.obj_appconfig.print_info(self.msgContent) + self.msg.exec_() + + except Exception as e: + self.msg = QtWidgets.QErrorMessage() + self.msg.setModal(True) + self.msg.setWindowTitle( + "Ngspice to Modelica conversion error") + self.msg.showMessage( + 'Unable to convert NgSpice netlist to\ + Modelica netlist :'+str(e)) + self.msg.exec_() + self.obj_appconfig.print_error(str(e)) + """ + + self.obj_Mainview.obj_dockarea.modelicaEditor(self.projDir) + + else: + self.msg = QtWidgets.QErrorMessage() + self.msg.setModal(True) + self.msg.setWindowTitle("Missing Ngspice Netlist") + self.msg.showMessage( + 'Current project does not contain any Ngspice file. ' + + 'Please create Ngspice file with extension .cir.out' + ) + self.msg.exec_() + else: + self.msg = QtWidgets.QErrorMessage() + self.msg.setModal(True) + self.msg.setWindowTitle("Error Message") + self.msg.showMessage( + 'Please select the project first. You can either ' + + 'create a new project or open an existing project' + ) + self.msg.exec_() + + def open_OMoptim(self): + """ + This function uses validateTool() method from Validation.py: + + - If 'OMOptim' is present in executables list then + it passes command 'OMOptim' to WorkerThread class of Worker.py + - If 'OMOptim' is not present, then it shows error message with + link to download it on Linux and Windows. + """ + print("Function : OMOptim") + self.obj_appconfig.print_info('OMOptim is called') + # Check if OMOptim is installed + if self.obj_validation.validateTool("OMOptim"): + # Creating a command to run + self.cmd = "OMOptim" + self.obj_workThread = Worker.WorkerThread(self.cmd) + self.obj_workThread.start() + else: + self.msg = QtWidgets.QMessageBox() + self.msgContent = ( + "There was an error while opening OMOptim.
" + "Please make sure OpenModelica is installed in your" + " system.
" + "To install it on Linux : Go to OpenModelica Linux and install nightly build" + " release.
" + "To install it on Windows : Go to OpenModelica Windows and install latest version.
" + ) + self.msg.setTextFormat(QtCore.Qt.RichText) + self.msg.setText(self.msgContent) + self.msg.setWindowTitle("Error Message") + self.obj_appconfig.print_info(self.msgContent) + self.msg.exec_() + + def open_conToeSim(self): + print("Function : Schematics converter") + self.obj_appconfig.print_info('Schematics converter is called') + self.obj_Mainview.obj_dockarea.eSimConverter() + +# This class initialize the Main View of Application + + +class MainView(QtWidgets.QWidget): + """ + This class defines whole view and style of main page: + + - Position of tool bars: + - Top tool bar. + - Left tool bar. + - Project explorer Area. + - Dock area. + - Console area. + """ + + def __init__(self, *args): + + # call init method of superclass + QtWidgets.QWidget.__init__(self, *args) + + self.obj_appconfig = Appconfig() + + self.leftSplit = QtWidgets.QSplitter() + self.middleSplit = QtWidgets.QSplitter() + + self.mainLayout = QtWidgets.QVBoxLayout() + # Intermediate Widget + self.middleContainer = QtWidgets.QWidget() + self.middleContainerLayout = QtWidgets.QVBoxLayout() + + # Area to be included in MainView + self.noteArea = QtWidgets.QTextEdit() + self.noteArea.setReadOnly(True) + self.obj_appconfig.noteArea['Note'] = self.noteArea + self.obj_appconfig.noteArea['Note'].append( + ' eSim Started......') + self.obj_appconfig.noteArea['Note'].append('Project Selected : None') + self.obj_appconfig.noteArea['Note'].append('\n') + # CSS + self.noteArea.setStyleSheet(" \ + QWidget { border-radius: 15px; border: 1px \ + solid gray; padding: 5px; } \ + ") + + self.obj_dockarea = DockArea.DockArea() + self.obj_projectExplorer = ProjectExplorer.ProjectExplorer() + + # Adding content to vertical middle Split. + self.middleSplit.setOrientation(QtCore.Qt.Vertical) + self.middleSplit.addWidget(self.obj_dockarea) + self.middleSplit.addWidget(self.noteArea) + + # Adding middle split to Middle Container Widget + self.middleContainerLayout.addWidget(self.middleSplit) + self.middleContainer.setLayout(self.middleContainerLayout) + + # Adding content of left split + self.leftSplit.addWidget(self.obj_projectExplorer) + self.leftSplit.addWidget(self.middleContainer) + + # Adding to main Layout + self.mainLayout.addWidget(self.leftSplit) + self.leftSplit.setSizes([int(self.width() / 4.5), self.height()]) + self.middleSplit.setSizes([self.width(), int(self.height() / 2)]) + self.setLayout(self.mainLayout) + + +# It is main function of the module and starts the application +def main(args): + """The splash screen opened at the starting of screen is performed by this function.""" + print("Starting eSim......") + + # Set environment variable before creating QApplication to suppress model hoster warnings + os.environ['DISABLE_MODEL_SOURCE_CHECK'] = 'True' + + app = QtWidgets.QApplication(args) + app.setApplicationName("eSim") + + + appView = Application() + appView.hide() + + splash_pix = QtGui.QPixmap(init_path + 'images/splash_screen_esim.png') + splash = QtWidgets.QSplashScreen( + appView, splash_pix, QtCore.Qt.WindowStaysOnTopHint + ) + splash.setMask(splash_pix.mask()) + splash.setDisabled(True) + splash.show() + + appView.splash = splash + appView.obj_workspace.returnWhetherClickedOrNot(appView) + + try: + if os.name == 'nt': + user_home = os.path.join('library', 'config') + else: + user_home = os.path.expanduser('~') + + file = open(os.path.join(user_home, ".esim/workspace.txt"), 'r') + work = int(file.read(1)) + file.close() + except IOError: + work = 0 + + if work != 0: + appView.obj_workspace.defaultWorkspace() + else: + appView.obj_workspace.show() + + sys.exit(app.exec_()) + +# Call main function +if __name__ == '__main__': + # Create and display the splash screen + try: + main(sys.argv) + except Exception as err: + print("Error: ", err) diff --git a/src/frontEnd/Chatbot.py b/src/frontEnd/Chatbot.py index 1a3e75701..cdaee7c0a 100644 --- a/src/frontEnd/Chatbot.py +++ b/src/frontEnd/Chatbot.py @@ -1,2914 +1,1893 @@ -from chatbot.chatbot_thread import ( - OllamaWorker, OllamaVisionWorker, MicWorker, - OllamaStatusWorker, ModelFetchWorker, - detect_topic_switch, get_stt_backend -) -from PyQt5.QtWidgets import ( - QWidget, QHBoxLayout, QTextBrowser, QVBoxLayout, - QLineEdit, QPushButton, QLabel, QComboBox, QApplication, - QFileDialog, QDialog, QListWidget, QListWidgetItem, QFrame, - QScrollArea, QSlider, QInputDialog -) -from PyQt5.QtCore import QTimer, Qt, pyqtSignal, QSize -from PyQt5.QtGui import QTextCursor, QKeyEvent, QDragEnterEvent, QDropEvent -from configuration.Appconfig import Appconfig -from datetime import datetime -import re -import os -import json -import uuid -import base64 - -if os.name == 'nt': - from frontEnd import pathmagic # noqa:F401 - init_path = '' -else: - import pathmagic # noqa:F401 - init_path = '../../' - -# ── Storage paths ───────────────────────────────────────────────────────────── -_ESIM_DIR = os.path.join(os.path.expanduser('~'), '.esim') -_HISTORY_FILE = os.path.join(_ESIM_DIR, 'chatbot_history.json') -_SESSIONS_DIR = os.path.join(_ESIM_DIR, 'chat_sessions') - -_IMG_FILTER = "Images (*.png *.jpg *.jpeg *.bmp *.gif *.tiff)" -_IMAGE_EXTS = {'.png', '.jpg', '.jpeg', '.bmp', '.gif', '.tiff', '.tif', '.webp'} -# NgSpice logs can be 10-50 KB; sending all of it blows past num_ctx: 2048. -# 60 lines is enough for any meaningful error message while staying well inside -# the context window even with history prepended. -_MAX_ERROR_LOG_LINES = 60 -# _save_history() is called after every bot response; without debouncing this -# causes synchronous I/O on the main thread on every message. -_SAVE_DEBOUNCE_MS = 5000 - -WELCOME_MESSAGE = """ -
-
🤖
-
- eSim AI Assistant -
-
- Ask me anything about KiCad, NgSpice,
- netlists, simulation errors, or circuit design.
- Attach an image 📎 or speak 🎤 your question. -
-
- Use the sidebar to access past chats -
-
-
-""" - -_TYPING_FRAMES = [ - '●  ', - ' ● ', - '  ●', -] - - -def _typing_bubble(frame=0): - dots = _TYPING_FRAMES[frame % 3] - return ( - '' - '' - '
' - '' - '
' - '
' - f'{dots}
' - ) - - -# ── Markdown renderer ───────────────────────────────────────────────────────── - -def _render_inline(text): - """ - Renders inline markdown: **bold**, *italic*, `code`, # headings, and [links](url). - """ - # Escape HTML special chars first so subsequent substitutions are safe - text = text.replace('&', '&').replace('<', '<').replace('>', '>') - - # Headings (must be processed line-by-line because they are block-level) - def _render_headings(t): - lines = t.split('\n') - out = [] - for line in lines: - m = re.match(r'^(#{1,4})\s+(.*)', line) - if m: - level = len(m.group(1)) - sizes = {1: '18px', 2: '16px', 3: '14px', 4: '13px'} - size = sizes.get(level, '13px') - content = m.group(2) - out.append( - f'{content}' - ) - else: - out.append(line) - return '\n'.join(out) - - text = _render_headings(text) - - # Bold (**text** or __text__) - text = re.sub(r'\*\*(.*?)\*\*', r'\1', text) - text = re.sub(r'__(.*?)__', r'\1', text) - - # Italic (*text* or _text_) — processed after bold so ** is already gone - text = re.sub(r'\*(.*?)\*', r'\1', text) - text = re.sub(r'_(.*?)_', r'\1', text) - - # Inline code (`code`) - text = re.sub( - r'`([^`]+)`', - r'\1', - text - ) - - # Markdown links [text](url) - text = re.sub( - r'\[([^\]]+)\]\((https?://[^\)]+)\)', - r'\1', - text - ) - - text = text.replace('\n', '
') - return text - - -def _render_markdown(text): - result = [] - pattern = re.compile(r'```(\w*)\n?(.*?)```', re.DOTALL) - last_end = 0 - - for match in pattern.finditer(text): - before = text[last_end:match.start()] - if before: - result.append(_render_inline(before)) - - lang = match.group(1) or 'code' - code = ( - match.group(2) - .replace('&', '&') - .replace('<', '<') - .replace('>', '>') - .replace('\n', '
') - .replace(' ', ' ') - ) - label = f'{lang}
' if lang else '' - result.append( - '' - '
' - '
' - f'{label}{code}
' - ) - last_end = match.end() - - tail = text[last_end:] - if tail: - result.append(_render_inline(tail)) - return ''.join(result) - - -# ── Bubble helpers ──────────────────────────────────────────────────────────── - -def _get_time(): - return datetime.now().strftime("%H:%M") - - -def _escape_text_preserve_breaks(text: str) -> str: - return ( - text.replace('&', '&') - .replace('<', '<') - .replace('>', '>') - .replace('\n', '
') - ) - - -def _image_thumbnail_html(b64_str: str, filename: str) -> str: - """Render a saved image as an inline base64 thumbnail in the chat.""" - safe_name = filename.replace('&', '&').replace('<', '<') - return ( - '' - '' - '
' - '' - '
' - f'' - f'
{safe_name}
' - '
' - '
' - ) - - -def _user_bubble(text, timestamp): - safe = _escape_text_preserve_breaks(text) - return ( - '' - '' - '
' - '' - '' - f'' - '
' - f'{safe}' - '
You  ·  {timestamp}
' - '
' - ) - - -def _approx_token_count(text: str) -> int: - return max(1, len(text) // 4) - - -def _bot_bubble(text, timestamp, response_idx): - rendered = _render_markdown(text) - copy_href = f'copy:///{response_idx}' - retry_href = f'retry:///{response_idx}' - token_est = _approx_token_count(text) - - return ( - '' - '' - '
' - '' - '' - '
' - f'{rendered}' - '
' - '' - f'' - f'' - '
' - f'eSim AI  ·  {timestamp}  ·  ~{token_est} tokens' - f'↻ Retry' - f'  ' - f'Copy
' - '
' - '
' - ) - - -def _bot_bubble_simple(text, timestamp): - rendered = _render_markdown(text) - return ( - '' - '' - '
' - '' - '' - f'' - '
' - f'{rendered}' - '
' - f'eSim AI  ·  {timestamp}
' - '
' - ) - - -def _system_bubble(text): - return ( - '' - '
' - '
' - f'{_escape_text_preserve_breaks(text)}
' - ) - - -def _staged_images_bubble(filenames, timestamp): - names_html = "".join( - f'' - f'📎 {_escape_text_preserve_breaks(n)} ' - for n in filenames - ) - return ( - '' - '' - '
' - '' - '' - f'' - '
' - f'{names_html}' - '
You  ·  {timestamp}
' - ) - - -def _topic_reset_banner(): - return ( - '' - '
' - '— New topic —' - '
' - ) - - -def _netlist_header_bubble(filename, timestamp): - safe = _escape_text_preserve_breaks(filename) - return ( - '' - '' - '
' - '' - '' - f'' - '
' - '
' - f'📄 Netlist: {safe}
' - '
You  ·  {timestamp}
' - ) - - -def _parse_custom_url(url): - scheme = url.scheme() - host = url.host() - path = url.path().strip('/') - - parts = [] - if host: - parts.append(host) - if path: - parts.extend([p for p in path.split('/') if p]) - - return scheme, parts - - -def _session_kind_badge(kind: str) -> str: - colors = { - "text": ("#eef4ff", "#2d6cdf"), - "image": ("#eefaf0", "#1f8b4c"), - "netlist": ("#fff4e8", "#b86a00"), - "simulation_error": ("#fff0f0", "#c62828"), - } - bg, fg = colors.get(kind, ("#f0f0f0", "#666")) - label = { - "text": "Text", - "image": "Image", - "netlist": "Netlist", - "simulation_error": "Sim Error", - }.get(kind, kind.title()) - return ( - f'{label}' - ) - - -def _is_image_file(path: str) -> bool: - return os.path.splitext(path)[1].lower() in _IMAGE_EXTS - - -# ── Smart input field ───────────────────────────────────────────────────────── - -class _HistoryLineEdit(QLineEdit): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._sent_history = [] - self._hist_idx = -1 - - def add_to_history(self, text): - if text and (not self._sent_history or self._sent_history[-1] != text): - self._sent_history.append(text) - self._hist_idx = -1 - - def keyPressEvent(self, event: QKeyEvent): - if event.key() == Qt.Key_Up and self._sent_history: - if self._hist_idx == -1: - self._draft = self.text() - self._hist_idx = len(self._sent_history) - 1 - elif self._hist_idx > 0: - self._hist_idx -= 1 - self.setText(self._sent_history[self._hist_idx]) - self.end(False) - elif event.key() == Qt.Key_Down and self._hist_idx >= 0: - self._hist_idx += 1 - if self._hist_idx >= len(self._sent_history): - self._hist_idx = -1 - self.setText(getattr(self, '_draft', '')) - else: - self.setText(self._sent_history[self._hist_idx]) - self.end(False) - else: - super().keyPressEvent(event) - - -# ── History viewer ──────────────────────────────────────────────────────────── - -class ChatHistoryViewer(QDialog): - def __init__(self, session: dict, parent=None): - super().__init__(parent) - raw_title = session.get('title', 'Chat') - self.setWindowTitle(raw_title[:60]) - self.setMinimumSize(460, 560) - self.resize(500, 640) - self.setStyleSheet("QDialog { background:#f9f9f9; }") - - msgs = session.get('messages', []) - n_usr = sum(1 for m in msgs if m.startswith("User:")) - n_bot = sum(1 for m in msgs if m.startswith("Bot:")) - created = session.get('created_at', '') - updated = session.get('updated_at', '') - kind = session.get('kind', 'text') - - root = QVBoxLayout(self) - root.setContentsMargins(0, 0, 0, 0) - root.setSpacing(0) - - top_bar = QWidget() - top_bar.setFixedHeight(54) - top_bar.setStyleSheet(""" - QWidget { - background:#ffffff; - border-bottom:1px solid #ececec; - } - """) - top_layout = QHBoxLayout(top_bar) - top_layout.setContentsMargins(16, 0, 12, 0) - top_layout.setSpacing(10) - - title_lbl = QLabel(raw_title[:50] + ("…" if len(raw_title) > 50 else "")) - title_lbl.setStyleSheet( - "font-size:14px; font-weight:700; color:#1a1a2e; background:transparent;" - ) - top_layout.addWidget(title_lbl, 1) - - meta_lbl = QLabel(f"{n_usr} msg{'s' if n_usr!=1 else ''} · {updated[:10]} · {kind}") - meta_lbl.setStyleSheet("font-size:10px; color:#aaa; background:transparent;") - top_layout.addWidget(meta_lbl) - - x_btn = QPushButton("✕") - x_btn.setFixedSize(26, 26) - x_btn.setStyleSheet(""" - QPushButton { - font-size:11px; color:#888; - background:transparent; border:none; border-radius:13px; - } - QPushButton:hover { background:#f0f0f0; color:#333; } - QPushButton:pressed{ background:#e0e0e0; } - """) - x_btn.clicked.connect(self.accept) - top_layout.addWidget(x_btn) - root.addWidget(top_bar) - - browser = QTextBrowser() - browser.setOpenLinks(False) - browser.setStyleSheet(""" - QTextBrowser { - background:#f9f9f9; - border:none; - padding:10px 4px; - font-family:'Segoe UI',Arial,sans-serif; - font-size:13px; - } - """) - - html = "" - for line in msgs: - if line.startswith("User:"): - html += _user_bubble(line[5:].strip(), "") - elif line.startswith("Bot:"): - html += _bot_bubble_simple(line[4:].strip(), "") - browser.setHtml(html if html else "

No messages

") - QTimer.singleShot(120, lambda: browser.verticalScrollBar().setValue(browser.verticalScrollBar().maximum())) - root.addWidget(browser) - - bot_bar = QWidget() - bot_bar.setFixedHeight(52) - bot_bar.setStyleSheet(""" - QWidget { - background:#ffffff; - border-top:1px solid #ececec; - } - """) - bot_layout = QHBoxLayout(bot_bar) - bot_layout.setContentsMargins(16, 0, 16, 0) - - info_lbl = QLabel(f"Created {created[:10]} · {n_usr + n_bot} total messages") - info_lbl.setStyleSheet("font-size:10px; color:#bbb; background:transparent;") - bot_layout.addWidget(info_lbl) - bot_layout.addStretch() - - done_btn = QPushButton("Done") - done_btn.setFixedHeight(32) - done_btn.setStyleSheet(""" - QPushButton { - font-size:12px; font-weight:600; - padding:4px 22px; - background:#0095f6; color:white; - border:none; border-radius:16px; - } - QPushButton:hover { background:#0082d8; } - QPushButton:pressed{ background:#006ab8; } - """) - done_btn.clicked.connect(self.accept) - bot_layout.addWidget(done_btn) - root.addWidget(bot_bar) - - -# ── Sidebar ─────────────────────────────────────────────────────────────────── - -class _DeleteConfirmDialog(QDialog): - def __init__(self, title: str, parent=None): - super().__init__(parent, Qt.FramelessWindowHint | Qt.Dialog) - self.setAttribute(Qt.WA_TranslucentBackground) - self.setMinimumWidth(320) - - outer = QWidget(self) - outer.setObjectName("card") - outer.setStyleSheet(""" - QWidget#card { - background: #ffffff; - border-radius: 20px; - border: 1px solid #e0e0e0; - } - """) - - card_layout = QVBoxLayout(outer) - card_layout.setContentsMargins(28, 24, 28, 20) - card_layout.setSpacing(14) - - title_lbl = QLabel("Delete chat?") - title_lbl.setStyleSheet("font-size:16px; font-weight:bold; color:#1a1a2e;") - title_lbl.setAlignment(Qt.AlignCenter) - card_layout.addWidget(title_lbl) - - body_lbl = QLabel( - f'' - f'Delete “{_escape_text_preserve_breaks(title[:40])}”?
' - f'This cannot be undone.
' - ) - body_lbl.setWordWrap(True) - body_lbl.setAlignment(Qt.AlignCenter) - card_layout.addWidget(body_lbl) - - div = QFrame() - div.setFrameShape(QFrame.HLine) - div.setStyleSheet("color:#f0f0f0;") - card_layout.addWidget(div) - - btn_row = QHBoxLayout() - btn_row.setSpacing(10) - - cancel_btn = QPushButton("Cancel") - cancel_btn.setFixedHeight(40) - cancel_btn.setStyleSheet(""" - QPushButton { - font-size:13px; font-weight:600; - background:#f0f0f0; color:#333; - border:none; border-radius:20px; padding:0 24px; - } - QPushButton:hover { background:#e0e0e0; } - QPushButton:pressed{ background:#d0d0d0; } - """) - cancel_btn.clicked.connect(self.reject) - - delete_btn = QPushButton("Delete") - delete_btn.setFixedHeight(40) - delete_btn.setStyleSheet(""" - QPushButton { - font-size:13px; font-weight:600; - background:#ff3b30; color:white; - border:none; border-radius:20px; padding:0 24px; - } - QPushButton:hover { background:#e0302a; } - QPushButton:pressed{ background:#c02520; } - """) - delete_btn.clicked.connect(self.accept) - - btn_row.addWidget(cancel_btn) - btn_row.addWidget(delete_btn) - card_layout.addLayout(btn_row) - - main = QVBoxLayout(self) - main.setContentsMargins(0, 0, 0, 0) - main.addWidget(outer) - - -class _SessionItemWidget(QWidget): - delete_requested = pyqtSignal(str) - rename_requested = pyqtSignal(str) - - def __init__(self, session_id: str, title: str, date: str, - msg_count: int = 0, preview: str = "", kind: str = "text", parent=None): - super().__init__(parent) - self.session_id = session_id - self.title = title - self.kind = kind - - self.setMinimumHeight(78) - self.setStyleSheet("QWidget { background: transparent; }") - - outer = QHBoxLayout(self) - outer.setContentsMargins(10, 8, 8, 8) - outer.setSpacing(10) - - avatar = QLabel(title[0].upper() if title else "C") - avatar.setFixedSize(38, 38) - avatar.setAlignment(Qt.AlignCenter) - avatar.setStyleSheet(""" - QLabel { - background: qlineargradient( - x1:0, y1:0, x2:1, y2:1, - stop:0 #0095f6, stop:1 #0055a5 - ); - color: white; - font-size: 14px; - font-weight: 700; - border-radius: 19px; - } - """) - outer.addWidget(avatar) - - text_col = QVBoxLayout() - text_col.setSpacing(3) - text_col.setContentsMargins(0, 0, 0, 0) - - title_row = QHBoxLayout() - title_row.setSpacing(4) - title_row.setContentsMargins(0, 0, 0, 0) - - title_lbl = QLabel(title[:22] + ("…" if len(title) > 22 else "")) - title_lbl.setStyleSheet( - "font-size:12px; font-weight:700; color:#1a1a2e; background:transparent;" - ) - title_row.addWidget(title_lbl, 1) - - date_lbl = QLabel(date) - date_lbl.setStyleSheet("font-size:10px; color:#bbb; background:transparent;") - title_row.addWidget(date_lbl) - text_col.addLayout(title_row) - - meta_row = QHBoxLayout() - meta_row.setSpacing(4) - meta_row.setContentsMargins(0, 0, 0, 0) - - kind_lbl = QLabel() - kind_lbl.setText(_session_kind_badge(kind)) - kind_lbl.setTextFormat(Qt.RichText) - kind_lbl.setStyleSheet("background:transparent;") - meta_row.addWidget(kind_lbl) - - if msg_count > 0: - count_lbl = QLabel(str(msg_count)) - count_lbl.setFixedSize(20, 16) - count_lbl.setAlignment(Qt.AlignCenter) - count_lbl.setStyleSheet(""" - QLabel { - background:#0095f6; color:white; - font-size:9px; font-weight:700; - border-radius:8px; - } - """) - meta_row.addWidget(count_lbl) - meta_row.addStretch() - text_col.addLayout(meta_row) - - preview_text = (preview[:32] + "…") if len(preview) > 32 else preview - preview_lbl = QLabel(preview_text if preview_text else "No messages yet") - preview_lbl.setStyleSheet("font-size:11px; color:#888; background:transparent;") - text_col.addWidget(preview_lbl) - - outer.addLayout(text_col, 1) - - btn_col = QVBoxLayout() - btn_col.setSpacing(4) - btn_col.setContentsMargins(0, 0, 0, 0) - - self._rename_btn = QPushButton("✎") - self._rename_btn.setFixedSize(28, 28) - self._rename_btn.setToolTip("Rename this chat") - self._rename_btn.setStyleSheet(""" - QPushButton { - font-size:12px; - background:#f2f7ff; - color:#0055a5; - border:1px solid #d0e0ff; - border-radius:14px; - } - QPushButton:hover { background:#e6f0ff; } - """) - self._rename_btn.clicked.connect(lambda: self.rename_requested.emit(self.session_id)) - btn_col.addWidget(self._rename_btn) - - self._del_btn = QPushButton("🗑") - self._del_btn.setFixedSize(28, 28) - self._del_btn.setToolTip("Delete this chat") - self._del_btn.setStyleSheet(""" - QPushButton { - font-size:12px; - background:#fff0f0; - color:#cc0000; - border:1px solid #ffd0d0; - border-radius:14px; - } - QPushButton:hover { background:#ffe0e0; border:1px solid #ffb0b0; } - QPushButton:pressed{ background:#ffc8c8; } - """) - self._del_btn.clicked.connect(self._on_delete_clicked) - btn_col.addWidget(self._del_btn) - - btn_col.addStretch() - outer.addLayout(btn_col) - - def sizeHint(self): - return QSize(252, 78) - - def _on_delete_clicked(self): - dlg = _DeleteConfirmDialog(self.title, self) - if dlg.exec_() == QDialog.Accepted: - self.delete_requested.emit(self.session_id) - - -class ChatSidebar(QWidget): - new_chat_requested = pyqtSignal() - session_deleted = pyqtSignal(str) - delete_all_requested = pyqtSignal() - rename_requested = pyqtSignal(str) - - def __init__(self, parent=None): - super().__init__(parent) - self.setFixedWidth(290) - self._all_sessions_cache = [] - - self.setStyleSheet(""" - QWidget { - background:#ffffff; - border-right:1px solid #ececec; - } - """) - - root = QVBoxLayout(self) - root.setContentsMargins(0, 0, 0, 0) - root.setSpacing(0) - - top = QWidget() - top.setFixedHeight(52) - top.setStyleSheet(""" - QWidget { - background:#ffffff; - border-bottom:1px solid #f0f0f0; - } - """) - top_row = QHBoxLayout(top) - top_row.setContentsMargins(14, 0, 10, 0) - top_row.setSpacing(8) - - title_lbl = QLabel("Chats") - title_lbl.setStyleSheet( - "font-size:16px; font-weight:700; color:#1a1a2e; background:transparent;" - ) - top_row.addWidget(title_lbl, 1) - - close_btn = QPushButton("✕") - close_btn.setFixedSize(26, 26) - close_btn.setStyleSheet(""" - QPushButton { - font-size:11px; color:#888; - background:transparent; border:none; border-radius:13px; - } - QPushButton:hover { background:#f0f0f0; color:#333; } - QPushButton:pressed{ background:#e0e0e0; } - """) - close_btn.clicked.connect(self.hide) - top_row.addWidget(close_btn) - root.addWidget(top) - - controls = QWidget() - controls_layout = QVBoxLayout(controls) - controls_layout.setContentsMargins(12, 8, 12, 8) - controls_layout.setSpacing(8) - - self.new_btn = QPushButton("+ New Chat") - self.new_btn.setFixedHeight(36) - self.new_btn.setStyleSheet(""" - QPushButton { - font-size:12px; font-weight:600; - background:#0095f6; color:white; - border:none; border-radius:18px; - } - QPushButton:hover { background:#0082d8; } - QPushButton:pressed{ background:#006ab8; } - """) - self.new_btn.clicked.connect(self.new_chat_requested) - controls_layout.addWidget(self.new_btn) - - self.search_input = QLineEdit() - self.search_input.setPlaceholderText("Search chats…") - self.search_input.setFixedHeight(34) - self.search_input.setStyleSheet(""" - QLineEdit { - font-size:12px; - padding:7px 12px; - border:1px solid #e0e0e0; - border-radius:17px; - background:#f7f7f7; - color:#1a1a2e; - } - QLineEdit:focus { - border:1px solid #0095f6; - background:#ffffff; - } - """) - self.search_input.textChanged.connect(self._apply_filter) - controls_layout.addWidget(self.search_input) - - delete_all_btn = QPushButton("Delete All Chats") - delete_all_btn.setFixedHeight(30) - delete_all_btn.setStyleSheet(""" - QPushButton { - font-size:11px; - font-weight:600; - background:#fff0f0; - color:#cc0000; - border:1px solid #ffd0d0; - border-radius:15px; - } - QPushButton:hover { background:#ffe0e0; } - QPushButton:pressed{ background:#ffc8c8; } - """) - delete_all_btn.clicked.connect(self.delete_all_requested) - controls_layout.addWidget(delete_all_btn) - - root.addWidget(controls) - - sep = QFrame() - sep.setFrameShape(QFrame.HLine) - sep.setFixedHeight(1) - sep.setStyleSheet("QFrame { background:#f0f0f0; border:none; }") - root.addWidget(sep) - - self.session_list = QListWidget() - self.session_list.setSpacing(2) - self.session_list.setStyleSheet(""" - QListWidget { - background:#ffffff; - border:none; - outline:0; - padding:6px; - } - QListWidget::item { - border:none; - padding:0; - margin:0; - } - QListWidget::item:hover { background:#f5f8ff; } - QListWidget::item:selected { background:#eaf3ff; } - """) - root.addWidget(self.session_list) - - self._empty_lbl = QLabel("No saved chats yet.\nStart a conversation!") - self._empty_lbl.setAlignment(Qt.AlignCenter) - self._empty_lbl.setStyleSheet(""" - QLabel { - color:#ccc; font-size:12px; - padding:30px 10px; - background:transparent; - } - """) - self._empty_lbl.setWordWrap(True) - self._empty_lbl.hide() - root.addWidget(self._empty_lbl) - - def populate(self): - self._all_sessions_cache = [] - self.session_list.clear() - - if not os.path.exists(_SESSIONS_DIR): - self._empty_lbl.show() - return - - for fname in os.listdir(_SESSIONS_DIR): - if not fname.endswith('.json'): - continue - try: - with open(os.path.join(_SESSIONS_DIR, fname), encoding='utf-8') as f: - s = json.load(f) - self._all_sessions_cache.append(s) - except Exception: - pass - - self._all_sessions_cache.sort(key=lambda s: s.get('updated_at', ''), reverse=True) - self._apply_filter() - - def _apply_filter(self): - self.session_list.clear() - query = self.search_input.text().strip().lower() - - filtered = [] - for s in self._all_sessions_cache: - title = s.get('title', 'Chat') - msgs = s.get('messages', []) - preview = next((m[5:].strip() for m in msgs if m.startswith("User:")), "") - kind = s.get('kind', 'text') - haystack = f"{title} {preview} {kind}".lower() - if not query or query in haystack: - filtered.append(s) - - if not filtered: - self._empty_lbl.show() - return - - self._empty_lbl.hide() - - for s in filtered: - sid = s['id'] - title = s.get('title', 'Chat') - date = s.get('updated_at', '')[:10] - msgs = s.get('messages', []) - msg_count = sum(1 for m in msgs if m.startswith("User:")) - preview = next((m[5:].strip() for m in msgs if m.startswith("User:")), "") - kind = s.get('kind', 'text') - - item = QListWidgetItem() - item.setData(Qt.UserRole, sid) - widget = _SessionItemWidget(sid, title, date, msg_count, preview, kind, self.session_list) - widget.delete_requested.connect(self._delete_session) - widget.rename_requested.connect(self.rename_requested) - - item.setSizeHint(widget.sizeHint()) - self.session_list.addItem(item) - self.session_list.setItemWidget(item, widget) - - def upsert_session(self, session: dict): - """ - Insert or update a session entry in the sidebar immediately, - without reading from disk. Called as soon as the first bot reply - arrives so the chat appears in the sidebar right away instead of - waiting for the debounced disk save to complete. - """ - sid = session.get('id') - if not sid: - return - - # Update existing entry in the cache if present, otherwise prepend it. - for i, s in enumerate(self._all_sessions_cache): - if s.get('id') == sid: - self._all_sessions_cache[i] = session - break - else: - self._all_sessions_cache.insert(0, session) - - # Re-sort so the newest session stays at the top. - self._all_sessions_cache.sort( - key=lambda s: s.get('updated_at', ''), reverse=True - ) - self._apply_filter() - - def _delete_session(self, session_id: str): - path = os.path.join(_SESSIONS_DIR, f"{session_id}.json") - try: - if os.path.exists(path): - os.remove(path) - except Exception: - pass - self.session_deleted.emit(session_id) - self.populate() - - -# ── Main Chatbot GUI ────────────────────────────────────────────────────────── - -class ChatbotGUI(QWidget): - # Emitted from _suspend_worker's background callback to safely update - # the sidebar from the main thread after a background save completes. - _background_session_saved = pyqtSignal(dict) - - def __init__(self): - super().__init__() - self.setWindowTitle("eSim AI Assistant") - self.setMinimumSize(420, 340) - self.resize(430, 350) - self.setAcceptDrops(True) - - self.chat_history = [] - self._retry_history = [] - self._bot_responses = {} - self._response_counter = 0 - self._last_user_text = "" - self._typing_frame = 0 - self._typing_start_pos = -1 - self._was_ollama_offline = True - self._mic_active = False - self._viewing_past_session = False - self._staged_images = [] - self._temperature = 0.35 - self._num_predict = 1024 - self._current_session_id = str(uuid.uuid4()) - self._session_created_at = datetime.now().strftime("%Y-%m-%d %H:%M") - self._current_session_kind = "text" - self._session_title_override = None - self._is_generating = False - self._images_store = {} # key -> [base64_str, ...] for image replay - self._last_image_paths = [] # image paths from last vision send (for follow-ups) - # batched rather than firing synchronously after every bot response. - self._save_pending = False - self._save_debounce_timer = QTimer(self) - self._save_debounce_timer.setSingleShot(True) - self._save_debounce_timer.timeout.connect(self._flush_save) - - self._thinking_timer = QTimer(self) - self._thinking_timer.timeout.connect(self._animate_thinking) - - self._typing_anim_timer = QTimer(self) - self._typing_anim_timer.timeout.connect(self._animate_typing_bubble) - - self._status_poll_timer = QTimer(self) - self._status_poll_timer.timeout.connect(self._update_ollama_status) - self._status_poll_timer.start(5000) - - self._toast = QLabel(" ✅ Copied! ", self) - self._toast.setStyleSheet(""" - QLabel { - background-color:#1a1a2e; color:#ffffff; - font-size:12px; font-weight:bold; - border-radius:14px; padding:4px 14px; - } - """) - self._toast.setAlignment(Qt.AlignCenter) - self._toast.hide() - - root = QHBoxLayout(self) - root.setContentsMargins(0, 0, 0, 0) - root.setSpacing(0) - - self._sidebar = ChatSidebar(self) - self._sidebar.new_chat_requested.connect(self._new_chat) - self._sidebar.session_deleted.connect(self._on_session_deleted) - self._sidebar.delete_all_requested.connect(self._delete_all_chats) - self._sidebar.rename_requested.connect(self._rename_session_by_id) - self._sidebar.session_list.itemClicked.connect(self._on_session_clicked) - self._sidebar.session_list.itemDoubleClicked.connect(self._open_session_viewer) - self._sidebar.hide() - root.addWidget(self._sidebar) - # Route background-thread session saves through a signal so the - # sidebar upsert always runs on the main thread (Qt requirement). - self._background_session_saved.connect(self._sidebar_upsert_from_signal) - - chat_container = QWidget() - chat_layout = QVBoxLayout(chat_container) - chat_layout.setContentsMargins(8, 8, 8, 8) - chat_layout.setSpacing(5) - root.addWidget(chat_container, 1) - - header_layout = QHBoxLayout() - header_layout.setSpacing(5) - - self._history_btn = QPushButton("≡") - self._history_btn.setFixedSize(32, 32) - self._history_btn.setToolTip("Chat history") - self._history_btn.setStyleSheet(""" - QPushButton { - font-size:16px; border:none; - border-radius:8px; background:transparent; color:#555; - } - QPushButton:hover { background:#f0f0f0; color:#1a1a2e; } - QPushButton:pressed { background:#e0e0e0; } - """) - self._history_btn.clicked.connect(self._toggle_sidebar) - header_layout.addWidget(self._history_btn) - - self.model_combo = QComboBox(self) - self.model_combo.setFixedHeight(30) - self.model_combo.setStyleSheet(""" - QComboBox { - font-size:12px; padding:2px 10px; - border:1px solid #e0e0e0; border-radius:8px; - background:#f7f7f7; color:#1a1a2e; - } - QComboBox:focus { border:1px solid #0095f6; background:#fff; } - QComboBox::drop-down { border:none; width:18px; } - """) - self._populate_models() - header_layout.addWidget(self.model_combo) - - self._refresh_models_btn = QPushButton("↻") - self._refresh_models_btn.setFixedSize(28, 28) - self._refresh_models_btn.setToolTip("Refresh available models") - self._refresh_models_btn.setStyleSheet(""" - QPushButton { - font-size:14px; border:none; - border-radius:8px; background:transparent; color:#555; - } - QPushButton:hover { background:#f0f0f0; color:#1a1a2e; } - """) - self._refresh_models_btn.clicked.connect(self._populate_models) - header_layout.addWidget(self._refresh_models_btn) - - self._rename_btn = QPushButton("✎") - self._rename_btn.setFixedSize(28, 28) - self._rename_btn.setToolTip("Rename current chat") - self._rename_btn.setStyleSheet(""" - QPushButton { - font-size:13px; border:none; - border-radius:8px; background:transparent; color:#555; - } - QPushButton:hover { background:#f0f0f0; color:#1a1a2e; } - """) - self._rename_btn.clicked.connect(self._rename_current_chat) - header_layout.addWidget(self._rename_btn) - - self._settings_btn = QPushButton("⚙") - self._settings_btn.setFixedSize(28, 28) - self._settings_btn.setToolTip("Model settings") - self._settings_btn.setCheckable(True) - self._settings_btn.setStyleSheet(""" - QPushButton { - font-size:14px; border:none; - border-radius:8px; background:transparent; color:#555; - } - QPushButton:hover { background:#f0f0f0; color:#1a1a2e; } - QPushButton:checked { background:#e8f0ff; color:#0095f6; } - """) - header_layout.addWidget(self._settings_btn) - - self._export_btn = QPushButton("⤓") - self._export_btn.setFixedSize(28, 28) - self._export_btn.setToolTip("Export current chat") - self._export_btn.setStyleSheet(""" - QPushButton { - font-size:13px; border:none; - border-radius:8px; background:transparent; color:#555; - } - QPushButton:hover { background:#f0f0f0; color:#1a1a2e; } - """) - self._export_btn.clicked.connect(self._export_current_chat) - header_layout.addWidget(self._export_btn) - - self._regen_btn = QPushButton("⟳") - self._regen_btn.setFixedSize(28, 28) - self._regen_btn.setToolTip("Regenerate last response") - self._regen_btn.setStyleSheet(""" - QPushButton { - font-size:13px; border:none; - border-radius:8px; background:transparent; color:#555; - } - QPushButton:hover { background:#f0f0f0; color:#1a1a2e; } - """) - self._regen_btn.clicked.connect(self._regenerate_last_response) - header_layout.addWidget(self._regen_btn) - - header_layout.addStretch() - - self.ollama_status_label = QLabel(self) - self.ollama_status_label.setFixedHeight(24) - header_layout.addWidget(self.ollama_status_label) - self._update_ollama_status() - - header_sep = QFrame() - header_sep.setFrameShape(QFrame.HLine) - header_sep.setStyleSheet("color:#ececec; margin:0;") - chat_layout.addLayout(header_layout) - chat_layout.addWidget(header_sep) - - self.chat_display = QTextBrowser(self) - self.chat_display.setOpenLinks(False) - self.chat_display.setOpenExternalLinks(False) - self.chat_display.setHtml(WELCOME_MESSAGE) - self.chat_display.anchorClicked.connect(self._handle_link_click) - self.chat_display.setStyleSheet(""" - QTextBrowser { - background-color:#fafafa; - border:none; - padding:8px 4px; - font-family:'Segoe UI',Arial,sans-serif; - font-size:13px; - selection-background-color:#cce4f7; - } - QScrollBar:vertical { - background:transparent; width:6px; - } - QScrollBar::handle:vertical { - background:#d0d0d0; border-radius:3px; min-height:24px; - } - QScrollBar::handle:vertical:hover { background:#a0a0a0; } - QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { - height:0px; - } - """) - chat_layout.addWidget(self.chat_display) - - status_layout = QHBoxLayout() - self.status_label = QLabel("", self) - self.status_label.setStyleSheet( - "color:#0095f6; font-size:11px; padding:1px 4px; background:transparent;" - ) - status_layout.addWidget(self.status_label) - status_layout.addStretch() - - chat_layout.addLayout(status_layout) - - self._settings_panel = QWidget() - self._settings_panel.setVisible(False) - self._settings_panel.setStyleSheet(""" - QWidget { - background:#f7f9fc; - border-top:1px solid #ececec; - border-bottom:1px solid #ececec; - } - """) - self._settings_btn.toggled.connect(lambda on: self._settings_panel.setVisible(on)) - - sp_layout = QHBoxLayout(self._settings_panel) - sp_layout.setContentsMargins(12, 8, 12, 8) - sp_layout.setSpacing(16) - - temp_col = QVBoxLayout() - self._temp_label = QLabel(f"Precision {self._temperature:.2f}") - self._temp_label.setStyleSheet("font-size:10px; color:#555;") - temp_col.addWidget(self._temp_label) - - self._temp_slider = QSlider(Qt.Horizontal) - self._temp_slider.setRange(1, 100) - self._temp_slider.setValue(int(self._temperature * 100)) - self._temp_slider.setFixedWidth(110) - self._temp_slider.valueChanged.connect(self._on_temp_changed) - temp_col.addWidget(self._temp_slider) - sp_layout.addLayout(temp_col) - - tok_col = QVBoxLayout() - self._tok_label = QLabel(f"Max tokens {self._num_predict}") - self._tok_label.setStyleSheet("font-size:10px; color:#555;") - tok_col.addWidget(self._tok_label) - - self._tok_slider = QSlider(Qt.Horizontal) - self._tok_slider.setRange(1, 40) - self._tok_slider.setValue(self._num_predict // 128) - self._tok_slider.setFixedWidth(110) - self._tok_slider.valueChanged.connect(self._on_tok_changed) - tok_col.addWidget(self._tok_slider) - sp_layout.addLayout(tok_col) - - sp_layout.addStretch() - - reset_btn = QPushButton("Reset") - reset_btn.setFixedHeight(26) - reset_btn.setStyleSheet(""" - QPushButton { - font-size:10px; padding:2px 12px; - background:#f0f0f0; color:#555; - border:none; border-radius:13px; - } - """) - reset_btn.clicked.connect(self._reset_settings) - sp_layout.addWidget(reset_btn) - chat_layout.addWidget(self._settings_panel) - - input_layout = QHBoxLayout() - - self.attach_button = QPushButton("📎") - self.attach_button.setFixedSize(38, 38) - self.attach_button.setToolTip( - "Attach image for analysis\n" - "Tip: install Pillow (pip install Pillow) to auto-downscale\n" - "large images for faster analysis" - ) - self.attach_button.setStyleSheet(""" - QPushButton { - font-size:16px; background:#f0f0f0; - border:none; border-radius:19px; - } - QPushButton:hover { background:#e0e8ff; } - """) - self.attach_button.clicked.connect(self._pick_image) - input_layout.addWidget(self.attach_button) - - self.mic_button = QPushButton("🎤") - self.mic_button.setFixedSize(38, 38) - QTimer.singleShot(200, self._update_mic_tooltip) - self.mic_button.setStyleSheet(""" - QPushButton { - font-size:15px; background:#f0f0f0; - border:none; border-radius:19px; - } - QPushButton:hover { background:#d0f8d0; } - """) - self.mic_button.clicked.connect(self._on_mic_clicked) - input_layout.addWidget(self.mic_button) - - self.user_input = _HistoryLineEdit( - self, placeholderText="Message eSim AI… (↑↓ for history)" - ) - self.user_input.setStyleSheet(""" - QLineEdit { - font-size:13px; padding:9px 14px; - border:1.5px solid #e0e0e0; border-radius:22px; - background:#f7f7f7; color:#1a1a2e; - } - QLineEdit:focus { - border:1.5px solid #0095f6; - background:#ffffff; - } - """) - self.user_input.returnPressed.connect(self.ask_ollama) - input_layout.addWidget(self.user_input) - - self.send_button = QPushButton("Send") - self.send_button.setFixedHeight(38) - self.send_button.setStyleSheet(""" - QPushButton { - font-size:13px; font-weight:600; padding:5px 20px; - background-color:#0095f6; color:white; - border:none; border-radius:19px; - } - QPushButton:hover { background-color:#0082d8; } - """) - self.send_button.clicked.connect(self.ask_ollama) - input_layout.addWidget(self.send_button) - - self.stop_button = QPushButton("Stop") - self.stop_button.setFixedHeight(38) - self.stop_button.setStyleSheet(""" - QPushButton { - font-size:13px; font-weight:600; padding:5px 16px; - background-color:#ff3b30; color:white; - border:none; border-radius:19px; - } - """) - self.stop_button.clicked.connect(self._stop_generating) - self.stop_button.hide() - input_layout.addWidget(self.stop_button) - - self.clear_button = QPushButton("Clear") - self.clear_button.setFixedHeight(38) - self.clear_button.setStyleSheet(""" - QPushButton { - font-size:13px; padding:5px 14px; - background-color:#f0f0f0; color:#666; - border:none; border-radius:19px; - } - QPushButton:hover { background-color:#ffe0e0; color:#cc0000; } - """) - self.clear_button.clicked.connect(self.clear_session) - input_layout.addWidget(self.clear_button) - - chat_layout.addLayout(input_layout) - - self._staging_area = QWidget() - self._staging_area.setStyleSheet("QWidget { background:#f5f8ff; border-radius:10px; }") - self._staging_area.setVisible(False) - - staging_outer = QVBoxLayout(self._staging_area) - staging_outer.setContentsMargins(6, 6, 6, 4) - staging_outer.setSpacing(4) - - staging_header = QHBoxLayout() - staged_lbl = QLabel("Images to send:") - staged_lbl.setStyleSheet("font-size:11px;color:#555;") - staging_header.addWidget(staged_lbl) - staging_header.addStretch() - - clear_all_btn = QPushButton("Remove all") - clear_all_btn.setFixedHeight(20) - clear_all_btn.setStyleSheet(""" - QPushButton { - font-size:10px; color:#cc0000; background:transparent; - border:none; padding:0 4px; - } - QPushButton:hover { text-decoration:underline; } - """) - clear_all_btn.clicked.connect(self._clear_staged_images) - staging_header.addWidget(clear_all_btn) - staging_outer.addLayout(staging_header) - - scroll = QScrollArea() - scroll.setFixedHeight(72) - scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) - scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) - scroll.setWidgetResizable(True) - scroll.setStyleSheet("QScrollArea { border:none; background:transparent; }") - - self._thumb_container = QWidget() - self._thumb_row = QHBoxLayout(self._thumb_container) - self._thumb_row.setContentsMargins(0, 0, 0, 0) - self._thumb_row.setSpacing(6) - self._thumb_row.addStretch() - - scroll.setWidget(self._thumb_container) - staging_outer.addWidget(scroll) - chat_layout.addWidget(self._staging_area) - - self.move_to_bottom_right() - self._load_history() - - # ── Drag & drop ─────────────────────────────────────────────────── - - def dragEnterEvent(self, event: QDragEnterEvent): - mime = event.mimeData() - if mime.hasUrls(): - for url in mime.urls(): - if url.isLocalFile() and _is_image_file(url.toLocalFile()): - event.acceptProposedAction() - return - event.ignore() - - def dropEvent(self, event: QDropEvent): - mime = event.mimeData() - if not mime.hasUrls(): - event.ignore() - return - - added = 0 - for url in mime.urls(): - if not url.isLocalFile(): - continue - path = url.toLocalFile() - if _is_image_file(path) and path not in self._staged_images: - self._staged_images.append(path) - added += 1 - - if added: - self._refresh_staging_strip() - self.status_label.setText(f"📎 Added {added} image{'s' if added != 1 else ''} by drag-and-drop.") - QTimer.singleShot(2500, lambda: self.status_label.setText("")) - - event.acceptProposedAction() - - # ── Sidebar / sessions ──────────────────────────────────────────── - - def _toggle_sidebar(self): - if self._sidebar.isVisible(): - self._sidebar.hide() - else: - self._sidebar.populate() - self._sidebar.show() - - def _refresh_sidebar_if_open(self): - if self._sidebar.isVisible(): - self._sidebar.populate() - - def _delete_all_chats(self): - dlg = _DeleteConfirmDialog("all chats", self) - if dlg.exec_() != QDialog.Accepted: - return - - try: - if os.path.exists(_SESSIONS_DIR): - for fname in os.listdir(_SESSIONS_DIR): - if fname.endswith(".json"): - os.remove(os.path.join(_SESSIONS_DIR, fname)) - except Exception: - pass - - self._sidebar.populate() - - def _open_session_viewer(self, item): - session_id = item.data(Qt.UserRole) - path = os.path.join(_SESSIONS_DIR, f"{session_id}.json") - try: - with open(path, encoding='utf-8') as f: - session = json.load(f) - except Exception: - return - dlg = ChatHistoryViewer(session, self) - dlg.exec_() - - def _rename_current_chat(self): - title, ok = QInputDialog.getText( - self, "Rename Chat", "New chat title:", - text=self._session_title_override or self._derive_session_title() - ) - if ok: - title = title.strip() - if title: - self._session_title_override = title - self._save_history() - self.status_label.setText("✏️ Chat renamed.") - QTimer.singleShot(2000, lambda: self.status_label.setText("")) - - def _rename_session_by_id(self, session_id: str): - path = os.path.join(_SESSIONS_DIR, f"{session_id}.json") - try: - with open(path, encoding='utf-8') as f: - session = json.load(f) - except Exception: - return - - current_title = session.get("title", "Chat") - title, ok = QInputDialog.getText( - self, "Rename Chat", "New chat title:", text=current_title - ) - if not ok: - return - title = title.strip() - if not title: - return - - try: - session["title"] = title - with open(path, "w", encoding="utf-8") as f: - json.dump(session, f, ensure_ascii=False, indent=2) - except Exception: - return - - if session_id == self._current_session_id: - self._session_title_override = title - - self._sidebar.populate() - - def _derive_session_title(self): - if self._session_title_override: - return self._session_title_override - return next( - (m[5:].strip()[:50] for m in self.chat_history if m.startswith("User:")), - "Chat" - ) - - def _rebuild_chat_html_from_history(self): - self.chat_display.setHtml(WELCOME_MESSAGE) - self._bot_responses = {} - self._response_counter = 0 - - for line in self.chat_history: - if line.startswith("User:"): - self.chat_display.append(_user_bubble(line[5:].strip(), "")) - elif line.startswith("Bot:"): - idx = self._response_counter - text = line[4:].strip() - self._bot_responses[idx] = text - self.chat_display.append(_bot_bubble(text, "", idx)) - self._response_counter += 1 - self._scroll_to_bottom() - - def _on_session_clicked(self, item): - session_id = item.data(Qt.UserRole) - - # If this is the session already showing, do nothing. - if (session_id == self._current_session_id - and not self._viewing_past_session): - return - - # Suspend BEFORE changing self._current_session_id so the worker - # snapshot captures the correct (old) session ID and history. - # Then flush the current session to disk so the file exists for - # _on_background_response to update when the worker finishes. - if self._is_generating: - self._suspend_worker( - session_id=self._current_session_id, - history=self.chat_history, - session_kind=self._current_session_kind, - images_store=self._images_store, - ) - - self._save_debounce_timer.stop() - self._save_pending = False - self._save_current_session() - - # Load the target session — try disk first, fall back to the - # in-memory sidebar cache (handles sessions not yet written to disk). - path = os.path.join(_SESSIONS_DIR, f"{session_id}.json") - session = None - try: - with open(path, encoding='utf-8') as f: - session = json.load(f) - except Exception: - pass - - if session is None: - for s in self._sidebar._all_sessions_cache: - if s.get('id') == session_id: - session = s - break - - if session is None: - return - - msgs = session.get('messages', []) - title = session.get('title', 'Chat') - created = session.get('created_at', '') - kind = session.get('kind', 'text') - - # Switch the active session context to the one being viewed so that - # if the user types a follow-up, it goes to the right session. - self._current_session_id = session_id - self._session_created_at = created - self._current_session_kind = kind - self._session_title_override = title if title != "Chat" else None - self.chat_history = list(msgs) - self._retry_history = list(msgs) - self._last_user_text = next( - (m[5:].strip() for m in reversed(msgs) if m.startswith("User:")), "" - ) - - # Restore image store from session so follow-ups can re-send images - saved_images = session.get("images", {}) - self._images_store = saved_images - self._last_image_paths = [] # original paths are gone; base64 stored instead - - html = WELCOME_MESSAGE - html += ( - '' - '
' - '
' - f'Viewing saved chat: {_escape_text_preserve_breaks(title[:50])}' - f'  ·  {_escape_text_preserve_breaks(created)}' - f'  ·  {kind}' - '
' - 'Scroll down to see full conversation' - '

' - ) - - self._bot_responses = {} - local_counter = 0 - - # Build a flat list of saved image thumbnails in order for replay - all_saved_imgs = [] - for key in sorted(saved_images.keys()): - all_saved_imgs.extend(saved_images[key]) - img_replay_idx = 0 - - for line in msgs: - if line.startswith("User:"): - text = line[5:].strip() - # If this line is an image-analysis request, show the thumbnail - if text.startswith("[Image analysis request:"): - # Show saved thumbnails for this entry - while img_replay_idx < len(all_saved_imgs): - fname, b64 = all_saved_imgs[img_replay_idx] - html += _image_thumbnail_html(b64, fname) - img_replay_idx += 1 - # Only consume images for this request - if img_replay_idx >= len(all_saved_imgs): - break - # Also show any user text after the image tag - user_text_part = text.split("\n", 1)[-1].strip() - if user_text_part and not user_text_part.startswith("[Image"): - html += _user_bubble(user_text_part, "") - else: - html += _user_bubble(text, "") - elif line.startswith("Bot:"): - text = line[4:].strip() - self._bot_responses[local_counter] = text - html += _bot_bubble(text, "", local_counter) - local_counter += 1 - self._response_counter = local_counter - - self.chat_display.setHtml(html) - QTimer.singleShot(120, lambda: self.chat_display.verticalScrollBar().setValue( - self.chat_display.verticalScrollBar().maximum() - )) - - # Load the session's messages into chat_history so follow-up questions - # have full context, and update the session ID so any new messages save - # to the correct file rather than the previous live session. - self.chat_history = list(msgs) - self._retry_history = list(msgs) - self._current_session_id = session_id - self._session_created_at = session.get('created_at', datetime.now().strftime("%Y-%m-%d %H:%M")) - self._current_session_kind = kind - self._session_title_override = session.get('title', None) - self._last_user_text = next( - (m[5:].strip() for m in reversed(msgs) if m.startswith("User:")), "" - ) - self._viewing_past_session = True - - def _abort_worker(self): - """ - Stop the active worker immediately and discard its response. - Use _suspend_worker() instead when switching sessions so the - generation can finish silently in the background. - """ - if hasattr(self, 'worker') and self.worker.isRunning(): - self.worker.stop() - try: - self.worker.response_signal.disconnect() - self.worker.status_signal.disconnect() - except Exception: - pass - self.worker.wait(300) - self._stop_thinking() - - def _sidebar_upsert_from_signal(self, session: dict): - """Slot — always called on the main thread via _background_session_saved.""" - self._sidebar.upsert_session(session) - - def _suspend_worker(self, session_id: str, history: list, - session_kind: str, images_store: dict): - """ - Detach the running worker from the UI and let it finish in the - background. When it completes, the bot reply is appended to the - session file on disk so the user sees the full conversation the - next time they open that chat from the sidebar. - """ - if not (hasattr(self, 'worker') and self.worker.isRunning()): - self._stop_thinking() - return - - # Snapshot everything the callback needs before self.* moves on. - _sid = session_id - _history = list(history) - _kind = session_kind - _images = dict(images_store) - _worker = self.worker - _signal = self._background_session_saved # Qt signal, safe to emit from thread - - def _on_background_response(bot_response: str): - """ - Called from the worker thread when generation finishes. - Saves the response to disk, then emits a signal so the sidebar - update happens on the main thread (direct QWidget calls from - worker threads cause crashes on some platforms). - """ - try: - _history.append(f"Bot: {bot_response}") - path = os.path.join(_SESSIONS_DIR, f"{_sid}.json") - - if os.path.exists(path): - with open(path, encoding="utf-8") as fp: - session = json.load(fp) - else: - # Session file doesn't exist yet — build it from the snapshot. - session = { - "id": _sid, - "title": next( - (m[5:].strip()[:50] for m in _history - if m.startswith("User:")), "Chat" - ), - "created_at": datetime.now().strftime("%Y-%m-%d %H:%M"), - "kind": _kind, - "images": _images, - } - - session["messages"] = _history[-40:] - session["updated_at"] = datetime.now().strftime("%Y-%m-%d %H:%M") - session["kind"] = _kind - - os.makedirs(_SESSIONS_DIR, exist_ok=True) - with open(path, "w", encoding="utf-8") as fp: - json.dump(session, fp, ensure_ascii=False, indent=2) - - # Emit signal — the connected slot runs on the main thread. - _signal.emit(session) - except Exception: - pass - - try: - _worker.response_signal.disconnect() - _worker.status_signal.disconnect() - except Exception: - pass - - _worker.response_signal.connect(_on_background_response) - self._stop_thinking() - - def _new_chat(self): - # Stop the debounce timer and flush the current session to disk NOW, - # before anything is reset, so the file is written under the correct ID. - self._save_debounce_timer.stop() - self._save_pending = False - if self._is_generating: - # Generation is running — detach it so it finishes silently and - # saves its reply into the current session file when it completes. - self._suspend_worker( - session_id=self._current_session_id, - history=self.chat_history, - session_kind=self._current_session_kind, - images_store=self._images_store, - ) - else: - # Save current session synchronously so it lands on disk before - # we move on. _save_current_session() is a no-op if chat_history - # is empty, so clicking New Chat on a blank window is safe. - self._save_current_session() - - # Reset UI and state for the new blank session WITHOUT calling - # clear_session() — that method deletes the session file, which - # would erase the chat we just saved above. - self.chat_display.setHtml(WELCOME_MESSAGE) - self.chat_history = [] - self._retry_history = [] - self._bot_responses = {} - self._response_counter = 0 - self._last_user_text = "" - self._viewing_past_session = False - self._clear_staged_images() - self._images_store = {} - self._last_image_paths = [] - self._current_session_kind = "text" - self._session_title_override = None - self._current_session_id = str(uuid.uuid4()) - self._session_created_at = datetime.now().strftime("%Y-%m-%d %H:%M") - - try: - if os.path.exists(_HISTORY_FILE): - os.remove(_HISTORY_FILE) - except Exception: - pass - - self._sidebar.populate() - self._current_session_kind = "text" - self._session_title_override = None - self._sidebar.populate() - - def _on_session_deleted(self, deleted_id: str): - if deleted_id == self._current_session_id or self._viewing_past_session: - self._abort_worker() - - # Cancel any pending debounced save so the deleted session - # file cannot be re-created by a timer that was already running. - self._save_debounce_timer.stop() - self._save_pending = False - - self._current_session_id = str(uuid.uuid4()) - self._session_created_at = datetime.now().strftime("%Y-%m-%d %H:%M") - self._current_session_kind = "text" - self._session_title_override = None - self._viewing_past_session = False - self.chat_history = [] - self._retry_history = [] - self._bot_responses = {} - self._response_counter = 0 - self._last_user_text = "" - self._images_store = {} - self._last_image_paths = [] - try: - if os.path.exists(_HISTORY_FILE): - os.remove(_HISTORY_FILE) - except Exception: - pass - self.chat_display.setHtml(WELCOME_MESSAGE) - - # ── Export ──────────────────────────────────────────────────────── - - def _export_current_chat(self): - if not self.chat_history: - self.status_label.setText("Nothing to export.") - QTimer.singleShot(2500, lambda: self.status_label.setText("")) - return - - path, _ = QFileDialog.getSaveFileName( - self, - "Export Chat", - os.path.join(os.path.expanduser("~"), "chat_export.txt"), - "Text Files (*.txt);;Markdown Files (*.md)" - ) - if not path: - return - - try: - with open(path, "w", encoding="utf-8") as f: - for line in self.chat_history: - f.write(line.strip() + "\n\n") - self.status_label.setText("✅ Chat exported.") - QTimer.singleShot(2500, lambda: self.status_label.setText("")) - except Exception as e: - self.status_label.setText(f"❌ Export failed: {e}") - QTimer.singleShot(3500, lambda: self.status_label.setText("")) - - # ── Ollama status ──────────────────────────────────────────────── - - def _update_ollama_status(self): - self._status_worker = OllamaStatusWorker() - self._status_worker.result_signal.connect(self._on_status_result) - self._status_worker.start() - - def _on_status_result(self, running: bool): - if running: - self.ollama_status_label.setText("🟢 Live") - self.ollama_status_label.setStyleSheet(""" - QLabel { - font-size:11px; font-weight:bold; padding:2px 10px; - border-radius:12px; background-color:#e6f9ee; - color:#1a7f3c; border:1px solid #a3d9b5; - } - """) - if self._was_ollama_offline: - self._was_ollama_offline = False - self._populate_models() - else: - self.ollama_status_label.setText("🔴 Offline") - self.ollama_status_label.setStyleSheet(""" - QLabel { - font-size:11px; font-weight:bold; padding:2px 10px; - border-radius:12px; background-color:#fdecea; - color:#b71c1c; border:1px solid #f5c0bc; - } - """) - self._was_ollama_offline = True - - # ── Typing bubble ───────────────────────────────────────────────── - - # ── Typing bubble (window-switch safe) ────────────────────────── - # - # (_typing_start_pos) and used it to select-and-replace the animated - # dots on every timer tick. When the user switches away from the - # chatbot window Qt reflows the QTextBrowser's HTML document, which - # shifts character positions. On the next timer tick the cursor - # landed in the wrong place and deleted real chat content. - # - # New approach: insert a sentinel anchor tag with a unique id - # ("_typing_anchor_") right before the bubble HTML. To update or - # remove the bubble we search the document for that anchor using - # QTextDocument.find() — which is position-independent and survives - # any reflow — then select from the match to the end of the document. - # The sentinel itself is a zero-width invisible link so it never - # appears in the rendered output. - - _TYPING_ANCHOR = '' - - def _find_typing_anchor_cursor(self): - """Return a cursor positioned at the typing-bubble sentinel, - or None if the sentinel is not in the document. - - PyQt5 exposes anchor names via QTextCharFormat.anchorNames() - (returns a list) not .anchorName() -- we handle both spellings - defensively so the code works across PyQt5 versions. - """ - doc = self.chat_display.document() - block = doc.begin() - while block.isValid(): - it = block.begin() - while not it.atEnd(): - frag = it.fragment() - if frag.isValid(): - fmt = frag.charFormat() - # PyQt5 uses anchorNames() -> list[str] - # Some builds also have anchorName() -> str - # We try both so it works regardless of version. - try: - names = fmt.anchorNames() # PyQt5 standard - matched = "_typing_anchor_" in (names or []) - except AttributeError: - try: - matched = fmt.anchorName() == "_typing_anchor_" - except AttributeError: - matched = False - if matched: - cursor = QTextCursor(doc) - cursor.setPosition(frag.position()) - return cursor - it += 1 - block = block.next() - return None - - def _show_typing_bubble(self): - self._typing_frame = 0 - cursor = QTextCursor(self.chat_display.document()) - cursor.movePosition(QTextCursor.End) - # Insert sentinel anchor + bubble in one operation so they form - # a contiguous block that can be fully removed later. - cursor.insertHtml(self._TYPING_ANCHOR + _typing_bubble(0)) - self._scroll_to_bottom() - self._typing_anim_timer.start(400) - - def _animate_typing_bubble(self): - self._typing_frame = (self._typing_frame + 1) % 3 - anchor_cursor = self._find_typing_anchor_cursor() - if anchor_cursor is None: - # Sentinel gone — stop the timer defensively - self._typing_anim_timer.stop() - return - # Select from the sentinel to the end of the document and replace. - # This is immune to any reflow that happened while the window was - # in the background because we locate by anchor name, not position. - anchor_cursor.movePosition(QTextCursor.End, QTextCursor.KeepAnchor) - anchor_cursor.insertHtml(self._TYPING_ANCHOR + _typing_bubble(self._typing_frame)) - # Only auto-scroll if the user is already near the bottom so we - # don't hijack their scroll position while they read earlier msgs. - sb = self.chat_display.verticalScrollBar() - if sb.maximum() - sb.value() < 60: - self._scroll_to_bottom() - - def _remove_typing_bubble(self): - self._typing_anim_timer.stop() - anchor_cursor = self._find_typing_anchor_cursor() - if anchor_cursor is not None: - anchor_cursor.movePosition(QTextCursor.End, QTextCursor.KeepAnchor) - anchor_cursor.removeSelectedText() - # Legacy guard: if somehow _typing_start_pos path left stale state - self._typing_start_pos = -1 - - # ── Links ──────────────────────────────────────────────────────── - - def _handle_link_click(self, url): - scheme, parts = _parse_custom_url(url) - - if scheme == 'copy': - if not parts: - return - try: - idx = int(parts[-1]) - except ValueError: - return - text = self._bot_responses.get(idx, "") - if text: - QApplication.clipboard().setText(text) - self._show_copy_toast() - - elif scheme == 'retry': - if not parts: - return - try: - idx = int(parts[-1]) - except ValueError: - return - self._retry_response(idx) - - elif scheme == 'clear': - self.clear_session() - - def _show_copy_toast(self): - self._toast.setText(" ✅ Copied! ") - chat_rect = self.chat_display.geometry() - tw, th = 110, 30 - x = chat_rect.x() + (chat_rect.width() - tw) // 2 - y = chat_rect.y() + chat_rect.height() - th - 16 - self._toast.setGeometry(x, y, tw, th) - self._toast.show() - self._toast.raise_() - QTimer.singleShot(1600, self._toast.hide) - - # ── Images ─────────────────────────────────────────────────────── - - def _pick_image(self): - paths, _ = QFileDialog.getOpenFileNames(self, "Select Images", "", _IMG_FILTER) - self._stage_image_paths(paths) - - def _stage_image_paths(self, paths): - added = 0 - for path in paths: - if path and _is_image_file(path) and path not in self._staged_images: - self._staged_images.append(path) - added += 1 - if added: - self._refresh_staging_strip() - - def _refresh_staging_strip(self): - while self._thumb_row.count() > 1: - item = self._thumb_row.takeAt(0) - if item.widget(): - item.widget().deleteLater() - - for path in self._staged_images: - self._thumb_row.insertWidget(self._thumb_row.count() - 1, self._make_thumbnail(path)) - - self._staging_area.setVisible(bool(self._staged_images)) - - def _make_thumbnail(self, image_path: str) -> QWidget: - from PyQt5.QtGui import QPixmap - - card = QWidget() - card.setFixedSize(80, 64) - card.setStyleSheet(""" - QWidget { - background:#ffffff; - border:1px solid #d0d8f0; - border-radius:10px; - } - """) - card_layout = QVBoxLayout(card) - card_layout.setContentsMargins(4, 4, 4, 2) - card_layout.setSpacing(2) - - thumb_lbl = QLabel() - thumb_lbl.setAlignment(Qt.AlignCenter) - thumb_lbl.setFixedHeight(36) - pix = QPixmap(image_path) - if not pix.isNull(): - orig_w, orig_h = pix.width(), pix.height() - card.setToolTip( - f"{os.path.basename(image_path)}\n{orig_w} × {orig_h} px" - ) - pix = pix.scaled(68, 36, Qt.KeepAspectRatio, Qt.SmoothTransformation) - thumb_lbl.setPixmap(pix) - else: - thumb_lbl.setText("🖼") - thumb_lbl.setStyleSheet("font-size:20px;background:transparent;") - card_layout.addWidget(thumb_lbl) - - fname = os.path.basename(image_path) - name_lbl = QLabel(fname[:10] + ("…" if len(fname) > 10 else "")) - name_lbl.setAlignment(Qt.AlignCenter) - name_lbl.setStyleSheet("font-size:9px;color:#555;background:transparent;") - card_layout.addWidget(name_lbl) - - remove_btn = QPushButton("✕", card) - remove_btn.setFixedSize(16, 16) - remove_btn.move(62, 2) - remove_btn.setStyleSheet(""" - QPushButton { - font-size:9px; font-weight:bold; - background:#ff3b30; color:white; - border:none; border-radius:8px; - padding:0; - } - QPushButton:hover { background:#cc2a22; } - """) - remove_btn.clicked.connect(lambda checked=False, p=image_path: self._remove_staged_image(p)) - return card - - def _remove_staged_image(self, path: str): - if path in self._staged_images: - self._staged_images.remove(path) - self._refresh_staging_strip() - - def _clear_staged_images(self): - self._staged_images.clear() - self._refresh_staging_strip() - - def _warn_or_switch_to_vision_model(self) -> bool: - """ - Ensure a vision-capable model is selected before sending images. - - Returns True if it is safe to proceed (a vision model is active), - or False if no vision model is installed and the request should be - blocked. Sending images to a text-only model causes it to fabricate - completely wrong answers because it cannot actually see the image. - """ - current = self.model_combo.currentText() - vision_keywords = ["llava", "bakllava", "vision", "moondream", "qwen2-vl", "minicpm-v"] - - # Already on a vision model — good to go. - if any(k in current.lower() for k in vision_keywords): - return True - - # Try to auto-switch to any vision model the user has installed. - preferred_order = ["moondream", "llava:7b", "llava", "bakllava", "llava:13b"] - for i in range(self.model_combo.count()): - name = self.model_combo.itemText(i) - if any(k in name.lower() for k in vision_keywords): - self.model_combo.setCurrentIndex(i) - self.chat_display.append(_system_bubble( - f"Switched to vision model: {name}" - )) - self._scroll_to_bottom() - return True - - # No vision model found — block the request and explain clearly. - self.chat_display.append(_system_bubble( - "⚠️ No vision model installed. Image analysis is not possible with the " - "current model — a text-only model cannot see images and will give " - "completely wrong answers.\n\n" - "Install a vision model by running this in a terminal:\n" - " ollama pull llava\n\n" - "Then restart eSim and select llava from the model dropdown." - )) - self._scroll_to_bottom() - return False - - # ── Mic ────────────────────────────────────────────────────────── - - def _on_temp_changed(self, value: int): - self._temperature = round(value / 100, 2) - self._temp_label.setText(f"Precision {self._temperature:.2f}") - - def _on_tok_changed(self, value: int): - self._num_predict = value * 128 - self._tok_label.setText(f"Max tokens {self._num_predict}") - - def _reset_settings(self): - self._temperature = 0.35 - self._num_predict = 1024 - self._temp_slider.setValue(35) - self._tok_slider.setValue(8) - - def _update_mic_tooltip(self): - backend = get_stt_backend() - tips = { - "whisper": "Speak your question\n✅ Offline STT active (faster-whisper)", - "vosk": "Speak your question\n✅ Offline STT active (vosk)", - "google": "Speak your question\n⚠ Online STT only (Google)", - "none": "Speak your question\n❌ No STT installed", - } - self.mic_button.setToolTip(tips.get(backend, "Speak your question")) - - def _on_mic_clicked(self): - if self._mic_active: - return - self._mic_active = True - self.mic_button.setEnabled(False) - self.status_label.setText("🎤 Starting microphone…") - self._mic_worker = MicWorker() - self._mic_worker.text_signal.connect(self._on_mic_text) - self._mic_worker.error_signal.connect(self._on_mic_error) - self._mic_worker.status_signal.connect(self._on_mic_status) - self._mic_worker.start() - - def _on_mic_status(self, msg: str): - self.status_label.setText(msg) - - def _on_mic_text(self, text: str): - self._reset_mic_button() - self.status_label.setText("") - self.user_input.setText(text) - self.user_input.setFocus() - - def _on_mic_error(self, msg: str): - self._reset_mic_button() - self.status_label.setText(msg) - QTimer.singleShot(3500, lambda: self.status_label.setText("")) - - def _reset_mic_button(self): - self._mic_active = False - self.mic_button.setEnabled(True) - - # ── Netlist analysis ───────────────────────────────────────────── - - def analyse_netlist(self, netlist_path: str): - if not os.path.exists(netlist_path): - self.chat_display.append( - f'
' - f'❌ Netlist file not found: {_escape_text_preserve_breaks(netlist_path)}
' - ) - return - - self._current_session_kind = "netlist" - - ts = _get_time() - filename = os.path.basename(netlist_path) - self.chat_display.append(_netlist_header_bubble(filename, ts)) - self._scroll_to_bottom() - - try: - with open(netlist_path, 'r', errors='replace') as f: - raw_lines = f.readlines() - except Exception as e: - self.chat_display.append( - f'
' - f'❌ Could not read file: {_escape_text_preserve_breaks(str(e))}
' - ) - return - - components, nodes, directives = [], set(), [] - for line in raw_lines: - s = line.strip() - if not s or s.startswith('*'): - continue - first = s[0].upper() - if first in 'RCLVIDQMEFGHJKTUWXZ': - components.append(s) - parts = s.split() - if len(parts) >= 3: - nodes.update([parts[1], parts[2]]) - elif first == '.': - directives.append(s) - - summary = ( - f"Netlist file: {filename}\n" - f"Total lines: {len(raw_lines)}\n" - f"Components ({len(components)}): " - f"{', '.join(components[:15])}{'...' if len(components) > 15 else ''}\n" - f"Unique nodes: {', '.join(sorted(nodes)[:20])}\n" - f"SPICE directives: {', '.join(directives[:10])}\n\n" - f"Full netlist:\n{''.join(raw_lines[:80])}" - f"{'[truncated]' if len(raw_lines) > 80 else ''}" - ) - - prompt = ( - f"Analyse this NgSpice netlist for me.\n\n{summary}\n\n" - "Please: (1) identify all components and their roles, " - "(2) describe what circuit this is and what it does, " - "(3) highlight any potential simulation issues, " - "(4) suggest any improvements." - ) - - self.chat_history = (self.chat_history + [f"User: {prompt}"])[-20:] - self._retry_history = list(self.chat_history) - self._last_user_text = prompt - self._start_thinking() - - self.worker = OllamaWorker( - self.chat_history, - model=self.model_combo.currentText(), - temperature=self._temperature, - num_predict=self._num_predict, - ) - self.worker.response_signal.connect(self.display_response) - self.worker.status_signal.connect(self._on_status_update) - self.worker.start() - - # ── Topic switch ───────────────────────────────────────────────── - - def _check_topic_switch(self, new_text: str) -> bool: - switched = detect_topic_switch(self._last_user_text, new_text) - if switched and self.chat_history: - self.chat_history = self.chat_history[-2:] - self.chat_display.append(_topic_reset_banner()) - self._scroll_to_bottom() - # Clear image follow-up context when topic changes - self._last_image_paths = [] - return switched - - # ── Persistence ────────────────────────────────────────────────── - - def _save_history(self): - """ - Schedules a debounced disk write so saves are batched rather than - firing synchronously after every message, preventing UI freezes. - """ - self._save_pending = True - # Restart the timer so the window slides forward from the last change. - # If the user sends multiple messages quickly, only the final state is - # written, avoiding redundant I/O. - if not self._save_debounce_timer.isActive(): - self._save_debounce_timer.start(_SAVE_DEBOUNCE_MS) - - def _flush_save(self): - """Perform the actual disk write when the debounce timer fires.""" - if not self._save_pending: - return - self._save_pending = False - try: - os.makedirs(os.path.dirname(_HISTORY_FILE), exist_ok=True) - with open(_HISTORY_FILE, 'w', encoding='utf-8') as f: - json.dump(self.chat_history[-20:], f, ensure_ascii=False, indent=2) - self._save_current_session() - self._refresh_sidebar_if_open() - except Exception: - pass - - def _save_current_session(self): - if not self.chat_history: - return - try: - os.makedirs(_SESSIONS_DIR, exist_ok=True) - session = { - "id": self._current_session_id, - "title": self._derive_session_title(), - "created_at": self._session_created_at, - "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), - "messages": self.chat_history[-40:], - "kind": self._current_session_kind, - "images": self._images_store, - "settings": { - "temperature": self._temperature, - "num_predict": self._num_predict, - }, - } - path = os.path.join(_SESSIONS_DIR, f"{self._current_session_id}.json") - with open(path, 'w', encoding='utf-8') as f: - json.dump(session, f, ensure_ascii=False, indent=2) - # Keep the sidebar in-memory cache in sync so the chat appears - # immediately without requiring a full populate() from disk. - self._sidebar.upsert_session(session) - except Exception: - pass - - def _load_history(self): - """ - On startup: if a leftover history file exists, archive it into the - sidebar sessions directory so the user can access it from the sidebar, - then delete the file. The chat window always opens fresh. - """ - if not os.path.exists(_HISTORY_FILE): - return - try: - with open(_HISTORY_FILE, 'r', encoding='utf-8') as f: - saved = json.load(f) - if isinstance(saved, list) and saved: - title = next( - (m[5:].strip()[:50] for m in saved if m.startswith("User:")), - "Previous session" - ) - old_session = { - "id": self._current_session_id, - "title": title, - "created_at": self._session_created_at, - "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), - "messages": saved[-40:], - "kind": "text", - "settings": { - "temperature": self._temperature, - "num_predict": self._num_predict, - }, - } - os.makedirs(_SESSIONS_DIR, exist_ok=True) - sess_path = os.path.join( - _SESSIONS_DIR, f"{self._current_session_id}.json" - ) - with open(sess_path, 'w', encoding='utf-8') as f: - json.dump(old_session, f, ensure_ascii=False, indent=2) - except Exception: - pass - finally: - try: - os.remove(_HISTORY_FILE) - except Exception: - pass - # New session ID so nothing from the old chat bleeds into the new one - self._current_session_id = str(uuid.uuid4()) - self._session_created_at = datetime.now().strftime("%Y-%m-%d %H:%M") - - # ── Models ─────────────────────────────────────────────────────── - - def _populate_models(self): - self.model_combo.clear() - self.model_combo.addItem("Loading models…") - self.model_combo.setEnabled(False) - self._model_worker = ModelFetchWorker() - self._model_worker.result_signal.connect(self._on_models_fetched) - self._model_worker.start() - - def _on_models_fetched(self, model_names: list): - self.model_combo.clear() - for name in model_names: - self.model_combo.addItem(name) - - preferred_order = [ - 'qwen2.5-coder:3b', - 'llava:13b', - 'llava:7b', - 'llava', - 'bakllava', - ] - chosen_idx = -1 - for preferred in preferred_order: - idx = self.model_combo.findText(preferred) - if idx >= 0: - chosen_idx = idx - break - if chosen_idx >= 0: - self.model_combo.setCurrentIndex(chosen_idx) - - self.model_combo.setEnabled(True) - - # ── Thinking / retry / regenerate ──────────────────────────────── - - def _animate_thinking(self): - pass - - def _start_thinking(self): - self._is_generating = True - self.user_input.setEnabled(False) - self.attach_button.setEnabled(False) - self.mic_button.setEnabled(False) - self._staging_area.setEnabled(False) - self.send_button.hide() - self.stop_button.show() - self.clear_button.setEnabled(False) - self._show_typing_bubble() - - def _stop_thinking(self): - self._is_generating = False - self._remove_typing_bubble() - self.status_label.setText("") - self.user_input.setEnabled(True) - self.attach_button.setEnabled(True) - self.mic_button.setEnabled(True) - self._staging_area.setEnabled(True) - self.stop_button.hide() - self.send_button.show() - self.clear_button.setEnabled(True) - - def _scroll_to_bottom(self): - self.chat_display.verticalScrollBar().setValue( - self.chat_display.verticalScrollBar().maximum() - ) - - def _stop_generating(self): - if hasattr(self, 'worker') and self.worker.isRunning(): - self.worker.stop() - - def _retry_response(self, response_idx: int): - """ - Retry the bot response at response_idx. - Trims chat_history back to just before that response, - rebuilds the UI cleanly, then re-fires the worker so the - new answer replaces the old one with no duplicate bubbles. - """ - if self._is_generating: - return - - # Walk chat_history counting Bot: entries to find the target, - # then slice everything from that point forward off. - bot_count = 0 - trim_to = None - for i, line in enumerate(self.chat_history): - if line.startswith("Bot:"): - if bot_count == response_idx: - trim_to = i - break - bot_count += 1 - - if trim_to is None: - # Fallback: trim the last bot entry - for i in range(len(self.chat_history) - 1, -1, -1): - if self.chat_history[i].startswith("Bot:"): - trim_to = i - break - - if trim_to is None or not any( - l.startswith("User:") for l in self.chat_history[:trim_to] - ): - self.status_label.setText("Nothing to retry.") - QTimer.singleShot(2000, lambda: self.status_label.setText("")) - return - - # Trim history then rebuild UI so the stale bubble is gone - # before the new response is appended. - self.chat_history = self.chat_history[:trim_to] - self._retry_history = list(self.chat_history) - self._rebuild_chat_html_from_history() - self._start_thinking() - - # Re-use vision worker if the last user turn included images. - last_user = next( - (l for l in reversed(self.chat_history) if l.startswith("User:")), "" - ) - followup_paths = [p for p in self._last_image_paths if os.path.exists(p)] - if followup_paths and "[Image analysis request:" in last_user: - prompt = last_user.split("\n", 1)[-1].strip() if "\n" in last_user else "" - self.worker = OllamaVisionWorker( - image_paths=followup_paths, - extra_prompt=prompt, - model=self.model_combo.currentText(), - ) - else: - self.worker = OllamaWorker( - self._retry_history, - model=self.model_combo.currentText(), - temperature=self._temperature, - num_predict=self._num_predict, - ) - self.worker.response_signal.connect(self.display_response) - self.worker.status_signal.connect(self._on_status_update) - self.worker.start() - - def _retry_last(self): - """Legacy shim kept so any external callers don't break.""" - if self.chat_history: - self._retry_response(self._response_counter - 1) - - def _regenerate_last_response(self): - if not self.chat_history: - return - - # Remove trailing bot response if present - if self.chat_history and self.chat_history[-1].startswith("Bot:"): - self.chat_history.pop() - - # Find last user prompt - if not self.chat_history or not self.chat_history[-1].startswith("User:"): - self.status_label.setText("No previous user prompt to regenerate.") - QTimer.singleShot(2500, lambda: self.status_label.setText("")) - return - - # Rebuild UI from trimmed history and retry from same state - self._retry_history = list(self.chat_history) - self._rebuild_chat_html_from_history() - self._start_thinking() - - self.worker = OllamaWorker( - self._retry_history, - model=self.model_combo.currentText(), - temperature=self._temperature, - num_predict=self._num_predict, - ) - self.worker.response_signal.connect(self.display_response) - self.worker.status_signal.connect(self._on_status_update) - self.worker.start() - - def _on_status_update(self, msg: str): - self.status_label.setText(msg) - # Only show as chat bubble for major state changes, not every progress tick - if "Starting Ollama" in msg or "Ollama started" in msg: - self.chat_display.append(_system_bubble(msg)) - self._scroll_to_bottom() - - # ── Main chat logic ────────────────────────────────────────────── - - def ask_ollama(self): - user_text = self.user_input.text().strip() - staged_paths = list(self._staged_images) - - if not user_text and not staged_paths: - return - - if self._is_generating: - return - - if self._viewing_past_session: - # chat_history was already synced when the session was loaded, - # so no rebuild is needed — just clear the read-only flag. - self._viewing_past_session = False - - ts = _get_time() - - if staged_paths: - self._current_session_kind = "image" - if not self._warn_or_switch_to_vision_model(): - # No vision model available — clear staged images and abort. - self._clear_staged_images() - return - - fnames = [os.path.basename(p) for p in staged_paths] - - if user_text: - self.user_input.add_to_history(user_text) - self.user_input.clear() - - # Pass the user's text directly to the vision worker. - # chatbot_thread._build_schematic_vision_prompt() handles both - # cases: if user_text is empty it requests a general analysis; - # if it contains a question that question drives the response. - vision_extra_prompt = user_text - - if user_text: - user_history_text = ( - f"[Image analysis request: {', '.join(fnames)}]\n{user_text}" - ) - else: - user_history_text = ( - f"[Image analysis request: {', '.join(fnames)}]" - ) - - self.chat_history = (self.chat_history + [f"User: {user_history_text}"])[-20:] - self._retry_history = list(self.chat_history) - self._last_user_text = user_text if user_text else "image analysis" - - # Read and encode images before displaying so thumbnails appear - # in the chat bubble immediately when the user sends. - img_key = ts + "_" + self._current_session_id - b64_list = [] - for p in staged_paths: - try: - with open(p, "rb") as f_img: - raw = f_img.read() - # Downscale for storage (reuse PIL if available) - try: - from PIL import Image as _PI - import io as _io2 - img_obj = _PI.open(_io2.BytesIO(raw)) - img_obj.thumbnail((320, 240)) - if img_obj.mode not in ("RGB", "L"): - img_obj = img_obj.convert("RGB") - buf = _io2.BytesIO() - img_obj.save(buf, format="JPEG", quality=75) - raw = buf.getvalue() - except Exception: - pass - b64_list.append((os.path.basename(p), base64.b64encode(raw).decode())) - except Exception: - pass - if b64_list: - self._images_store[img_key] = b64_list - - # Show image thumbnails inline so the user can see what was sent. - if b64_list: - for fname, b64 in b64_list: - self.chat_display.append(_image_thumbnail_html(b64, fname)) - else: - # Fallback to filename badges if encoding failed for all images - self.chat_display.append(_staged_images_bubble(fnames, ts)) - - if user_text: - self.chat_display.append(_user_bubble(user_text, ts)) - self._scroll_to_bottom() - - # Keep paths for follow-up context - self._last_image_paths = list(staged_paths) - - self._clear_staged_images() - self._start_thinking() - - self.worker = OllamaVisionWorker( - image_paths=staged_paths, - extra_prompt=vision_extra_prompt, - model=self.model_combo.currentText(), - ) - self.worker.response_signal.connect(self.display_response) - self.worker.status_signal.connect(self._on_status_update) - self.worker.start() - return - - self._current_session_kind = "text" - self._check_topic_switch(user_text) - self.chat_history = (self.chat_history + [f"User: {user_text}"])[-20:] - self.chat_display.append(_user_bubble(user_text, ts)) - self._scroll_to_bottom() - - self.user_input.add_to_history(user_text) - self.user_input.clear() - self._last_user_text = user_text - self._retry_history = list(self.chat_history) - self._start_thinking() - - # If the user is following up on an image session, re-send the last - # images so the model has visual context for its answer. - followup_image_paths = [ - p for p in self._last_image_paths if os.path.exists(p) - ] - if followup_image_paths and self._current_session_kind in ("image", "text"): - self.worker = OllamaVisionWorker( - image_paths=followup_image_paths, - extra_prompt=user_text, - model=self.model_combo.currentText(), - ) - else: - self.worker = OllamaWorker( - self.chat_history, - model=self.model_combo.currentText(), - temperature=self._temperature, - num_predict=self._num_predict, - ) - self.worker.response_signal.connect(self.display_response) - self.worker.status_signal.connect(self._on_status_update) - self.worker.start() - - # ── Window / response / clear ──────────────────────────────────── - - def move_to_bottom_right(self): - # in Qt 6. Use QApplication.primaryScreen().availableGeometry() instead. - screen = QApplication.primaryScreen().availableGeometry() - widget = self.geometry() - x = screen.width() - widget.width() - 10 - y = screen.height() - widget.height() - 50 - self.move(x, y) - - def display_response(self, bot_response: str): - self._stop_thinking() - ts = _get_time() - idx = self._response_counter - self._response_counter += 1 - self._bot_responses[idx] = bot_response - - self.chat_display.append(_bot_bubble(bot_response, ts, idx)) - self.chat_history.append(f"Bot: {bot_response}") - self._scroll_to_bottom() - self._update_ollama_status() - - # Push a lightweight session entry into the sidebar immediately so - # the new chat appears at the top as soon as the first reply lands, - # without waiting for the debounced disk save (up to 5 seconds). - self._sidebar.upsert_session({ - "id": self._current_session_id, - "title": self._derive_session_title(), - "created_at": self._session_created_at, - "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), - "messages": self.chat_history[-40:], - "kind": self._current_session_kind, - }) - - self._save_history() - - # (Retry is now an inline link in every bot bubble; - # the old navbar retry_button has been removed.) - - def clear_session(self): - # Cancel any pending debounced save so _flush_save() can't - # resurrect the session file after we delete it below. - self._save_debounce_timer.stop() - self._save_pending = False - - # Remove session file so it never reappears in the sidebar. - session_file = os.path.join(_SESSIONS_DIR, f"{self._current_session_id}.json") - try: - if os.path.exists(session_file): - os.remove(session_file) - except Exception: - pass - - self.chat_display.setHtml(WELCOME_MESSAGE) - self.chat_history = [] - self._retry_history = [] - self._bot_responses = {} - self._response_counter = 0 - self._last_user_text = "" - self._viewing_past_session = False - self._clear_staged_images() - self._images_store = {} - self._last_image_paths = [] - self._viewing_past_session = False - self._current_session_kind = "text" - self._session_title_override = None - - # Assign a fresh session ID so the next conversation starts clean - self._current_session_id = str(uuid.uuid4()) - self._session_created_at = datetime.now().strftime("%Y-%m-%d %H:%M") - - try: - if os.path.exists(_HISTORY_FILE): - os.remove(_HISTORY_FILE) - except Exception: - pass - - # Refresh sidebar so the cleared session disappears immediately - self._refresh_sidebar_if_open() - - # ── Debug helpers ──────────────────────────────────────────────── - - def debug_ollama(self): - self._current_session_kind = "simulation_error" - self.chat_display.append( - '' - '
' - '
' - '⚠️ Simulation Failed — Analyzing error log…' - '
' - ) - self._scroll_to_bottom() - self._retry_history = list(self.chat_history) - self._start_thinking() - self.worker = OllamaWorker( - self.chat_history, - model=self.model_combo.currentText(), - temperature=self._temperature, - num_predict=self._num_predict, - ) - self.worker.response_signal.connect(self.display_response) - self.worker.status_signal.connect(self._on_status_update) - self.worker.start() - self.user_input.clear() - - def debug_error(self, log): - self.setWindowFlags(self.windowFlags()) - self.show() - self.raise_() - self.activateWindow() - - self.chat_history = [] - self._current_session_kind = "simulation_error" - - if os.path.exists(log): - with open(log, "r") as f: - lines = [ln for ln in f.readlines() if ln.strip()] - - no_compat_index = next( - (i for i, ln in enumerate(lines) if "No compatibility mode selected!" in ln), None - ) - circuit_index = next((i for i, ln in enumerate(lines) if "Circuit:" in ln), None) - total_cpu_index = next( - (i for i, ln in enumerate(lines) if "Total CPU time (seconds)" in ln), None - ) - - before_no_compat = lines[:no_compat_index] if no_compat_index else [] - between = ( - 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 - # before sending to the model. NgSpice logs can be 10-50 KB; sending - # all of it blows past num_ctx: 2048 and makes the model ignore the - # actual error. The most actionable errors always appear at the end. - if len(filtered_lines) > _MAX_ERROR_LOG_LINES: - truncated_notice = [ - f"[Log truncated: showing last {_MAX_ERROR_LOG_LINES} " - f"of {len(filtered_lines)} lines]\n" - ] - filtered_lines = truncated_notice + filtered_lines[-_MAX_ERROR_LOG_LINES:] - - combined_text = "".join(filtered_lines) - # QLineEdit); display a compact summary label in the status bar instead. - self.status_label.setText( - f"🔍 Analysing error log ({len(filtered_lines)} lines)…" - ) - - 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.chat_history.append( - f"User: I got a simulation error. Here is the log:\n{combined_text}" - ) - self.debug_ollama() \ No newline at end of file +# ============================================================================= +# Chatbot.py — eSim AI Assistant +# +# UI : ChatGPT/Claude-style interface (bubbles, sidebar with date groups) +# Backend: RAG / OCR / netlist-analysis / ESIMCopilotWrapper pipeline +# STT : faster-whisper (offline) → vosk → Google (online fallback) +# +# SESSION MODEL (how it works like GPT/Claude): +# • Every conversation is a separate JSON file in ~/.esim/chat_sessions/ +# • _current_session_id always points to the active session +# • Clicking a past session in the sidebar SWITCHES into it fully — +# its messages load into self.chat_history and its ID becomes current +# • New messages always append to self.chat_history and save to the +# current session file — no cross-session contamination +# • "New Chat" saves current session and creates a fresh one +# ============================================================================= + +import sys +import os +import re +import json +import uuid +import subprocess +import tempfile +from datetime import datetime, date, timedelta +from configuration.Appconfig import Appconfig + +# ── PyQt5 ───────────────────────────────────────────────────────────────────── +from PyQt5.QtWidgets import ( + QWidget, QHBoxLayout, QVBoxLayout, QTextBrowser, QLineEdit, + QPushButton, QLabel, QComboBox, QApplication, QFileDialog, + QListWidget, QListWidgetItem, QFrame, QScrollArea, + QSlider, QMessageBox, QInputDialog, QDockWidget +) +from PyQt5.QtCore import QSize, QTimer, Qt, pyqtSignal, QThread +from PyQt5.QtGui import QTextCursor, QKeyEvent, QPixmap + +# ── Path setup ───────────────────────────────────────────────────────────────── +if os.name == 'nt': + try: + from frontEnd import pathmagic # noqa + except ImportError: + pass +else: + try: + import pathmagic # noqa + except ImportError: + pass + +current_dir = os.path.dirname(os.path.abspath(__file__)) +src_dir = os.path.dirname(current_dir) +if src_dir not in sys.path: + sys.path.append(src_dir) + +# ── Backend ──────────────────────────────────────────────────────────────────── +from chatbot.chatbot_core import ESIMCopilotWrapper, clear_history + +# ── STT ──────────────────────────────────────────────────────────────────────── +try: + import speech_recognition as sr + _SR_AVAILABLE = True +except ImportError: + _SR_AVAILABLE = False + +try: + from faster_whisper import WhisperModel + _WHISPER_AVAILABLE = True +except ImportError: + _WHISPER_AVAILABLE = False + +try: + import vosk + import json as _json + _VOSK_AVAILABLE = True +except ImportError: + _VOSK_AVAILABLE = False + +# ── Storage ──────────────────────────────────────────────────────────────────── +_ESIM_DIR = os.path.join(os.path.expanduser('~'), '.esim') +_SESSIONS_DIR = os.path.join(_ESIM_DIR, 'chat_sessions') +_LAST_SID_FILE = os.path.join(_ESIM_DIR, 'last_session_id.txt') +_IMG_FILTER = "Images (*.png *.jpg *.jpeg *.bmp *.gif *.tiff)" + +# ── Netlist contract ─────────────────────────────────────────────────────────── +MANUALS_DIR = os.path.join(os.path.dirname(__file__), "manuals") +NETLIST_CONTRACT = "" +try: + with open(os.path.join(MANUALS_DIR, "esim_netlist_analysis_output_contract.txt"), + "r", encoding="utf-8") as f: + NETLIST_CONTRACT = f.read() + print("[COPILOT] Netlist contract loaded.") +except Exception as e: + print(f"[COPILOT] WARNING: {e}") + NETLIST_CONTRACT = ( + "You are a SPICE netlist analyzer.\n" + "Use the FACT lines to detect issues.\n" + "Output sections:\n" + "1. Syntax / SPICE rule errors\n" + "2. Topology / connection problems\n" + "3. Simulation setup issues (.ac/.tran/.op etc.)\n" + "4. Summary\n" + "Do NOT invent issues not present in FACT lines.\n" + ) + + +# ============================================================================= +# NETLIST DETECTOR FUNCTIONS +# ============================================================================= + +def _validate_netlist_with_ngspice(netlist_text: str) -> bool: + try: + with tempfile.NamedTemporaryFile(mode='w', suffix='.cir', + delete=False, encoding='utf-8') as tmp: + tmp.write(netlist_text) + tmp_path = tmp.name + result = subprocess.run(['ngspice', '-b', tmp_path], + capture_output=True, text=True, timeout=5) + try: + os.unlink(tmp_path) + except Exception: + pass + stderr_lower = result.stderr.lower() + syntax_errors = ['syntax error', 'unrecognized', 'parse error', 'fatal'] + ignore_patterns = ['model', 'library', 'warning', 'no such file', 'cannot find'] + for line in stderr_lower.split('\n'): + if any(p in line for p in ignore_patterns): + continue + if any(e in line for e in syntax_errors): + return False + return True + except Exception: + return True + + +def _detect_missing_subcircuits(netlist_text: str) -> list: + referenced, defined = {}, set() + for ln, line in enumerate(netlist_text.split('\n'), 1): + line = line.strip() + if not line or line.startswith('*'): + continue + if line.lower().startswith('.subckt'): + t = line.split() + if len(t) >= 2: + defined.add(t[1].upper()) + elif line.lower().startswith(('.include', '.lib')): + return [] + elif line[0].upper() == 'X': + t = line.split() + if len(t) < 2: + continue + name = t[-1].upper() + if '=' in name: + for tok in reversed(t[1:]): + if '=' not in tok: + name = tok.upper(); break + referenced.setdefault(name, []).append((ln, t[0])) + return [(s, o) for s, o in referenced.items() if s not in defined] + + +def _detect_voltage_source_conflicts(netlist_text: str) -> list: + vsources = {} + for ln, line in enumerate(netlist_text.split('\n'), 1): + line = line.strip() + if not line or line.startswith('*') or line.startswith('.'): + continue + t = line.split() + if len(t) < 4 or t[0][0].upper() != 'V': + continue + np_ = re.sub(r'[^\w\-_]', '', t[1]) + nm_ = re.sub(r'[^\w\-_]', '', t[2]) + np_ = '0' if np_.lower() in ['0', 'gnd', 'ground', 'vss'] else np_ + nm_ = '0' if nm_.lower() in ['0', 'gnd', 'ground', 'vss'] else nm_ + pair = tuple(sorted([np_, nm_])) + val = "?" + for i, tok in enumerate(t[3:], 3): + tu = tok.upper() + if tu in ['DC', 'AC', 'PULSE', 'SIN', 'PWL']: + val = t[i + 1] if i + 1 < len(t) else "?" + break + elif not tu.startswith('.'): + val = tok; break + vsources.setdefault(pair, []).append((ln, t[0], val)) + return [(p, s) for p, s in vsources.items() if len(s) > 1] + + +def _netlist_ground_info(netlist_text: str): + has0 = has_gnd = False + for line in netlist_text.split('\n'): + line = line.strip() + if not line or line.startswith('*') or line.startswith('.'): + continue + t = line.split() + if len(t) < 3: + continue + et = t[0][0].upper() + nodes = [] + if et in ['R', 'C', 'L', 'V', 'I', 'D']: + nodes = [t[1], t[2]] + elif et == 'Q' and len(t) >= 4: + nodes = [t[1], t[2], t[3]] + elif et in ['M', 'S'] and len(t) >= 5: + nodes = t[1:5] + elif et == 'X' and len(t) >= 3: + nodes = t[1:-1] + for n in nodes: + n = re.sub(r'[=\(\)].*$', '', n) + n = re.sub(r'[^\w\-_]', '', n) + if n.lower() == '0': + has0 = True + if n.lower() in ['gnd', 'ground', 'vss']: + has_gnd = True + return has0, has_gnd + + +def _detect_floating_nodes(netlist_text: str) -> list: + counts = {} + for ln, line in enumerate(netlist_text.split('\n'), 1): + line = line.strip() + if not line or line.startswith('*') or line.startswith('.'): + continue + t = line.split() + if len(t) < 3: + continue + et = t[0][0].upper() + nodes = [] + if et in ['R', 'C', 'L', 'V', 'I', 'D']: + nodes = [t[1], t[2]] + elif et == 'Q' and len(t) >= 4: + nodes = t[1:4] + elif et in ['M', 'S'] and len(t) >= 5: + nodes = t[1:5] + elif et in ['T', 'E', 'G'] and len(t) >= 5: + nodes = t[1:5] + elif et in ['H', 'F', 'B'] and len(t) >= 3: + nodes = [t[1], t[2]] + elif et == 'X' and len(t) >= 3: + nodes = [x for x in t[1:-1] if '=' not in x] + for n in nodes: + n = re.sub(r'[=\(\)].*$', '', n) + n = re.sub(r'[^\w\-_]', '', n) + if not n or n[0].isdigit(): + continue + if n.upper() in ['VALUE', 'V', 'I', 'IF', 'THEN', 'ELSE']: + continue + if n.lower() in ['0', 'gnd', 'ground', 'vss']: + n = '0' + counts.setdefault(n, []).append((ln, t[0])) + return [(nd, occ[0][0], occ[0][1]) for nd, occ in counts.items() + if len(occ) == 1 and nd != '0'] + + +def _detect_missing_models(netlist_text: str) -> list: + referenced, defined = {}, set() + for ln, line in enumerate(netlist_text.split('\n'), 1): + line = line.strip() + if not line or line.startswith('*'): + continue + if line.lower().startswith('.model'): + t = line.split() + if len(t) >= 2: + defined.add(t[1].upper()) + elif line.lower().startswith(('.include', '.lib')): + return [] + elif line[0].upper() in ['D', 'Q', 'M', 'J']: + t = line.split() + et = t[0][0].upper() + if et == 'D' and len(t) >= 4: + referenced.setdefault(t[3].upper(), []).append((ln, t[0])) + elif et == 'Q' and len(t) >= 5: + m = t[-1].upper() + if not m[0].isdigit(): + referenced.setdefault(m, []).append((ln, t[0])) + elif et == 'M' and len(t) >= 6: + referenced.setdefault(t[5].upper(), []).append((ln, t[0])) + return [(m, o) for m, o in referenced.items() if m not in defined] + + +# ============================================================================= +# STT +# ============================================================================= + +_whisper_model = None + +def _get_whisper_model(): + global _whisper_model + if _whisper_model is None: + _whisper_model = WhisperModel("base", device="cpu", compute_type="int8") + return _whisper_model + +def _is_online() -> bool: + try: + import socket + socket.create_connection(("8.8.8.8", 53), timeout=2) + return True + except OSError: + return False + +def get_stt_backend() -> str: + if _is_online() and _SR_AVAILABLE: + return "google" + if _WHISPER_AVAILABLE: + return "whisper" + if _VOSK_AVAILABLE: + return "vosk" + if _SR_AVAILABLE: + return "google" + return "none" + + +# ============================================================================= +# WORKER THREADS +# ============================================================================= + +class ChatWorker(QThread): + response_ready = pyqtSignal(str) + + def __init__(self, user_input, copilot): + super().__init__() + self.user_input = user_input + self.copilot = copilot + self._stop_requested = False + + def stop(self): + self._stop_requested = True + + def run(self): + try: + response = self.copilot.handle_input(self.user_input) + self.response_ready.emit(response) + except Exception as e: + self.response_ready.emit(f"❌ Error: {e}") + + +class MicWorker(QThread): + text_signal = pyqtSignal(str) + error_signal = pyqtSignal(str) + status_signal = pyqtSignal(str) + + def run(self): + backend = get_stt_backend() + if backend == "none": + self.error_signal.emit("No STT library. pip install faster-whisper SpeechRecognition pyaudio") + return + if not _SR_AVAILABLE: + self.error_signal.emit("pip install SpeechRecognition pyaudio") + return + try: + r = sr.Recognizer() + r.energy_threshold = 100 + r.dynamic_energy_threshold = True + r.pause_threshold = 1.5 + r.phrase_threshold = 0.1 + r.non_speaking_duration = 0.5 + with sr.Microphone() as source: + self.status_signal.emit("🎤 Adjusting for noise…") + r.adjust_for_ambient_noise(source, duration=0.3) + self.status_signal.emit("🎤 Listening… speak now") + audio = r.listen(source, timeout=10, phrase_time_limit=30) + except sr.WaitTimeoutError: + self.error_signal.emit("🎤 No speech detected."); return + except OSError: + self.error_signal.emit("🎤 Mic not found."); return + except Exception as e: + self.error_signal.emit(f"🎤 Mic error: {e}"); return + + if backend == "whisper": + self._whisper(audio) + elif backend == "vosk": + self._vosk(audio) + else: + self._google(audio) + + def _whisper(self, audio): + try: + self.status_signal.emit("🎤 Transcribing offline…") + model = _get_whisper_model() + wav = audio.get_wav_data(convert_rate=16000, convert_width=2) + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as tmp: + tmp.write(wav); tmp_path = tmp.name + try: + segs, _ = model.transcribe(tmp_path, language="en", beam_size=1, vad_filter=True) + text = " ".join(s.text for s in segs).strip() + finally: + try: os.remove(tmp_path) + except: pass + if text: self.text_signal.emit(text) + else: self.error_signal.emit("🎤 Could not understand.") + except Exception: + if _SR_AVAILABLE: self._google(audio) + else: self.error_signal.emit("🎤 Whisper error.") + + def _vosk(self, audio): + try: + dirs = [os.path.join(os.path.expanduser("~"), ".vosk", "model"), + os.path.join(os.path.expanduser("~"), "vosk-model"), "vosk-model"] + model_dir = next((d for d in dirs if os.path.isdir(d)), None) + if not model_dir: self._google(audio); return + self.status_signal.emit("🎤 Transcribing (vosk)…") + rec = vosk.KaldiRecognizer(vosk.Model(model_dir), 16000) + rec.AcceptWaveform(audio.get_wav_data(convert_rate=16000, convert_width=2)) + text = _json.loads(rec.FinalResult()).get("text", "").strip() + if text: self.text_signal.emit(text) + else: self.error_signal.emit("🎤 Could not understand.") + except Exception: + self._google(audio) + + def _google(self, audio): + try: + self.status_signal.emit("🎤 Processing (online)…") + text = sr.Recognizer().recognize_google(audio) + if text: self.text_signal.emit(text) + else: self.error_signal.emit("🎤 Could not understand.") + except sr.UnknownValueError: + self.error_signal.emit("🎤 Could not understand.") + except Exception as e: + self.error_signal.emit(f"🎤 Online STT failed: {e}") + + +# ============================================================================= +# UI HELPERS +# ============================================================================= + +WELCOME_MESSAGE = """ +
+
🤖
+
+ eSim AI Assistant +
+
+ Ask me anything about KiCad, NgSpice,
+ netlists, simulation errors, or circuit design.
+ Attach an image 📎 or speak 🎤 your question. +
+
+ Use the sidebar ≡ to access past chats +
+

+""" + +_TYPING_FRAMES = [ + '●  ', + ' ● ', + '  ●', +] + + +def _typing_bubble(frame=0): + dots = _TYPING_FRAMES[frame % 3] + return ( + '' + '' + '
' + '
' + '
' + f'{dots}
' + ) + + +def _render_inline(text): + text = text.replace('&', '&').replace('<', '<').replace('>', '>') + text = re.sub(r'\*\*(.*?)\*\*', r'\1', text) + text = re.sub(r'`([^`]+)`', + r'\1', text) + return text.replace('\n', '
') + + +def _render_markdown(text): + result, last = [], 0 + for m in re.compile(r'```(\w*)\n?(.*?)```', re.DOTALL).finditer(text): + if text[last:m.start()]: + result.append(_render_inline(text[last:m.start()])) + lang = m.group(1) or 'code' + code = (m.group(2) + .replace('&','&').replace('<','<').replace('>','>') + .replace('\n','
').replace(' ',' ')) + label = f'{lang}
' if lang else '' + result.append( + '
' + '
' + f'{label}{code}
' + ) + last = m.end() + if text[last:]: + result.append(_render_inline(text[last:])) + return ''.join(result) + + +def _get_time(): + return datetime.now().strftime("%H:%M") + + +def _user_bubble(text, timestamp=""): + safe = text.replace('&', '&').replace('<', '<').replace('>', '>') + ts_part = f'  ·  {timestamp}' if timestamp else '' + return ( + '' + '' + '
' + '' + '' + f'' + '
' + f'{safe}
You{ts_part}
' + ) + + +def _bot_bubble(text, timestamp="", response_idx=0): + rendered = _render_markdown(text) + ts_part = f'  ·  {timestamp}' if timestamp else '' + copy_href = f'copy://{response_idx}' + up_href = f'feedback://up/{response_idx}' + down_href = f'feedback://down/{response_idx}' + return ( + '' + '
' + '' + '' + '
' + f'{rendered}
' + '' + f'' + f'' + '
' + f'eSim AI{ts_part}' + f'👍' + f'👎' + f'Copy
' + '
' + ) + + +def _system_bubble(text): + return ( + '' + '
' + '
' + f'{text}
' + ) + + +def _netlist_header_bubble(filename, timestamp): + safe = filename.replace('&', '&').replace('<', '<') + return ( + '' + '' + '
' + '' + f'' + '
' + '
' + f'📄 Netlist: {safe}
You  ·  {timestamp}
' + ) + + +def _section_header_html(title): + """Date group header for the chat display (not sidebar).""" + return ( + '' + '
' + f'— {title} —
' + ) + + +# ============================================================================= +# SMART INPUT (Up/Down history) +# ============================================================================= + +class _HistoryLineEdit(QLineEdit): + def __init__(self, *a, **kw): + super().__init__(*a, **kw) + self._sent_history = [] + self._hist_idx = -1 + self._draft = '' + + def add_to_history(self, text): + if text and (not self._sent_history or self._sent_history[-1] != text): + self._sent_history.append(text) + self._hist_idx = -1 + + def keyPressEvent(self, event: QKeyEvent): + if event.key() == Qt.Key_Up and self._sent_history: + if self._hist_idx == -1: + self._draft = self.text() + self._hist_idx = len(self._sent_history) - 1 + elif self._hist_idx > 0: + self._hist_idx -= 1 + self.setText(self._sent_history[self._hist_idx]); self.end(False) + elif event.key() == Qt.Key_Down and self._hist_idx >= 0: + self._hist_idx += 1 + if self._hist_idx >= len(self._sent_history): + self._hist_idx = -1 + self.setText(self._draft) + else: + self.setText(self._sent_history[self._hist_idx]) + self.end(False) + else: + super().keyPressEvent(event) + + +# ============================================================================= +# SESSION ITEM WIDGET +# ============================================================================= + +class _SessionItemWidget(QWidget): + delete_requested = pyqtSignal(str) + + def __init__(self, session_id, title, preview="", active=False, parent=None): + super().__init__(parent) + self.session_id = session_id + self.title = title + self.setMinimumHeight(46) + + outer = QHBoxLayout(self) + outer.setContentsMargins(14, 5, 8, 5) + outer.setSpacing(0) + + text_col = QVBoxLayout() + text_col.setSpacing(2) + text_col.setContentsMargins(0, 0, 0, 0) + + color = "#0095f6" if active else "#1a1a2e" + weight = "600" if active else "500" + title_lbl = QLabel(title[:38] + ("…" if len(title) > 38 else "")) + title_lbl.setStyleSheet( + f"font-size:13px;font-weight:{weight};color:{color};background:transparent;" + ) + text_col.addWidget(title_lbl) + + ptext = (preview[:44] + "…") if len(preview) > 44 else preview + prev_lbl = QLabel(ptext or "") + prev_lbl.setStyleSheet("font-size:11px;color:#aaa;background:transparent;") + text_col.addWidget(prev_lbl) + outer.addLayout(text_col, 1) + + del_btn = QPushButton("⋯") + del_btn.setFixedSize(22, 22) + del_btn.setStyleSheet(""" + QPushButton { font-size:13px;color:#ccc;background:transparent;border:none;border-radius:11px; } + QPushButton:hover { background:#ffe0e0;color:#cc0000; } + """) + del_btn.clicked.connect(self._confirm_delete) + outer.addWidget(del_btn) + + def _confirm_delete(self): + if QMessageBox.question(self, "Delete", f"Delete '{self.title}'?", + QMessageBox.Yes | QMessageBox.No) == QMessageBox.Yes: + self.delete_requested.emit(self.session_id) + + +# ============================================================================= +# CHAT SIDEBAR +# ============================================================================= + +class ChatSidebar(QWidget): + new_chat_requested = pyqtSignal() + session_selected = pyqtSignal(str) # emits session_id + session_deleted = pyqtSignal(str) # emits session_id + + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedWidth(260) + self._active_session_id = None + self.setStyleSheet("QWidget { background:#ffffff; border-right:1px solid #ececec; }") + + root = QVBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # Header + top = QWidget() + top.setFixedHeight(52) + top.setStyleSheet("QWidget { background:#ffffff;border-bottom:1px solid #f0f0f0; }") + top_row = QHBoxLayout(top) + top_row.setContentsMargins(14, 0, 10, 0) + top_row.setSpacing(8) + lbl = QLabel("Chats") + lbl.setStyleSheet("font-size:16px;font-weight:700;color:#1a1a2e;background:transparent;") + top_row.addWidget(lbl, 1) + close_btn = QPushButton("✕") + close_btn.setFixedSize(26, 26) + close_btn.setStyleSheet(""" + QPushButton { font-size:11px;color:#888;background:transparent;border:none;border-radius:13px; } + QPushButton:hover { background:#f0f0f0;color:#333; } + """) + close_btn.clicked.connect(self.hide) + top_row.addWidget(close_btn) + root.addWidget(top) + + # New chat button + btn_w = QWidget() + btn_w.setStyleSheet("QWidget { background:#ffffff; }") + btn_l = QHBoxLayout(btn_w) + btn_l.setContentsMargins(12, 8, 12, 8) + self.new_btn = QPushButton("+ New Chat") + self.new_btn.setFixedHeight(36) + self.new_btn.setStyleSheet(""" + QPushButton { font-size:12px;font-weight:600;background:#0095f6;color:white;border:none;border-radius:18px; } + QPushButton:hover { background:#0082d8; } + QPushButton:pressed { background:#006ab8; } + """) + self.new_btn.clicked.connect(self.new_chat_requested) + btn_l.addWidget(self.new_btn) + root.addWidget(btn_w) + + sep = QFrame(); sep.setFrameShape(QFrame.HLine) + sep.setFixedHeight(1) + sep.setStyleSheet("QFrame { background:#f0f0f0;border:none; }") + root.addWidget(sep) + + self.session_list = QListWidget() + self.session_list.setSpacing(0) + self.session_list.setStyleSheet(""" + QListWidget { background:#ffffff;border:none;outline:0; } + QListWidget::item { border:none;padding:0; } + QListWidget::item:hover { background:#f5f8ff; } + QListWidget::item:selected { background:#eaf3ff; } + """) + self.session_list.itemClicked.connect(self._on_item_clicked) + root.addWidget(self.session_list) + + self._empty_lbl = QLabel("No saved chats yet.\nStart a conversation!") + self._empty_lbl.setAlignment(Qt.AlignCenter) + self._empty_lbl.setStyleSheet("QLabel { color:#ccc;font-size:12px;padding:30px 10px;background:transparent; }") + self._empty_lbl.setWordWrap(True) + self._empty_lbl.hide() + root.addWidget(self._empty_lbl) + + def set_active_session(self, session_id: str): + self._active_session_id = session_id + + def populate(self): + self.session_list.clear() + if not os.path.exists(_SESSIONS_DIR): + self._empty_lbl.show(); return + + sessions = [] + for fname in os.listdir(_SESSIONS_DIR): + if not fname.endswith('.json'): + continue + try: + with open(os.path.join(_SESSIONS_DIR, fname), encoding='utf-8') as f: + sessions.append(json.load(f)) + except Exception: + pass + + if not sessions: + self._empty_lbl.show(); return + self._empty_lbl.hide() + + sessions.sort(key=lambda s: s.get('updated_at', ''), reverse=True) + + today_s = date.today().strftime("%Y-%m-%d") + yesterday_s = (date.today() - timedelta(days=1)).strftime("%Y-%m-%d") + week_s = (date.today() - timedelta(days=7)).strftime("%Y-%m-%d") + month_s = (date.today() - timedelta(days=30)).strftime("%Y-%m-%d") + + groups = [ + ("Today", []), + ("Yesterday", []), + ("Previous 7 days", []), + ("Previous 30 days", []), + ("Older", []), + ] + for s in sessions: + d = s.get('updated_at', '')[:10] + if d == today_s: groups[0][1].append(s) + elif d == yesterday_s: groups[1][1].append(s) + elif d >= week_s: groups[2][1].append(s) + elif d >= month_s: groups[3][1].append(s) + else: groups[4][1].append(s) + + for group_name, group_sessions in groups: + if not group_sessions: + continue + # Section header (non-selectable) + hdr = QListWidgetItem(self.session_list) + hdr.setFlags(Qt.NoItemFlags) + hdr.setSizeHint(QSize(240, 28)) + hdr_w = QLabel(group_name) + hdr_w.setStyleSheet( + "font-size:10px;font-weight:600;color:#aaa;letter-spacing:0.5px;" + "padding:6px 14px 2px 14px;background:transparent;" + ) + self.session_list.setItemWidget(hdr, hdr_w) + + for s in group_sessions: + sid = s['id'] + title = s.get('title', 'New chat') + msgs = s.get('messages', []) + preview = next((m[5:].strip() for m in msgs if m.startswith("User:")), "") + active = (sid == self._active_session_id) + + item = QListWidgetItem(self.session_list) + item.setData(Qt.UserRole, sid) + item.setSizeHint(QSize(240, 50)) + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + + widget = _SessionItemWidget(sid, title, preview, active, self.session_list) + widget.delete_requested.connect(self._delete_session) + self.session_list.setItemWidget(item, widget) + + def _on_item_clicked(self, item): + sid = item.data(Qt.UserRole) + if sid: + self.session_selected.emit(sid) + + def _delete_session(self, session_id: str): + path = os.path.join(_SESSIONS_DIR, f"{session_id}.json") + try: + if os.path.exists(path): + os.remove(path) + except Exception: + pass + self.session_deleted.emit(session_id) + self.populate() + + +# ============================================================================= +# SESSION MANAGER — pure data, no UI +# ============================================================================= + +class SessionManager: + """Handles all session file I/O so ChatbotGUI stays clean.""" + + @staticmethod + def load(session_id: str) -> dict: + path = os.path.join(_SESSIONS_DIR, f"{session_id}.json") + try: + with open(path, encoding='utf-8') as f: + return json.load(f) + except Exception: + return {} + + @staticmethod + def save(session_id: str, title: str, created_at: str, + messages: list, feedback: dict): + try: + os.makedirs(_SESSIONS_DIR, exist_ok=True) + path = os.path.join(_SESSIONS_DIR, f"{session_id}.json") + with open(path, 'w', encoding='utf-8') as f: + json.dump({ + "id": session_id, + "title": title, + "created_at": created_at, + "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), + "messages": messages[-40:], + "feedback": feedback, + }, f, ensure_ascii=False, indent=2) + except Exception: + pass + + @staticmethod + def delete(session_id: str): + try: + path = os.path.join(_SESSIONS_DIR, f"{session_id}.json") + if os.path.exists(path): + os.remove(path) + except Exception: + pass + + @staticmethod + def save_last_sid(session_id: str): + try: + os.makedirs(_ESIM_DIR, exist_ok=True) + with open(_LAST_SID_FILE, 'w') as f: + f.write(session_id) + except Exception: + pass + + @staticmethod + def load_last_sid() -> str: + try: + with open(_LAST_SID_FILE) as f: + return f.read().strip() + except Exception: + return "" + + +# ============================================================================= +# MAIN ChatbotGUI +# ============================================================================= + +class ChatbotGUI(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("eSim AI Assistant") + self.setMinimumSize(420, 350) + + self.copilot = ESIMCopilotWrapper() + self.worker = None + self._project_dir = None + self._generation_id = 0 + + # ── Active session state ────────────────────────────────────── + # These ALWAYS reflect the currently active session. + # Switching sessions updates ALL of these atomically. + self._current_session_id = str(uuid.uuid4()) + self._session_created_at = datetime.now().strftime("%Y-%m-%d %H:%M") + self.chat_history = [] # list of "User: ..." / "Bot: ..." strings + self._feedback = {} + + # ── UI helpers ──────────────────────────────────────────────── + self._bot_responses = {} # idx → response text (for Copy/feedback) + self._response_counter = 0 + self._last_user_text = "" + self._retry_history = [] + self._typing_frame = 0 + self._typing_start_pos = -1 + self._mic_active = False + self._staged_images = [] + self._temperature = 0.35 + self._num_predict = 1024 + + # ── Timers ──────────────────────────────────────────────────── + self._thinking_timer = QTimer(self) + self._thinking_timer.timeout.connect(self._animate_thinking) + self._typing_anim_timer = QTimer(self) + self._typing_anim_timer.timeout.connect(self._animate_typing_bubble) + self._status_poll_timer = QTimer(self) + self._status_poll_timer.timeout.connect(self._update_ollama_status) + self._status_poll_timer.start(5000) + + # Toast + self._toast = QLabel(" ✅ Copied! ", self) + self._toast.setStyleSheet(""" + QLabel { background-color:#1a1a2e;color:#ffffff; + font-size:12px;font-weight:bold;border-radius:14px;padding:4px 14px; } + """) + self._toast.setAlignment(Qt.AlignCenter) + self._toast.hide() + + self._build_ui() + self._restore_last_session() + + # ========================================================================= + # BUILD UI + # ========================================================================= + + def _build_ui(self): + root = QHBoxLayout(self) + root.setContentsMargins(0, 0, 0, 0) + root.setSpacing(0) + + # Sidebar + self._sidebar = ChatSidebar(self) + self._sidebar.new_chat_requested.connect(self._new_chat) + self._sidebar.session_selected.connect(self._switch_to_session) + self._sidebar.session_deleted.connect(self._on_session_deleted) + self._sidebar.hide() + root.addWidget(self._sidebar) + + # Main area + chat_container = QWidget() + cl = QVBoxLayout(chat_container) + cl.setContentsMargins(8, 8, 8, 8) + cl.setSpacing(5) + root.addWidget(chat_container, 1) + + # Header + header = QHBoxLayout() + header.setSpacing(5) + + hist_btn = QPushButton("≡") + hist_btn.setFixedSize(32, 32) + hist_btn.setToolTip("Chat history") + hist_btn.setStyleSheet(""" + QPushButton { font-size:16px;border:none;border-radius:8px;background:transparent;color:#555; } + QPushButton:hover { background:#f0f0f0;color:#1a1a2e; } + QPushButton:pressed { background:#e0e0e0; } + """) + hist_btn.clicked.connect(self._toggle_sidebar) + header.addWidget(hist_btn) + + self.model_combo = QComboBox(self) + self.model_combo.setFixedHeight(30) + self.model_combo.setStyleSheet(""" + QComboBox { font-size:12px;padding:2px 10px;border:1px solid #e0e0e0; + border-radius:8px;background:#f7f7f7;color:#1a1a2e; } + QComboBox:focus { border:1px solid #0095f6;background:#fff; } + QComboBox::drop-down { border:none;width:18px; } + """) + self.model_combo.addItem("qwen2.5-coder:1.5b (RAG)") + self.model_combo.addItem("tinyllama:1.1b (RAG)") + self.model_combo.addItem("qwen2.5-coder:3b (RAG)") + header.addWidget(self.model_combo) + + settings_btn = QPushButton("⚙") + settings_btn.setFixedSize(28, 28) + settings_btn.setCheckable(True) + settings_btn.setStyleSheet(""" + QPushButton { font-size:14px;border:none;border-radius:8px;background:transparent;color:#555; } + QPushButton:hover { background:#f0f0f0; } + QPushButton:checked { background:#e8f0ff;color:#0095f6; } + """) + settings_btn.toggled.connect(lambda on: self._settings_panel.setVisible(on)) + header.addWidget(settings_btn) + header.addStretch() + + self.analyze_netlist_btn = QPushButton("Netlist ▶") + self.analyze_netlist_btn.setFixedHeight(28) + self.analyze_netlist_btn.setToolTip("Analyze active project netlist") + self.analyze_netlist_btn.setCursor(Qt.PointingHandCursor) + self.analyze_netlist_btn.setStyleSheet(""" + QPushButton { font-size:11px;font-weight:600;padding:0 12px; + background-color:#2ecc71;color:white;border:none;border-radius:14px; } + QPushButton:hover { background-color:#27ae60; } + QPushButton:pressed { background-color:#1e8449; } + QPushButton:disabled { background-color:#a9dfbf;color:#fff; } + """) + self.analyze_netlist_btn.clicked.connect(self.analyze_current_netlist) + header.addWidget(self.analyze_netlist_btn) + + self.ollama_status_label = QLabel(self) + self.ollama_status_label.setFixedHeight(24) + header.addWidget(self.ollama_status_label) + self._update_ollama_status() + + sep = QFrame(); sep.setFrameShape(QFrame.HLine) + sep.setStyleSheet("color:#ececec;margin:0;") + cl.addLayout(header) + cl.addWidget(sep) + + # Chat display + self.chat_display = QTextBrowser(self) + self.chat_display.setOpenLinks(False) + self.chat_display.setHtml(WELCOME_MESSAGE) + self.chat_display.anchorClicked.connect(self._handle_link_click) + self.chat_display.setStyleSheet(""" + QTextBrowser { background-color:#fafafa;border:none;padding:8px 4px; + font-family:'Segoe UI',Arial,sans-serif;font-size:13px; + selection-background-color:#cce4f7; } + QScrollBar:vertical { background:transparent;width:6px;border-radius:3px; } + QScrollBar::handle:vertical { background:#d0d0d0;border-radius:3px;min-height:24px; } + QScrollBar::handle:vertical:hover { background:#a0a0a0; } + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height:0px; } + """) + cl.addWidget(self.chat_display) + + # Status row + status_row = QHBoxLayout() + self.status_label = QLabel("", self) + self.status_label.setStyleSheet( + "color:#0095f6;font-size:11px;padding:1px 4px;background:transparent;" + ) + status_row.addWidget(self.status_label) + status_row.addStretch() + self.retry_button = QPushButton("Retry", self) + self.retry_button.setFixedHeight(26) + self.retry_button.setStyleSheet(""" + QPushButton { font-size:11px;padding:2px 10px;background:#fff3e0; + color:#b85c00;border:1px solid #f0c080;border-radius:13px; } + QPushButton:hover { background:#ffe0b2; } + """) + self.retry_button.clicked.connect(self._retry_last) + self.retry_button.hide() + status_row.addWidget(self.retry_button) + cl.addLayout(status_row) + + # Settings panel + self._settings_panel = QWidget() + self._settings_panel.setVisible(False) + self._settings_panel.setStyleSheet(""" + QWidget { background:#f7f9fc;border-top:1px solid #ececec;border-bottom:1px solid #ececec; } + """) + sp = QHBoxLayout(self._settings_panel) + sp.setContentsMargins(12, 8, 12, 8) + sp.setSpacing(16) + + temp_col = QVBoxLayout(); temp_col.setSpacing(2) + self._temp_label = QLabel(f"Precision {self._temperature:.2f}") + self._temp_label.setStyleSheet("font-size:10px;color:#555;background:transparent;") + temp_col.addWidget(self._temp_label) + self._temp_slider = QSlider(Qt.Horizontal) + self._temp_slider.setRange(1, 100) + self._temp_slider.setValue(int(self._temperature * 100)) + self._temp_slider.setFixedWidth(110) + self._temp_slider.valueChanged.connect(self._on_temp_changed) + self._temp_slider.setStyleSheet(""" + QSlider::groove:horizontal { height:4px;background:#ddd;border-radius:2px; } + QSlider::handle:horizontal { width:14px;height:14px;margin:-5px 0;background:#0095f6;border-radius:7px; } + QSlider::sub-page:horizontal { background:#0095f6;border-radius:2px; } + """) + temp_col.addWidget(self._temp_slider) + sp.addLayout(temp_col) + + tok_col = QVBoxLayout(); tok_col.setSpacing(2) + self._tok_label = QLabel(f"Max tokens {self._num_predict}") + self._tok_label.setStyleSheet("font-size:10px;color:#555;background:transparent;") + tok_col.addWidget(self._tok_label) + self._tok_slider = QSlider(Qt.Horizontal) + self._tok_slider.setRange(1, 40) + self._tok_slider.setValue(self._num_predict // 128) + self._tok_slider.setFixedWidth(110) + self._tok_slider.valueChanged.connect(self._on_tok_changed) + self._tok_slider.setStyleSheet(""" + QSlider::groove:horizontal { height:4px;background:#ddd;border-radius:2px; } + QSlider::handle:horizontal { width:14px;height:14px;margin:-5px 0;background:#0095f6;border-radius:7px; } + QSlider::sub-page:horizontal { background:#0095f6;border-radius:2px; } + """) + tok_col.addWidget(self._tok_slider) + sp.addLayout(tok_col) + sp.addStretch() + reset_btn = QPushButton("Reset") + reset_btn.setFixedHeight(26) + reset_btn.setStyleSheet(""" + QPushButton { font-size:10px;padding:2px 12px;background:#f0f0f0; + color:#555;border:none;border-radius:13px; } + QPushButton:hover { background:#e0e0e0; } + """) + reset_btn.clicked.connect(self._reset_settings) + sp.addWidget(reset_btn) + cl.addWidget(self._settings_panel) + + # Input row + input_row = QHBoxLayout() + input_row.setSpacing(5) + + self.attach_button = QPushButton("📎") + self.attach_button.setFixedSize(38, 38) + self.attach_button.setToolTip("Attach image") + self.attach_button.setStyleSheet(""" + QPushButton { font-size:16px;background:#f0f0f0;border:none;border-radius:19px; } + QPushButton:hover { background:#e0e8ff; } + QPushButton:disabled { background:#f5f5f5;color:#ccc; } + """) + self.attach_button.clicked.connect(self._pick_image) + input_row.addWidget(self.attach_button) + + self.mic_button = QPushButton("🎤") + self.mic_button.setFixedSize(38, 38) + self.mic_button.setToolTip("Speak your question") + self.mic_button.setStyleSheet(""" + QPushButton { font-size:15px;background:#f0f0f0;border:none;border-radius:19px; } + QPushButton:hover { background:#d0f8d0; } + QPushButton:disabled { background:#f5f5f5;color:#ccc; } + """) + self.mic_button.clicked.connect(self._on_mic_clicked) + QTimer.singleShot(200, self._update_mic_tooltip) + input_row.addWidget(self.mic_button) + + self.user_input = _HistoryLineEdit( + self, placeholderText="Message eSim AI… (↑↓ for history)" + ) + self.user_input.setStyleSheet(""" + QLineEdit { font-size:13px;padding:9px 14px;border:1.5px solid #e0e0e0; + border-radius:22px;background:#f7f7f7;color:#1a1a2e; } + QLineEdit:focus { border:1.5px solid #0095f6;background:#ffffff; } + QLineEdit:disabled { background:#efefef;color:#ccc; } + """) + self.user_input.returnPressed.connect(self.send_message) + input_row.addWidget(self.user_input) + + self.send_button = QPushButton("Send") + self.send_button.setFixedHeight(38) + self.send_button.setStyleSheet(""" + QPushButton { font-size:13px;font-weight:600;padding:5px 20px; + background-color:#0095f6;color:white;border:none;border-radius:19px; } + QPushButton:hover { background-color:#0082d8; } + QPushButton:disabled { background-color:#d0d0d0;color:#fff; } + """) + self.send_button.clicked.connect(self.send_message) + input_row.addWidget(self.send_button) + + self.stop_button = QPushButton("Stop") + self.stop_button.setFixedHeight(38) + self.stop_button.setStyleSheet(""" + QPushButton { font-size:13px;font-weight:600;padding:5px 16px; + background-color:#ff3b30;color:white;border:none;border-radius:19px; } + QPushButton:hover { background-color:#e0302a; } + """) + self.stop_button.clicked.connect(self._stop_generating) + self.stop_button.hide() + input_row.addWidget(self.stop_button) + + self.clear_button = QPushButton("Clear") + self.clear_button.setFixedHeight(38) + self.clear_button.setStyleSheet(""" + QPushButton { font-size:13px;padding:5px 14px;background-color:#f0f0f0; + color:#666;border:none;border-radius:19px; } + QPushButton:hover { background-color:#ffe0e0;color:#cc0000; } + QPushButton:disabled { background-color:#f5f5f5;color:#bbb; } + """) + self.clear_button.clicked.connect(self._clear_current_session_ui) + input_row.addWidget(self.clear_button) + cl.addLayout(input_row) + + # Image staging strip + self._staging_area = QWidget() + self._staging_area.setStyleSheet("QWidget { background:#f5f8ff;border-radius:10px; }") + self._staging_area.setVisible(False) + sa_layout = QVBoxLayout(self._staging_area) + sa_layout.setContentsMargins(6, 6, 6, 4) + sa_layout.setSpacing(4) + sa_header = QHBoxLayout() + sa_lbl = QLabel("Images to send:") + sa_lbl.setStyleSheet("font-size:11px;color:#555;background:transparent;") + sa_header.addWidget(sa_lbl) + sa_header.addStretch() + ca_btn = QPushButton("Remove all") + ca_btn.setFixedHeight(20) + ca_btn.setStyleSheet(""" + QPushButton { font-size:10px;color:#cc0000;background:transparent;border:none;padding:0 4px; } + QPushButton:hover { text-decoration:underline; } + """) + ca_btn.clicked.connect(self._clear_staged_images) + sa_header.addWidget(ca_btn) + sa_layout.addLayout(sa_header) + scroll = QScrollArea() + scroll.setFixedHeight(72) + scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff) + scroll.setWidgetResizable(True) + scroll.setStyleSheet("QScrollArea { border:none;background:transparent; }") + self._thumb_container = QWidget() + self._thumb_container.setStyleSheet("background:transparent;") + self._thumb_row = QHBoxLayout(self._thumb_container) + self._thumb_row.setContentsMargins(0, 0, 0, 0) + self._thumb_row.setSpacing(6) + self._thumb_row.addStretch() + scroll.setWidget(self._thumb_container) + sa_layout.addWidget(scroll) + cl.addWidget(self._staging_area) + + # ========================================================================= + # SESSION SWITCHING — the core of proper history isolation + # ========================================================================= + + def _restore_last_session(self): + """On startup, restore the last active session (if any).""" + last_sid = SessionManager.load_last_sid() + if last_sid: + data = SessionManager.load(last_sid) + if data: + self._load_session_data(last_sid, data) + self._render_session(show_banner=False) + return + # No previous session — show welcome + self.chat_display.setHtml(WELCOME_MESSAGE) + + def _switch_to_session(self, session_id: str): + """Switch the active session to session_id. Called from sidebar click.""" + if session_id == self._current_session_id: + # Already on this session — just close sidebar + self._sidebar.hide() + return + + # Save the current session before switching away + self._save_current_session() + + # Load the target session + data = SessionManager.load(session_id) + if not data: + return + + self._load_session_data(session_id, data) + self._render_session(show_banner=True) + + # Update sidebar highlight + self._sidebar.set_active_session(session_id) + self._sidebar.populate() + + SessionManager.save_last_sid(session_id) + + def _load_session_data(self, session_id: str, data: dict): + """Atomically load session data into all instance variables.""" + self._current_session_id = session_id + self._session_created_at = data.get('created_at', datetime.now().strftime("%Y-%m-%d %H:%M")) + self.chat_history = list(data.get('messages', [])) + self._feedback = dict(data.get('feedback', {})) + self._last_user_text = next( + (m[5:].strip() for m in reversed(self.chat_history) if m.startswith("User:")), "" + ) + self._retry_history = list(self.chat_history) + + def _render_session(self, show_banner: bool = False): + """Re-render self.chat_history into the chat display.""" + html = WELCOME_MESSAGE + if show_banner: + title = next((m[5:].strip()[:50] for m in self.chat_history + if m.startswith("User:")), "Chat") + created = self._session_created_at + html += ( + '' + '
' + '
' + f'📂 Switched to: {title}  ·  {created}' + '

' + ) + + for line in self.chat_history: + if line.startswith("User:"): + html += _user_bubble(line[5:].strip()) + elif line.startswith("Bot:"): + idx = self._response_counter + self._response_counter += 1 + self._bot_responses[idx] = line[4:].strip() + html += _bot_bubble(line[4:].strip(), "", idx) + + self.chat_display.setHtml(html) + QTimer.singleShot(120, lambda: + self.chat_display.verticalScrollBar().setValue( + self.chat_display.verticalScrollBar().maximum() + ) + ) + + # ========================================================================= + # NEW CHAT / CLEAR + # ========================================================================= + + def _new_chat(self): + """Save current session and start a fresh one.""" + self._save_current_session() + + self._current_session_id = str(uuid.uuid4()) + self._session_created_at = datetime.now().strftime("%Y-%m-%d %H:%M") + self.chat_history = [] + self._feedback = {} + self._bot_responses = {} + self._response_counter = 0 + self._last_user_text = "" + self._retry_history = [] + + self.chat_display.setHtml(WELCOME_MESSAGE) + self.retry_button.hide() + self._clear_staged_images() + + self._sidebar.set_active_session(self._current_session_id) + self._sidebar.populate() + SessionManager.save_last_sid(self._current_session_id) + + def _clear_current_session_ui(self): + """Clear messages from current session (keep session ID, reset messages).""" + self.chat_history = [] + self._feedback = {} + self._bot_responses = {} + self._response_counter = 0 + self._last_user_text = "" + self._retry_history = [] + self.retry_button.hide() + self._clear_staged_images() + self.chat_display.setHtml(WELCOME_MESSAGE) + # Overwrite session file with empty messages + self._save_current_session() + self._refresh_sidebar_if_open() + + # ========================================================================= + # SEND MESSAGE + # ========================================================================= + + def send_message(self): + user_text = self.user_input.text().strip() + staged_paths = list(self._staged_images) + + if not user_text and not staged_paths: + return + + ts = _get_time() + + if staged_paths: + fnames = [os.path.basename(p) for p in staged_paths] + self.chat_display.append(_user_bubble("📎 " + ", ".join(fnames), ts)) + if user_text: + self.chat_display.append(_user_bubble(user_text, ts)) + self._scroll_to_bottom() + self.user_input.add_to_history(user_text) + self.user_input.clear() + self._clear_staged_images() + self._dispatch(f"[Image: {staged_paths[0]}] {user_text}".strip()) + return + + # Topic switch detection + if self._last_user_text: + try: + from chatbot.chatbot_thread import detect_topic_switch + if detect_topic_switch(self._last_user_text, user_text) and self.chat_history: + self.chat_history = self.chat_history[-2:] + self.chat_display.append( + '' + '
' + '— New topic —
' + ) + except ImportError: + pass + + # Append to current session + self.chat_history = (self.chat_history + [f"User: {user_text}"])[-20:] + self.chat_display.append(_user_bubble(user_text, ts)) + self._scroll_to_bottom() + self.user_input.add_to_history(user_text) + self.user_input.clear() + self._last_user_text = user_text + self._retry_history = list(self.chat_history) + self._dispatch(user_text) + + # ========================================================================= + # DISPATCH / RESPONSE + # ========================================================================= + + def _is_busy(self) -> bool: + if self.worker and self.worker.isRunning(): + QMessageBox.warning(self, "Busy", "Please wait for current response.") + return True + return False + + def _dispatch(self, full_query: str): + self._start_thinking() + self._generation_id += 1 + gen = self._generation_id + if self.worker and self.worker.isRunning(): + self.worker.quit() + self.worker.wait(300) + self.worker = ChatWorker(full_query, self.copilot) + self.worker.response_ready.connect(lambda resp, g=gen: self._on_response(resp, g)) + self.worker.start() + + def _on_response(self, response: str, gen_id: int): + self._stop_thinking() + if gen_id != self._generation_id: + return + ts = _get_time() + idx = self._response_counter + self._response_counter += 1 + self._bot_responses[idx] = response + self.chat_display.append(_bot_bubble(response, ts, idx)) + self.chat_history.append(f"Bot: {response}\n") + self._scroll_to_bottom() + self._save_current_session() + self._update_ollama_status() + self._refresh_sidebar_if_open() + if response.startswith("❌") or response.startswith("⚠️"): + self.retry_button.show() + else: + self.retry_button.hide() + + # ========================================================================= + # PERSISTENCE + # ========================================================================= + + def _save_current_session(self): + title = next((m[5:].strip()[:50] for m in self.chat_history + if m.startswith("User:")), "New chat") + SessionManager.save( + self._current_session_id, title, + self._session_created_at, + self.chat_history, self._feedback + ) + SessionManager.save_last_sid(self._current_session_id) + + # ========================================================================= + # SESSION DELETED + # ========================================================================= + + def _on_session_deleted(self, deleted_id: str): + if deleted_id == self._current_session_id: + # The active session was deleted — start fresh + self._new_chat() + + # ========================================================================= + # NETLIST + # ========================================================================= + + def set_project_context(self, project_dir: str): + if project_dir and os.path.isdir(project_dir): + self._project_dir = project_dir + self.chat_display.append(_system_bubble(f"Project: {os.path.basename(project_dir)}")) + else: + self._project_dir = None + self.chat_display.append(_system_bubble("Project context cleared.")) + self._scroll_to_bottom() + + def _build_netlist_query(self, netlist_path: str): + try: + with open(netlist_path, "r", encoding="utf-8", errors="ignore") as f: + netlist_text = f.read() + except Exception as e: + QMessageBox.warning(self, "Error", f"Failed to read netlist:\n{e}") + return None + + is_ok = _validate_netlist_with_ngspice(netlist_text) + floats = _detect_floating_nodes(netlist_text) + mmodels = _detect_missing_models(netlist_text) + msubckt = _detect_missing_subcircuits(netlist_text) + vconfl = _detect_voltage_source_conflicts(netlist_text) + tl = netlist_text.lower() + has_tran, has_ac, has_op = ".tran" in tl, ".ac" in tl, ".op" in tl + has0, has_gnd = _netlist_ground_info(netlist_text) + + fd = "; ".join([f"{n}(line {l},{e})" for n,l,e in floats]) or "NONE" + md = "; ".join([f"{m}(x{len(o)})" for m,o in mmodels]) or "NONE" + sd = "; ".join([f"{s}(x{len(o)})" for s,o in msubckt]) or "NONE" + vd = "; ".join([f"{p}: {','.join(f'{nm}={v}' for _,nm,v in srcs)}" + for p,srcs in vconfl]) if vconfl else "NONE" + + facts = "\n".join(f"[FACT {f}]" for f in [ + f"NET_SYNTAX_VALID={'YES' if is_ok else 'NO'}", + f"NET_HAS_NODE_0={'YES' if has0 else 'NO'}", + f"NET_HAS_GND_LABEL={'YES' if has_gnd else 'NO'}", + f"NET_HAS_TRAN={'YES' if has_tran else 'NO'}", + f"NET_HAS_AC={'YES' if has_ac else 'NO'}", + f"NET_HAS_OP={'YES' if has_op else 'NO'}", + f"FLOATING_NODES={fd}", f"MISSING_MODELS={md}", + f"MISSING_SUBCKTS={sd}", f"VOLTAGE_CONFLICTS={vd}", + ]) + return ( + f"{NETLIST_CONTRACT}\n\n=== NETLIST FACTS ===\n{facts}\n\n" + f"=== RAW NETLIST ===\n[ESIM_NETLIST_START]\n{netlist_text}\n" + "[ESIM_NETLIST_END]\n\nDo NOT invent issues not in FACT lines." + ) + + def analyze_current_netlist(self): + if self._is_busy(): return + if not self._project_dir: + try: + ac = Appconfig() + ap = ac.current_project.get("ProjectName") + if ap and os.path.isdir(ap): + self._project_dir = ap + except Exception: + pass + if not self._project_dir: + QMessageBox.warning(self, "No project", "No active project set."); return + proj_name = os.path.basename(self._project_dir) + try: + all_files = os.listdir(self._project_dir) + except Exception as e: + QMessageBox.warning(self, "Error", f"Cannot read project:\n{e}"); return + candidates = [f for f in all_files if f.endswith('.cir') or f.endswith('.cir.out')] + if not candidates: + QMessageBox.warning(self, "Not found", "No .cir files found."); return + path = None + for pref in [proj_name + ".cir.out", proj_name + ".cir"]: + if pref in candidates: + path = os.path.join(self._project_dir, pref); break + if not path: + if len(candidates) == 1: + path = os.path.join(self._project_dir, candidates[0]) + else: + item, ok = QInputDialog.getItem(self, "Select netlist", "Choose:", candidates, 0, False) + if ok and item: + path = os.path.join(self._project_dir, item) + if not path: return + self.chat_display.append(_netlist_header_bubble(os.path.basename(path), _get_time())) + self._scroll_to_bottom() + query = self._build_netlist_query(path) + if query: self._dispatch(query) + + def analyze_specific_netlist(self, netlist_path: str): + if self._is_busy(): return + if not os.path.exists(netlist_path): + QMessageBox.warning(self, "Not found", f"File not found:\n{netlist_path}"); return + self.chat_display.append(_netlist_header_bubble(os.path.basename(netlist_path), _get_time())) + self._scroll_to_bottom() + query = self._build_netlist_query(netlist_path) + if query: self._dispatch(query) + + def debug_error(self, error_log_path: str): + if not error_log_path or not os.path.exists(error_log_path): return + try: + with open(error_log_path, "r", encoding="utf-8", errors="ignore") as f: + log_text = f.read() + except Exception: + return + self.chat_display.append(_system_bubble("⚠️ Simulation error — analysing…")) + self._scroll_to_bottom() + self._dispatch( + "The following is an ngspice error log from an eSim simulation.\n" + "1) Explain the root cause simply.\n" + "2) Give step-by-step fix instructions for eSim.\n\n" + f"[NGSPICE_ERROR_LOG_START]\n{log_text}\n[NGSPICE_ERROR_LOG_END]" + ) + + # ========================================================================= + # THINKING / TYPING BUBBLE + # ========================================================================= + + def _animate_thinking(self): + pass + + def _start_thinking(self): + self._thinking_timer.start(500) + self.user_input.setEnabled(False) + self.attach_button.setEnabled(False) + self.mic_button.setEnabled(False) + self.send_button.hide() + self.stop_button.show() + self.clear_button.setEnabled(False) + if hasattr(self, "analyze_netlist_btn"): + self.analyze_netlist_btn.setEnabled(False) + self.retry_button.hide() + self._show_typing_bubble() + + def _stop_thinking(self): + self._thinking_timer.stop() + self._remove_typing_bubble() + self.status_label.setText("") + self.user_input.setEnabled(True) + self.attach_button.setEnabled(True) + self.mic_button.setEnabled(True) + self.stop_button.hide() + self.send_button.show() + self.clear_button.setEnabled(True) + if hasattr(self, "analyze_netlist_btn"): + self.analyze_netlist_btn.setEnabled(True) + + def _show_typing_bubble(self): + self._typing_frame = 0 + self._typing_start_pos = self.chat_display.document().characterCount() - 1 + cursor = QTextCursor(self.chat_display.document()) + cursor.movePosition(QTextCursor.End) + cursor.insertHtml(_typing_bubble(0)) + self._scroll_to_bottom() + self._typing_anim_timer.start(400) + + def _animate_typing_bubble(self): + self._typing_frame = (self._typing_frame + 1) % 3 + cursor = QTextCursor(self.chat_display.document()) + cursor.setPosition(self._typing_start_pos) + cursor.movePosition(QTextCursor.End, QTextCursor.KeepAnchor) + cursor.insertHtml(_typing_bubble(self._typing_frame)) + self._scroll_to_bottom() + + def _remove_typing_bubble(self): + self._typing_anim_timer.stop() + if self._typing_start_pos >= 0: + cursor = QTextCursor(self.chat_display.document()) + cursor.setPosition(self._typing_start_pos) + cursor.movePosition(QTextCursor.End, QTextCursor.KeepAnchor) + cursor.removeSelectedText() + self._typing_start_pos = -1 + + def _scroll_to_bottom(self): + self.chat_display.verticalScrollBar().setValue( + self.chat_display.verticalScrollBar().maximum() + ) + + def _stop_generating(self): + if self.worker and self.worker.isRunning(): + self.worker.stop() + + def _retry_last(self): + if not self._retry_history: return + self.retry_button.hide() + last_user = next((m[5:].strip() for m in reversed(self._retry_history) + if m.startswith("User:")), "") + if last_user: + self._dispatch(last_user) + + # ========================================================================= + # OLLAMA STATUS + # ========================================================================= + + def _update_ollama_status(self): + try: + import socket + socket.create_connection(("localhost", 11434), timeout=0.5).close() + running = True + except Exception: + running = False + if running: + self.ollama_status_label.setText("🟢 Live") + self.ollama_status_label.setStyleSheet(""" + QLabel { font-size:11px;font-weight:bold;padding:2px 10px; + border-radius:12px;background-color:#e6f9ee;color:#1a7f3c;border:1px solid #a3d9b5; } + """) + else: + self.ollama_status_label.setText("🔴 Offline") + self.ollama_status_label.setStyleSheet(""" + QLabel { font-size:11px;font-weight:bold;padding:2px 10px; + border-radius:12px;background-color:#fdecea;color:#b71c1c;border:1px solid #f5c0bc; } + """) + + # ========================================================================= + # SIDEBAR + # ========================================================================= + + def _toggle_sidebar(self): + if self._sidebar.isVisible(): + self._sidebar.hide() + else: + self._sidebar.set_active_session(self._current_session_id) + self._sidebar.populate() + self._sidebar.show() + + def _refresh_sidebar_if_open(self): + if self._sidebar.isVisible(): + self._sidebar.set_active_session(self._current_session_id) + self._sidebar.populate() + + # ========================================================================= + # LINK CLICK + # ========================================================================= + + def _handle_link_click(self, url): + scheme = url.scheme() + if scheme == 'copy': + try: + text = self._bot_responses.get(int(url.host()), "") + if text: + QApplication.clipboard().setText(text) + self._show_toast(" ✅ Copied! ") + except Exception: + pass + elif scheme == 'feedback': + direction = url.host() + try: + idx = int(url.path().strip('/').split('/')[0]) + except Exception: + return + self._feedback[idx] = direction + self._show_toast(f" {'👍' if direction=='up' else '👎'} Thanks! ") + self._save_current_session() + + def _show_toast(self, text: str): + self._toast.setText(text) + cr = self.chat_display.geometry() + tw, th = 120, 30 + self._toast.setGeometry( + cr.x() + (cr.width() - tw) // 2, + cr.y() + cr.height() - th - 16, tw, th + ) + self._toast.show(); self._toast.raise_() + QTimer.singleShot(1600, self._toast.hide) + + # ========================================================================= + # IMAGE STAGING + # ========================================================================= + + def _pick_image(self): + paths, _ = QFileDialog.getOpenFileNames(self, "Select Images", "", _IMG_FILTER) + for path in paths: + if path and path not in self._staged_images: + self._staged_images.append(path) + if self._staged_images: + self._refresh_staging_strip() + + def _refresh_staging_strip(self): + while self._thumb_row.count() > 1: + item = self._thumb_row.takeAt(0) + if item.widget(): item.widget().deleteLater() + for path in self._staged_images: + self._thumb_row.insertWidget(self._thumb_row.count() - 1, self._make_thumbnail(path)) + self._staging_area.setVisible(bool(self._staged_images)) + + def _make_thumbnail(self, image_path: str) -> QWidget: + card = QWidget(); card.setFixedSize(80, 64) + card.setStyleSheet("QWidget { background:#ffffff;border:1px solid #d0d8f0;border-radius:10px; }") + cl = QVBoxLayout(card); cl.setContentsMargins(4, 4, 4, 2); cl.setSpacing(2) + thumb = QLabel(); thumb.setAlignment(Qt.AlignCenter); thumb.setFixedHeight(36) + pix = QPixmap(image_path) + if not pix.isNull(): + thumb.setPixmap(pix.scaled(68, 36, Qt.KeepAspectRatio, Qt.SmoothTransformation)) + else: + thumb.setText("🖼"); thumb.setStyleSheet("font-size:20px;background:transparent;") + cl.addWidget(thumb) + fname = os.path.basename(image_path) + nl = QLabel(fname[:10] + ("…" if len(fname) > 10 else "")) + nl.setAlignment(Qt.AlignCenter) + nl.setStyleSheet("font-size:9px;color:#555;background:transparent;") + cl.addWidget(nl) + rem = QPushButton("✕", card); rem.setFixedSize(16, 16); rem.move(62, 2) + rem.setStyleSheet(""" + QPushButton { font-size:9px;font-weight:bold;background:#ff3b30;color:white; + border:none;border-radius:8px;padding:0; } + QPushButton:hover { background:#cc2a22; } + """) + rem.clicked.connect(lambda checked, p=image_path: self._remove_staged_image(p)) + return card + + def _remove_staged_image(self, path: str): + if path in self._staged_images: self._staged_images.remove(path) + self._refresh_staging_strip() + + def _clear_staged_images(self): + self._staged_images.clear(); self._refresh_staging_strip() + + # ========================================================================= + # MIC + # ========================================================================= + + def _update_mic_tooltip(self): + tips = { + "whisper": "🎤 Speak ✅ Offline (Whisper)", + "google": "🎤 Speak 🌐 Online (Google)", + "vosk": "🎤 Speak ✅ Offline (vosk)", + "none": "🎤 No STT — pip install faster-whisper", + } + self.mic_button.setToolTip(tips.get(get_stt_backend(), "Speak")) + + def _on_mic_clicked(self): + if self._mic_active: return + self._mic_active = True + self.mic_button.setEnabled(False) + self.mic_button.setStyleSheet(""" + QPushButton { font-size:15px;background:#d0f8d0;border:2px solid #28a745;border-radius:18px; } + """) + self.status_label.setText("🎤 Starting microphone…") + self._mic_worker = MicWorker() + self._mic_worker.text_signal.connect(self._on_mic_text) + self._mic_worker.error_signal.connect(self._on_mic_error) + self._mic_worker.status_signal.connect(lambda msg: self.status_label.setText(msg)) + self._mic_worker.start() + + def _on_mic_text(self, text: str): + self._reset_mic_button(); self.status_label.setText("") + cur = self.user_input.text().strip() + self.user_input.setText((cur + " " + text) if cur else text) + self.user_input.setFocus() + + def _on_mic_error(self, msg: str): + self._reset_mic_button(); self.status_label.setText(msg) + QTimer.singleShot(3500, lambda: self.status_label.setText("")) + + def _reset_mic_button(self): + self._mic_active = False + self.mic_button.setEnabled(True) + self.mic_button.setStyleSheet(""" + QPushButton { font-size:15px;background:#f0f0f0;border:none;border-radius:19px; } + QPushButton:hover { background:#d0f8d0; } + """) + + # ========================================================================= + # SETTINGS + # ========================================================================= + + def _on_temp_changed(self, value: int): + self._temperature = round(value / 100, 2) + self._temp_label.setText(f"Precision {self._temperature:.2f}") + + def _on_tok_changed(self, value: int): + self._num_predict = value * 128 + self._tok_label.setText(f"Max tokens {self._num_predict}") + + def _reset_settings(self): + self._temperature = 0.35; self._num_predict = 1024 + self._temp_slider.setValue(35); self._tok_slider.setValue(8) + + # ========================================================================= + # SHUTDOWN + # ========================================================================= + + def closeEvent(self, event): + self._save_current_session() + if self.worker and self.worker.isRunning(): + self.worker.quit(); self.worker.wait(500) + try: clear_history() + except Exception: pass + event.accept() + + +# ============================================================================= +# DOCK FACTORY +# ============================================================================= + +def createchatbotdock(parent=None): + dock = QDockWidget("🤖 eSim AI Assistant", parent) + dock.setAllowedAreas(Qt.RightDockWidgetArea | Qt.LeftDockWidgetArea) + dock.setWidget(ChatbotGUI(parent)) + return dock + +def create_chatbot_dock(parent=None): + return createchatbotdock(parent) + + +# ============================================================================= +# STANDALONE TEST +# ============================================================================= + +if __name__ == "__main__": + app = QApplication(sys.argv) + w = ChatbotGUI() + w.resize(700, 620) + w.show() + sys.exit(app.exec_()) \ No newline at end of file diff --git a/src/frontEnd/tts_handler.py b/src/frontEnd/tts_handler.py new file mode 100644 index 000000000..766ff553a --- /dev/null +++ b/src/frontEnd/tts_handler.py @@ -0,0 +1,61 @@ +# tts_handler.py +# Place this file in src/frontEnd/ alongside Chatbot.py +# Install: sudo apt install espeak + +import re +import subprocess +from PyQt5.QtCore import QThread + + +class TTSWorker(QThread): + def __init__(self, text: str): + super().__init__() + self.text = text + self._proc = None + + def run(self): + try: + self._proc = subprocess.Popen( + ["espeak", "-s", "150", "-a", "200", self.text], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + print(f"[TTS] espeak started, pid={self._proc.pid}") + self._proc.wait() + print(f"[TTS] espeak finished naturally") + except FileNotFoundError: + print("[TTS] espeak not found. Run: sudo apt install espeak") + except Exception as e: + print(f"[TTS] Error: {e}") + finally: + self._proc = None + + def stop(self): + print(f"[TTS] stop() called, _proc={self._proc}") + try: + if self._proc and self._proc.poll() is None: + print(f"[TTS] killing pid {self._proc.pid}") + self._proc.kill() + self._proc.wait() + print(f"[TTS] killed!") + else: + print(f"[TTS] proc already done or None — nothing to kill") + except Exception as e: + print(f"[TTS] kill error: {e}") + finally: + self._proc = None + self.terminate() + self.wait(300) + + +def clean_for_tts(text: str) -> str: + text = re.sub(r'```.*?```', 'Code block.', text, flags=re.DOTALL) + text = re.sub(r'`([^`]+)`', r'\1', text) + text = re.sub(r'\*\*(.*?)\*\*', r'\1', text) + text = re.sub(r'\*(.*?)\*', r'\1', text) + text = re.sub(r'#+\s*', '', text) + text = re.sub(r'https?://\S+', 'link', text) + text = re.sub(r'[#\*\_~<>]', '', text) + text = re.sub(r'\n+', '. ', text) + text = re.sub(r'\s+', ' ', text) + return text.strip() \ No newline at end of file