fix: Amélioration navigation TOC, preview et API Mistral

- TOC: Ajout indentation par niveau et correction des puces sur h6+
  - TOC: Correction navigation avec scroll précis (TreeWalker)
  - TOC: Séparation scroll page/TOC avec position sticky
  - Preview: Correction bug ** apparaissant au retour en mode édition
  - Enhanced Mode: Nouveau format de réponse (blocs comment/document)
  - API: Ajout timeouts 120s et agent HTTPS pour éviter erreurs réseau
  - CSS: Ajout overflow-y sur sections pour scroll indépendant

  Fichiers modifiés :
  - assets/js/app.js (TOC, navigation, preview)
  - assets/css/style.css (scroll, sticky positioning)
  - routes/ai.js (timeouts, format réponse)
This commit is contained in:
Augustin ROUX 2025-10-14 20:28:32 +02:00
parent daef2bbf1a
commit 89c10b14a7
3 changed files with 160 additions and 66 deletions

View File

@ -590,7 +590,11 @@ section h2 {
/* Table des matières */
#table-of-contents {
/* Comportement normal de scroll */
max-height: calc(100vh - 200px);
overflow-y: auto;
position: sticky;
top: 100px;
align-self: flex-start;
}
#toc-nav {
@ -599,10 +603,12 @@ section h2 {
#toc-nav ul {
list-style: none;
padding-left: 0;
}
#toc-nav li {
margin-bottom: 0.5rem;
list-style: none;
}
#toc-nav a {
@ -621,8 +627,9 @@ section h2 {
}
#toc-nav ul ul {
margin-left: 1rem;
margin-left: 0;
margin-top: 0.5rem;
padding-left: 0;
}
#toc-nav ul ul a {
@ -633,6 +640,8 @@ section h2 {
/* Zone d'écriture */
#design-journal {
min-height: 600px;
max-height: calc(100vh - 200px);
overflow-y: auto;
}
#journal-editor {
@ -644,7 +653,6 @@ section h2 {
border: none;
background: var(--surface-color);
color: var(--text-color);
resize: vertical;
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
@ -693,7 +701,10 @@ section h2 {
#ai-assistant {
display: flex;
flex-direction: column;
/* Comportement normal de scroll */
max-height: calc(100vh - 200px);
position: sticky;
top: 100px;
align-self: flex-start;
}
/* Styles pour "Coming Soon" */

View File

@ -434,7 +434,6 @@ class ConceptionAssistant {
const content = this.editor.innerText;
const lines = content.split('\n');
const toc = [];
let tocHtml = '';
// Remove existing anchors
const existingAnchors = this.editor.querySelectorAll('.heading-anchor');
@ -451,29 +450,42 @@ class ConceptionAssistant {
}
}
// Temporarily disabled to avoid disrupting typing
// this.addHeadingAnchors(lines, toc);
let tocHtml = '';
if (toc.length > 0) {
tocHtml = '<ul>';
let currentLevel = 0;
// Nouvelle génération de TOC avec indentation correcte
let previousLevel = 0;
for (const item of toc) {
if (item.level > currentLevel) {
for (let i = currentLevel; i < item.level - 1; i++) {
for (let i = 0; i < toc.length; i++) {
const item = toc[i];
// Ouvrir les listes nécessaires
if (item.level > previousLevel) {
for (let j = previousLevel; j < item.level; j++) {
tocHtml += '<ul>';
}
} else if (item.level < currentLevel) {
for (let i = item.level; i < currentLevel; i++) {
tocHtml += '</ul>';
}
// Fermer les listes nécessaires
else if (item.level < previousLevel) {
for (let j = item.level; j < previousLevel; j++) {
tocHtml += '</ul></li>';
}
}
// Même niveau, fermer le li précédent
else if (i > 0) {
tocHtml += '</li>';
}
tocHtml += `<li><a href="#${item.id}" onclick="app.scrollToHeading('${item.title}'); return false;">${item.title}</a></li>`;
currentLevel = item.level;
// Ajouter l'élément avec indentation CSS
const indent = (item.level - 1) * 1; // 1rem par niveau
tocHtml += `<li style="margin-left: ${indent}rem;"><a href="#${item.id}" onclick="app.scrollToHeading('${item.title.replace(/'/g, "\\'")}'); return false;">${item.title}</a>`;
previousLevel = item.level;
}
tocHtml += '</ul>';
// Fermer toutes les listes restantes
for (let j = 0; j < previousLevel; j++) {
tocHtml += '</li></ul>';
}
} else {
tocHtml = '<div class="toc-placeholder"><p>Add headings (# ## ###) to your journal to generate the table of contents.</p></div>';
}
@ -505,18 +517,21 @@ class ConceptionAssistant {
scrollToHeading(title) {
try {
// Simpler and more robust method: search text directly in editor
const content = this.editor.innerText;
const lines = content.split('\n');
// Find the line index corresponding to the title
// Find the line index and calculate character position
let targetLineIndex = -1;
let charPosition = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (line.startsWith('#') && line.replace(/^#+\s*/, '') === title) {
targetLineIndex = i;
break;
}
// Count characters including newlines
charPosition += lines[i].length + 1; // +1 for \n
}
if (targetLineIndex === -1) {
@ -524,37 +539,56 @@ class ConceptionAssistant {
return;
}
// Calculate approximate line position
const editorStyles = window.getComputedStyle(this.editor);
const lineHeight = parseFloat(editorStyles.lineHeight) || 20;
const paddingTop = parseFloat(editorStyles.paddingTop) || 0;
// Create a range at the target position to get accurate coordinates
const selection = window.getSelection();
const range = document.createRange();
// Calculate scroll position based on line number
const targetScrollPosition = (targetLineIndex * lineHeight) + paddingTop;
// Find the text node at the character position
const walker = document.createTreeWalker(
this.editor,
NodeFilter.SHOW_TEXT,
null
);
// Determine if editor or window should be scrolled
const editorRect = this.editor.getBoundingClientRect();
const editorHasScroll = this.editor.scrollHeight > this.editor.clientHeight;
let currentChar = 0;
let targetNode = null;
let offsetInNode = 0;
if (editorHasScroll) {
// Scroll within editor
this.editor.scrollTo({
top: Math.max(0, targetScrollPosition - 60),
behavior: 'smooth'
});
} else {
// Scroll entire page
const editorTop = this.editor.offsetTop;
const windowScrollTarget = editorTop + targetScrollPosition - 100;
while (walker.nextNode()) {
const node = walker.currentNode;
const nodeLength = node.textContent.length;
window.scrollTo({
top: Math.max(0, windowScrollTarget),
behavior: 'smooth'
});
if (currentChar + nodeLength >= charPosition) {
targetNode = node;
offsetInNode = charPosition - currentChar;
break;
}
currentChar += nodeLength;
}
// Optional: temporarily highlight the title
this.highlightHeading(title);
if (targetNode) {
// Set range at the target position
range.setStart(targetNode, Math.min(offsetInNode, targetNode.textContent.length));
range.setEnd(targetNode, Math.min(offsetInNode, targetNode.textContent.length));
// Get the bounding rectangle of the range
const rect = range.getBoundingClientRect();
// Scroll to the element - use the parent container (#design-journal)
const journalSection = document.getElementById('design-journal');
const sectionRect = journalSection.getBoundingClientRect();
const scrollOffset = rect.top - sectionRect.top + journalSection.scrollTop - 100;
journalSection.scrollTo({
top: Math.max(0, scrollOffset),
behavior: 'smooth'
});
// Temporarily highlight the heading
selection.removeAllRanges();
selection.addRange(range);
setTimeout(() => selection.removeAllRanges(), 1000);
}
this.showNotification(`Navigating to: ${title}`, 'success');
@ -1297,8 +1331,8 @@ class ConceptionAssistant {
const mainElement = document.querySelector('main');
if (!this.isPreviewMode) {
// Switch to preview mode
this.originalContent = this.editor.innerHTML;
// Switch to preview mode - Save innerText instead of innerHTML
this.originalContent = this.editor.innerText;
const markdownContent = this.editor.innerText;
// Add preview-mode class to main to adjust grid layout
@ -1392,9 +1426,9 @@ class ConceptionAssistant {
this.showNotification('Preview mode activated', 'success');
} else {
// Return to edit mode
// Return to edit mode - Restore innerText instead of innerHTML
this.editor.contentEditable = true;
this.editor.innerHTML = this.originalContent;
this.editor.innerText = this.originalContent;
this.editor.style.background = '';
this.editor.style.border = '';
this.editor.style.borderRadius = '';

View File

@ -1,5 +1,6 @@
const express = require('express');
const router = express.Router();
const https = require('https');
require('dotenv').config({ path: './config/.env' });
// Mistral AI Configuration
@ -27,8 +28,18 @@ function checkAIEnabled(req, res, next) {
next();
}
// Function to call Mistral API
// Create a custom HTTPS agent with longer timeouts
const httpsAgent = new https.Agent({
keepAlive: true,
timeout: 120000, // 120 seconds
maxSockets: 5
});
// Function to call Mistral API with timeout handling
async function callMistralAPI(messages, temperature = null) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 120000); // 120 seconds timeout
try {
const response = await fetch(`${MISTRAL_BASE_URL}/chat/completions`, {
method: 'POST',
@ -42,9 +53,13 @@ async function callMistralAPI(messages, temperature = null) {
temperature: temperature !== null ? temperature : parseFloat(process.env.AI_TEMPERATURE) || 0.3,
max_tokens: parseInt(process.env.AI_MAX_TOKENS) || 35000,
top_p: parseFloat(process.env.AI_TOP_P) || 0.85
})
}),
signal: controller.signal,
agent: httpsAgent
});
clearTimeout(timeoutId);
if (!response.ok) {
const error = await response.text();
throw new Error(`Mistral API Error: ${response.status} - ${error}`);
@ -53,7 +68,20 @@ async function callMistralAPI(messages, temperature = null) {
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
clearTimeout(timeoutId);
console.error('Mistral API Error:', error);
// Better error messages
if (error.name === 'AbortError') {
throw new Error('Request timeout: The API took too long to respond (>120s)');
}
if (error.cause && error.cause.code === 'UND_ERR_HEADERS_TIMEOUT') {
throw new Error('Connection timeout: Unable to connect to Mistral API. Check your network or API endpoint.');
}
if (error.cause && error.cause.code === 'ECONNREFUSED') {
throw new Error('Connection refused: Cannot reach Mistral API endpoint.');
}
throw error;
}
}
@ -307,17 +335,22 @@ router.post('/liberty-mode', checkAIEnabled, async (req, res) => {
3. Develop existing ideas with the allowed creativity
4. Maintain logical structure
MANDATORY RESPONSE IN 2 PARTS SEPARATED BY "---SPLIT---":
MANDATORY RESPONSE FORMAT - Use code blocks with triple backticks:
## Analysis (Iteration ${i + 1}/${maxIterations})
[Explain the improvements made, sections added, reasoning]
First, write your comment explaining the improvements inside a code block labeled "comment${i + 1}":
- What sections were added
- What was enhanced
- Reasoning behind changes
---SPLIT---
Then, write the complete improved markdown document inside a code block labeled "document".
[THE COMPLETE AND IMPROVED MARKDOWN DOCUMENT - WITHOUT "## Document" TITLE - DIRECTLY THE CONTENT]
Example format:
First block: comment${i + 1}
Second block: document
Focus: ${focus}
Precision: ${precisionPercent}%`
Precision: ${precisionPercent}%
Iteration: ${i + 1}/${maxIterations}`
},
{
role: 'user',
@ -330,20 +363,36 @@ router.post('/liberty-mode', checkAIEnabled, async (req, res) => {
const result = await callMistralAPI(messages, temperature);
// Separate explanation from markdown
const parts = result.split('---SPLIT---');
// Extract code blocks
const commentRegex = /```comment\d+\s*([\s\S]*?)```/;
const documentRegex = /```document\s*([\s\S]*?)```/;
const commentMatch = result.match(commentRegex);
const documentMatch = result.match(documentRegex);
let explanation = '';
let newMarkdown = currentContent; // Default, keep old content
if (parts.length >= 2) {
explanation = parts[0].trim();
newMarkdown = parts[1].trim();
if (commentMatch && commentMatch[1]) {
explanation = commentMatch[1].trim();
}
if (documentMatch && documentMatch[1]) {
newMarkdown = documentMatch[1].trim();
// Update for next iteration
currentContent = newMarkdown;
} else {
// Fallback if no split found
explanation = result;
}
// Fallback: if no code blocks found, try old format for compatibility
if (!commentMatch && !documentMatch) {
const parts = result.split('---SPLIT---');
if (parts.length >= 2) {
explanation = parts[0].trim();
newMarkdown = parts[1].trim();
currentContent = newMarkdown;
} else {
explanation = result;
}
}
// Send this iteration's response