diff --git a/run_chatbot.py b/run_chatbot.py new file mode 100644 index 000000000..b0fc4e559 --- /dev/null +++ b/run_chatbot.py @@ -0,0 +1,98 @@ +""" +run_chatbot.py — Standalone launcher for the eSim AI Chatbot +===================================================================== +Run from the project root: + python run_chatbot.py + +Requirements: + pip install PyQt5 ollama pillow + +For voice input: + pip install SpeechRecognition pyaudio + +For offline voice (no internet): + pip install faster-whisper + +LLM backend: + 1. Install Ollama from https://ollama.com + 2. ollama serve + 3. ollama pull qwen2.5:3b # text model (~2GB) + 4. ollama pull moondream # vision model (~1.6GB, optional) +""" +import os +import sys + +# Add src/ to Python path so all local imports resolve correctly +_HERE = os.path.dirname(os.path.abspath(__file__)) +_SRC = os.path.join(_HERE, 'src') +if _SRC not in sys.path: + sys.path.insert(0, _SRC) + +# Minimal Appconfig stub so Chatbot.py imports cleanly without the +# full eSim configuration layer (which expects an installed eSim). +# Only used when running chatbot standalone (not via Application.py). +import types +_conf_mod = types.ModuleType("configuration") +_app_mod = types.ModuleType("configuration.Appconfig") + +class _AppConfigStub: + """Minimal stub — only attributes that Chatbot.py actually uses.""" + noteArea = {} + procThread_list = [] + process_obj = [] + _APPLICATION = "eSim" + _VERSION = "2.4" + current_project = {"ProjectName": None} + def print_info(self, *a): pass + def print_error(self, *a): pass + +_app_mod.Appconfig = _AppConfigStub +_conf_mod.Appconfig = _app_mod +sys.modules["configuration"] = _conf_mod +sys.modules["configuration.Appconfig"] = _app_mod + +# Now safe to import the chatbot UI +from PyQt5.QtWidgets import QApplication +from PyQt5.QtGui import QFont +from frontEnd.Chatbot import ChatbotGUI # noqa: E402 + + +def main(): + app = QApplication(sys.argv) + app.setApplicationName("eSim AI Chatbot") + app.setFont(QFont("Segoe UI", 10)) + + # High-DPI support (Windows) + try: + from PyQt5.QtCore import Qt + QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) + QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) + except Exception: + pass + + window = ChatbotGUI() + window.setWindowTitle("eSim AI Assistant — Standalone") + window.resize(520, 640) + window.show() + + print("=" * 60) + print(" eSim AI Chatbot — running standalone") + print("=" * 60) + print() + print(" Status: Window opened [OK]") + print() + print(" To enable AI responses:") + print(" 1. Install Ollama: https://ollama.com") + print(" 2. Run: ollama serve") + print(" 3. Run: ollama pull qwen2.5:3b") + print(" 4. Restart this script") + print() + print(" The Live/Offline indicator in the top-right") + print(" of the chat window shows Ollama's status.") + print("=" * 60) + + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main() diff --git a/src/chatbot/chatbot_thread.py b/src/chatbot/chatbot_thread.py index c8cce620b..15d4c2193 100644 --- a/src/chatbot/chatbot_thread.py +++ b/src/chatbot/chatbot_thread.py @@ -62,9 +62,29 @@ def _downscale_image_bytes(raw_bytes: bytes) -> bytes: def get_stt_backend() -> str: - """Returns 'google' if SpeechRecognition is installed, else 'none'.""" + """Return the best available speech-to-text backend. + + Priority: faster-whisper (offline, best quality) > vosk (offline, lightweight) + > google (online, no install) > none + """ + # Check faster-whisper first: offline, no internet needed + try: + import faster_whisper # noqa: F401 + return "whisper" + except ImportError: + pass + + # Check vosk: offline, very lightweight + try: + import vosk # noqa: F401 + return "vosk" + except ImportError: + pass + + # Fall back to Google online STT (requires internet) if _SR_AVAILABLE: return "google" + return "none" @@ -85,13 +105,19 @@ def start_ollama(stop_flag=None): The polling loop checks stop_flag() each second and exits early if cancelled. """ if os.name == 'nt': - subprocess.Popen('start cmd /k "ollama serve"', shell=True) + # Run ollama serve silently in the background without a visible CMD window. + # CREATE_NO_WINDOW prevents a terminal from popping up and staying open. + subprocess.Popen( + ['ollama', 'serve'], + creationflags=subprocess.CREATE_NO_WINDOW, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) else: subprocess.Popen( - ['bash', '-c', - 'x-terminal-emulator -e "ollama serve" || ' - 'gnome-terminal -- ollama serve || ' - 'xterm -e "ollama serve"'] + ['bash', '-c', 'ollama serve'], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) for _ in range(30): @@ -344,9 +370,10 @@ def run(self): bot_response = "" for chunk in stream: if self._stop_requested: - bot_response += "\n\n⏹ Generation stopped." + bot_response += "\n\n\u23f9 Generation stopped." break - bot_response += chunk["message"]["content"] + # Use .get() to safely handle malformed/done chunks from Ollama + bot_response += chunk.get("message", {}).get("content", "") bot_response = bot_response.strip() if not bot_response: @@ -510,7 +537,8 @@ def _chat_once(self, model_name: str, prompt: str, image_bytes_list): if self._stop_requested: response += "\n\n\u23f9 Generation stopped." break - piece = chunk["message"]["content"] + # Use .get() to safely handle malformed/done chunks from Ollama + piece = chunk.get("message", {}).get("content", "") response += piece token_count += 1 diff --git a/src/frontEnd/Chatbot.py b/src/frontEnd/Chatbot.py index 2339681de..3a4232060 100644 --- a/src/frontEnd/Chatbot.py +++ b/src/frontEnd/Chatbot.py @@ -2220,8 +2220,23 @@ def _warn_or_switch_to_vision_model(self) -> bool: return False def _switch_to_text_model(self): - """Auto-switch to qwen2.5 for text queries.""" - self._auto_switch_model(["qwen2.5"], [], "text") + """Auto-switch away from vision model when sending plain text. + + Only switches if the currently selected model is a vision-only model + (llava, moondream, etc.) — vision models work for text but are 3–10x + slower than small text models like qwen2.5:3b. + If the user already has a text model selected, do nothing. + """ + from chatbot.chatbot_thread import _is_vision_model + current = self.model_combo.currentText() + # If the current model is already a text model, respect the user's choice. + if not _is_vision_model(current): + return + # Current model is vision-only — try to switch to a faster text model. + text_fallbacks = ['qwen2.5:3b', 'qwen2.5:7b', 'qwen2.5-coder', 'qwen2.5', + 'mistral', 'gemma3', 'llama3.2', 'llama3', 'phi3', 'phi4'] + self._auto_switch_model(["qwen2.5", "mistral", "gemma", "llama", "phi"], + text_fallbacks, "text") # ── Mic ────────────────────────────────────────────────────────── @@ -2380,8 +2395,16 @@ def _flush_save(self): self._save_pending = False try: os.makedirs(os.path.dirname(_HISTORY_FILE), exist_ok=True) + # Store the session_id alongside messages so _load_history() knows + # this chat was already saved as a proper session file. Without + # this, every restart recreates the session from the history file + # even though _save_current_session() already wrote it, producing + # a duplicate entry in the sidebar. with open(_HISTORY_FILE, 'w', encoding='utf-8') as f: - json.dump(self.chat_history[-20:], f, ensure_ascii=False, indent=2) + json.dump({ + "session_id": self._current_session_id, + "messages": self.chat_history[-20:], + }, f, ensure_ascii=False, indent=2) self._save_current_session() self._refresh_sidebar_if_open() except Exception: @@ -2416,26 +2439,57 @@ def _save_current_session(self): 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. + On startup: if a leftover chatbot_history.json exists, check whether + the session was already written as a proper session file during the + previous run (the normal case when the app exits cleanly). If it was, + just delete chatbot_history.json — no duplicate session is created. + Only creates a new session file when the session file is genuinely + missing (e.g. the app crashed before _save_current_session() ran). """ 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: + + # Support both the new dict format {session_id, messages} + # and the legacy plain-list format for backwards compatibility. + if isinstance(saved, dict): + saved_session_id = saved.get("session_id", "") + messages = saved.get("messages", []) + elif isinstance(saved, list): + saved_session_id = "" + messages = saved + else: + saved_session_id = "" + messages = [] + + # ── Duplicate-prevention guard ────────────────────────────── + # _flush_save() already called _save_current_session() which + # wrote the session .json file. If that file still exists, + # skip creating another one — it would produce an identical + # entry in the sidebar on every restart. + if saved_session_id: + existing = os.path.join(_SESSIONS_DIR, f"{saved_session_id}.json") + if os.path.exists(existing): + # Session already on disk — nothing to do; just clean up. + return # finally block below still removes _HISTORY_FILE + + # Session file is missing (crash recovery path): recreate it. + if isinstance(messages, list) and messages: title = next( - (m[5:].strip()[:50] for m in saved if m.startswith("User:")), + (m[5:].strip()[:50] for m in messages if m.startswith("User:")), "Previous session" ) + # Use the stored session_id when available so the recovered + # file has a stable identity; fall back to a fresh UUID. + sid = saved_session_id if saved_session_id else self._current_session_id old_session = { - "id": self._current_session_id, + "id": sid, "title": title, "created_at": self._session_created_at, "updated_at": datetime.now().strftime("%Y-%m-%d %H:%M"), - "messages": saved[-40:], + "messages": messages[-40:], "kind": "text", "settings": { "temperature": self._temperature, @@ -2443,9 +2497,7 @@ def _load_history(self): }, } os.makedirs(_SESSIONS_DIR, exist_ok=True) - sess_path = os.path.join( - _SESSIONS_DIR, f"{self._current_session_id}.json" - ) + sess_path = os.path.join(_SESSIONS_DIR, f"{sid}.json") with open(sess_path, 'w', encoding='utf-8') as f: json.dump(old_session, f, ensure_ascii=False, indent=2) except Exception: @@ -2473,36 +2525,80 @@ def _on_models_fetched(self, model_names: list): self.model_combo.clear() if not model_names: - # No models found — Ollama may be offline or has no models pulled. + # No models found — Ollama is not installed, not running, or has no models. self.model_combo.addItem("No models found") self.model_combo.setEnabled(False) self.status_label.setText( - "⚠️ No Ollama models found. Run 'ollama pull qwen2.5-coder' " - "in a terminal to install one." + "No Ollama models found. " + "Install Ollama from https://ollama.com, run 'ollama serve', " + "then 'ollama pull qwen2.5:3b'." + ) + # Show a more helpful message in the chat display + self.chat_display.append( + '
| '
+ ' '
+ 'No AI models installed ' + 'To use the chatbot: ' + '1. Download & install Ollama: ' + 'ollama.com ' + '2. Open a terminal and run: ollama serve' + '3. Pull a model: ollama pull qwen2.5:3b' + '4. Click ↻ (refresh) in the model bar above' + ' |