diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0431b69..028521d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,38 @@
# Changelog — FlowDeck
+## v5.1.8 (2026-09-07) — Multi-lignes réellement conservées + modèles IA validés (Nvidia)
+
+> Deux correctifs : le bug multi-lignes (v5.1.7 corrigeait le symptôme au
+> re-rendu, mais la vraie cause était la lecture en `textContent` qui élimine
+> les `
`) et, côté Agent, seuls les modèles réellement utilisables du
+> fournisseur Nvidia sont désormais proposés dans la configuration IA.
+
+### Éditeur — multilignes conservées (vraie cause)
+- `sync()` lisait `el.textContent`, qui **supprime** les `
` : les sauts de
+ ligne étaient perdus dès la sauvegarde du bloc. Ajout de `gt()` (basé sur
+ `innerText`, qui rend bien les `
` en `\n`) utilisé par `sync()`, les
+ tableaux (toggle/columns) et le calcul de position du curseur.
+- La scission sur Entrée (et la limite bas/haut de bloc) utilisait désormais
+ `splitCaret()` : un marqueur temporaire est inséré au curseur puis
+ `gt()`/`innerText` découpe le contenu en « avant » / « après », exactement
+ comme un vrai clavier. Fini le décalage `before` qui perdait la fin d'un
+ bloc multi-lignes.
+- `_resultText` (composer IA) lit par `gt()` pour préserver les `\n`.
+
+### Agent — validation des modèles (fournisseur bruité)
+- `GET {base}/models` de Nvidia liste tout le catalogue, mais la plupart des
+ modèles (embeddings, rerank, génération d'images…) répondent **404** sur
+ `/v1/chat/completions` — l'erreur observée dans la config IA.
+- `fetch_provider_models()` filtre désormais pour `nvidia` : suppression des
+ modèles manifestement non-chat (nom), puis « probe » réelle de chaque
+ candidat (`POST /chat/completions`, payload minimal, pipeline limité à 6 en
+ parallèle, 6 s par appel) — seuls les modèles qui répondent sont listés.
+ Un 429 (limite de débit) est conservé (le modèle est routé, donc utilisable).
+- Filet de sécurité : si toutes les validations échouent, on garde la liste
+ filtrée par nom plutôt que de vider le sélecteur.
+- Tests : `tests/test_llm_config.py` (5 cas) — catalogue mock, 404 chat,
+ retour sans validation pour les autres providers, chute de sécurité, offline.
+
## v5.1.7 (2026-09-07) — Retours à la ligne conservés quand on change de bloc (Entrée)
> Un bloc multi-lignes (Shift+Entrée) perdait ses retours à la ligne dès qu'on
diff --git a/VERSION b/VERSION
index 693ad74..a106d2a 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-5.1.7
\ No newline at end of file
+5.1.8
\ No newline at end of file
diff --git a/app/main.py b/app/main.py
index 842c1e7..df0c96c 100644
--- a/app/main.py
+++ b/app/main.py
@@ -60,7 +60,7 @@ async def lifespan(_app: FastAPI):
app = FastAPI(
title="FlowDeck",
- version="5.1.7",
+ version="5.1.8",
docs_url="/docs" if settings.log_level == "DEBUG" else None,
redoc_url=None,
lifespan=lifespan,
diff --git a/app/services/llm_config.py b/app/services/llm_config.py
index 3c4d5c2..a7c548d 100644
--- a/app/services/llm_config.py
+++ b/app/services/llm_config.py
@@ -11,11 +11,32 @@ Rows are created lazily, so .env stays the default until saved from the UI.
from __future__ import annotations
import json
+import time
from typing import Optional
from app.config import settings
from app.services.llm_client import PROVIDERS, PROVIDER_MODELS
+# Providers whose /v1/models lists far more entries than /v1/chat/completions
+# actually serves. The fetched list is validated (name filter + live probe)
+# before being exposed as "usable models". NVIDIA exposes all of its catalog
+# (embeddings, rerank, image/video/audio gen…) many of which answer 404 on
+# chat completions — the exact failure the user hit.
+_CHAT_VALIDATED_PROVIDERS = frozenset({"nvidia"})
+
+# Markers that identify clearly non-chat models (embeddings, rerank, media gen…).
+_NON_CHAT_MARKERS = (
+ "embed", "bge-", "rerank", "retriev", "tts", "asr", "stt", "whisper", "speech",
+ "transcrib", "translate", "image", "video", "audio", "music", "sound", "dall",
+ "stable", "diffus", "flux", "sora", "veo", "midjourney", "clip", "segmentation",
+ "ocr", "inpainting", "depth", "motion", "sento-",
+)
+
+
+def _is_likely_chat(model_id: str) -> bool:
+ ml = model_id.lower()
+ return not any(m in ml for m in _NON_CHAT_MARKERS)
+
__all__ = [
"get_llm_config", "set_llm_config", "provider_info",
"get_user_llm_key", "list_user_llm_keys", "upsert_user_llm_key",
@@ -315,4 +336,59 @@ async def fetch_provider_models(provider: str, *, api_key: str = "",
if i not in seen:
seen.add(i)
out.append(i)
- return out[:300]
\ No newline at end of file
+
+ # Providers like NVIDIA list their whole catalog, most of which is NOT served
+ # by /v1/chat/completions (404 sur « model not found »). Filter by name first,
+ # then probe the survivors with a minimal chat call so only usable models stay.
+ if provider in _CHAT_VALIDATED_PROVIDERS and out:
+ candidates = [m for m in out if _is_likely_chat(m)] or out
+ validated = await _validate_chat_models(base_url, api_key, candidates)
+ # Ne vidons jamais la liste : en cas d'échec de validation (débit limité,
+ # indisponibilité passagère) on garde la liste filtrée par nom.
+ out = validated if validated else candidates
+
+ return out[:300]
+
+
+async def _validate_chat_models(base_url: str, api_key: str, candidates: list[str],
+ *, timeout: float = 6.0, concurrency: int = 6,
+ deadline: float = 55.0) -> list[str]:
+ """Probe `POST {base_url}/chat/completions` for each candidate with a minimal
+ payload and keep the models that accept it. A 429 (rate limit) is treated as
+ "probably fine" since it proves the model is routed, not that it's invalid."""
+ import asyncio
+ import httpx
+
+ valid: list[str] = []
+ sem = asyncio.Semaphore(concurrency)
+ start = time.monotonic()
+ headers = {"Content-Type": "application/json"}
+ if api_key:
+ headers["Authorization"] = f"Bearer {api_key}"
+ payload: dict = {
+ "model": None,
+ "messages": [{"role": "user", "content": "ping"}],
+ "max_tokens": 4,
+ }
+
+ async def probe(model: str) -> str | None:
+ if time.monotonic() - start > deadline:
+ return None
+ payload["model"] = model
+ try:
+ async with sem:
+ async with httpx.AsyncClient(timeout=timeout) as client:
+ resp = await client.post(
+ f"{base_url}/chat/completions", headers=headers, json=payload
+ )
+ if resp.status_code < 300 or resp.status_code == 429:
+ return model
+ except Exception: # noqa: BLE001 — réseau/timeout ⇒ rejeté
+ return None
+ return None
+
+ results = await asyncio.gather(*(probe(m) for m in candidates))
+ for m in results:
+ if m:
+ valid.append(m)
+ return valid
\ No newline at end of file
diff --git a/app/templates/_page_editor_scripts.html b/app/templates/_page_editor_scripts.html
index 9d7512d..8ec2367 100644
--- a/app/templates/_page_editor_scripts.html
+++ b/app/templates/_page_editor_scripts.html
@@ -15,6 +15,16 @@
let _bid=0;function genId(){return 'b'+(++_bid)+'_'+Date.now().toString(36);}
function cp(e){const s=window.getSelection();if(!s.rangeCount)return 0;const r=s.getRangeAt(0).cloneRange();r.selectNodeContents(e);r.setEnd(s.getRangeAt(0).endContainer,s.getRangeAt(0).endOffset);return r.toString().length;}
+ function gt(e){return (e&&e.innerText!==undefined)?e.innerText:(e?e.textContent:'');}
+ function splitCaret(e){
+ const s=window.getSelection(); if(!s.rangeCount||!s.rangeCount)return null; const sel=s.getRangeAt(0);
+ const MK='\uF000FD'; const mark=document.createElement('span'); mark.textContent=MK;
+ sel.insertNode(mark);
+ const full=gt(e)||'';
+ const parts=full.split(MK);
+ mark.remove(); e.normalize();
+ return {before:parts[0]||'',after:(parts.length>1?parts[1]:'')||'',content:full.split(MK).join('')};
+ }
function ce(e){const r=document.createRange();r.selectNodeContents(e);r.collapse(false);window.getSelection().removeAllRanges();window.getSelection().addRange(r);}
function cs(e){const r=document.createRange();r.selectNodeContents(e);r.collapse(true);window.getSelection().removeAllRanges();window.getSelection().addRange(r);}
function esc(s){return s.replace(/&/g,'&').replace(//g,'>');}
@@ -339,7 +349,7 @@
_err(msg){ this._html='
⚠ '+esc(msg)+'
'; this._stage='result'; this.paint(); }, insert(){ const E=window.E; if(!E||typeof E.applyAIBlocks!=='function')return; E.sync(); const result=this._resultText(); if(!result)return; E.applyAIBlocks(result); E.dirty=true; E.autoSave(); this.close(); }, - _resultText(){ const el=this.ROOT&&this.ROOT.querySelector('.aici-result'); return el?el.textContent:''; }, + _resultText(){ const el=this.ROOT&&this.ROOT.querySelector('.aici-result'); if(!el)return ''; const r=document.createRange();r.selectNodeContents(el);return r.toString(); }, toast(m){ if(window.showToast)window.showToast(m,'info'); } }; window.AIC=AIC; @@ -741,9 +751,9 @@ sync(){const ct=document.getElementById('_blocksCt');if(!ct)return;for(const b of this.blocks){if(b.type==='table'){const wrap=ct.querySelector(`[data-bid="${b.id}"].ftable-editor`);if(wrap){const rows=[];wrap.querySelectorAll('tr.ftable-row').forEach(function(tr){const cells=tr.querySelectorAll('th.ftable-cell,td.ftable-cell');if(!cells.length)return;const rowarr=[];cells.forEach(function(cell){rowarr.push(cell.textContent||'');});rows.push(rowarr);});if(rows.length)b.rows=rows;}continue;} if(b.type==='divider'||b.type==='image'||b.type==='embed'||b.type==='table_of_contents')continue; if(b.type==='math'){const te=ct.querySelector(`[data-math-bid="${b.id}"]`);if(te)b.content=te.value||'';continue;} - const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=el.textContent||'';else if(el)b.content=el.textContent||''; - if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=che.textContent||'';}); - if(b.type==='columns'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=che.textContent||'';}); + const el=ct.querySelector(`[data-bid="${b.id}"]`);if(el&&!el.classList.contains('toggle-title'))b.content=gt(el)||'';else if(el)b.content=gt(el)||''; + if(b.type==='toggle'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gt(che)||'';}); + if(b.type==='columns'&&b.children)b.children.forEach(function(ch){const che=ct.querySelector(`[data-bid="${ch.id}"]`);if(che)ch.content=gt(che)||'';}); }}, render(){const ct=document.getElementById('_blocksCt');if(!ct)return;let html='';for(let i=0;i