diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..05fd8a2 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +PYTHON_PATH=path_to_python +EMAIL_ADDRESS=example@gmail.com +EMAIL_PASSWORD=ztgzegzeg +EMAIL_SMTP_SERVER=smtp.gmail.com +EMAIL_SMTP_PORT=587 +LICENSE_API_URL=url +PURCHASE_URL=url +LICENSE_API_KEY=key \ No newline at end of file diff --git a/.gitignore b/.gitignore index 347c5af..5464992 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,5 @@ htmlcov/ .nuxt .serverless/ +# Data +models \ No newline at end of file diff --git a/app/core/llm_worker.py b/app/core/llm_worker.py new file mode 100644 index 0000000..ce5f77a --- /dev/null +++ b/app/core/llm_worker.py @@ -0,0 +1,113 @@ +import platform +import subprocess +from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot + + +def detect_backend() -> dict: + system = platform.system() + if system == "Darwin": + try: + result = subprocess.run( + ["sysctl", "-n", "hw.optional.arm64"], + capture_output=True, text=True + ) + if result.stdout.strip() == "1": + return {"n_gpu_layers": -1} + except FileNotFoundError: + pass + if system in ("Linux", "Windows"): + try: + import ctypes + lib = "libcuda.so" if system == "Linux" else "nvcuda.dll" + ctypes.CDLL(lib) + return {"n_gpu_layers": -1} + except OSError: + pass + return {"n_gpu_layers": 0} + + +class LLMWorker(QObject): + token_received = pyqtSignal(str) + message_finished = pyqtSignal() + error_occurred = pyqtSignal(str) + model_loaded = pyqtSignal() + model_info = pyqtSignal(str) + + def __init__(self, model_path: str = None, parent=None): + super().__init__(parent) + self.model_path = model_path + self.llm = None + self.history = [] + self._stopped = False + self._system_prompt = "Tu es un assistant IA utile, concis et précis. Réponds en français." + + @pyqtSlot() + def load_model(self): + if not self.model_path: + self.error_occurred.emit("Aucun chemin de modèle configuré.") + return + try: + from llama_cpp import Llama + backend_params = detect_backend() + gpu_layers = backend_params.get("n_gpu_layers", 0) + self.llm = Llama( + model_path=self.model_path, + n_ctx=4096, + n_batch=512, + verbose=False, + n_gpu_layers=gpu_layers, + ) + label = "GPU" if gpu_layers != 0 else "CPU" + self.model_info.emit(f"Modèle chargé ({label})") + self.model_loaded.emit() + except ImportError: + self.error_occurred.emit( + "llama-cpp-python n'est pas installé.\n" + "Exécutez : pip install llama-cpp-python" + ) + except Exception as e: + self.error_occurred.emit(f"Erreur de chargement du modèle : {e}") + + @pyqtSlot(str) + def send_message(self, message: str): + if not self.llm: + self.error_occurred.emit("Le modèle n'est pas chargé.") + return + self._stopped = False + messages = [{"role": "system", "content": self._system_prompt}] + messages.extend(self.history) + messages.append({"role": "user", "content": message}) + full_response = "" + try: + stream = self.llm.create_chat_completion( + messages=messages, + max_tokens=2048, + temperature=0.7, + top_p=0.9, + stream=True, + ) + for chunk in stream: + if self._stopped: + break + delta = chunk["choices"][0].get("delta", {}) + if "content" in delta: + token = delta["content"] + full_response += token + self.token_received.emit(token) + if not self._stopped: + self.history.append({"role": "user", "content": message}) + self.history.append({"role": "assistant", "content": full_response}) + self.message_finished.emit() + except Exception as e: + self.error_occurred.emit(str(e)) + + @pyqtSlot() + def stop(self): + self._stopped = True + + @pyqtSlot() + def clear_history(self): + self.history = [] + + def set_system_prompt(self, prompt: str): + self._system_prompt = prompt diff --git a/app/core/settings_manager.py b/app/core/settings_manager.py index c9e138b..ba52802 100644 --- a/app/core/settings_manager.py +++ b/app/core/settings_manager.py @@ -178,4 +178,20 @@ class SettingsManager: try: self.settings.setValue("maximized", maximized) except Exception as e: - logger.error(f"Error setting maximized state: {e}") \ No newline at end of file + logger.error(f"Error setting maximized state: {e}") + + # Model path + def get_model_path(self) -> str: + """Get the LLM model path""" + try: + return self.settings.value("model_path", self.default_settings.get("model_path", "")) + except Exception as e: + logger.error(f"Error getting model path: {e}") + return "" + + def set_model_path(self, path: str) -> None: + """Set the LLM model path""" + try: + self.settings.setValue("model_path", path) + except Exception as e: + logger.error(f"Error setting model path: {e}") \ No newline at end of file diff --git a/app/core/theme_manager.py b/app/core/theme_manager.py index d42f4b9..ff2d3d8 100644 --- a/app/core/theme_manager.py +++ b/app/core/theme_manager.py @@ -215,4 +215,118 @@ class ThemeManager: #tab_bar {{ background-color: {self.current_theme.get_color("background_color")}; }} + QSplitter::handle {{ + background-color: {self.current_theme.get_color("border_color")}; + }} + QScrollArea#chat_scroll {{ + background-color: {self.current_theme.get_color("background_secondary_color")}; + border: none; + }} + #messages_container {{ + background-color: transparent; + }} + #button_container {{ + background-color: {self.current_theme.get_color("background_color")}; + border-right: 1px solid {self.current_theme.get_color("border_color")}; + }} + QListWidget#chat_list {{ + background-color: transparent; + border: none; + outline: none; + font-size: 13px; + color: {self.current_theme.get_color("text_color")}; + }} + QListWidget#chat_list::item {{ + padding: 10px 12px; + border-radius: 10px; + background-color: transparent; + margin: 1px 0; + }} + QListWidget#chat_list::item:selected {{ + background-color: {self.current_theme.get_color("background_tertiary_color")}; + color: {self.current_theme.get_color("text_color")}; + }} + QListWidget#chat_list::item:hover {{ + background-color: {self.current_theme.get_color("background_tertiary_color")}; + }} + QListWidget#chat_list QLineEdit {{ + background-color: {self.current_theme.get_color("background_tertiary_color")}; + color: {self.current_theme.get_color("text_color")}; + border: none; + border-radius: 0; + padding: 0; + font-size: 13px; + selection-background-color: {self.current_theme.get_color("primary_color")}; + }} + QPushButton#sidebar_new_btn {{ + background-color: transparent; + color: {self.current_theme.get_color("text_color")}; + border: 1px solid {self.current_theme.get_color("border_color")}; + border-radius: 10px; + font-size: 13px; + padding: 10px; + }} + QPushButton#sidebar_new_btn:hover {{ + background-color: {self.current_theme.get_color("background_tertiary_color")}; + }} + #greeting_label {{ + font-size: 24px; + color: {self.current_theme.get_color("text_color")}; + background-color: transparent; + padding: 20px; + }} + #chat_input_container {{ + background-color: transparent; + }} + #chat_input_wrapper {{ + background-color: {self.current_theme.get_color("background_tertiary_color")}; + border: 2px solid {self.current_theme.get_color("border_color")}; + border-radius: 24px; + }} + QTextEdit#chat_input_field {{ + border: none; + background: transparent; + font-size: 14px; + color: {self.current_theme.get_color("text_color")}; + selection-background-color: {self.current_theme.get_color("primary_color")}; + }} + QPushButton#chat_send_btn {{ + background-color: {self.current_theme.get_color("primary_color")}; + color: #FFFFFF; + border: none; + border-radius: 19px; + font-size: 20px; + padding: 0; + }} + QPushButton#chat_send_btn:hover {{ + background-color: {self.current_theme.get_color("primary_hover_color")}; + }} + QPushButton#chat_send_btn:disabled {{ + background-color: {self.current_theme.get_color("background_secondary_color")}; + color: {self.current_theme.get_color("text_color")}; + }} + QPushButton#chat_stop_btn {{ + background-color: #FF453A; + color: #FFFFFF; + border: none; + border-radius: 19px; + font-size: 18px; + padding: 0; + }} + QPushButton#chat_stop_btn:hover {{ + background-color: #FF6961; + }} + QFrame#chat_bubble {{ + border-radius: 14px; + }} + + #status_label {{ + color: {self.current_theme.get_color("text_color")}; + font-size: 12px; + background-color: transparent; + padding: 4px; + }} + #chat_stack {{ + background-color: {self.current_theme.get_color("background_secondary_color")}; + }} """ \ No newline at end of file diff --git a/app/ui/main_window.py b/app/ui/main_window.py index cef1341..7f778c6 100644 --- a/app/ui/main_window.py +++ b/app/ui/main_window.py @@ -6,6 +6,7 @@ from app.ui.widgets.tabs_widget import TabsWidget, MenuDirection, ButtonPosition from app.ui.windows.settings_window import SettingsWindow from app.ui.windows.suggestion_window import SuggestionWindow from app.ui.windows.activation_window import ActivationWindow +from app.ui.windows.chat_window import ChatWindow import app.utils.paths as paths, shutil from typing import Optional @@ -232,6 +233,8 @@ class MainWindow(QMainWindow): def closeEvent(self, event: QCloseEvent) -> None: """Handle application close event""" + if hasattr(self, 'chat_window'): + self.chat_window.cleanup() super().closeEvent(event) # si la difference de taille est plus grande que 10 pixels, enregistrer previoussize if abs(self.current_size.width() - self.previous_size.width()) > 10 or abs(self.current_size.height() - self.previous_size.height()) > 10: @@ -247,20 +250,46 @@ class MainWindow(QMainWindow): def setup_ui(self) -> None: - self.side_menu = TabsWidget(self, MenuDirection.HORIZONTAL, 70, None, 10, BorderSide.BOTTOM, TabSide.TOP) + self.side_menu = TabsWidget(self, MenuDirection.VERTICAL, 160, self._on_tab_changed, 4, BorderSide.LEFT, TabSide.LEFT) - self.suggestion_window = SuggestionWindow(self) - self.side_menu.add_widget(self.suggestion_window, self.language_manager.get_text("tab_suggestions"), paths.get_asset_svg_path("suggestion"), position=ButtonPosition.CENTER, text_position=TextPosition.BOTTOM) + self.chat_window = ChatWindow(self) - self.settings_window = SettingsWindow(self) - self.side_menu.add_widget(self.settings_window, self.language_manager.get_text("tab_settings"), paths.get_asset_svg_path("settings"), position=ButtonPosition.CENTER, text_position=TextPosition.BOTTOM) - - # Ajouter la tab d'activation uniquement si le système de licence est activé if self.settings_manager.get_config("enable_licensing"): self.activation_window = ActivationWindow(self) - self.side_menu.add_widget(self.activation_window, self.language_manager.get_text("tab_licensing"), paths.get_asset_svg_path("license"), position=ButtonPosition.END, text_position=TextPosition.BOTTOM) + self.side_menu.add_widget(self.activation_window, self.language_manager.get_text("tab_licensing"), paths.get_asset_svg_path("license"), position=ButtonPosition.START, text_position=TextPosition.RIGHT) + + self.settings_window = SettingsWindow(self) + self.side_menu.add_widget(self.settings_window, self.language_manager.get_text("tab_settings"), paths.get_asset_svg_path("settings"), position=ButtonPosition.START, text_position=TextPosition.RIGHT) + + self.suggestion_window = SuggestionWindow(self) + self.side_menu.add_widget(self.suggestion_window, self.language_manager.get_text("tab_suggestions"), paths.get_asset_svg_path("suggestion"), position=ButtonPosition.START, text_position=TextPosition.RIGHT) + + self.side_menu.add_widget(self.chat_window, self.language_manager.get_text("tab_chat"), paths.get_asset_svg_path("chat"), position=ButtonPosition.START, text_position=TextPosition.RIGHT) + + bl = self.side_menu.button_layout + for i in reversed(range(bl.count())): + item = bl.itemAt(i) + if item.spacerItem(): + bl.removeItem(item) + + self._chat_sidebar = self.chat_window.get_sidebar_widget() + bl.addWidget(self._chat_sidebar, 1) + + self.side_menu.switch_to_tab(len(self.side_menu.buttons) - 1) self.setCentralWidget(self.side_menu) + self._update_session_visibility(len(self.side_menu.buttons) - 1) + + def _on_tab_changed(self, index: int): + self._update_session_visibility(index) + + def _update_session_visibility(self, index: int): + if not hasattr(self, 'side_menu') or len(self.side_menu.buttons) < 1: + return + chat_idx = len(self.side_menu.buttons) - 1 + session_active = self.side_menu.buttons[chat_idx].isChecked() + self.chat_window.chat_list.setVisible(session_active) + self.chat_window.new_chat_btn.setVisible(session_active) def get_tab_widget(self): """Retourne le widget TabsWidget pour permettre le changement d'onglet""" @@ -270,8 +299,13 @@ class MainWindow(QMainWindow): self.setStyleSheet(self.theme_manager.get_sheet()) def update_language(self) -> None: - # Mettre à jour les textes des onglets - self.side_menu.update_button_text(0, self.language_manager.get_text("tab_suggestions")) - self.side_menu.update_button_text(1, self.language_manager.get_text("tab_settings")) - if self.settings_manager.get_config("enable_licensing"): - self.side_menu.update_button_text(2, self.language_manager.get_text("tab_licensing")) \ No newline at end of file + num_buttons = len(self.side_menu.buttons) + if num_buttons >= 4: + self.side_menu.update_button_text(0, self.language_manager.get_text("tab_licensing")) + self.side_menu.update_button_text(1, self.language_manager.get_text("tab_settings")) + self.side_menu.update_button_text(2, self.language_manager.get_text("tab_suggestions")) + self.side_menu.update_button_text(3, self.language_manager.get_text("tab_chat")) + elif num_buttons >= 3: + self.side_menu.update_button_text(0, self.language_manager.get_text("tab_settings")) + self.side_menu.update_button_text(1, self.language_manager.get_text("tab_suggestions")) + self.side_menu.update_button_text(2, self.language_manager.get_text("tab_chat")) \ No newline at end of file diff --git a/app/ui/windows/chat_window.py b/app/ui/windows/chat_window.py new file mode 100644 index 0000000..c164ebb --- /dev/null +++ b/app/ui/windows/chat_window.py @@ -0,0 +1,722 @@ +import re +from PyQt6.QtWidgets import ( + QWidget, QVBoxLayout, QHBoxLayout, QScrollArea, + QTextEdit, QPushButton, QLabel, QSizePolicy, + QStackedWidget, QFrame, QApplication, + QListWidget, QListWidgetItem, QMenu, + QStyledItemDelegate, QStyle, QStyleOptionViewItem +) +from PyQt6.QtCore import Qt, QThread, QSize, QTimer, pyqtSignal +from PyQt6.QtGui import QResizeEvent, QAction, QPainter +from app.core.main_manager import MainManager, NotificationType +from app.core.llm_worker import LLMWorker +from typing import Optional + + +_LATEX = { + "\\alpha": "α", "\\beta": "β", "\\gamma": "γ", "\\delta": "δ", + "\\epsilon": "ε", "\\zeta": "ζ", "\\eta": "η", "\\theta": "θ", + "\\iota": "ι", "\\kappa": "κ", "\\lambda": "λ", "\\mu": "μ", + "\\nu": "ν", "\\xi": "ξ", "\\omicron": "ο", "\\pi": "π", + "\\rho": "ρ", "\\sigma": "σ", "\\tau": "τ", "\\upsilon": "υ", + "\\phi": "φ", "\\chi": "χ", "\\psi": "ψ", "\\omega": "ω", + "\\Gamma": "Γ", "\\Delta": "Δ", "\\Theta": "Θ", "\\Lambda": "Λ", + "\\Xi": "Ξ", "\\Pi": "Π", "\\Sigma": "Σ", "\\Phi": "Φ", + "\\Psi": "Ψ", "\\Omega": "Ω", + "\\nabla": "∇", "\\partial": "∂", "\\cdot": "·", "\\times": "×", + "\\infty": "∞", "\\rightarrow": "→", "\\Rightarrow": "⇒", + "\\left(": "(", "\\right)": ")", "\\left[": "[", "\\right]": "]", +} + + +def _latex_to_text(s: str) -> str: + s = re.sub(r"\\text\{([^}]*)\}", r"\1", s) + s = re.sub(r"\\mathbf\{([a-zA-Z])\}", lambda m: chr(ord('𝐚') - ord('a') + ord(m.group(1))) if 'a' <= m.group(1) <= 'z' else chr(ord('𝐀') - ord('A') + ord(m.group(1))) if 'A' <= m.group(1) <= 'Z' else m.group(1), s) + s = re.sub(r"\\frac\{([^}]*)\}\{([^}]*)\}", r"\1/\2", s) + for cmd, char in _LATEX.items(): + s = s.replace(cmd, char) + s = s.replace("\\,", " ").replace("\\;", " ").replace("\\!", "") + s = s.replace("{", "").replace("}", "") + return s + + +def _inline(text: str) -> str: + ph, n = {}, [0] + def save(m): + k = f"\x00_{n[0]}\x00"; n[0] += 1 + ph[k] = m.group(0); return k + text = re.sub(r"`([^`]+)`", save, text) + text = re.sub(r"\$\$(.+?)\$\$", save, text) + text = re.sub(r"\$(.+?)\$", save, text) + text = re.sub(r"\*\*\*([^*]+)\*\*\*", r"\1", text) + text = re.sub(r"\*\*([^*]+)\*\*", r"\1", text) + text = re.sub(r"\*([^*]+)\*", r"\1", text) + for k, v in ph.items(): + if v.startswith("`"): + text = text.replace(k, f"{v[1:-1]}") + elif v.startswith("$$"): + text = text.replace(k, f"{_latex_to_text(v[2:-2])}") + else: + text = text.replace(k, f"{_latex_to_text(v[1:-1])}") + return text + + +def md_to_html(text: str) -> str: + text = text.replace("&", "amp;").replace("<", "lt;").replace(">", "gt;") + lines = text.split("\n") + parts, buf, mode = [], [], "normal" + + def flush_buf(): + nonlocal buf + if not buf: + return + if mode == "code": + parts.append(f"
{''.join(buf)}
") + elif mode == "math": + parts.append(f"
{_latex_to_text(chr(10).join(buf))}
") + buf = [] + + for line in lines: + s = line.strip() + + if mode == "code": + if s.startswith("```"): + flush_buf() + mode = "normal" + else: + buf.append(line + "\n") + continue + + if s.startswith("```"): + flush_buf() + mode = "code" + continue + + if mode == "math": + if s.startswith("$$"): + flush_buf() + mode = "normal" + else: + buf.append(s) + continue + + if s.startswith("$$"): + flush_buf() + rest = s[2:] + j = rest.find("$$") + if j != -1: + parts.append(f"
{_latex_to_text(rest[:j])}
") + else: + mode = "math" + continue + + if s == "---" or s == "___" or s == "***": + parts.append("
") + continue + + m = re.match(r"^(#{1,3})\s+(.+)$", s) + if m: + parts.append(f"{_inline(m.group(2))}") + continue + + if re.match(r"^[-*]\s", s) or re.match(r"^\d+\.\s", s): + c = re.sub(r"^[-*]\s+", "", s) + c = re.sub(r"^\d+\.\s+", "", c) + if parts and parts[-1] != "") + + return "".join(parts).replace("amp;", "&").replace("lt;", "<").replace("gt;", ">").replace("&amp;", "&") + + +class MessageBubble(QFrame): + def __init__(self, text: str, is_user: bool, theme_manager, parent=None): + super().__init__(parent) + self.is_user = is_user + self.theme_manager = theme_manager + self._raw_text = text + + self.setObjectName("chat_bubble") + self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + self._max_allowed = 400 + + self.label = QLabel() + self.label.setWordWrap(True) + self.label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Minimum) + self.label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + + layout = QVBoxLayout(self) + layout.setContentsMargins(14, 10, 14, 10) + layout.addWidget(self.label) + + self._apply_style() + self.set_text(text) + + def _apply_style(self): + theme = self.theme_manager.current_theme + bg = theme.get_color("primary_color") if self.is_user else "transparent" + txt = "#FFFFFF" if self.is_user else theme.get_color("text_color") + self.setStyleSheet(f""" + QFrame#chat_bubble {{ + background-color: {bg}; + border-radius: 14px; + }} + """) + self.label.setStyleSheet(f""" + background-color: transparent; + color: {txt}; + font-size: 14px; + """) + + def set_text(self, text: str): + self._raw_text = text + self.label.setText(md_to_html(text)) + self._fit() + + def _fit(self, text: str = ""): + self.label.setMaximumWidth(self._max_allowed) + sh = self.label.sizeHint() + cw = self.width() + fw = sh.width() + 28 + if text: + fw = max(fw, cw) + fw = min(fw, self._max_allowed + 28) + self.setFixedSize(fw, sh.height() + 20) + self.updateGeometry() + p = self.parentWidget() + if p: + p.updateGeometry() + + def set_text(self, text: str): + self._raw_text = text + self.label.setText(md_to_html(text)) + self._fit() + + def update_text(self, text: str): + self._raw_text = text + self.label.setText(md_to_html(text)) + self._fit(text) + + def set_max_allowed(self, px: int): + self._max_allowed = px - 28 + self._fit() + + +class ElideDelegate(QStyledItemDelegate): + def paint(self, painter, option, index): + if option.state & QStyle.StateFlag.State_Editing: + super().paint(painter, option, index) + return + opt = QStyleOptionViewItem(option) + self.initStyleOption(opt, index) + text = opt.text + if text: + vw = option.widget.viewport().width() if option.widget else option.rect.width() + opt.text = painter.fontMetrics().elidedText( + text, Qt.TextElideMode.ElideRight, vw - 12 + ) + QApplication.style().drawControl( + QStyle.ControlElement.CE_ItemViewItem, opt, painter + ) + + def updateEditorGeometry(self, editor, option, index): + r = option.rect + if option.widget: + r.setWidth(option.widget.viewport().width()) + editor.setGeometry(r) + + +class ChatMessagesView(QWidget): + send_requested = pyqtSignal(str) + stop_requested = pyqtSignal() + + def __init__(self, theme_manager, parent=None): + super().__init__(parent) + self.theme_manager = theme_manager + self._bubbles: list[MessageBubble] = [] + self._has_messages = False + + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + + btn_size = 38 + self.input_container = QWidget() + self.input_container.setObjectName("chat_input_container") + outer_layout = QHBoxLayout(self.input_container) + outer_layout.setContentsMargins(100, 10, 100, 18) + outer_layout.setSpacing(6) + + self.input_wrapper = QWidget() + self.input_wrapper.setObjectName("chat_input_wrapper") + wrapper_layout = QHBoxLayout(self.input_wrapper) + wrapper_layout.setContentsMargins(0, 0, 0, 0) + + self.input_edit = QTextEdit() + self.input_edit.setObjectName("chat_input_field") + self.input_edit.setPlaceholderText("Posez votre question...") + self.input_edit.setFixedHeight(btn_size) + self.input_edit.document().setDocumentMargin(3) + self.input_edit.setViewportMargins(18, 6, 6, 6) + self.input_edit.setSizePolicy( + QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed + ) + self.input_edit.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff + ) + self.input_edit.installEventFilter(self) + + wrapper_layout.addWidget(self.input_edit) + + self.send_button = QPushButton("\u2191") + self.send_button.setObjectName("chat_send_btn") + self.send_button.setFixedSize(QSize(btn_size, btn_size)) + self.send_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.send_button.clicked.connect(self._on_send_clicked) + + self.stop_button = QPushButton("\u25A0") + self.stop_button.setObjectName("chat_stop_btn") + self.stop_button.setFixedSize(QSize(btn_size, btn_size)) + self.stop_button.setCursor(Qt.CursorShape.PointingHandCursor) + self.stop_button.clicked.connect(self._on_stop_clicked) + self.stop_button.hide() + + outer_layout.addWidget(self.input_wrapper, 1) + outer_layout.addWidget(self.send_button, 0) + outer_layout.addWidget(self.stop_button, 0) + + self.mode_stack = QStackedWidget() + self.mode_stack.setObjectName("chat_mode_stack") + + self.welcome_page = QWidget() + self.welcome_page.setObjectName("welcome_page") + self.welcome_layout = QVBoxLayout(self.welcome_page) + self.welcome_layout.setContentsMargins(0, 0, 0, 0) + self.welcome_layout.addStretch() + + self.greeting_label = QLabel("Comment puis-je vous aider ?") + self.greeting_label.setObjectName("greeting_label") + self.greeting_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.welcome_layout.addWidget(self.greeting_label, 0, Qt.AlignmentFlag.AlignCenter) + + self.welcome_layout.addSpacing(24) + + # input_container added here via _enter_welcome_mode(), expands full width + + self.welcome_layout.addStretch() + + self.chat_page = QWidget() + self.chat_page.setObjectName("chat_page") + self.chat_layout = QVBoxLayout(self.chat_page) + self.chat_layout.setContentsMargins(0, 0, 0, 0) + self.chat_layout.setSpacing(0) + + self.scroll_area = QScrollArea() + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setHorizontalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff + ) + self.scroll_area.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff + ) + self.scroll_area.setObjectName("chat_scroll") + + self.messages_container = QWidget() + self.messages_container.setObjectName("messages_container") + self.messages_layout = QVBoxLayout(self.messages_container) + self.messages_layout.setAlignment(Qt.AlignmentFlag.AlignTop) + self.messages_layout.setSpacing(6) + self.messages_layout.setContentsMargins(100, 20, 100, 20) + + self.scroll_area.setWidget(self.messages_container) + self.chat_layout.addWidget(self.scroll_area, 1) + + self.mode_stack.addWidget(self.welcome_page) + self.mode_stack.addWidget(self.chat_page) + + main_layout.addWidget(self.mode_stack) + + self._enter_welcome_mode() + + def _enter_welcome_mode(self): + self.input_container.setParent(None) + self.welcome_layout.insertWidget(3, self.input_container, 0) + self.mode_stack.setCurrentIndex(0) + self.scroll_area.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAlwaysOff + ) + + def _enter_chat_mode(self): + self.input_container.setParent(None) + self.chat_layout.addWidget(self.input_container, 0) + self.mode_stack.setCurrentIndex(1) + self.scroll_area.setVerticalScrollBarPolicy( + Qt.ScrollBarPolicy.ScrollBarAsNeeded + ) + + def _max_allowed_width(self) -> int: + return int(self.width() * 0.72) + + def add_bubble(self, text: str, is_user: bool) -> MessageBubble: + if not self._has_messages: + self._has_messages = True + self._enter_chat_mode() + + bubble = MessageBubble(text, is_user, self.theme_manager) + bubble._max_allowed = self._max_allowed_width() - 28 + bubble._fit() + self._bubbles.append(bubble) + + row = QWidget() + row.setStyleSheet("background-color: transparent;") + row.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Minimum) + row_layout = QHBoxLayout(row) + row_layout.setContentsMargins(0, 0, 0, 0) + align = Qt.AlignmentFlag.AlignRight if is_user else Qt.AlignmentFlag.AlignLeft + row_layout.addWidget(bubble, 0, align | Qt.AlignmentFlag.AlignTop) + + self.messages_layout.addWidget(row) + self._scroll_to_bottom() + return bubble + + def clear_messages(self): + self._bubbles.clear() + self._has_messages = False + for i in reversed(range(self.messages_layout.count())): + item = self.messages_layout.takeAt(i) + w = item.widget() + if w: + w.deleteLater() + self._enter_welcome_mode() + + def apply_theme_to_bubbles(self): + for b in self._bubbles: + b._apply_style() + + def _scroll_to_bottom(self): + sb = self.scroll_area.verticalScrollBar() + sb.setValue(sb.maximum()) + QApplication.processEvents() + + def resizeEvent(self, event): + super().resizeEvent(event) + m = self._max_allowed_width() - 28 + for b in self._bubbles: + b._max_allowed = m + b._fit() + + def eventFilter(self, obj, event): + if obj is self.input_edit and event.type() == event.Type.KeyPress: + if event.key() == Qt.Key.Key_Return and not ( + event.modifiers() & Qt.KeyboardModifier.ShiftModifier + ): + self._on_send_clicked() + return True + return super().eventFilter(obj, event) + + def _on_send_clicked(self): + self.send_requested.emit(self.input_edit.toPlainText().strip()) + + def _on_stop_clicked(self): + self.stop_requested.emit() + + +class ChatSession: + def __init__(self, title: str = "Nouveau Chat"): + self.title = title + self.messages: list[tuple[str, bool]] = [] + self.llm_history: list[dict] = [] + self.view: Optional[ChatMessagesView] = None + + +class ChatWindow(QWidget): + _send_request = pyqtSignal(str) + + def __init__(self, parent: Optional[QWidget] = None): + super().__init__(parent) + self.main_manager = MainManager.get_instance() + self.language_manager = self.main_manager.get_language_manager() + self.settings_manager = self.main_manager.get_settings_manager() + self.theme_manager = self.main_manager.get_theme_manager() + self.observer_manager = self.main_manager.get_observer_manager() + + self.observer_manager.subscribe(NotificationType.LANGUAGE, self.update_language) + self.observer_manager.subscribe(NotificationType.THEME, self._on_theme_changed) + + self.sessions: list[ChatSession] = [] + self._current_index: int = -1 + self._streaming = False + self._finished = False + self._session_counter = 0 + + self.thread: Optional[QThread] = None + self.worker: Optional[LLMWorker] = None + self._streaming_bubble: Optional[MessageBubble] = None + + self.setup_ui() + QTimer.singleShot(0, self._init_llm) + QTimer.singleShot(0, self._new_session) + + def _get_model_path(self) -> str: + return self.settings_manager.get_model_path() + + def _init_llm(self): + model_path = self._get_model_path() + if not model_path: + self.status_label.setText("Aucun modèle configuré") + self.status_label.show() + return + self.status_label.show() + self.thread = QThread() + self.worker = LLMWorker(model_path) + self.worker.moveToThread(self.thread) + + self.worker.token_received.connect(self._on_token) + self.worker.message_finished.connect(self._on_message_finished) + self.worker.error_occurred.connect(self._on_llm_error) + self.worker.model_loaded.connect(self._on_model_loaded) + self.worker.model_info.connect(self.status_label.setText) + self.worker.model_loaded.connect(lambda: self.status_label.hide()) + self._send_request.connect(self.worker.send_message) + + self.thread.started.connect(self.worker.load_model) + self.thread.start() + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + + self.chat_stack = QStackedWidget() + self.chat_stack.setObjectName("chat_stack") + layout.addWidget(self.chat_stack, 1) + + self.status_label = QLabel( + self.language_manager.get_text("chat_loading") + ) + self.status_label.setObjectName("status_label") + self.status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.status_label.setFixedHeight(28) + self.status_label.hide() + layout.addWidget(self.status_label) + + self.chat_list = QListWidget() + self.chat_list.setObjectName("chat_list") + self.chat_list.setSpacing(2) + self.chat_list.setItemDelegate(ElideDelegate()) + self.chat_list.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.chat_list.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu) + self.chat_list.customContextMenuRequested.connect(self._on_list_context_menu) + self.chat_list.currentRowChanged.connect(self._switch_to_session) + self.chat_list.itemChanged.connect(self._on_item_renamed) + + self.new_chat_btn = QPushButton("+ Nouveau Chat") + self.new_chat_btn.setObjectName("sidebar_new_btn") + self.new_chat_btn.clicked.connect(self._new_session) + + def get_sidebar_widget(self) -> QWidget: + container = QWidget() + container.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) + layout = QVBoxLayout(container) + layout.setContentsMargins(8, 4, 8, 4) + layout.setSpacing(4) + layout.addWidget(self.chat_list, 1) + layout.addWidget(self.new_chat_btn) + return container + + def _new_session(self): + self._session_counter += 1 + session = ChatSession(title=f"Chat {self._session_counter}") + view = ChatMessagesView(self.theme_manager) + view.send_requested.connect(self._on_send_requested) + view.stop_requested.connect(self._stop_generation) + session.view = view + + self.sessions.append(session) + self.chat_stack.addWidget(view) + + item = QListWidgetItem(session.title) + item.setFlags(item.flags() | Qt.ItemFlag.ItemIsEditable) + self.chat_list.addItem(item) + + self._current_index = len(self.sessions) - 1 + self.chat_list.setCurrentRow(self._current_index) + self.chat_stack.setCurrentWidget(view) + view.input_edit.setFocus() + + def _switch_to_session(self, index: int): + if index < 0 or index >= len(self.sessions) or index == self._current_index: + return + self._current_index = index + session = self.sessions[index] + if session.view: + self.chat_stack.setCurrentWidget(session.view) + session.view.input_edit.setFocus() + + def _close_session(self, index: int): + if index < 0 or index >= len(self.sessions) or len(self.sessions) <= 1: + return + self.chat_list.blockSignals(True) + self.chat_list.takeItem(index) + self.chat_list.blockSignals(False) + session = self.sessions.pop(index) + if session.view: + self.chat_stack.removeWidget(session.view) + session.view.deleteLater() + remaining = len(self.sessions) + if remaining > 0: + new_idx = min(index, remaining - 1) + self._current_index = -1 + self.chat_list.setCurrentRow(-1) + self.chat_list.setCurrentRow(new_idx) + + def _on_item_renamed(self, item: QListWidgetItem): + row = self.chat_list.row(item) + if 0 <= row < len(self.sessions): + self.sessions[row].title = item.text() + + def _on_list_context_menu(self, pos): + item = self.chat_list.itemAt(pos) + if not item: + return + index = self.chat_list.row(item) + menu = QMenu() + rename_action = QAction("Renommer", self) + rename_action.triggered.connect(lambda: self.chat_list.editItem(item)) + menu.addAction(rename_action) + if len(self.sessions) > 1: + delete_action = QAction("Supprimer", self) + delete_action.triggered.connect(lambda: self._close_session(index)) + menu.addAction(delete_action) + menu.exec(self.chat_list.mapToGlobal(pos)) + + def current_session(self) -> Optional[ChatSession]: + if 0 <= self._current_index < len(self.sessions): + return self.sessions[self._current_index] + return None + + def _on_send_requested(self, text: str): + text = text.strip() + if not text or self._streaming: + return + + session = self.current_session() + if not session or not session.view: + return + + view = session.view + view.input_edit.clear() + view.add_bubble(text, True) + session.messages.append((text, True)) + + if not self.worker: + view.add_bubble( + self.language_manager.get_text("chat_loading"), False + ) + return + + self._streaming = True + self._finished = False + view.send_button.hide() + view.stop_button.show() + self.status_label.setText("Génération...") + self.status_label.show() + + bubble = view.add_bubble("", False) + self._streaming_bubble = bubble + self._send_request.emit(text) + + def _on_token(self, token: str): + session = self.current_session() + if not session or not session.view: + return + if self._streaming_bubble: + raw = self._streaming_bubble._raw_text + token + self._streaming_bubble.update_text(raw) + session.view._scroll_to_bottom() + + def _on_message_finished(self): + if self._finished: + return + self._finished = True + self._streaming = False + session = self.current_session() + if session and session.view: + if self._streaming_bubble: + t = self._streaming_bubble._raw_text + self._streaming_bubble.set_text(t) + session.messages.append((t, False)) + self._streaming_bubble = None + session.view.send_button.show() + session.view.stop_button.hide() + session.view.input_edit.setFocus() + self.status_label.hide() + + def _on_llm_error(self, error_msg: str): + self._streaming = False + self._streaming_bubble = None + session = self.current_session() + if session and session.view: + session.view.send_button.show() + session.view.stop_button.hide() + session.view.input_edit.setFocus() + self.status_label.setText(f"Erreur : {error_msg}") + self.status_label.show() + + def _stop_generation(self): + if self.worker: + self.worker.stop() + session = self.current_session() + if session and session.view: + if self._streaming_bubble: + t = self._streaming_bubble._raw_text + if not t.strip(): + self._streaming_bubble.set_text( + self.language_manager.get_text("chat_cancelled") + ) + else: + self._streaming_bubble.set_text(t) + session.messages.append((t, False)) + self._streaming_bubble = None + session.view.send_button.show() + session.view.stop_button.hide() + self._finished = True + self._streaming = False + self.status_label.hide() + + def _on_model_loaded(self): + self.status_label.hide() + session = self.current_session() + if session and session.view: + session.view.input_edit.setFocus() + + def _on_theme_changed(self): + for s in self.sessions: + if s.view: + s.view.apply_theme_to_bubbles() + + def update_language(self): + for s in self.sessions: + if s.view: + s.view.input_edit.setPlaceholderText( + self.language_manager.get_text("chat_placeholder") + ) + + def cleanup(self): + if self.worker: + self.worker.stop() + if self.thread and self.thread.isRunning(): + self.thread.quit() + self.thread.wait() diff --git a/app/ui/windows/settings_window.py b/app/ui/windows/settings_window.py index 34d4302..0769372 100644 --- a/app/ui/windows/settings_window.py +++ b/app/ui/windows/settings_window.py @@ -1,4 +1,4 @@ -from PyQt6.QtWidgets import QWidget, QVBoxLayout, QComboBox, QLabel, QHBoxLayout, QSizePolicy +from PyQt6.QtWidgets import QWidget, QVBoxLayout, QComboBox, QLabel, QHBoxLayout, QSizePolicy, QLineEdit, QPushButton, QFileDialog from PyQt6.QtCore import Qt from app.core.main_manager import MainManager, NotificationType from typing import Optional @@ -21,6 +21,10 @@ class SettingsWindow(QWidget): self.theme_layout: QHBoxLayout self.themeLabel: QLabel self.themeCombo: QComboBox + self.model_layout: QHBoxLayout + self.modelLabel: QLabel + self.modelEdit: QLineEdit + self.modelButton: QPushButton self.setup_ui() @@ -60,6 +64,27 @@ class SettingsWindow(QWidget): self.theme_layout.addWidget(self.themeCombo) layout.addLayout(self.theme_layout) + + layout.addStretch(1) + + self.model_layout = QHBoxLayout() + + self.modelLabel = QLabel(self.language_manager.get_text("chat_model_path"), self) + self.modelLabel.setMinimumWidth(100) + self.modelLabel.setSizePolicy(QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Fixed) + self.model_layout.addWidget(self.modelLabel) + + self.modelEdit = QLineEdit(self.settings_manager.get_model_path(), self) + self.modelEdit.setPlaceholderText(self.language_manager.get_text("chat_model_placeholder")) + self.modelEdit.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) + self.modelEdit.textChanged.connect(self.change_model_path) + self.model_layout.addWidget(self.modelEdit) + + self.modelButton = QPushButton(self.language_manager.get_text("chat_model_browse"), self) + self.modelButton.clicked.connect(self.browse_model) + self.model_layout.addWidget(self.modelButton) + + layout.addLayout(self.model_layout) layout.addStretch(1) @@ -96,9 +121,23 @@ class SettingsWindow(QWidget): theme: str = self.themeCombo.itemData(index) self.settings_manager.set_theme(theme) + def change_model_path(self, path: str) -> None: + self.settings_manager.set_model_path(path) + + def browse_model(self) -> None: + path, _ = QFileDialog.getOpenFileName( + self, self.language_manager.get_text("chat_model_placeholder"), + "", "GGUF Models (*.gguf);;All Files (*)" + ) + if path: + self.modelEdit.setText(path) + def update_language(self) -> None: self.languageLabel.setText(self.language_manager.get_text("language")) self.themeLabel.setText(self.language_manager.get_text("theme")) + self.modelLabel.setText(self.language_manager.get_text("chat_model_path")) + self.modelEdit.setPlaceholderText(self.language_manager.get_text("chat_model_placeholder")) + self.modelButton.setText(self.language_manager.get_text("chat_model_browse")) # Mettre à jour les textes dans la combo de thème for i in range(self.themeCombo.count()): diff --git a/config.json b/config.json index 95719dc..39bb785 100644 --- a/config.json +++ b/config.json @@ -7,7 +7,7 @@ "splash_image": "splash", "main_script": "main.py", "git_repo": "https://gitea.louismazin.ovh/LouisMazin/PythonApplicationTemplate", - "enable_licensing": true, + "enable_licensing": false, "features_by_license": { "basic": [ "support" diff --git a/data/assets/chat.svg b/data/assets/chat.svg new file mode 100644 index 0000000..2a58da8 --- /dev/null +++ b/data/assets/chat.svg @@ -0,0 +1,3 @@ + + + diff --git a/data/lang/en.json b/data/lang/en.json index 11979a1..ed0b4f6 100644 --- a/data/lang/en.json +++ b/data/lang/en.json @@ -61,7 +61,17 @@ "activation_required": "Activation is required to continue.", "compare_versions": "Compare Versions", "no_license": "No License", + "tab_chat": "Chat", "tab_suggestions": "Suggestions", "tab_settings": "Settings", - "tab_licensing": "Licensing" + "tab_licensing": "Licensing", + "chat_placeholder": "Ask your question...", + "chat_send": "Send", + "chat_stop": "Stop", + "chat_new": "New Chat", + "chat_loading": "Loading model...", + "chat_cancelled": "[Generation cancelled]", + "chat_model_path": "Model path:", + "chat_model_placeholder": "Path to .gguf file", + "chat_model_browse": "Browse" } \ No newline at end of file diff --git a/data/lang/fr.json b/data/lang/fr.json index a985795..25c35b6 100644 --- a/data/lang/fr.json +++ b/data/lang/fr.json @@ -62,7 +62,17 @@ "activation_required": "L'activation est requise pour continuer.", "compare_versions": "Comparer les versions", "no_license": "Pas de licence", + "tab_chat": "Chat", "tab_suggestions": "Suggestions", "tab_settings": "Paramètres", - "tab_licensing": "Licence" + "tab_licensing": "Licence", + "chat_placeholder": "Posez votre question...", + "chat_send": "Envoyer", + "chat_stop": "Stop", + "chat_new": "Nouveau", + "chat_loading": "Chargement du modèle...", + "chat_cancelled": "[Génération annulée]", + "chat_model_path": "Chemin du modèle :", + "chat_model_placeholder": "Chemin vers le fichier .gguf", + "chat_model_browse": "Parcourir" } \ No newline at end of file diff --git a/data/others/defaults_settings.json b/data/others/defaults_settings.json index f963074..8e591ba 100644 --- a/data/others/defaults_settings.json +++ b/data/others/defaults_settings.json @@ -2,5 +2,6 @@ "theme": "dark", "lang": "fr", "window_size": {"width": 1000, "height": 600}, - "maximized": true + "maximized": true, + "model_path": "models/google_gemma-4-E4B-it-Q4_K_M.gguf" } \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 390d828..32b0bc1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ PyQt6 pyinstaller python-dotenv -requests \ No newline at end of file +requests +llama-cpp-python \ No newline at end of file diff --git a/src/llm_local.py b/src/llm_local.py new file mode 100644 index 0000000..fe62292 --- /dev/null +++ b/src/llm_local.py @@ -0,0 +1,47 @@ +import platform +import subprocess +from llama_cpp import Llama + + +def detect_backend() -> dict: + system = platform.system() + if system == "Darwin": + try: + result = subprocess.run( + ["sysctl", "-n", "hw.optional.arm64"], + capture_output=True, text=True + ) + if result.stdout.strip() == "1": + return {"n_gpu_layers": -1} + except FileNotFoundError: + pass + if system in ("Linux", "Windows"): + try: + import ctypes + lib = "libcuda.so" if system == "Linux" else "nvcuda.dll" + ctypes.CDLL(lib) + return {"n_gpu_layers": -1} + except OSError: + pass + return {"n_gpu_layers": 0} + + +def load_model(model_path: str) -> Llama: + return Llama( + model_path=model_path, + n_ctx=4096, + n_batch=512, + verbose=False, + **detect_backend() + ) + + +def chat(llm: Llama, user_message: str, history: list = None) -> tuple[str, list]: + messages = history or [] + messages.append({"role": "user", "content": user_message}) + response = llm.create_chat_completion( + messages=messages, max_tokens=512, temperature=0.7, top_p=0.9 + ) + reply = response["choices"][0]["message"]["content"] + messages.append({"role": "assistant", "content": reply}) + return reply, messages diff --git a/tools/build.command b/tools/build.command new file mode 100755 index 0000000..7d73185 --- /dev/null +++ b/tools/build.command @@ -0,0 +1,88 @@ +#!/bin/bash + +set -e +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PARENT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +CONFIG_FILE="$PARENT_DIR/config.json" +ICON_FILE="$PARENT_DIR/data/assets/icon.png" +ENV_FILE="$PARENT_DIR/.env" + +# === Check .env === +if [ ! -f "$ENV_FILE" ]; then + echo "[ERROR] .env file not found. Please copy .env.example to .env and configure it." + exit 1 +fi + +# === Extract values from config.json === +ICON_PATH=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE'))['icon_path'])") +APP_NAME=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE'))['app_name'])") +ARCHITECTURE=$(python3 -c "import json; print(json.load(open('$CONFIG_FILE'))['architecture'])") + +# === Extract PYTHON_PATH from .env === +SYSTEM_PYTHON=$(grep '^PYTHON_PATH=' "$ENV_FILE" | cut -d '=' -f2-) + +VENV_PATH="$PARENT_DIR/MACenv_$ARCHITECTURE" +APP_FILE="$APP_NAME.app" +PYTHON_IN_VENV="$VENV_PATH/bin/python" +BUILD_DIR="$PARENT_DIR/build" +ZIP_FILE="$BUILD_DIR/$APP_NAME.zip" + +# === Verify Python existence === +if [ ! -x "$SYSTEM_PYTHON" ]; then + echo "[ERROR] Python not found at: $SYSTEM_PYTHON" + exit 1 +fi + +# === Check virtual environment === +if [ ! -f "$VENV_PATH/bin/activate" ]; then + echo "[INFO] Virtual environment not found. Creating..." + "$SYSTEM_PYTHON" -m venv "$VENV_PATH" + "$PYTHON_IN_VENV" -m pip install --upgrade pip + "$PYTHON_IN_VENV" -m pip install -r "$PARENT_DIR/requirements.txt" +else + echo "[INFO] Virtual environment found." +fi + +# === Run PyInstaller === +"$PYTHON_IN_VENV" -m PyInstaller \ + --distpath "$BUILD_DIR" \ + --workpath "$BUILD_DIR/dist" \ + --clean \ + "$PARENT_DIR/BUILD.spec" + +# === Clean build cache === +rm -rf "$BUILD_DIR/dist" + +# === Create ZIP === +echo "[INFO] Creating ZIP archive..." + +TEMP_ZIP_DIR="$BUILD_DIR/temp_zip" + +# Remove old temp dir +rm -rf "$TEMP_ZIP_DIR" +mkdir -p "$TEMP_ZIP_DIR" + +# Move compiled app +mv "$BUILD_DIR/$APP_FILE" "$TEMP_ZIP_DIR/" + +# Copy config.json +cp "$CONFIG_FILE" "$TEMP_ZIP_DIR/" + +# Copy icon.png +cp "$ICON_FILE" "$TEMP_ZIP_DIR/" + +# Copy data/lang +cp -R "$PARENT_DIR/data/lang" "$TEMP_ZIP_DIR/lang" + +# Remove old ZIP +rm -f "$ZIP_FILE" + +# Create ZIP +cd "$BUILD_DIR" +zip -r "$ZIP_FILE" "temp_zip" >/dev/null + +# Remove temp folder +rm -rf "$TEMP_ZIP_DIR" + +echo "[INFO] ZIP created at: $ZIP_FILE" \ No newline at end of file diff --git a/tools/open.command b/tools/open.command new file mode 100755 index 0000000..675450f --- /dev/null +++ b/tools/open.command @@ -0,0 +1,59 @@ +#!/bin/bash +set -e + +# Root paths +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CONFIG_FILE="$ROOT_DIR/config.json" +REQUIREMENTS="$ROOT_DIR/requirements.txt" +ENV_FILE="$ROOT_DIR/.env" + +# Check if .env exists +if [[ ! -f "$ENV_FILE" ]]; then + echo "[ERROR] .env file not found. Please copy .env.example to .env and configure it." + exit 1 +fi + +# Extract architecture from config.json (requires jq) +if ! command -v jq &>/dev/null; then + echo "[ERROR] 'jq' is required. Install it with: brew install jq" + exit 1 +fi +ARCHITECTURE=$(jq -r '.architecture' "$CONFIG_FILE") + +# Extract python path from .env +PYTHON_EXEC=$(grep -E "^PYTHON_PATH=" "$ENV_FILE" | cut -d '=' -f2) + +# Construct venv path +ENV_NAME="MACenv_$ARCHITECTURE" +ENV_PATH="$ROOT_DIR/$ENV_NAME" + +# Check python executable +if [[ ! -x "$PYTHON_EXEC" ]]; then + echo "[ERROR] Python not found at: $PYTHON_EXEC" + echo "Please check your .env or installation path." + exit 1 +fi + +# Show info +echo "[INFO] Configuration:" +echo " Python: $PYTHON_EXEC" +echo " Env: $ENV_NAME" + +# Create virtual env if missing +if [[ ! -d "$ENV_PATH/bin" ]]; then + echo "[INFO] Virtual environment not found, creating..." + "$PYTHON_EXEC" -m venv "$ENV_PATH" + echo "[INFO] Installing dependencies..." + "$ENV_PATH/bin/python" -m pip install --upgrade pip + "$ENV_PATH/bin/pip" install -r "$REQUIREMENTS" +else + echo "[INFO] Virtual environment found." +fi + +# Activate and launch VS Code +echo "[INFO] Launching VS Code..." +# shellcheck source=/dev/null +source "$ENV_PATH/bin/activate" +open -a "Visual Studio Code" "$ROOT_DIR" + +exit 0 \ No newline at end of file