generated from LouisMazin/PythonApplicationTemplate
Interface fonctionnel
This commit is contained in:
parent
e9d895338d
commit
251c7eb71d
8
.env.example
Normal file
8
.env.example
Normal 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
2
.gitignore
vendored
@ -87,3 +87,5 @@ htmlcov/
|
|||||||
.nuxt
|
.nuxt
|
||||||
.serverless/
|
.serverless/
|
||||||
|
|
||||||
|
# Data
|
||||||
|
models
|
||||||
113
app/core/llm_worker.py
Normal file
113
app/core/llm_worker.py
Normal 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
|
||||||
@ -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}")
|
||||||
@ -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")};
|
||||||
|
}}
|
||||||
"""
|
"""
|
||||||
@ -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"))
|
||||||
722
app/ui/windows/chat_window.py
Normal file
722
app/ui/windows/chat_window.py
Normal 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;", "&").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()
|
||||||
@ -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()):
|
||||||
|
|||||||
@ -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
3
data/assets/chat.svg
Normal 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 |
@ -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"
|
||||||
}
|
}
|
||||||
@ -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"
|
||||||
}
|
}
|
||||||
@ -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"
|
||||||
}
|
}
|
||||||
@ -2,3 +2,4 @@ PyQt6
|
|||||||
pyinstaller
|
pyinstaller
|
||||||
python-dotenv
|
python-dotenv
|
||||||
requests
|
requests
|
||||||
|
llama-cpp-python
|
||||||
47
src/llm_local.py
Normal file
47
src/llm_local.py
Normal 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
88
tools/build.command
Executable 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
59
tools/open.command
Executable 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
|
||||||
Loading…
x
Reference in New Issue
Block a user