diff --git a/run_chatbot.py b/run_chatbot.py new file mode 100644 index 0000000000..b0fc4e559c --- /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 101b084ab4..103fae2ce1 100644 --- a/src/chatbot/chatbot_thread.py +++ b/src/chatbot/chatbot_thread.py @@ -147,8 +147,29 @@ def _downscale_image_bytes(raw_bytes: bytes) -> bytes: def get_stt_backend() -> str: + """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" @@ -422,7 +443,7 @@ 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 piece = chunk["message"]["content"] bot_response += piece @@ -582,7 +603,8 @@ def _chat_once(self, model_name: str, prompt: str, image_bytes_list): if self._stop_requested: response += "\n\n⏹ 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 self.chunk_signal.emit(piece) diff --git a/src/frontEnd/Chatbot.py b/src/frontEnd/Chatbot.py index 54bdaf3e77..013be78874 100644 --- a/src/frontEnd/Chatbot.py +++ b/src/frontEnd/Chatbot.py @@ -2296,8 +2296,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") # ── Settings ───────────────────────────────────────────────────── @@ -2442,8 +2457,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: @@ -2475,22 +2498,58 @@ def _save_current_session(self): pass def _load_history(self): + """ + 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, @@ -2498,9 +2557,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: @@ -2528,44 +2585,88 @@ def _on_models_fetched(self, model_names: list): from chatbot.chatbot_thread import is_ollama_running if not model_names: + # 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) - if is_ollama_running(): - self.status_label.setText( - "⚠️ No Ollama models found. Run 'ollama pull qwen2.5-coder:3b' " - "in a terminal to install one." - ) - else: - self.status_label.setText("🔴 Ollama is offline.") + self.status_label.setText( + "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' + ' |