Interface fonctionnel

This commit is contained in:
Antoine Richer 2026-06-06 10:21:48 +02:00
parent e9d895338d
commit 251c7eb71d
17 changed files with 1287 additions and 20 deletions

8
.env.example Normal file
View File

@ -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

2
.gitignore vendored
View File

@ -87,3 +87,5 @@ htmlcov/
.nuxt .nuxt
.serverless/ .serverless/
# Data
models

113
app/core/llm_worker.py Normal file
View File

@ -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

View File

@ -179,3 +179,19 @@ class SettingsManager:
self.settings.setValue("maximized", maximized) self.settings.setValue("maximized", maximized)
except Exception as e: except Exception as e:
logger.error(f"Error setting maximized state: {e}") 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}")

View File

@ -215,4 +215,118 @@ class ThemeManager:
#tab_bar {{ #tab_bar {{
background-color: {self.current_theme.get_color("background_color")}; 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")};
}}
""" """

View File

@ -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.settings_window import SettingsWindow
from app.ui.windows.suggestion_window import SuggestionWindow from app.ui.windows.suggestion_window import SuggestionWindow
from app.ui.windows.activation_window import ActivationWindow from app.ui.windows.activation_window import ActivationWindow
from app.ui.windows.chat_window import ChatWindow
import app.utils.paths as paths, shutil import app.utils.paths as paths, shutil
from typing import Optional from typing import Optional
@ -232,6 +233,8 @@ class MainWindow(QMainWindow):
def closeEvent(self, event: QCloseEvent) -> None: def closeEvent(self, event: QCloseEvent) -> None:
"""Handle application close event""" """Handle application close event"""
if hasattr(self, 'chat_window'):
self.chat_window.cleanup()
super().closeEvent(event) super().closeEvent(event)
# si la difference de taille est plus grande que 10 pixels, enregistrer previoussize # 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: 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: 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.chat_window = ChatWindow(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.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"): if self.settings_manager.get_config("enable_licensing"):
self.activation_window = ActivationWindow(self) 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.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): def get_tab_widget(self):
"""Retourne le widget TabsWidget pour permettre le changement d'onglet""" """Retourne le widget TabsWidget pour permettre le changement d'onglet"""
@ -270,8 +299,13 @@ class MainWindow(QMainWindow):
self.setStyleSheet(self.theme_manager.get_sheet()) self.setStyleSheet(self.theme_manager.get_sheet())
def update_language(self) -> None: def update_language(self) -> None:
# Mettre à jour les textes des onglets num_buttons = len(self.side_menu.buttons)
self.side_menu.update_button_text(0, self.language_manager.get_text("tab_suggestions")) if num_buttons >= 4:
self.side_menu.update_button_text(1, self.language_manager.get_text("tab_settings")) self.side_menu.update_button_text(0, self.language_manager.get_text("tab_licensing"))
if self.settings_manager.get_config("enable_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_licensing")) 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"))

View File

@ -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"<b><i>\1</i></b>", text)
text = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", text)
text = re.sub(r"\*([^*]+)\*", r"<i>\1</i>", text)
for k, v in ph.items():
if v.startswith("`"):
text = text.replace(k, f"<code>{v[1:-1]}</code>")
elif v.startswith("$$"):
text = text.replace(k, f"<code>{_latex_to_text(v[2:-2])}</code>")
else:
text = text.replace(k, f"<code>{_latex_to_text(v[1:-1])}</code>")
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"<pre><code>{''.join(buf)}</code></pre>")
elif mode == "math":
parts.append(f"<pre><code>{_latex_to_text(chr(10).join(buf))}</code></pre>")
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"<pre><code>{_latex_to_text(rest[:j])}</code></pre>")
else:
mode = "math"
continue
if s == "---" or s == "___" or s == "***":
parts.append("<hr>")
continue
m = re.match(r"^(#{1,3})\s+(.+)$", s)
if m:
parts.append(f"<h{len(m.group(1))}>{_inline(m.group(2))}</h{len(m.group(1))}>")
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] != "<ul>" and not parts[-1].startswith("<li>"):
parts.append("<ul>")
parts.append(f"<li>{_inline(c)}</li>")
continue
if not s:
if parts and parts[-1].startswith("<li>"):
parts.append("</ul>")
continue
parts.append(f"<p>{_inline(s)}</p>")
flush_buf()
if parts and parts[-1].startswith("<li>"):
parts.append("</ul>")
return "".join(parts).replace("amp;", "&amp;").replace("lt;", "<").replace("gt;", ">").replace("&amp;amp;", "&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()

View File

@ -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 PyQt6.QtCore import Qt
from app.core.main_manager import MainManager, NotificationType from app.core.main_manager import MainManager, NotificationType
from typing import Optional from typing import Optional
@ -21,6 +21,10 @@ class SettingsWindow(QWidget):
self.theme_layout: QHBoxLayout self.theme_layout: QHBoxLayout
self.themeLabel: QLabel self.themeLabel: QLabel
self.themeCombo: QComboBox self.themeCombo: QComboBox
self.model_layout: QHBoxLayout
self.modelLabel: QLabel
self.modelEdit: QLineEdit
self.modelButton: QPushButton
self.setup_ui() self.setup_ui()
@ -63,6 +67,27 @@ class SettingsWindow(QWidget):
layout.addStretch(1) 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)
def createLanguageSelector(self) -> QComboBox: def createLanguageSelector(self) -> QComboBox:
combo: QComboBox = QComboBox() combo: QComboBox = QComboBox()
# Ajouter toutes les langues disponibles # Ajouter toutes les langues disponibles
@ -96,9 +121,23 @@ class SettingsWindow(QWidget):
theme: str = self.themeCombo.itemData(index) theme: str = self.themeCombo.itemData(index)
self.settings_manager.set_theme(theme) 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: def update_language(self) -> None:
self.languageLabel.setText(self.language_manager.get_text("language")) self.languageLabel.setText(self.language_manager.get_text("language"))
self.themeLabel.setText(self.language_manager.get_text("theme")) 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 # Mettre à jour les textes dans la combo de thème
for i in range(self.themeCombo.count()): for i in range(self.themeCombo.count()):

View File

@ -7,7 +7,7 @@
"splash_image": "splash", "splash_image": "splash",
"main_script": "main.py", "main_script": "main.py",
"git_repo": "https://gitea.louismazin.ovh/LouisMazin/PythonApplicationTemplate", "git_repo": "https://gitea.louismazin.ovh/LouisMazin/PythonApplicationTemplate",
"enable_licensing": true, "enable_licensing": false,
"features_by_license": { "features_by_license": {
"basic": [ "basic": [
"support" "support"

3
data/assets/chat.svg Normal file
View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>

After

Width:  |  Height:  |  Size: 242 B

View File

@ -61,7 +61,17 @@
"activation_required": "Activation is required to continue.", "activation_required": "Activation is required to continue.",
"compare_versions": "Compare Versions", "compare_versions": "Compare Versions",
"no_license": "No License", "no_license": "No License",
"tab_chat": "Chat",
"tab_suggestions": "Suggestions", "tab_suggestions": "Suggestions",
"tab_settings": "Settings", "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"
} }

View File

@ -62,7 +62,17 @@
"activation_required": "L'activation est requise pour continuer.", "activation_required": "L'activation est requise pour continuer.",
"compare_versions": "Comparer les versions", "compare_versions": "Comparer les versions",
"no_license": "Pas de licence", "no_license": "Pas de licence",
"tab_chat": "Chat",
"tab_suggestions": "Suggestions", "tab_suggestions": "Suggestions",
"tab_settings": "Paramètres", "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"
} }

View File

@ -2,5 +2,6 @@
"theme": "dark", "theme": "dark",
"lang": "fr", "lang": "fr",
"window_size": {"width": 1000, "height": 600}, "window_size": {"width": 1000, "height": 600},
"maximized": true "maximized": true,
"model_path": "models/google_gemma-4-E4B-it-Q4_K_M.gguf"
} }

View File

@ -2,3 +2,4 @@ PyQt6
pyinstaller pyinstaller
python-dotenv python-dotenv
requests requests
llama-cpp-python

47
src/llm_local.py Normal file
View File

@ -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

88
tools/build.command Executable file
View File

@ -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"

59
tools/open.command Executable file
View File

@ -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