From 889307962e9fd84ec465e4ae3d26f838d6be3980 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Delorme?= Date: Sat, 21 Mar 2026 23:14:54 +0100 Subject: [PATCH 1/5] feat: add LLM and RAG pipeline illustrations - Added SVG illustration for LLM processing pipeline. - Added SVG illustration for RAG processing pipeline. docs: introduce LLM and RAG fundamentals - Created a comprehensive introduction document covering LLMs and RAG, including definitions, processing pipelines, limitations, and practical implementation examples. feat: propose enriched context feature - Implemented a feature to enhance the prompt input area by integrating open document files into the context for improved user experience. refactor: synchronize visual link panel with active tab - Updated the synchronization logic to ensure the visual link panel reflects the currently active document tab, enhancing user navigation. --- .../llm-and-rag/llm-pipeline.svg | 74 ++++ .../llm-and-rag/rag-pipeline.svg | 130 +++++++ src/docs/into-to-llm-and-rag.md | 354 ++++++++++++++++++ ...oser-co\303\247ntext-enrichi-avec-docs.md" | 34 ++ src/main/java/MarkNote.java | 36 +- 5 files changed, 617 insertions(+), 11 deletions(-) create mode 100644 src/docs/illustrations/llm-and-rag/llm-pipeline.svg create mode 100644 src/docs/illustrations/llm-and-rag/rag-pipeline.svg create mode 100644 src/docs/into-to-llm-and-rag.md create mode 100644 "src/docs/proposer-co\303\247ntext-enrichi-avec-docs.md" diff --git a/src/docs/illustrations/llm-and-rag/llm-pipeline.svg b/src/docs/illustrations/llm-and-rag/llm-pipeline.svg new file mode 100644 index 0000000..e45a9af --- /dev/null +++ b/src/docs/illustrations/llm-and-rag/llm-pipeline.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + Pipeline d'un LLM — Flux de traitement + + + + Texte + d'entrée + « Que signifie + le RAG ? » + + + + + + + Tokeni- + sation + [42, 198, 74, + 391, 27 …] + + + + + + + Projec- + tion + Vecteurs + d'entrée + + + + + + + Couches + Transformer + Self-Attention + Feed-Forward (×N) + + + + + + + Décodage + Softmax + Proba. par + token + + + + + + + Texte + généré + + + Les couches Transformer sont répétées N fois (ex. GPT-4 : ~96 couches) — chaque couche affine la représentation contextuelle. + diff --git a/src/docs/illustrations/llm-and-rag/rag-pipeline.svg b/src/docs/illustrations/llm-and-rag/rag-pipeline.svg new file mode 100644 index 0000000..520a990 --- /dev/null +++ b/src/docs/illustrations/llm-and-rag/rag-pipeline.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + + + + + + + + + ⚙ Phase 1 — Indexation (offline / préparation) + + + + Documents + sources + PDF, MD, + HTML, TXT… + + + + + + + Découpage + Chunking + Passages de + 256–512 tokens + + + + + + + Modèle + d'embedding + text-embedding- + 3-small… + + + + + + + + + Vector Store + Faiss / Chroma + ⎈ N vecteurs + + + + 💬 Phase 2 — Inférence (online / à la demande) + + + + Question + utilisateur + + + + + + + Embedding + requête + + + + + + + Recherche + similarité (Top-K) + + + + + + + + + + + Contexte + K passages + + + + + + + + + + + Prompt + augmenté + Contexte + Question + + + + + + + LLM + GPT-4 / Mistral + LLaMA 3… + + + + + + + Réponse + + + Le même modèle d'embedding est utilisé dans les deux phases pour garantir la cohérence de l'espace vectoriel. + diff --git a/src/docs/into-to-llm-and-rag.md b/src/docs/into-to-llm-and-rag.md new file mode 100644 index 0000000..ab4e4a3 --- /dev/null +++ b/src/docs/into-to-llm-and-rag.md @@ -0,0 +1,354 @@ +# Introduction aux LLM et au RAG + +> **Résumé** — Ce document présente les fondamentaux des _Large Language Models_ (LLM) et de la technique _Retrieval-Augmented Generation_ (RAG) : leurs principes, leurs composants, leurs limites et un exemple concret d'implémentation. + +--- + +## 1. Les LLM — Modèles de Langage de Grande Taille + +### 1.1 Définition + +Un **LLM** (_Large Language Model_) est un modèle de réseau de neurones entraîné sur des corpus textuels massifs pour prédire le token suivant dans une séquence. Cette tâche apparemment simple donne naissance à une capacité de généralisation remarquable : raisonnement, traduction, résumé, génération de code, etc. + +Les modèles phares actuels sont : **GPT-4o** (OpenAI), **Claude 3** (Anthropic), **Gemini 1.5** (Google), **Mistral Large** et **LLaMA 3** (Meta, open-source). + +### 1.2 Pipeline de traitement + +![Pipeline d'un LLM](illustrations/llm-and-rag/llm-pipeline.svg) + +| Étape | Rôle | +| ------------------------- | ----------------------------------------------------------------------------------------- | +| **Tokenisation** | Découpe le texte en unités (tokens ≈ sous-mots) via un vocabulaire BPE ou SentencePiece | +| **Projection** | Associe chaque token à un vecteur dense d'entrée (_input embedding_) | +| **Couches Transformer** | Calcule des représentations contextuelles via le mécanisme _Self-Attention_ (×N couches) | +| **Décodage / Softmax** | Produit une distribution de probabilité sur le vocabulaire pour choisir le prochain token | +| **Stratégie de décodage** | Greedy, Beam Search, ou échantillonnage (température, top-p) | + +### 1.3 Limites des LLM seuls + +| Limite | Description | +| --------------------------- | -------------------------------------------------------------------------------------------------------- | +| **Hallucinations** | Le modèle peut « inventer » des faits inexistants avec une apparente confiance | +| **Coupure de connaissance** | Le modèle ne connaît pas les événements postérieurs à sa date d'entraînement (_knowledge cutoff_) | +| **Contexte limité** | Même avec de grandes fenêtres (128 k tokens), la totalité d'une base de connaissance ne peut pas y tenir | +| **Pas de source citée** | Difficile de tracer quelle information a produit quelle réponse | +| **Coût de re-entraînement** | Incorporer de nouvelles données propres nécessite un _fine-tuning_ coûteux | + +--- + +## 2. RAG — Retrieval-Augmented Generation + +### 2.1 Principe + +Le **RAG** est une architecture qui vient _compléter_ un LLM plutôt que le remplacer. Au lieu de mémoriser toutes les connaissances dans les poids du modèle, on externalise la base de connaissance dans un **store vectoriel** et on la _récupère à la demande_ au moment de répondre. + +``` +Réponse = LLM ( question + documents_pertinents_retrouvés ) +``` + +Le gain est triple : **fraîcheur** (les documents sont mis à jour sans re-entraîner le modèle), **précision** (les faits issus des sources réelles), et **traçabilité** (on peut citer la source). + +### 2.2 Architecture des composants + +```mermaid +flowchart TB + subgraph IDX["⚙ Phase d'indexation — offline"] + direction LR + D["📄 Documents\nsources"] --> CH["✂ Chunking\nDécoupage"] + CH --> EM1["🔢 Modèle\nd'embedding"] + EM1 --> VS[("🗄 Vector Store\nFaiss · Chroma · Weaviate")] + end + + subgraph INF["💬 Phase d'inférence — online"] + direction LR + Q["👤 Question\nutilisateur"] --> EM2["🔢 Modèle\nd'embedding"] + EM2 --> SIM["🔍 Recherche\nsimilarité Top-K"] + VS -- "K vecteurs\nproches" --> SIM + SIM --> CTX["📑 Passages\npertinents"] + Q --> PRO["📝 Prompt\naugmenté"] + CTX --> PRO + PRO --> LLM["🤖 LLM\nGPT-4 · Mistral · LLaMA"] + LLM --> REP["✅ Réponse\ncitant les sources"] + end + + IDX -.->|"Même espace\nvectoriel"| INF +``` + +### 2.3 Pipeline complet + +![Pipeline RAG — deux phases](illustrations/llm-and-rag/rag-pipeline.svg) + +### 2.4 Composants clés détaillés + +#### Chunking — Découpage des documents + +Le découpage conditionne la qualité de la recherche. Stratégies courantes : + +- **Taille fixe** : fenêtre glissante de 256–512 tokens avec chevauchement (_overlap_) de 10–20 % +- **Sémantique** : découpage aux frontières de paragraphes ou de sections +- **Récursif** : LangChain `RecursiveCharacterTextSplitter` — essaie `\n\n`, puis `\n`, puis `.` + +#### Embedding — Vectorisation + +Le texte est converti en un vecteur dense (768 à 3 072 dimensions) qui encode sa _sémantique_. +Deux passages proches dans cet espace partagent un sens similaire. + +| Modèle | Dimensions | Contexte max | Cas d'usage | +| -------------------------------- | ---------- | ------------ | --------------------------------- | +| `text-embedding-3-small` | 1 536 | 8 191 tokens | Usage général, économique | +| `text-embedding-3-large` | 3 072 | 8 191 tokens | Précision maximale | +| `all-MiniLM-L6-v2` (open-source) | 384 | 512 tokens | Local, léger, rapide | +| `nomic-embed-text` (Ollama) | 768 | 8 192 tokens | Local, bon rapport qualité/taille | + +#### Vector Store — Base de vecteurs + +| Solution | Type | Point fort | +| ------------ | -------------------- | ------------------------------------- | +| **Faiss** | In-process | Ultra-rapide, idéal pour prototyper | +| **Chroma** | Serveur léger | Simple à déployer, persistance locale | +| **Weaviate** | Cloud/on-prem | Scalable, multi-modal | +| **pgvector** | Extension PostgreSQL | S'intègre dans une base existante | +| **Qdrant** | Rust-natif | Performances élevées, filtrage riche | + +#### Recherche — Similarité cosinus + +$$\text{sim}(\vec{q}, \vec{d}) = \frac{\vec{q} \cdot \vec{d}}{|\vec{q}|\ |\vec{d}|}$$ + +Les **K** passages ayant les scores les plus élevés sont inclus dans le prompt. +K varie typiquement entre 3 et 10 selon la taille du contexte disponible et la précision souhaitée. + +--- + +## 3. Implémentation — Exemple pratique + +### 3.1 Stack technique + +```mermaid +flowchart LR + subgraph APP["Application"] + UI["Interface\nutilisateur"] --> ORC + ORC["Orchestrateur\nLangChain4j / LangChain"] --> EMB + ORC --> VDB + ORC --> LLLM + end + subgraph INFRA["Infrastructure"] + EMB["Embedding\nAPI / local"] + VDB[("Vector DB\nChroma / Faiss")] + LLLM["LLM\nOpenAI / Ollama"] + end +``` + +### 3.2 Exemple Python avec LangChain + +Installation des dépendances : + +```bash +pip install langchain langchain-openai langchain-chroma pypdf +``` + +#### Indexation des documents + +```python +from langchain_community.document_loaders import PyPDFDirectoryLoader +from langchain.text_splitter import RecursiveCharacterTextSplitter +from langchain_openai import OpenAIEmbeddings +from langchain_chroma import Chroma + +# 1. Chargement des documents +loader = PyPDFDirectoryLoader("./docs/") +documents = loader.load() + +# 2. Découpage (chunking) +splitter = RecursiveCharacterTextSplitter( + chunk_size=512, + chunk_overlap=64, + separators=["\n\n", "\n", ". ", " "] +) +chunks = splitter.split_documents(documents) + +# 3. Vectorisation et stockage +embeddings = OpenAIEmbeddings(model="text-embedding-3-small") +vector_store = Chroma.from_documents( + documents=chunks, + embedding=embeddings, + persist_directory="./chroma_db" +) +print(f"Indexé : {len(chunks)} passages dans le Vector Store.") +``` + +#### Pipeline de question-réponse (RAG) + +```python +from langchain_openai import ChatOpenAI +from langchain.chains import RetrievalQA +from langchain.prompts import PromptTemplate + +# Chargement du store existant +vector_store = Chroma( + persist_directory="./chroma_db", + embedding_function=OpenAIEmbeddings(model="text-embedding-3-small") +) + +# Template de prompt — injecte le contexte retrouvé +PROMPT_TEMPLATE = """Tu es un assistant expert. +Utilise uniquement les informations du CONTEXTE ci-dessous pour répondre. +Si la réponse n'est pas dans le contexte, réponds "Je ne sais pas." + +CONTEXTE : +{context} + +QUESTION : {question} + +RÉPONSE :""" + +prompt = PromptTemplate( + template=PROMPT_TEMPLATE, + input_variables=["context", "question"] +) + +# Chaîne RAG +llm = ChatOpenAI(model="gpt-4o-mini", temperature=0) +rag_chain = RetrievalQA.from_chain_type( + llm=llm, + chain_type="stuff", # concatène tous les passages dans un seul prompt + retriever=vector_store.as_retriever(search_kwargs={"k": 5}), + chain_type_kwargs={"prompt": prompt}, + return_source_documents=True # pour tracer les sources +) + +# Interrogation +result = rag_chain.invoke({"query": "Qu'est-ce que le RAG ?"}) +print(result["result"]) +print("\nSources :") +for doc in result["source_documents"]: + print(f" - {doc.metadata.get('source', '?')} p.{doc.metadata.get('page', '?')}") +``` + +### 3.3 Exemple Java avec LangChain4j + +```xml + + + dev.langchain4j + langchain4j + 0.31.0 + + + dev.langchain4j + langchain4j-open-ai + 0.31.0 + + + dev.langchain4j + langchain4j-chroma + 0.31.0 + +``` + +```java +import dev.langchain4j.data.document.loader.FileSystemDocumentLoader; +import dev.langchain4j.data.document.splitter.DocumentSplitters; +import dev.langchain4j.model.embedding.onnx.allminilml6v2.AllMiniLmL6V2EmbeddingModel; +import dev.langchain4j.store.embedding.chroma.ChromaEmbeddingStore; +import dev.langchain4j.rag.content.retriever.EmbeddingStoreContentRetriever; +import dev.langchain4j.service.AiServices; + +// ── Interface déclarative du service RAG ──────────────────────────────────── +interface DocumentAssistant { + String answer(String question); +} + +public class RagExample { + + public static void main(String[] args) { + + // 1. Modèle d'embedding local (ONNX, sans clé API) + var embeddingModel = new AllMiniLmL6V2EmbeddingModel(); + + // 2. Vector store (Chroma doit tourner localement) + var store = ChromaEmbeddingStore.builder() + .baseUrl("http://localhost:8000") + .collectionName("my-docs") + .build(); + + // 3. Indexation + var documents = FileSystemDocumentLoader.loadDocuments("./docs"); + var splitter = DocumentSplitters.recursive(512, 64); + var segments = splitter.splitAll(documents); + var embeddings = embeddingModel.embedAll(segments).content(); + store.addAll(embeddings, segments); + + // 4. Retriever + var retriever = EmbeddingStoreContentRetriever.builder() + .embeddingStore(store) + .embeddingModel(embeddingModel) + .maxResults(5) + .minScore(0.6) + .build(); + + // 5. Service RAG déclaratif + var assistant = AiServices.builder(DocumentAssistant.class) + .chatLanguageModel(OpenAiChatModel.withApiKey(System.getenv("OPENAI_API_KEY"))) + .contentRetriever(retriever) + .build(); + + // 6. Requête + System.out.println(assistant.answer("Qu'est-ce que le RAG ?")); + } +} +``` + +### 3.4 Utilisation locale avec Ollama (sans clé API) + +Pour un déploiement entièrement local et gratuit : + +```bash +# Installer Ollama, puis télécharger les modèles +ollama pull nomic-embed-text # Embedding +ollama pull mistral # LLM génération +``` + +```python +from langchain_ollama import OllamaEmbeddings, OllamaLLM + +embeddings = OllamaEmbeddings(model="nomic-embed-text") +llm = OllamaLLM(model="mistral", temperature=0) +# Le reste du pipeline RAG est identique +``` + +--- + +## 4. Bonnes pratiques + +| Domaine | Recommandation | +| ----------------- | --------------------------------------------------------------------------------------------- | +| **Chunking** | Préférer le découpage sémantique (paragraphes) au découpage brut par taille | +| **Chevauchement** | Conserver 10–15 % de chevauchement entre chunks pour ne pas couper le sens | +| **Métadonnées** | Stocker source, date, auteur dans les métadonnées des chunks pour la traçabilité | +| **Re-ranking** | Ajouter un re-ranker (Cohere, `cross-encoder/ms-marco`) après le Top-K pour affiner | +| **Prompt** | Indiquer explicitement au LLM de répondre "Je ne sais pas" si le contexte est insuffisant | +| **Évaluation** | Utiliser RAGAS (_Faithfulness_, _Answer Relevancy_, _Context Recall_) pour mesurer la qualité | +| **Sécurité** | Ne jamais inclure de données sensibles dans les vecteurs sans contrôle d'accès sur le store | + +--- + +## 5. Conclusion + +Le RAG réconcilie deux besoins contradictoires : la **puissance générale** des LLM et la **précision factuelle** d'une base de connaissance maîtrisée. Son adoption est aujourd'hui la voie privilégiée pour intégrer des LLM dans des applications métier sans re-entraînement. + +```mermaid +graph LR + A("LLM seul + 🧠 Puissant + ❌ Hallucinant + ❌ Figé") -->|"+ RAG"| B("LLM + RAG + 🧠 Puissant + ✅ Ancré dans les faits + ✅ Mise à jour facile") +``` + +Les prochaines évolutions (RAG agentique, _Graph RAG_, _Multimodal RAG_) enrichissent encore ce paradigme en permettant des raisonnements multi-étapes sur des bases de connaissance hétérogènes. + +--- + +*Document rédigé le 21 mars 2026 — illustrations : `src/docs/illustrations/llm-and-rag/`* diff --git "a/src/docs/proposer-co\303\247ntext-enrichi-avec-docs.md" "b/src/docs/proposer-co\303\247ntext-enrichi-avec-docs.md" new file mode 100644 index 0000000..c6f4dd7 --- /dev/null +++ "b/src/docs/proposer-co\303\247ntext-enrichi-avec-docs.md" @@ -0,0 +1,34 @@ +--- +title: Proposer un context Enrichi +author: Frédéric Delorme +create_at: 2026-03-21 +tags: ai,llm,prompt,context +summary: Proposer un amelioration de la zone de pompt (`PromptInputArea`) en d'intégrant dans le context du prompt les fichiers ouverts (`DocumentTab`). +--- + +# Proposer un context Enrichi + +title: Proposer un context Enrichi +author: Frédéric Delorme +create_at: 2026-03-21 +tags: ai,llm,prompt,context + +## But + +Proposer un amelioration de la zone de pompt (`PromptInputArea`) en d'intégrant dans le context du prompt les fichiers ouverts (`DocumentTab`). + +## Proposition + +Il faut lister autant de boutons que de fichier ouvert, juste au dessus de la zone de saise du prompt. + +Ainsi, tous ceux qui sont pressés seront ajoutés au contexte lors de la soumission du prompt au LLM. + +## Remarques + + > [!NOTE] le design de bouton utilisé ici devra être très fin avec un texte en police 8pt et un fond clair. Le tour est en pointillé par défaut, si il est activé il devient plein. + + +> [!NOTE] Un petit '+' dans un carré est affiché sur la partie gauche de chaque bouton. si activé le '+' devien un 'check'. Su la partie droite, le nom du fichier. + + + >[!WARNING] les boutons sont stackés les uns après les autres ligne après ligne si besoin. \ No newline at end of file diff --git a/src/main/java/MarkNote.java b/src/main/java/MarkNote.java index d144847..b3bca1a 100644 --- a/src/main/java/MarkNote.java +++ b/src/main/java/MarkNote.java @@ -153,7 +153,7 @@ public void start(Stage stage) { projectExplorerPanel = new ProjectExplorerPanel(); projectExplorerPanel.setOnFileDoubleClick(this::openFileInTab); - // Synchronise l'arbre de l'explorateur avec l'onglet actif + // Synchronise l'arbre de l'explorateur et le diagramme réseau avec l'onglet actif mainTabPane.getSelectionModel().selectedItemProperty().addListener((obs, oldTab, newTab) -> { if (newTab instanceof DocumentTab docTab) { File activeFile = docTab.getFile(); @@ -161,6 +161,7 @@ public void start(Stage stage) { projectExplorerPanel.revealFile(activeFile); } } + syncVisualLinkToActiveTab(); }); // Git service @@ -720,6 +721,9 @@ private void showManagedPanel(BasePanel panel) { } else { lastDockedState.put(panel, true); } + if (panel == visualLinkPanel) { + syncVisualLinkToActiveTab(); + } } private void hideManagedPanel(BasePanel panel) { @@ -742,6 +746,26 @@ private void saveManagedPanelStates() { config.save(); } + /** + * Met en valeur le document actif dans le VisualLinkPanel. + * À appeler à chaque changement d'onglet actif ou quand le panel devient visible. + */ + private void syncVisualLinkToActiveTab() { + var selected = mainTabPane.getSelectionModel().getSelectedItem(); + if (selected instanceof DocumentTab docTab) { + File docFile = docTab.getFile(); + File projectDir = projectExplorerPanel.getProjectDirectory(); + if (docFile != null && projectDir != null) { + String relativePath = projectDir.toPath().relativize(docFile.toPath()).toString(); + visualLinkPanel.setCurrentDocument(relativePath); + } else { + visualLinkPanel.setCurrentDocument(null); + } + } else { + visualLinkPanel.setCurrentDocument(null); + } + } + /** * Configure un onglet de document avec les listeners nécessaires. */ @@ -777,19 +801,9 @@ private void setupDocumentTab(DocumentTab tab) { previewPanel.updatePreview(docTab.getFullContent()); previewPanel.setCurrentFile(docTab.getFile()); updateStatusBarForTab(docTab); - // Mettre en valeur le document dans le diagramme réseau - File docFile = docTab.getFile(); - File projectDir = projectExplorerPanel.getProjectDirectory(); - if (docFile != null && projectDir != null) { - String relativePath = projectDir.toPath().relativize(docFile.toPath()).toString(); - visualLinkPanel.setCurrentDocument(relativePath); - } else { - visualLinkPanel.setCurrentDocument(null); - } } else { statusBar.clearDocumentInfo(); statusBar.updateStats(indexService.getEntries().size(), 0, 0); - visualLinkPanel.setCurrentDocument(null); } }); From 8419a6f732df5396a8fc70cde4fbd3a6742e1fba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Delorme?= Date: Sat, 21 Mar 2026 23:51:50 +0100 Subject: [PATCH 2/5] feat: implement document context selection in prompt area and chat messages --- .../proposer-context-enrichi-avec-docs.md | 10 +- src/main/java/MarkNote.java | 15 +++ src/main/java/ui/ConversationView.java | 64 ++++++++-- src/main/java/ui/PromptInputArea.java | 95 ++++++++++++++- src/main/java/ui/PromptPanel.java | 110 +++++++++++++++++- src/main/resources/css/markdown-editor.css | 67 +++++++++++ src/main/resources/css/themes/dark.css | 8 ++ .../resources/css/themes/high-contrast.css | 8 ++ src/main/resources/css/themes/light.css | 35 ++++++ .../resources/css/themes/solarized-dark.css | 8 ++ .../resources/css/themes/solarized-light.css | 8 ++ 11 files changed, 412 insertions(+), 16 deletions(-) rename "src/docs/proposer-co\303\247ntext-enrichi-avec-docs.md" => src/docs/proposer-context-enrichi-avec-docs.md (57%) diff --git "a/src/docs/proposer-co\303\247ntext-enrichi-avec-docs.md" b/src/docs/proposer-context-enrichi-avec-docs.md similarity index 57% rename from "src/docs/proposer-co\303\247ntext-enrichi-avec-docs.md" rename to src/docs/proposer-context-enrichi-avec-docs.md index c6f4dd7..fee69a5 100644 --- "a/src/docs/proposer-co\303\247ntext-enrichi-avec-docs.md" +++ b/src/docs/proposer-context-enrichi-avec-docs.md @@ -23,12 +23,16 @@ Il faut lister autant de boutons que de fichier ouvert, juste au dessus de la zo Ainsi, tous ceux qui sont pressés seront ajoutés au contexte lors de la soumission du prompt au LLM. -## Remarques +Si dans la zone de promt, des fichiers sont ajoutés, le texte de ceux ci n'est ajouté qu'au moment de l'envoi via l'API, le contenu des différents fichiers ne doit pas apparaitre. +Seuls les noms des fichiers en gras sont listés en début de réponse pour indiquer leur présence dans le contexte du prompt. + - > [!NOTE] le design de bouton utilisé ici devra être très fin avec un texte en police 8pt et un fond clair. Le tour est en pointillé par défaut, si il est activé il devient plein. +## Remarques +> [!NOTE] le design de bouton utilisé ici devra être très fin avec un texte en police 8pt et un fond clair. Le tour est en pointillé par défaut, si il est activé il devient plein. > [!NOTE] Un petit '+' dans un carré est affiché sur la partie gauche de chaque bouton. si activé le '+' devien un 'check'. Su la partie droite, le nom du fichier. +>[!WARNING] les boutons sont stackés les uns après les autres ligne après ligne si besoin. - >[!WARNING] les boutons sont stackés les uns après les autres ligne après ligne si besoin. \ No newline at end of file +>[!NOTE] le focus ne doit resté sur le bas du chat que si l'ascensceur est en base, sinon, on est libre de bouger l'ascenseur. \ No newline at end of file diff --git a/src/main/java/MarkNote.java b/src/main/java/MarkNote.java index b3bca1a..bc3e8ae 100644 --- a/src/main/java/MarkNote.java +++ b/src/main/java/MarkNote.java @@ -267,6 +267,21 @@ public void start(Stage stage) { var sel = mainTabPane.getSelectionModel().getSelectedItem(); return (sel instanceof DocumentTab dt) ? dt : null; }); + // Fournit la liste de tous les DocumentTab ouverts pour la barre de contexte + promptPanel.setOpenTabsSupplier(() -> + mainTabPane.getTabs().stream() + .filter(t -> t instanceof DocumentTab) + .map(t -> (DocumentTab) t) + .toList() + ); + // Rafraîchir la barre de documents à chaque ajout/suppression d'onglet + mainTabPane.getTabs().addListener( + (javafx.collections.ListChangeListener) change -> { + if (promptPanel != null) promptPanel.refreshDocumentContext(); + } + ); + // Navigation vers un onglet depuis un lien de contexte dans le chat + promptPanel.setOnOpenTab(tab -> mainTabPane.getSelectionModel().select(tab)); promptPanel.setOnDetach(() -> detachPanel(promptPanel)); registerManagedPanel(promptPanel); } diff --git a/src/main/java/ui/ConversationView.java b/src/main/java/ui/ConversationView.java index bb7f305..914c0ae 100644 --- a/src/main/java/ui/ConversationView.java +++ b/src/main/java/ui/ConversationView.java @@ -7,15 +7,19 @@ import java.util.Locale; import java.util.ResourceBundle; +import java.util.List; + import javafx.application.Platform; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Button; +import javafx.scene.control.Hyperlink; import javafx.scene.control.Label; import javafx.scene.control.ScrollPane; import javafx.scene.control.Tooltip; import javafx.scene.input.Clipboard; import javafx.scene.input.ClipboardContent; +import javafx.scene.layout.FlowPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; @@ -32,6 +36,14 @@ */ public class ConversationView extends VBox { + /** + * Lien vers un document de contexte affiché dans un message utilisateur. + * + * @param name Nom du document (affiché dans le lien) + * @param action Action exécutée au clic (ex: ouvrir l'onglet correspondant) + */ + public record ContextLink(String name, Runnable action) {} + private static final String LOG_SOURCE = "ConversationView"; private final LogService log = LogService.getInstance(); @@ -70,8 +82,19 @@ public ConversationView() { * @param message Le message à ajouter */ public void addMessage(Message message) { + addMessage(message, List.of()); + } + + /** + * Ajoute un message à la conversation avec des liens de contexte. + * Les liens sont affichés sous forme de puces cliquables au-dessus du texte du message. + * + * @param message Le message à ajouter + * @param contextLinks Les liens vers les documents de contexte + */ + public void addMessage(Message message, List contextLinks) { Platform.runLater(() -> { - HBox wrapper = createMessageWrapper(message, messagesContainer.getChildren().size()); + HBox wrapper = createMessageWrapper(message, messagesContainer.getChildren().size(), contextLinks); messagesContainer.getChildren().add(wrapper); scrollToBottom(); }); @@ -85,7 +108,7 @@ public void addMessage(Message message) { public Message createAssistantMessage() { Message message = new Message(MessageRole.ASSISTANT, ""); Platform.runLater(() -> { - HBox wrapper = createMessageWrapper(message, messagesContainer.getChildren().size()); + HBox wrapper = createMessageWrapper(message, messagesContainer.getChildren().size(), List.of()); messagesContainer.getChildren().add(wrapper); }); return message; @@ -121,10 +144,18 @@ public void clear() { } /** - * Fait défiler jusqu'en bas. + * Fait défiler jusqu'en bas, seulement si l'ascenseur est déjà en position basse. + * Si l'utilisateur a fait défiler vers le haut, sa position est préservée. */ public void scrollToBottom() { - Platform.runLater(() -> scrollPane.setVvalue(1.0)); + Platform.runLater(() -> { + double vvalue = scrollPane.getVvalue(); + double max = scrollPane.getVmax(); + // On auto-scroll uniquement si l'ascenseur est au bas (ou très proche) + if (max <= 0 || vvalue >= max - 0.01) { + scrollPane.setVvalue(max); + } + }); } /** @@ -208,8 +239,8 @@ public int getMessageCount() { // --- Private methods --- - private HBox createMessageWrapper(Message message, int index) { - MessageBlock block = new MessageBlock(message); + private HBox createMessageWrapper(Message message, int index, List contextLinks) { + MessageBlock block = new MessageBlock(message, contextLinks); block.setOnCopy(() -> copyToClipboard(message.getContent())); block.setOnExport(() -> exportMessage(message, getScene().getWindow())); block.setOnInsert(() -> { if (onInsertToDocument != null) onInsertToDocument.accept(message.getContent()); }); @@ -278,6 +309,10 @@ public static class MessageBlock extends VBox { private Runnable onInsert; public MessageBlock(Message message) { + this(message, List.of()); + } + + public MessageBlock(Message message, List contextLinks) { setSpacing(4); setPadding(new Insets(8)); getStyleClass().add("conversation-message"); @@ -324,12 +359,27 @@ public MessageBlock(Message message) { header.getChildren().add(editBtn); } + getChildren().add(header); + + // Barre de liens vers les documents de contexte (si présents) + if (!contextLinks.isEmpty()) { + FlowPane linksPane = new FlowPane(6, 4); + linksPane.getStyleClass().add("context-docs-bar"); + for (ContextLink link : contextLinks) { + Hyperlink hl = new Hyperlink("\uD83D\uDCC4 " + link.name()); + hl.getStyleClass().add("context-doc-link"); + hl.setOnAction(e -> { if (link.action() != null) link.action().run(); }); + linksPane.getChildren().add(hl); + } + getChildren().add(linksPane); + } + // Contenu du message contentLabel = new Label(message.getContent()); contentLabel.setWrapText(true); contentLabel.getStyleClass().add("message-content"); - getChildren().addAll(header, contentLabel); + getChildren().add(contentLabel); setUserData(message); } diff --git a/src/main/java/ui/PromptInputArea.java b/src/main/java/ui/PromptInputArea.java index cefd90d..e621541 100644 --- a/src/main/java/ui/PromptInputArea.java +++ b/src/main/java/ui/PromptInputArea.java @@ -1,17 +1,22 @@ package ui; +import java.util.LinkedHashMap; + import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.ResourceBundle; import java.util.function.Consumer; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Button; +import javafx.scene.control.Label; import javafx.scene.control.ProgressIndicator; import javafx.scene.control.TextArea; import javafx.scene.control.Tooltip; import javafx.scene.input.KeyCode; import javafx.scene.input.KeyEvent; +import javafx.scene.layout.FlowPane; import javafx.scene.layout.HBox; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; @@ -36,6 +41,11 @@ private static ResourceBundle getMessages() { private final ProgressIndicator spinner; private boolean isProcessing = false; + /** Barre de sélection des documents ouverts (au-dessus du TextArea). */ + private final FlowPane docContextBar; + /** filename → bouton Toggle (true = sélectionné). */ + private final Map docButtons = new LinkedHashMap<>(); + private Consumer onSubmit; private Runnable onCancel; private Runnable onContextClick; @@ -48,6 +58,15 @@ public PromptInputArea() { setPadding(new Insets(10)); getStyleClass().add("prompt-input-area"); + // Barre de sélection des documents ouverts + docContextBar = new FlowPane(); + docContextBar.setHgap(4); + docContextBar.setVgap(4); + docContextBar.setPadding(new Insets(0, 0, 2, 0)); + docContextBar.getStyleClass().add("doc-context-bar"); + docContextBar.setVisible(false); + docContextBar.setManaged(false); + // Zone de texte textArea = new TextArea(); textArea.setPromptText(getMessages().getString("llm.prompt.placeholder")); @@ -110,7 +129,45 @@ public PromptInputArea() { actionButtons.getChildren().addAll(contextButton, cancelButton, submitButton); buttonBar.getChildren().addAll(spacer, spinner, actionButtons); - getChildren().addAll(textArea, buttonBar); + getChildren().addAll(docContextBar, textArea, buttonBar); + } + + /** + * Met à jour la barre de documents ouverts. + * Crée un bouton compact par fichier. + * + * @param fileNames liste ordonnée des noms de fichiers ouverts + */ + public void updateDocumentTabs(List fileNames) { + // Conserver l'état de sélection des boutons existants + Map previousState = new LinkedHashMap<>(); + docButtons.forEach((name, btn) -> previousState.put(name, isDocSelected(btn))); + + docContextBar.getChildren().clear(); + docButtons.clear(); + + for (String name : fileNames) { + boolean wasSelected = previousState.getOrDefault(name, false); + Button btn = buildDocButton(name, wasSelected); + docButtons.put(name, btn); + docContextBar.getChildren().add(btn); + } + + boolean hasItems = !fileNames.isEmpty(); + docContextBar.setVisible(hasItems); + docContextBar.setManaged(hasItems); + } + + /** + * Retourne la liste des noms de documents dont le bouton est activé. + * + * @return liste des fichiers sélectionnés comme contexte + */ + public List getSelectedDocuments() { + return docButtons.entrySet().stream() + .filter(e -> isDocSelected(e.getValue())) + .map(Map.Entry::getKey) + .toList(); } /** @@ -214,4 +271,40 @@ private void applyActionButtonSize(Button button) { button.setMinHeight(ACTION_BUTTON_HEIGHT); button.setPrefHeight(ACTION_BUTTON_HEIGHT); } + + /** + * Construit un bouton de sélection de document. + * Style compact : 8pt, fond clair, tour pointillé (inactif) ou plein (actif). + */ + private Button buildDocButton(String fileName, boolean selected) { + Button btn = new Button(); + btn.getStyleClass().add("doc-context-btn"); + btn.setUserData(selected); + updateDocButtonAppearance(btn, fileName, selected); + btn.setTooltip(new Tooltip(fileName)); + btn.setOnAction(e -> { + boolean nowSelected = !isDocSelected(btn); + btn.setUserData(nowSelected); + updateDocButtonAppearance(btn, fileName, nowSelected); + }); + return btn; + } + + private void updateDocButtonAppearance(Button btn, String fileName, boolean selected) { + // Icône gauche + nom fichier + String icon = selected ? "\u2713" : "+"; // ✓ ou + + btn.setText("[" + icon + "] " + fileName); + if (selected) { + btn.getStyleClass().remove("doc-context-btn-off"); + btn.getStyleClass().add("doc-context-btn-on"); + } else { + btn.getStyleClass().remove("doc-context-btn-on"); + btn.getStyleClass().add("doc-context-btn-off"); + } + } + + private boolean isDocSelected(Button btn) { + Object data = btn.getUserData(); + return Boolean.TRUE.equals(data); + } } diff --git a/src/main/java/ui/PromptPanel.java b/src/main/java/ui/PromptPanel.java index 172b467..d71b89d 100644 --- a/src/main/java/ui/PromptPanel.java +++ b/src/main/java/ui/PromptPanel.java @@ -3,7 +3,9 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.function.Consumer; import java.util.function.Supplier; +import java.util.stream.Collectors; import javafx.application.Platform; import javafx.geometry.Orientation; @@ -42,6 +44,12 @@ public class PromptPanel extends BasePanel { /** Fournit le DocumentTab actif pour l'insertion de réponses. */ private Supplier activeDocumentSupplier; + /** Fournit la liste de tous les DocumentTab ouverts pour le contexte. */ + private Supplier> openTabsSupplier; + + /** Callback pour naviguer vers un DocumentTab (depuis les liens de contexte). */ + private Consumer onOpenTab; + /** * Crée un nouveau panel de chat LLM. * @@ -99,11 +107,22 @@ public void submitPrompt() { return; } - // Ajouter le message user - Message userMessage = new Message(MessageRole.USER, promptText); - currentSession.add(userMessage); - conversationView.addMessage(userMessage); - + List selectedNames = promptInput.getSelectedDocuments(); + + // Message envoyé à l'API : contenu complet des fichiers + question + String apiText = buildEnrichedPrompt(promptText, selectedNames); + + // Message affiché dans le chat : texte brut uniquement + Message displayMessage = new Message(MessageRole.USER, promptText); + // Message pour l'API + Message apiMessage = new Message(MessageRole.USER, apiText); + + currentSession.add(apiMessage); + + // Construire les liens de contexte (icone + nom du fichier, avec navigation au clic) + List contextLinks = buildContextLinks(selectedNames); + conversationView.addMessage(displayMessage, contextLinks); + promptInput.clear(); promptInput.setProcessing(true); @@ -153,6 +172,40 @@ public void setActiveDocumentSupplier(Supplier supplier) { this.activeDocumentSupplier = supplier; } + /** + * Définit le fournisseur de la liste de tous les onglets ouverts. + * Utilisé pour alimenter la barre de sélection des documents-contexte. + * + * @param supplier Le supplier retournant la liste des DocumentTab ouverts + */ + public void setOpenTabsSupplier(Supplier> supplier) { + this.openTabsSupplier = supplier; + } + + /** + * Définit le callback appelé lorsque l'utilisateur clique sur un lien de contexte. + * Typiquement : sélectionner l'onglet correspondant dans mainTabPane. + * + * @param onOpenTab Consumer recevant le DocumentTab à activer + */ + public void setOnOpenTab(Consumer onOpenTab) { + this.onOpenTab = onOpenTab; + } + + /** + * Rafraîchit la barre de sélection des documents dans {@link PromptInputArea}. + * À appeler depuis {@code MarkNote} lors de tout changement de la liste d'onglets. + */ + public void refreshDocumentContext() { + List names = List.of(); + if (openTabsSupplier != null) { + names = openTabsSupplier.get().stream() + .map(t -> t.getFile() != null ? t.getFile().getName() : t.getText()) + .toList(); + } + promptInput.updateDocumentTabs(names); + } + /** * Insère l'intégralité de la session dans le document actif. */ @@ -268,6 +321,53 @@ private void setupCallbacks() { }); } + /** + * Construit la liste des liens de contexte à afficher dans le message chat. + * Chaque lien identifie un document et ouvre l'onglet correspondant au clic. + */ + private List buildContextLinks(List selectedNames) { + if (selectedNames.isEmpty() || openTabsSupplier == null) return List.of(); + List openTabs = openTabsSupplier.get(); + List links = new ArrayList<>(); + for (String name : selectedNames) { + openTabs.stream() + .filter(t -> name.equals(t.getFile() != null ? t.getFile().getName() : t.getText())) + .findFirst() + .ifPresent(tab -> links.add(new ConversationView.ContextLink( + name, + () -> { if (onOpenTab != null) onOpenTab.accept(tab); } + ))); + } + return links; + } + + /** + * Construit le texte envoy\u00e9 \u00e0 l'API (contenu complet des fichiers + question). + * Si aucun document n'est s\u00e9lectionn\u00e9, retourne le prompt brut inchang\u00e9. + */ + private String buildEnrichedPrompt(String userPrompt, List selectedNames) { + if (selectedNames.isEmpty() || openTabsSupplier == null) { + return userPrompt; + } + + List openTabs = openTabsSupplier.get(); + StringBuilder sb = new StringBuilder(); + for (String name : selectedNames) { + openTabs.stream() + .filter(t -> name.equals(t.getFile() != null ? t.getFile().getName() : t.getText())) + .findFirst() + .ifPresent(tab -> { + sb.append("### Document : ").append(name).append("\n\n"); + sb.append(tab.getFullContent().strip()).append("\n\n"); + }); + } + if (!sb.isEmpty()) { + sb.append("---\n\n").append(userPrompt); + return sb.toString(); + } + return userPrompt; + } + private void setupHeaderButtons() { HBox header = getHeader(); if (header == null) return; diff --git a/src/main/resources/css/markdown-editor.css b/src/main/resources/css/markdown-editor.css index 1a7965f..e7d4e69 100644 --- a/src/main/resources/css/markdown-editor.css +++ b/src/main/resources/css/markdown-editor.css @@ -252,6 +252,73 @@ -fx-padding: 10 12 10 12; } +/* ── Barre de sélection des documents-contexte ─────────────────────────── */ +.doc-context-bar { + -fx-padding: 0 0 4 0; +} + +/* Bouton document — état inactif : fond très clair, bordure pointillée */ +.doc-context-btn { + -fx-font-size: 9px; + -fx-padding: 2 6 2 6; + -fx-background-color: #f7f8fa; + -fx-background-radius: 4; + -fx-cursor: hand; + -fx-min-height: 18; + -fx-pref-height: 18; + -fx-max-height: 18; + -fx-text-fill: #5a6a80; +} + +.doc-context-btn-off { + -fx-border-color: #aabcce; + -fx-border-width: 1; + -fx-border-radius: 4; + -fx-border-style: dashed; +} + +.doc-context-btn-off:hover { + -fx-background-color: #eef1f6; +} + +/* Bouton document — état actif : fond teinté bleu, bordure pleine */ +.doc-context-btn-on { + -fx-background-color: #e3ecfb; + -fx-border-color: #1f6fd1; + -fx-border-width: 1; + -fx-border-radius: 4; + -fx-border-style: solid; + -fx-text-fill: #1a4fa0; + -fx-font-weight: bold; +} + +.doc-context-btn-on:hover { + -fx-background-color: #d3e3f8; +} + +/* Barre de liens de contexte document dans les messages chat */ +.context-docs-bar { + -fx-padding: 4 0 2 0; +} + +.context-doc-link { + -fx-font-size: 12px; + -fx-text-fill: #2a6496; + -fx-underline: true; + -fx-cursor: hand; + -fx-padding: 1 4 1 2; + -fx-border-color: transparent; + -fx-background-color: transparent; +} + +.context-doc-link:hover { + -fx-text-fill: #1a4a76; + -fx-background-color: rgba(42,100,150,0.08); + -fx-background-radius: 3; +} + +/* ──────────────────────────────────────────────────────────────────────── */ + .prompt-button-bar { -fx-alignment: bottom-right; -fx-min-height: 36px; diff --git a/src/main/resources/css/themes/dark.css b/src/main/resources/css/themes/dark.css index 2fa8b19..b0e0845 100644 --- a/src/main/resources/css/themes/dark.css +++ b/src/main/resources/css/themes/dark.css @@ -547,3 +547,11 @@ .cancel-button:hover { -fx-background-color: #ad3a3a; } .panel-header-button { -fx-text-fill: #aeb9c8; } .panel-header-button:hover { -fx-text-fill: #74b2ff; } +/* doc-context buttons */ +.doc-context-btn { -fx-background-color: #2b303a; -fx-text-fill: #9aafc4; } +.doc-context-btn-off { -fx-border-color: #48515f; -fx-border-style: dashed; -fx-border-width: 1; -fx-border-radius: 4; } +.doc-context-btn-off:hover { -fx-background-color: #333a46; } +.doc-context-btn-on { -fx-background-color: #1d3859; -fx-border-color: #74b2ff; -fx-border-style: solid; -fx-border-width: 1; -fx-border-radius: 4; -fx-text-fill: #a8cdff; } +.doc-context-btn-on:hover { -fx-background-color: #1a3352; } +.context-doc-link { -fx-text-fill: #74b2ff; -fx-underline: true; -fx-background-color: transparent; -fx-border-color: transparent; } +.context-doc-link:hover { -fx-text-fill: #a8cdff; -fx-background-color: rgba(116,178,255,0.12); -fx-background-radius: 3; } diff --git a/src/main/resources/css/themes/high-contrast.css b/src/main/resources/css/themes/high-contrast.css index 081bc0f..b851b0c 100644 --- a/src/main/resources/css/themes/high-contrast.css +++ b/src/main/resources/css/themes/high-contrast.css @@ -542,3 +542,11 @@ .cancel-button:hover { -fx-background-color: #ff5f55; } .panel-header-button { -fx-text-fill: #ffff00; } .panel-header-button:hover { -fx-text-fill: #00ffff; } +/* doc-context buttons */ +.doc-context-btn { -fx-background-color: #000000; -fx-text-fill: #ffffff; -fx-font-weight: bold; } +.doc-context-btn-off { -fx-border-color: #ffffff; -fx-border-style: dashed; -fx-border-width: 2; -fx-border-radius: 4; } +.doc-context-btn-off:hover { -fx-background-color: #1a1a1a; } +.doc-context-btn-on { -fx-background-color: #000000; -fx-border-color: #ffff00; -fx-border-style: solid; -fx-border-width: 2; -fx-border-radius: 4; -fx-text-fill: #ffff00; } +.doc-context-btn-on:hover { -fx-background-color: #1a1a00; } +.context-doc-link { -fx-text-fill: #ffff00; -fx-underline: true; -fx-background-color: transparent; -fx-border-color: transparent; } +.context-doc-link:hover { -fx-text-fill: #ffffff; -fx-background-color: rgba(255,255,0,0.18); -fx-background-radius: 3; } diff --git a/src/main/resources/css/themes/light.css b/src/main/resources/css/themes/light.css index 26e4e09..90e3102 100644 --- a/src/main/resources/css/themes/light.css +++ b/src/main/resources/css/themes/light.css @@ -431,3 +431,38 @@ .console-area .content { -fx-background-color: #f8f8f8; } + +/* === Chat Panel === */ +.prompt-panel { -fx-background-color: #f2f4f7; } +.conversation-view { -fx-background-color: #ffffff; } +.conversation-scroll > .viewport { -fx-background-color: #ffffff; } +.conversation-container { -fx-background-color: #ffffff; } +.conversation-message.user { + -fx-background-color: #e8f0fe; + -fx-border-color: #a8c4f5; +} +.conversation-message.assistant { + -fx-background-color: #f5f5f5; + -fx-border-color: #d0d5dd; +} +.message-role { -fx-text-fill: #1a3c6e; } +.message-time { -fx-text-fill: #8a94a6; } +.message-content { -fx-text-fill: #1a1a2e; } +.message-action-button { -fx-text-fill: #8a94a6; } +.message-action-button:hover { -fx-text-fill: #3584e4; } +.prompt-input-area { -fx-background-color: #f2f4f7; -fx-border-color: #d2d8e2; } +.prompt-textarea { -fx-background-color: #ffffff; -fx-border-color: #c5cdd8; } +.submit-button { -fx-background-color: #3584e4; } +.submit-button:hover { -fx-background-color: #2870c8; } +.cancel-button { -fx-background-color: #e01b24; } +.cancel-button:hover { -fx-background-color: #c01119; } +.panel-header-button { -fx-text-fill: #6f8098; } +.panel-header-button:hover { -fx-text-fill: #3584e4; } +/* doc-context buttons */ +.doc-context-btn { -fx-background-color: #f0f2f5; -fx-text-fill: #5a6a80; } +.doc-context-btn-off { -fx-border-color: #b0bcc9; -fx-border-style: dashed; -fx-border-width: 1; -fx-border-radius: 4; } +.doc-context-btn-off:hover { -fx-background-color: #e8edf2; } +.doc-context-btn-on { -fx-background-color: #e3ecfb; -fx-border-color: #3584e4; -fx-border-style: solid; -fx-border-width: 1; -fx-border-radius: 4; -fx-text-fill: #1a4fa0; } +.doc-context-btn-on:hover { -fx-background-color: #d3e3f8; } +.context-doc-link { -fx-text-fill: #2a6496; -fx-underline: true; -fx-background-color: transparent; -fx-border-color: transparent; } +.context-doc-link:hover { -fx-text-fill: #1a4a76; -fx-background-color: rgba(42,100,150,0.08); -fx-background-radius: 3; } diff --git a/src/main/resources/css/themes/solarized-dark.css b/src/main/resources/css/themes/solarized-dark.css index fa43b52..9f1c53e 100644 --- a/src/main/resources/css/themes/solarized-dark.css +++ b/src/main/resources/css/themes/solarized-dark.css @@ -471,3 +471,11 @@ .cancel-button:hover { -fx-background-color: #b92b28; } .panel-header-button { -fx-text-fill: #93a1a1; } .panel-header-button:hover { -fx-text-fill: #2aa198; } +/* doc-context buttons */ +.doc-context-btn { -fx-background-color: #0a3d4a; -fx-text-fill: #839496; } +.doc-context-btn-off { -fx-border-color: #4d6e76; -fx-border-style: dashed; -fx-border-width: 1; -fx-border-radius: 4; } +.doc-context-btn-off:hover { -fx-background-color: #0d4455; } +.doc-context-btn-on { -fx-background-color: #0c4763; -fx-border-color: #2aa198; -fx-border-style: solid; -fx-border-width: 1; -fx-border-radius: 4; -fx-text-fill: #2aa198; } +.doc-context-btn-on:hover { -fx-background-color: #0a3d54; } +.context-doc-link { -fx-text-fill: #268bd2; -fx-underline: true; -fx-background-color: transparent; -fx-border-color: transparent; } +.context-doc-link:hover { -fx-text-fill: #6ab0e4; -fx-background-color: rgba(38,139,210,0.15); -fx-background-radius: 3; } diff --git a/src/main/resources/css/themes/solarized-light.css b/src/main/resources/css/themes/solarized-light.css index ee46bb2..18088ad 100644 --- a/src/main/resources/css/themes/solarized-light.css +++ b/src/main/resources/css/themes/solarized-light.css @@ -444,3 +444,11 @@ .cancel-button:hover { -fx-background-color: #ae3f12; } .panel-header-button { -fx-text-fill: #657b83; } .panel-header-button:hover { -fx-text-fill: #268bd2; } +/* doc-context buttons */ +.doc-context-btn { -fx-background-color: #ede8d5; -fx-text-fill: #657b83; } +.doc-context-btn-off { -fx-border-color: #b8b39f; -fx-border-style: dashed; -fx-border-width: 1; -fx-border-radius: 4; } +.doc-context-btn-off:hover { -fx-background-color: #e4dfc9; } +.doc-context-btn-on { -fx-background-color: #daedf7; -fx-border-color: #268bd2; -fx-border-style: solid; -fx-border-width: 1; -fx-border-radius: 4; -fx-text-fill: #1a6090; } +.doc-context-btn-on:hover { -fx-background-color: #cce3f1; } +.context-doc-link { -fx-text-fill: #268bd2; -fx-underline: true; -fx-background-color: transparent; -fx-border-color: transparent; } +.context-doc-link:hover { -fx-text-fill: #1a6aa0; -fx-background-color: rgba(38,139,210,0.10); -fx-background-radius: 3; } From f1737c10a28a2f9745a4568cb93c01982aea122e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Delorme?= Date: Sun, 22 Mar 2026 00:02:43 +0100 Subject: [PATCH 3/5] refactor: improve code readability and formatting in PromptPanel --- src/main/java/ui/PromptPanel.java | 79 +++++++++++++++---------------- 1 file changed, 39 insertions(+), 40 deletions(-) diff --git a/src/main/java/ui/PromptPanel.java b/src/main/java/ui/PromptPanel.java index d71b89d..4ffb21c 100644 --- a/src/main/java/ui/PromptPanel.java +++ b/src/main/java/ui/PromptPanel.java @@ -5,24 +5,22 @@ import java.util.Optional; import java.util.function.Consumer; import java.util.function.Supplier; -import java.util.stream.Collectors; +import config.LLMConfig; import javafx.application.Platform; import javafx.geometry.Orientation; import javafx.scene.control.Button; import javafx.scene.control.SplitPane; import javafx.scene.control.Tooltip; import javafx.scene.layout.HBox; - -import config.LLMConfig; import services.LLMService; import services.Message; import services.MessageRole; import utils.LogService; /** - * Panel principal pour le chat LLM. - * Étend BasePanel pour s'intégrer au système de docking. + * Panel principal pour le chat LLM. Étend BasePanel pour s'intégrer au système + * de docking. */ public class PromptPanel extends BasePanel { @@ -47,7 +45,9 @@ public class PromptPanel extends BasePanel { /** Fournit la liste de tous les DocumentTab ouverts pour le contexte. */ private Supplier> openTabsSupplier; - /** Callback pour naviguer vers un DocumentTab (depuis les liens de contexte). */ + /** + * Callback pour naviguer vers un DocumentTab (depuis les liens de contexte). + */ private Consumer onOpenTab; /** @@ -119,7 +119,8 @@ public void submitPrompt() { currentSession.add(apiMessage); - // Construire les liens de contexte (icone + nom du fichier, avec navigation au clic) + // Construire les liens de contexte (icone + nom du fichier, avec navigation au + // clic) List contextLinks = buildContextLinks(selectedNames); conversationView.addMessage(displayMessage, contextLinks); @@ -135,23 +136,20 @@ public void submitPrompt() { currentSession.add(assistantMessage); // Envoyer au LLM seulement les messages jusqu'au user message inclus - llmService.sendPromptAsync( - messagesForApi, + llmService.sendPromptAsync(messagesForApi, chunk -> Platform.runLater(() -> conversationView.appendToLastMessage(chunk)), () -> Platform.runLater(() -> { promptInput.setProcessing(false); conversationView.scrollToBottom(); log.info(LOG_SOURCE, "Response completed"); - }), - error -> Platform.runLater(() -> { + }), error -> Platform.runLater(() -> { promptInput.setProcessing(false); // Ajouter un message d'erreur String detail = error.getMessage() != null ? error.getMessage() : error.getClass().getSimpleName(); String errorMsg = getMessages().getString("llm.error.connection") + ": " + detail; conversationView.appendToLastMessage("\n\n**Erreur:** " + errorMsg); log.error(LOG_SOURCE, "LLM error: " + error); - }) - ); + })); } /** @@ -173,8 +171,8 @@ public void setActiveDocumentSupplier(Supplier supplier) { } /** - * Définit le fournisseur de la liste de tous les onglets ouverts. - * Utilisé pour alimenter la barre de sélection des documents-contexte. + * Définit le fournisseur de la liste de tous les onglets ouverts. Utilisé pour + * alimenter la barre de sélection des documents-contexte. * * @param supplier Le supplier retournant la liste des DocumentTab ouverts */ @@ -183,8 +181,8 @@ public void setOpenTabsSupplier(Supplier> supplier) { } /** - * Définit le callback appelé lorsque l'utilisateur clique sur un lien de contexte. - * Typiquement : sélectionner l'onglet correspondant dans mainTabPane. + * Définit le callback appelé lorsque l'utilisateur clique sur un lien de + * contexte. Typiquement : sélectionner l'onglet correspondant dans mainTabPane. * * @param onOpenTab Consumer recevant le DocumentTab à activer */ @@ -194,13 +192,13 @@ public void setOnOpenTab(Consumer onOpenTab) { /** * Rafraîchit la barre de sélection des documents dans {@link PromptInputArea}. - * À appeler depuis {@code MarkNote} lors de tout changement de la liste d'onglets. + * À appeler depuis {@code MarkNote} lors de tout changement de la liste + * d'onglets. */ public void refreshDocumentContext() { List names = List.of(); if (openTabsSupplier != null) { - names = openTabsSupplier.get().stream() - .map(t -> t.getFile() != null ? t.getFile().getName() : t.getText()) + names = openTabsSupplier.get().stream().map(t -> t.getFile() != null ? t.getFile().getName() : t.getText()) .toList(); } promptInput.updateDocumentTabs(names); @@ -210,9 +208,11 @@ public void refreshDocumentContext() { * Insère l'intégralité de la session dans le document actif. */ public void insertAllToDocument() { - if (activeDocumentSupplier == null) return; + if (activeDocumentSupplier == null) + return; DocumentTab tab = activeDocumentSupplier.get(); - if (tab == null) return; + if (tab == null) + return; StringBuilder sb = new StringBuilder(); for (Message msg : currentSession) { sb.append(msg.getRole() == MessageRole.USER ? "> " : ""); @@ -326,24 +326,24 @@ private void setupCallbacks() { * Chaque lien identifie un document et ouvre l'onglet correspondant au clic. */ private List buildContextLinks(List selectedNames) { - if (selectedNames.isEmpty() || openTabsSupplier == null) return List.of(); + if (selectedNames.isEmpty() || openTabsSupplier == null) + return List.of(); List openTabs = openTabsSupplier.get(); List links = new ArrayList<>(); for (String name : selectedNames) { - openTabs.stream() - .filter(t -> name.equals(t.getFile() != null ? t.getFile().getName() : t.getText())) - .findFirst() - .ifPresent(tab -> links.add(new ConversationView.ContextLink( - name, - () -> { if (onOpenTab != null) onOpenTab.accept(tab); } - ))); + openTabs.stream().filter(t -> name.equals(t.getFile() != null ? t.getFile().getName() : t.getText())) + .findFirst().ifPresent(tab -> links.add(new ConversationView.ContextLink(name, () -> { + if (onOpenTab != null) + onOpenTab.accept(tab); + }))); } return links; } /** - * Construit le texte envoy\u00e9 \u00e0 l'API (contenu complet des fichiers + question). - * Si aucun document n'est s\u00e9lectionn\u00e9, retourne le prompt brut inchang\u00e9. + * Construit le texte envoy\u00e9 \u00e0 l'API (contenu complet des fichiers + + * question). Si aucun document n'est s\u00e9lectionn\u00e9, retourne le prompt + * brut inchang\u00e9. */ private String buildEnrichedPrompt(String userPrompt, List selectedNames) { if (selectedNames.isEmpty() || openTabsSupplier == null) { @@ -353,13 +353,11 @@ private String buildEnrichedPrompt(String userPrompt, List selectedNames List openTabs = openTabsSupplier.get(); StringBuilder sb = new StringBuilder(); for (String name : selectedNames) { - openTabs.stream() - .filter(t -> name.equals(t.getFile() != null ? t.getFile().getName() : t.getText())) - .findFirst() - .ifPresent(tab -> { - sb.append("### Document : ").append(name).append("\n\n"); - sb.append(tab.getFullContent().strip()).append("\n\n"); - }); + openTabs.stream().filter(t -> name.equals(t.getFile() != null ? t.getFile().getName() : t.getText())) + .findFirst().ifPresent(tab -> { + sb.append("### Document : ").append(name).append("\n\n"); + sb.append(tab.getFullContent().strip()).append("\n\n"); + }); } if (!sb.isEmpty()) { sb.append("---\n\n").append(userPrompt); @@ -370,7 +368,8 @@ private String buildEnrichedPrompt(String userPrompt, List selectedNames private void setupHeaderButtons() { HBox header = getHeader(); - if (header == null) return; + if (header == null) + return; // Bouton export exportButton = new Button("\u2913"); // ⤓ From 7530a0fbd39b45c4418d35a3602f1437786fbde8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Delorme?= Date: Sun, 22 Mar 2026 14:46:32 +0100 Subject: [PATCH 4/5] feat: implement Markdown rendering in ConversationView using WebView --- build | 77 +++++++++++++ src/main/java/ui/ConversationView.java | 145 ++++++++++++++++++++++--- 2 files changed, 208 insertions(+), 14 deletions(-) diff --git a/build b/build index 925f53f..f2eef9b 100755 --- a/build +++ b/build @@ -471,6 +471,83 @@ WIN_INSTALL_EOF2 echo -e " ${GREEN}Windows installer created.${NC}" elif [[ "$TARGET_OS" == "mac" ]]; then + if [[ "$TARGET_OS" == "$HOST_OS" ]] && command -v jpackage &>/dev/null; then + # ── Native macOS DMG via jpackage ───────────────────────────────── + echo "Creating macOS DMG with jpackage..." + + # Input directory: main JAR + non-JavaFX libs only. + # JavaFX modules are bundled into the app image via --module-path / --add-modules. + local JPACKAGE_INPUT="${TARGET}/jpackage-input" + rm -rf "${JPACKAGE_INPUT}" + mkdir -p "${JPACKAGE_INPUT}" + cp "${BUILD}/${project_name}-${project_version}-${GIT_COMMIT_ID:0:12}.jar" "${JPACKAGE_INPUT}/" + if [[ -d "libs/common" ]] && ls libs/common/*.jar &>/dev/null 2>&1; then + cp libs/common/*.jar "${JPACKAGE_INPUT}/" 2>/dev/null || true + echo " Copied common libs into jpackage input." + fi + + # Icon resolution: prefer .icns, convert from PNG with sips+iconutil if needed + local ICON_ICNS="${SRC}/main/resources/images/icons/marknote.icns" + local ICON_PNG="${SRC}/main/resources/images/icons/marknote-128.png" + local JPACKAGE_ICON_OPT=() + local ICON_TMPDIR="" + if [[ -f "${ICON_ICNS}" ]]; then + JPACKAGE_ICON_OPT=(--icon "${ICON_ICNS}") + elif [[ -f "${ICON_PNG}" ]] && command -v sips &>/dev/null && command -v iconutil &>/dev/null; then + echo " Converting PNG icon to .icns..." + ICON_TMPDIR=$(mktemp -d) + local ICONSET_DIR="${ICON_TMPDIR}/marknote.iconset" + mkdir -p "${ICONSET_DIR}" + for sz in 16 32 64 128 256 512; do + sips -z ${sz} ${sz} "${ICON_PNG}" --out "${ICONSET_DIR}/icon_${sz}x${sz}.png" 2>/dev/null || true + done + for sz in 16 32 128 256; do + local dbl=$(( sz * 2 )) + sips -z ${dbl} ${dbl} "${ICON_PNG}" --out "${ICONSET_DIR}/icon_${sz}x${sz}@2x.png" 2>/dev/null || true + done + local GENERATED_ICNS="${ICON_TMPDIR}/marknote.icns" + iconutil -c icns "${ICONSET_DIR}" -o "${GENERATED_ICNS}" 2>/dev/null || true + [[ -f "${GENERATED_ICNS}" ]] && JPACKAGE_ICON_OPT=(--icon "${GENERATED_ICNS}") + fi + + mkdir -p "${TARGET}/dist" + jpackage \ + --type dmg \ + --name "${project_name}" \ + --app-version "${project_version}" \ + --vendor "${vendor_name}" \ + --description "Markdown Note Editor" \ + --input "${JPACKAGE_INPUT}" \ + --main-jar "${project_name}-${project_version}-${GIT_COMMIT_ID:0:12}.jar" \ + --main-class "${main_class}" \ + --module-path "${TARGET}/jfx-libs" \ + --add-modules "${JFX_MODULES}" \ + --java-options "-Djava.net.preferIPv6Addresses=true" \ + --dest "${TARGET}/dist" \ + --mac-package-identifier "com.snapgames.marknote" \ + --mac-package-name "${project_name}" \ + "${JPACKAGE_ICON_OPT[@]}" + + [[ -n "${ICON_TMPDIR}" ]] && rm -rf "${ICON_TMPDIR}" + + local DMG_FILE + DMG_FILE=$(ls "${TARGET}/dist/${project_name}"*.dmg 2>/dev/null | head -1) + if [[ -f "${DMG_FILE}" ]]; then + echo -e "${GREEN}macOS DMG created: ${DMG_FILE}${NC}" + echo "Package size: $(du -sh "${DMG_FILE}" | cut -f1)" + else + echo -e "${YELLOW}Warning: DMG not found – check ${TARGET}/dist/${NC}" + ls -lh "${TARGET}/dist/" 2>/dev/null || true + fi + echo "done." + return 0 + fi + # ── Fallback: .app bundle + install.sh ──────────────────────────────── + if [[ "$HOST_OS" != "mac" ]]; then + echo -e " ${YELLOW}Cross-platform build: jpackage DMG requires a macOS host – falling back to .app bundle + install.sh.${NC}" + else + echo -e " ${YELLOW}jpackage not found in PATH – falling back to .app bundle + install.sh.${NC}" + fi #----- macOS installer: create .app bundle in ~/Applications ----- cat > "${DIST_DIR}/install.sh" << 'MAC_INSTALL_EOF' #!/bin/bash diff --git a/src/main/java/ui/ConversationView.java b/src/main/java/ui/ConversationView.java index 914c0ae..9b4f227 100644 --- a/src/main/java/ui/ConversationView.java +++ b/src/main/java/ui/ConversationView.java @@ -2,14 +2,22 @@ import java.io.File; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.Base64; +import java.util.List; import java.util.Locale; import java.util.ResourceBundle; -import java.util.List; +import com.vladsch.flexmark.ext.tables.TablesExtension; +import com.vladsch.flexmark.html.HtmlRenderer; +import com.vladsch.flexmark.parser.Parser; +import com.vladsch.flexmark.util.data.MutableDataSet; import javafx.application.Platform; +import javafx.concurrent.Worker; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.control.Button; @@ -24,6 +32,7 @@ import javafx.scene.layout.Priority; import javafx.scene.layout.Region; import javafx.scene.layout.VBox; +import javafx.scene.web.WebView; import javafx.stage.FileChooser; import javafx.stage.Window; @@ -299,10 +308,24 @@ private void exportMessage(Message message, Window owner) { /** * Bloc représentant un message dans la conversation. + * Le contenu est rendu en HTML via flexmark (Markdown) dans un WebView, + * ce qui permet la sélection de texte et la mise en forme complète. */ public static class MessageBlock extends VBox { - private final Label contentLabel; + // Parseur/rendu Markdown partagé entre toutes les bulles + private static final Parser MD_PARSER; + private static final HtmlRenderer MD_RENDERER; + + static { + MutableDataSet opts = new MutableDataSet(); + opts.set(Parser.EXTENSIONS, Arrays.asList(TablesExtension.create())); + MD_PARSER = Parser.builder(opts).build(); + MD_RENDERER = HtmlRenderer.builder(opts).build(); + } + + private final WebView contentView; + private final boolean isUser; private Runnable onCopy; private Runnable onExport; private Runnable onEdit; @@ -316,19 +339,20 @@ public MessageBlock(Message message, List contextLinks) { setSpacing(4); setPadding(new Insets(8)); getStyleClass().add("conversation-message"); - getStyleClass().add(message.getRole() == MessageRole.USER ? "user" : "assistant"); + isUser = message.getRole() == MessageRole.USER; + getStyleClass().add(isUser ? "user" : "assistant"); // Header avec rôle et timestamp HBox header = new HBox(5); header.setAlignment(Pos.CENTER_LEFT); - - Label roleLabel = new Label(message.getRole() == MessageRole.USER ? "You" : "Assistant"); + + Label roleLabel = new Label(isUser ? "You" : "Assistant"); roleLabel.getStyleClass().add("message-role"); - + Label timeLabel = new Label(message.getTimestamp() .format(DateTimeFormatter.ofPattern("HH:mm"))); timeLabel.getStyleClass().add("message-time"); - + Region spacer = new Region(); HBox.setHgrow(spacer, Priority.ALWAYS); @@ -351,7 +375,7 @@ public MessageBlock(Message message, List contextLinks) { header.getChildren().addAll(roleLabel, timeLabel, spacer, copyBtn, exportBtn, insertBtn); // Ajouter bouton édition seulement pour les messages user - if (message.getRole() == MessageRole.USER) { + if (isUser) { Button editBtn = new Button("\u270E"); // ✎ editBtn.getStyleClass().add("message-action-button"); editBtn.setTooltip(new Tooltip("Edit")); @@ -374,22 +398,115 @@ public MessageBlock(Message message, List contextLinks) { getChildren().add(linksPane); } - // Contenu du message - contentLabel = new Label(message.getContent()); - contentLabel.setWrapText(true); - contentLabel.getStyleClass().add("message-content"); + // Contenu du message : WebView affichant le Markdown rendu en HTML. + // La sélection de texte est gérée nativement par le WebView. + contentView = new WebView(); + contentView.setContextMenuEnabled(true); + contentView.setMaxWidth(Double.MAX_VALUE); + contentView.setMinHeight(20); + contentView.setPrefHeight(40); // ajusté dynamiquement après le chargement + + // Ajuster la hauteur du WebView dès que le contenu est chargé. + // Platform.runLater garantit que le layout JavaFX a fixé la largeur réelle + // avant que l'on interroge scrollHeight dans le DOM. + contentView.getEngine().getLoadWorker().stateProperty().addListener( + (obs, oldState, newState) -> { + if (newState == Worker.State.SUCCEEDED) { + Platform.runLater(this::adjustHeight); + } + } + ); + + // Recalculer la hauteur si la largeur du WebView change (redimensionnement du panel). + contentView.widthProperty().addListener((obs, ov, nv) -> { + if (contentView.getEngine().getLoadWorker().getState() == Worker.State.SUCCEEDED) { + Platform.runLater(this::adjustHeight); + } + }); - getChildren().add(contentLabel); + loadMarkdown(message.getContent()); + getChildren().add(contentView); setUserData(message); } + /** + * Met à jour le contenu (appelé pendant le streaming). + * Injecte le nouveau HTML via JavaScript pour éviter un rechargement complet. + */ public void updateContent(String content) { - contentLabel.setText(content); + String bodyHtml = MD_RENDERER.render(MD_PARSER.parse(content)); + // Encodage base64 UTF-8 → injection JS sans risque d'échappement + String b64 = Base64.getEncoder().encodeToString( + bodyHtml.getBytes(StandardCharsets.UTF_8)); + try { + contentView.getEngine().executeScript( + "(function(){" + + "var a=Uint8Array.from(atob('" + b64 + "'),function(c){return c.charCodeAt(0);});" + + "document.getElementById('md').innerHTML=new TextDecoder().decode(a);" + + "})()" + ); + // Différé pour laisser le DOM se recalculer avant de mesurer + Platform.runLater(this::adjustHeight); + } catch (Exception ex) { + // Repli sur chargement complet si la page n'est pas encore prête + loadMarkdown(content); + } } public void setOnCopy(Runnable action) { this.onCopy = action; } public void setOnExport(Runnable action) { this.onExport = action; } public void setOnEdit(Runnable action) { this.onEdit = action; } public void setOnInsert(Runnable action) { this.onInsert = action; } + + // --- Private helpers --- + + private void loadMarkdown(String markdown) { + contentView.getEngine().loadContent(buildPage(markdown), "text/html"); + } + + private void adjustHeight() { + try { + // #md.scrollHeight donne la hauteur réelle du contenu même si body + // a overflow:hidden. On ajoute une marge de sécurité de 16px. + Object result = contentView.getEngine().executeScript( + "(function(){" + + "var el=document.getElementById('md');" + + "return el ? el.scrollHeight : document.body.scrollHeight;" + + "})()" + ); + if (result instanceof Number n && n.doubleValue() > 0) { + contentView.setPrefHeight(n.doubleValue() + 16); + } + } catch (Exception ex) { + // Engine pas encore prêt – ignoré silencieusement + } + } + + private String buildPage(String markdown) { + String bg = isUser ? "#ddeeff" : "#f0f2f5"; + String body = MD_RENDERER.render(MD_PARSER.parse(markdown.isBlank() ? " " : markdown)); + return "
" + body + "
"; + } } } From 124047daded3bd4a9ad5d6be2977f42db41ac627 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Delorme?= Date: Sun, 22 Mar 2026 15:52:41 +0100 Subject: [PATCH 5/5] feat: add Maven support with build configuration and file associations for Markdown --- .github/workflows/build-packages.yml | 103 ++++ pom.xml | 531 ++++++++++++++++++ .../file-association-markdown.properties | 3 + src/packaging/file-association-md.properties | 3 + 4 files changed, 640 insertions(+) create mode 100644 .github/workflows/build-packages.yml create mode 100644 pom.xml create mode 100644 src/packaging/file-association-markdown.properties create mode 100644 src/packaging/file-association-md.properties diff --git a/.github/workflows/build-packages.yml b/.github/workflows/build-packages.yml new file mode 100644 index 0000000..f11a549 --- /dev/null +++ b/.github/workflows/build-packages.yml @@ -0,0 +1,103 @@ +name: Build Native Packages + +# ─── Triggers ────────────────────────────────────────────────────────────── +# • Automatic : every push of a version tag (e.g. v0.1.3) +# • Manual : "Run workflow" button in the Actions tab +on: + push: + tags: + - 'v*' + workflow_dispatch: + +# Allow the release job to create/update GitHub Releases +permissions: + contents: write + +# ─── Jobs ────────────────────────────────────────────────────────────────── +jobs: + + # ══════════════════════════════════════════════════════════════════════════ + # Build a native installer on each target OS + # ══════════════════════════════════════════════════════════════════════════ + package: + name: Package – ${{ matrix.label }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false # let all three platforms build even if one fails + matrix: + include: + - os: ubuntu-latest + label: Linux (DEB) + - os: macos-latest + label: macOS (DMG) + - os: windows-latest + label: Windows (EXE) + + steps: + + # ── 1. Checkout with full history ──────────────────────────────────── + # git-commit-id-maven-plugin needs the full git log to resolve + # ${git.commit.id.abbrev} used in the fat-JAR name. + - name: Checkout (full history) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + # ── 2. JDK 24 (Azul Zulu) ──────────────────────────────────────────── + - name: Set up JDK 24 (Zulu) + uses: actions/setup-java@v4 + with: + java-version: '24' + distribution: 'zulu' + cache: maven # cache ~/.m2 between runs + + # ── 3. Linux: ensure DEB packaging toolchain is present ────────────── + # jpackage --type deb requires fakeroot + dpkg (pre-installed on + # ubuntu-latest but explicit install guarantees availability). + - name: Install DEB packaging tools + if: runner.os == 'Linux' + run: sudo apt-get install -y fakeroot + + # ── 4. Build fat JAR + native installer ────────────────────────────── + # The active OS profile (linux / mac / win) is detected automatically + # by Maven via activation in pom.xml. + # --batch-mode suppresses interactive prompts and colorises output for CI. + - name: Build and package + run: mvn package -Ppackage --batch-mode + + # ── 5. Upload installer as workflow artifact ────────────────────────── + # Artifact is available in the Actions run page for 90 days. + # The release job below will also attach it to the GitHub Release. + - name: Upload installer artifact + uses: actions/upload-artifact@v4 + with: + name: installer-${{ runner.os }} + path: target/dist/ + if-no-files-found: error + + # ══════════════════════════════════════════════════════════════════════════ + # Publish a GitHub Release with all three installers (tag pushes only) + # ══════════════════════════════════════════════════════════════════════════ + release: + name: Publish GitHub Release + needs: package # wait for all three builds + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') # skip on workflow_dispatch + + steps: + + # ── Download every installer artifact into dist/ ───────────────────── + - name: Download all installers + uses: actions/download-artifact@v4 + with: + path: dist/ + merge-multiple: true # flatten sub-dirs into dist/ + + # ── Create (or update) the GitHub Release ──────────────────────────── + # Attach DEB + DMG + EXE to the release. + # Release notes are auto-generated from merged PRs / commits. + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: dist/** + generate_release_notes: true diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..7e19c7a --- /dev/null +++ b/pom.xml @@ -0,0 +1,531 @@ + + + 4.0.0 + + + com.snapgames + marknote + 0.1.3 + jar + + MarkNote + Markdown Note Editor + + SNAPGAMES + + + + Frédéric Delorme + contact.snapgames@gmail.com + + + + + + + 24 + true + true + true + UTF-8 + UTF-8 + + + 24 + 0.64.8 + 0.11.4 + 0.7.3 + 2.0-M5 + 2.1.1 + 0.3.3 + 5.11.4 + + + Main + -Djava.net.preferIPv6Addresses=true -Xmx512m + javafx.base,javafx.graphics,javafx.controls,javafx.fxml,javafx.media,javafx.web + + + SNAPGAMES + Markdown Note Editor + com.snapgames.marknote + + DEB + false + false + false + false + + + + + src/main/java + src/test/java + + + src/main/resources + + + + + src/test/resources + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + org.codehaus.mojo + exec-maven-plugin + 3.4.1 + + + org.panteleyev + jpackage-maven-plugin + 1.6.5 + + + io.github.git-commit-id + git-commit-id-maven-plugin + 9.0.1 + + + + + + + + + io.github.git-commit-id + git-commit-id-maven-plugin + + + git-info + revision + initialize + + + + false + 12 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + ${maven.compiler.release} + ${project.build.sourceEncoding} + + -Xlint:unchecked + -Xlint:deprecation + -parameters + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + false + + --module-path ${project.build.directory}/dependency + --add-modules ${javafx.modules} + -Djava.net.preferIPv6Addresses=true + -Xmx512m + + + + + + + org.apache.maven.plugins + maven-jar-plugin + + + + ${app.main.class} + true + + + ${project.name} + ${project.version}-build_${git.commit.id.abbrev} + ${jpackage.vendor} + + + ${project.name}-${project.version}-${git.commit.id.abbrev} + + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.8.1 + + + copy-deps-for-modulepath + generate-test-resources + copy-dependencies + + ${project.build.directory}/dependency + runtime + + + + + + + + + org.apache.maven.plugins + maven-shade-plugin + + + fat-jar + package + shade + + ${project.name}-${project.version}-${git.commit.id.abbrev} + ${project.build.directory}/build + false + + + ${app.main.class} + + + + + + + + org.openjfx:* + + + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + module-info.class + + + + + + + + + + + + org.codehaus.mojo + exec-maven-plugin + + java + + ${app.jvm.options} + --module-path + ${project.build.directory}/dependency + --add-modules + ${javafx.modules} + -cp + ${project.build.directory}/build/${project.name}-${project.version}-${git.commit.id.abbrev}.jar + ${app.main.class} + + + + + + + + + + + + + org.openjfx + javafx-base + ${javafx.version} + ${javafx.platform} + + + org.openjfx + javafx-graphics + ${javafx.version} + ${javafx.platform} + + + org.openjfx + javafx-controls + ${javafx.version} + ${javafx.platform} + + + org.openjfx + javafx-fxml + ${javafx.version} + ${javafx.platform} + + + org.openjfx + javafx-media + ${javafx.version} + ${javafx.platform} + + + org.openjfx + javafx-web + ${javafx.version} + ${javafx.platform} + + + + + com.vladsch.flexmark + flexmark + ${flexmark.version} + + + com.vladsch.flexmark + flexmark-ext-tables + ${flexmark.version} + + + + + org.fxmisc.richtext + richtextfx + ${richtextfx.version} + + + org.fxmisc.flowless + flowless + ${flowless.version} + + + org.reactfx + reactfx + ${reactfx.version} + + + org.fxmisc.undo + undofx + ${undofx.version} + + + org.fxmisc.wellbehaved + wellbehavedfx + ${wellbehavedfx.version} + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + + + + linux + + unixlinux + + + linux + DEB + true + + + + + + mac + + mac + + + mac + DMG + + + + + + win + + windows + + + win + EXE + true + true + true + + + + + + package + + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.8.1 + + + copy-deps + prepare-package + copy-dependencies + + ${project.build.directory}/dependency + runtime + + + + + + + + org.panteleyev + jpackage-maven-plugin + + + native-installer + + package + jpackage + + + ${project.name} + ${project.version} + ${jpackage.vendor} + ${jpackage.description} + Copyright 2024 ${jpackage.vendor} + + + ${project.build.directory}/build + ${project.name}-${project.version}-${git.commit.id.abbrev}.jar + ${app.main.class} + + + + ${project.build.directory}/dependency + + ${javafx.modules} + + + + -Djava.net.preferIPv6Addresses=true + -Xmx512m + + + + ${project.build.directory}/dist + + + ${jpackage.type} + + + + ${project.basedir}/src/packaging/file-association-md.properties + ${project.basedir}/src/packaging/file-association-markdown.properties + + + + ${jpackage.linux.shortcut} + Utility + Utility + marknote + + + ${jpackage.bundle.id} + ${project.name} + + + ${jpackage.win.dir.chooser} + ${jpackage.win.menu} + ${jpackage.win.shortcut} + + + + + + + + + + + diff --git a/src/packaging/file-association-markdown.properties b/src/packaging/file-association-markdown.properties new file mode 100644 index 0000000..dfc967d --- /dev/null +++ b/src/packaging/file-association-markdown.properties @@ -0,0 +1,3 @@ +mime-type=text/markdown +extension=markdown +description=Markdown Document diff --git a/src/packaging/file-association-md.properties b/src/packaging/file-association-md.properties new file mode 100644 index 0000000..c398755 --- /dev/null +++ b/src/packaging/file-association-md.properties @@ -0,0 +1,3 @@ +mime-type=text/markdown +extension=md +description=Markdown Document