Compare commits

...

5 Commits

Author SHA1 Message Date
5eb15dc449 passage 0.10.4
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-05 16:54:41 +02:00
da5b602f20 Meilleure gestion des erreurs, moins permissif au niveau de la créativité du modèle. 2026-06-05 16:54:28 +02:00
9ea43a1889 Ajout d'open router en fournisseur IA ; ajout de la possibilité de mettre des conditions de déverouillage pour les chapitres optionnels
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-05 00:23:19 +02:00
211e26dae1 changement de version
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled
2026-06-04 14:14:19 +02:00
53065c952b Correction de la configuration nginx pour ne pas bloquer un gros PDF 2026-06-04 14:13:41 +02:00
23 changed files with 463 additions and 62 deletions

View File

@@ -26,7 +26,9 @@ from app.domain.ports import LLMProvider, PdfTextExtractor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_TEMPERATURE = 0.2 # Très basse : structuration = recopie/réorganisation fidèle, pas de créativité.
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
_TEMPERATURE = 0.1
# Nom de l'arc unique quand le livre n'est pas découpé en actes/parties. # Nom de l'arc unique quand le livre n'est pas découpé en actes/parties.
_DEFAULT_ARC_NAME = "Aventure principale" _DEFAULT_ARC_NAME = "Aventure principale"

View File

@@ -23,7 +23,9 @@ from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Température basse : tâche de tri/réécriture fidèle, pas de créativité. # Température basse : tâche de tri/réécriture fidèle, pas de créativité.
_TEMPERATURE = 0.2 # Très basse : structuration = recopie/réorganisation fidèle, pas de créativité.
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
_TEMPERATURE = 0.1
# Taxonomie canonique suggérée au modèle pour homogénéiser les titres entre # Taxonomie canonique suggérée au modèle pour homogénéiser les titres entre
# morceaux (sinon "Combat" / "Le combat" / "Règles de combat" se dispersent). # morceaux (sinon "Combat" / "Le combat" / "Règles de combat" se dispersent).

View File

@@ -12,13 +12,45 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import re
from app.domain.ports import LLMProvider, LLMProviderError from app.domain.ports import LLMProvider, LLMProviderError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_ATTEMPTS = 3 _ATTEMPTS = 4
_BASE_DELAY_SECONDS = 2.0 _BASE_DELAY_SECONDS = 3.0
# Un rate limit (429) "par minute" ne se libère pas en 2-3s : on attend plus
# longtemps pour ces erreurs-là (le free tier OpenRouter plafonne ~20 req/min).
_RATE_LIMIT_DELAYS = [10.0, 25.0, 45.0]
def _is_rate_limit(exc: LLMProviderError) -> bool:
msg = str(exc).lower()
return "429" in msg or "rate" in msg or "too many requests" in msg
def _is_daily_quota(exc: LLMProviderError) -> bool:
"""Limite PAR JOUR (vs par minute) : réessayer est inutile, elle ne se libère
qu'au reset quotidien. OpenRouter le précise dans le corps du 429."""
msg = str(exc).lower()
return "per-day" in msg or "per day" in msg or "free-models-per-day" in msg
# OpenRouter renvoie souvent le délai conseillé (saturation amont) :
# "retry_after_seconds": 8 ou "Retry-After": "8". On le respecte plutôt que
# d'attendre une durée fixe arbitraire.
_RETRY_AFTER_RE = re.compile(r'retry[_-]?after(?:_seconds)?"?\s*:\s*"?([0-9]+(?:\.[0-9]+)?)', re.IGNORECASE)
def _suggested_retry_after(exc: LLMProviderError) -> float | None:
match = _RETRY_AFTER_RE.search(str(exc))
if not match:
return None
try:
return float(match.group(1))
except ValueError:
return None
async def generate_with_retry( async def generate_with_retry(
@@ -28,7 +60,12 @@ async def generate_with_retry(
output_format: str | None = None, output_format: str | None = None,
temperature: float | None = None, temperature: float | None = None,
) -> str: ) -> str:
"""Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff x2).""" """Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff).
Backoff plus long pour les 429 (rate limit) afin de laisser la fenêtre se
libérer. Nombre de tentatives borné : si le quota est durablement épuisé
(ex. limite/jour), l'erreur finit par remonter au lieu de boucler sans fin.
"""
delay = _BASE_DELAY_SECONDS delay = _BASE_DELAY_SECONDS
last_error: LLMProviderError | None = None last_error: LLMProviderError | None = None
for attempt in range(_ATTEMPTS): for attempt in range(_ATTEMPTS):
@@ -36,12 +73,27 @@ async def generate_with_retry(
return await llm.generate(prompt, output_format=output_format, temperature=temperature) return await llm.generate(prompt, output_format=output_format, temperature=temperature)
except LLMProviderError as exc: except LLMProviderError as exc:
last_error = exc last_error = exc
# Quota JOURNALIER épuisé : inutile d'insister, on remonte tout de suite
# (sinon on enchaîne des attentes longues pour rien, et on spamme l'API).
if _is_daily_quota(exc):
logger.warning("Quota journalier du fournisseur épuisé — abandon : %s", exc)
raise
if attempt < _ATTEMPTS - 1: if attempt < _ATTEMPTS - 1:
if _is_rate_limit(exc):
suggested = _suggested_retry_after(exc)
if suggested is not None:
# Indication serveur (saturation amont) + petite marge, plafonnée.
wait = min(suggested + 2.0, 60.0)
else:
wait = _RATE_LIMIT_DELAYS[min(attempt, len(_RATE_LIMIT_DELAYS) - 1)]
else:
wait = delay
delay *= 2
logger.warning( logger.warning(
"Appel LLM échoué (tentative %s/%s) : %s — nouvelle tentative dans %ss.", "Appel LLM échoué (tentative %s/%s)%s : %s — nouvelle tentative dans %ss.",
attempt + 1, _ATTEMPTS, exc, delay, attempt + 1, _ATTEMPTS, " [rate limit]" if _is_rate_limit(exc) else "",
exc, wait,
) )
await asyncio.sleep(delay) await asyncio.sleep(wait)
delay *= 2
assert last_error is not None assert last_error is not None
raise last_error raise last_error

View File

@@ -25,8 +25,8 @@ class Settings(BaseSettings):
extra="ignore", extra="ignore",
) )
# Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai (etage 2). # Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai ; "openrouter" = OpenRouter.
llm_provider: Literal["ollama", "onemin"] = "ollama" llm_provider: Literal["ollama", "onemin", "openrouter"] = "ollama"
ollama_base_url: str = "http://localhost:11434" ollama_base_url: str = "http://localhost:11434"
llm_model: str = "gemma4:26b" llm_model: str = "gemma4:26b"
@@ -47,6 +47,12 @@ class Settings(BaseSettings):
onemin_api_key: str = "" onemin_api_key: str = ""
onemin_model: str = "gpt-4o-mini" onemin_model: str = "gpt-4o-mini"
# OpenRouter (OpenAI-compatible). Cle + modele modifiables depuis l'UI.
# Defaut = routeur `openrouter/free` : choisit un modele GRATUIT (0 credit).
# Pour un modele precis gratuit : id finissant par `:free`.
openrouter_api_key: str = ""
openrouter_model: str = "openrouter/free"
# Taille cible d'un morceau (en tokens) pour l'import de PDF (regles/campagne). # Taille cible d'un morceau (en tokens) pour l'import de PDF (regles/campagne).
# Plus c'est gros, moins il y a de morceaux => moins de fragmentation et un # Plus c'est gros, moins il y a de morceaux => moins de fragmentation et un
# import plus rapide, MAIS il faut que ca tienne dans la fenetre du modele. # import plus rapide, MAIS il faut que ca tienne dans la fenetre du modele.

View File

@@ -29,6 +29,8 @@ _ALLOWED_KEYS = frozenset({
"llm_num_ctx", "llm_num_ctx",
"onemin_api_key", "onemin_api_key",
"onemin_model", "onemin_model",
"openrouter_api_key",
"openrouter_model",
"import_chunk_tokens", "import_chunk_tokens",
}) })

View File

@@ -0,0 +1,139 @@
"""Adapter OpenRouter — implémente les ports LLMProvider / LLMChatProvider.
OpenRouter expose l'API OpenAI standard (POST {base}/chat/completions, SSE), donc
cet adapter est en réalité un client "OpenAI-compatible". Le `generate` one-shot
passe lui aussi par le streaming (puis recollage) pour éviter les coupures de
passerelle sur les longues générations (cf. 1min.ai / Cloudflare 524).
Modèles GRATUITS : utiliser un id finissant par `:free` (ex.
`meta-llama/llama-3.3-70b-instruct:free`) ou le routeur `openrouter/free` (défaut)
qui choisit automatiquement un modèle gratuit — aucun crédit consommé.
"""
from __future__ import annotations
import json
from typing import AsyncIterator
import httpx
from app.core.config import Settings
from app.domain.models import ChatMessage
from app.domain.ports import LLMProviderError
_API_URL = "https://openrouter.ai/api/v1/chat/completions"
class OpenRouterLLMProvider:
"""Adapter OpenRouter (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
def __init__(self, settings: Settings) -> None:
if not settings.openrouter_api_key:
raise LLMProviderError(
"Clé API OpenRouter manquante. Configure-la depuis l'écran Paramètres."
)
self._api_key = settings.openrouter_api_key
self._model = settings.openrouter_model
self._timeout = settings.llm_timeout_seconds
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
# Attribution facultative (classement OpenRouter) — sans impact fonctionnel.
"HTTP-Referer": "https://loremind.app",
"X-Title": "LoreMind",
}
async def generate(
self,
prompt: str,
*,
output_format: str | None = None,
temperature: float | None = None,
) -> str:
"""One-shot via streaming (puis recollage) pour robustesse sur longues sorties."""
chunks: list[str] = []
async for token in self._stream([ChatMessage(role="user", content=prompt)], None, temperature):
chunks.append(token)
return "".join(chunks)
async def stream_chat(
self,
messages: list[ChatMessage],
*,
system_prompt: str | None = None,
temperature: float | None = None,
) -> AsyncIterator[str]:
async for token in self._stream(messages, system_prompt, temperature):
yield token
async def _stream(
self,
messages: list[ChatMessage],
system_prompt: str | None,
temperature: float | None,
) -> AsyncIterator[str]:
payload_messages: list[dict[str, str]] = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
for m in messages:
payload_messages.append({"role": m.role, "content": m.content})
body: dict[str, object] = {
"model": self._model,
"messages": payload_messages,
"stream": True,
}
if temperature is not None:
body["temperature"] = temperature
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream(
"POST", _API_URL, headers=self._headers(), json=body
) as response:
if response.status_code >= 400:
# En streaming, le corps n'est pas lu automatiquement : on le
# lit pour exposer le détail d'OpenRouter (ex. le 429 précise
# "free-models-per-day" vs "per-minute"), sinon on n'a que le
# code HTTP nu et le diagnostic est impossible.
detail = (await response.aread()).decode("utf-8", "replace").strip()
raise LLMProviderError(
f"Erreur OpenRouter (HTTP {response.status_code})"
+ (f" : {detail[:500]}" if detail else "")
)
async for token in self._parse_sse(response):
yield token
except httpx.HTTPError as exc:
raise LLMProviderError(self._format_http_error(exc)) from exc
@staticmethod
async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]:
"""SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`."""
async for line in response.aiter_lines():
if not line or not line.startswith("data:"):
continue # lignes vides ou commentaires keep-alive (`: ...`)
data = line[len("data:"):].strip()
if data == "[DONE]":
return
try:
obj = json.loads(data)
except json.JSONDecodeError:
continue
choices = obj.get("choices")
if not choices:
continue
delta = choices[0].get("delta") or {}
content = delta.get("content")
if content:
yield content
def _format_http_error(self, exc: httpx.HTTPError) -> str:
"""Message lisible (timeout, quota 429, crédits 402, modèle inconnu…)."""
if isinstance(exc, httpx.TimeoutException):
return (
f"Erreur OpenRouter : délai dépassé (timeout {self._timeout}s). Le modèle a "
"mis trop de temps — réduis la taille des morceaux d'import ou augmente le timeout."
)
detail = str(exc) or exc.__class__.__name__
return f"Erreur OpenRouter ({exc.__class__.__name__}) : {detail}"

View File

@@ -45,12 +45,13 @@ from app.domain.models import (
from app.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError from app.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError
from app.infrastructure.ollama_adapter import OllamaLLMProvider from app.infrastructure.ollama_adapter import OllamaLLMProvider
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="0.10.0-beta", version="0.10.3-beta",
) )
@@ -354,6 +355,8 @@ def get_llm_provider(
try: try:
if settings.llm_provider == "onemin": if settings.llm_provider == "onemin":
return OneMinAiLLMProvider(settings) return OneMinAiLLMProvider(settings)
if settings.llm_provider == "openrouter":
return OpenRouterLLMProvider(settings)
return OllamaLLMProvider(settings) return OllamaLLMProvider(settings)
except LLMProviderError as exc: except LLMProviderError as exc:
# Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500 # Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500
@@ -688,7 +691,9 @@ async def chat_stream(
"system": _count_tokens(system_prompt_preview), "system": _count_tokens(system_prompt_preview),
"history": sum(_count_tokens(m.content) for m in history_msgs), "history": sum(_count_tokens(m.content) for m in history_msgs),
"current": _count_tokens(current_msg.content) if current_msg else 0, "current": _count_tokens(current_msg.content) if current_msg else 0,
"max": settings.llm_num_ctx, # Plafond connu seulement pour Ollama (num_ctx). Pour le cloud (1min/OpenRouter)
# on ne connaît pas la fenêtre réelle → 0 = "pas de max" (jauge sans dénominateur).
"max": settings.llm_num_ctx if settings.llm_provider == "ollama" else 0,
} }
async def event_stream() -> AsyncIterator[str]: async def event_stream() -> AsyncIterator[str]:
@@ -885,12 +890,15 @@ class SettingsDTO(BaseModel):
Les secrets (onemin_api_key) sont masques en lecture. Les secrets (onemin_api_key) sont masques en lecture.
""" """
llm_provider: Literal["ollama", "onemin"] llm_provider: Literal["ollama", "onemin", "openrouter"]
ollama_base_url: str ollama_base_url: str
llm_model: str llm_model: str
onemin_model: str onemin_model: str
# True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme. # True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme.
onemin_api_key_set: bool onemin_api_key_set: bool
openrouter_model: str
# True si une cle OpenRouter est deja configuree (cle elle-meme jamais renvoyee).
openrouter_api_key_set: bool
# Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert # Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert
# aussi de plafond a la jauge de contexte UI. # aussi de plafond a la jauge de contexte UI.
llm_num_ctx: int llm_num_ctx: int
@@ -903,12 +911,14 @@ class SettingsDTO(BaseModel):
class SettingsUpdateDTO(BaseModel): class SettingsUpdateDTO(BaseModel):
"""Patch partiel des settings. Tous les champs sont optionnels.""" """Patch partiel des settings. Tous les champs sont optionnels."""
llm_provider: Literal["ollama", "onemin"] | None = None llm_provider: Literal["ollama", "onemin", "openrouter"] | None = None
ollama_base_url: str | None = None ollama_base_url: str | None = None
llm_model: str | None = None llm_model: str | None = None
onemin_model: str | None = None onemin_model: str | None = None
# Chaine vide => on efface la cle. None => pas de changement. # Chaine vide => on efface la cle. None => pas de changement.
onemin_api_key: str | None = None onemin_api_key: str | None = None
openrouter_model: str | None = None
openrouter_api_key: str | None = None
llm_num_ctx: int | None = None llm_num_ctx: int | None = None
import_chunk_tokens: int | None = None import_chunk_tokens: int | None = None
llm_timeout_seconds: int | None = None llm_timeout_seconds: int | None = None
@@ -921,6 +931,8 @@ def _to_settings_dto(s: Settings) -> SettingsDTO:
llm_model=s.llm_model, llm_model=s.llm_model,
onemin_model=s.onemin_model, onemin_model=s.onemin_model,
onemin_api_key_set=bool(s.onemin_api_key), onemin_api_key_set=bool(s.onemin_api_key),
openrouter_model=s.openrouter_model,
openrouter_api_key_set=bool(s.openrouter_api_key),
llm_num_ctx=s.llm_num_ctx, llm_num_ctx=s.llm_num_ctx,
import_chunk_tokens=s.import_chunk_tokens, import_chunk_tokens=s.import_chunk_tokens,
llm_timeout_seconds=s.llm_timeout_seconds, llm_timeout_seconds=s.llm_timeout_seconds,
@@ -1081,6 +1093,51 @@ async def delete_ollama_model(
return {"status": "deleted", "name": name} return {"status": "deleted", "name": name}
@app.get("/models/openrouter")
async def list_openrouter_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue DYNAMIQUE des modeles OpenRouter (API publique, sans cle).
Renvoie {models: [{id, name, context_length, free}]}, trie gratuits d'abord
puis contexte decroissant. `free` = id finissant par ':free' OU prix nul.
"""
try:
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get("https://openrouter.ai/api/v1/models")
response.raise_for_status()
data = response.json()
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"OpenRouter injoignable : {exc}")
def _is_zero(value: object) -> bool:
try:
return float(value) == 0.0 # type: ignore[arg-type]
except (TypeError, ValueError):
return False
models: list[dict[str, object]] = []
for m in data.get("data", []) or []:
mid = str(m.get("id") or "")
if not mid:
continue
pricing = m.get("pricing") or {}
is_free = mid.endswith(":free") or (
_is_zero(pricing.get("prompt")) and _is_zero(pricing.get("completion"))
)
try:
ctx = int(m.get("context_length") or 0)
except (TypeError, ValueError):
ctx = 0
models.append({
"id": mid,
"name": str(m.get("name") or mid),
"context_length": ctx,
"free": is_free,
})
models.sort(key=lambda x: (not x["free"], -int(x["context_length"]))) # type: ignore[index]
return {"models": models}
@app.get("/models/onemin") @app.get("/models/onemin")
def list_onemin_models() -> dict[str, list[dict[str, object]]]: def list_onemin_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur. """Catalogue statique des modeles 1min.ai, groupes par fournisseur.

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.10.0-beta</version> <version>0.10.3-beta</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>

View File

@@ -174,10 +174,9 @@ public class SessionStructuralContextBuilder {
.filter(a -> a.getId() != null) .filter(a -> a.getId() != null)
.collect(Collectors.toMap(Arc::getId, a -> a)); .collect(Collectors.toMap(Arc::getId, a -> a));
boolean anyHub = arcs.stream().anyMatch(a -> a.getType() == ArcType.HUB); // On suit comme "quêtes" les chapitres CONDITIONNELS : ceux d'un arc HUB, ET
if (!anyHub) { // ceux d'un arc linéaire qui portent des prérequis. Un chapitre linéaire sans
return new HubStatus(List.of(), List.of(), List.of(), activeFlags); // condition reste hors du tableau (sinon tous les chapitres deviendraient des quêtes).
}
// Map chapterId -> ProgressionStatus pour ce Playthrough // Map chapterId -> ProgressionStatus pour ce Playthrough
Map<String, ProgressionStatus> progressionByChapter = new HashMap<>(); Map<String, ProgressionStatus> progressionByChapter = new HashMap<>();
@@ -197,8 +196,10 @@ public class SessionStructuralContextBuilder {
List<String> lockedTitles = new ArrayList<>(); List<String> lockedTitles = new ArrayList<>();
for (Arc arc : arcs) { for (Arc arc : arcs) {
if (arc.getType() != ArcType.HUB) continue; boolean isHub = arc.getType() == ArcType.HUB;
for (Chapter c : chapterRepository.findByArcId(arc.getId())) { for (Chapter c : chapterRepository.findByArcId(arc.getId())) {
boolean hasPrereqs = c.getPrerequisites() != null && !c.getPrerequisites().isEmpty();
if (!isHub && !hasPrereqs) continue; // chapitre linéaire sans condition : ignoré
ProgressionStatus prog = progressionByChapter.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED); ProgressionStatus prog = progressionByChapter.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
QuestStatus status = prerequisiteEvaluator.computeStatus(prog, c.getPrerequisites(), ctx); QuestStatus status = prerequisiteEvaluator.computeStatus(prog, c.getPrerequisites(), ctx);
Arc parent = arcsById.get(c.getArcId()); Arc parent = arcsById.get(c.getArcId());

View File

@@ -140,6 +140,11 @@ public class SettingsController {
return forward(HttpMethod.GET, "/models/onemin", null); return forward(HttpMethod.GET, "/models/onemin", null);
} }
@GetMapping("/models/openrouter")
public ResponseEntity<Map<String, Object>> listOpenRouterModels() {
return forward(HttpMethod.GET, "/models/openrouter", null);
}
/** /**
* Serialiseur JSON minimal pour eviter d'instancier ObjectMapper a chaque * Serialiseur JSON minimal pour eviter d'instancier ObjectMapper a chaque
* appel. Suffisant pour notre cas d'usage : Map<String,Object> avec des * appel. Suffisant pour notre cas d'usage : Map<String,Object> avec des

View File

@@ -5,7 +5,9 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
client_max_body_size 10M; # Upload max : aligné sur la limite multipart du Core (64 Mo). Couvre les
# imports/adaptations de PDF (livres de règles/campagne illustrés) et les images.
client_max_body_size 64M;
location /api/ { location /api/ {
proxy_pass http://core:8080/api/; proxy_pass http://core:8080/api/;
@@ -15,9 +17,11 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# Streaming SSE (chat, import, adaptation) : pas de buffering, et timeouts
# longs car une génération sur gros PDF peut tarder avant le 1er octet.
proxy_buffering off; proxy_buffering off;
proxy_read_timeout 300s; proxy_read_timeout 600s;
proxy_send_timeout 300s; proxy_send_timeout 600s;
} }
# index.html : toujours revalide. Empeche un navigateur qui a precedemment # index.html : toujours revalide. Empeche un navigateur qui a precedemment

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.10.0-beta", "version": "0.10.3-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "loremind-web", "name": "loremind-web",
"version": "0.10.0-beta", "version": "0.10.3-beta",
"dependencies": { "dependencies": {
"@angular/animations": "^17.0.0", "@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0", "@angular/common": "^17.0.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.10.0-beta", "version": "0.10.3-beta",
"description": "LoreMind Frontend - Angular", "description": "LoreMind Frontend - Angular",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",

View File

@@ -127,6 +127,8 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
id: `chapter-${ch.id}`, id: `chapter-${ch.id}`,
label: ch.name, label: ch.name,
iconKey: ch.icon ?? undefined, iconKey: ch.icon ?? undefined,
// Cadenas si le chapitre porte des conditions de déblocage (hub ou linéaire).
meta: (ch.prerequisites?.length ?? 0) > 0 ? '🔒' : undefined,
children: sceneItems, children: sceneItems,
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`, route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`,
createActions: [{ createActions: [{

View File

@@ -26,13 +26,17 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form"> <form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
<!-- Section Hub : conditions de déblocage (scénario). Visible si l'arc parent est HUB. --> <!-- Conditions de déblocage (scénario) — pour TOUT chapitre : arc HUB (= quête)
<div class="hub-section" *ngIf="parentArc?.type === 'HUB'"> comme arc linéaire (= chapitre conditionnel). Vide = disponible d'emblée. -->
<h2 class="hub-section-title">🗺️ Conditions de déblocage de la quête</h2> <div class="hub-section">
<h2 class="hub-section-title">
{{ parentArc?.type === 'HUB' ? '🗺️ Conditions de déblocage de la quête' : '🔒 Conditions de déblocage du chapitre' }}
</h2>
<small class="field-hint"> <small class="field-hint">
Définition du scénario : toutes les conditions doivent être remplies (ET) pour Définition du scénario : toutes les conditions doivent être remplies (ET) pour que
que la quête soit débloquée dans une Partie. La progression en cours et l'état {{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} soit débloqué(e) dans une Partie.
des faits se gèrent dans l'écran de la Partie, pas ici. Laissez vide pour qu'il soit disponible dès le départ. La progression et l'état des
faits se gèrent dans l'écran de la Partie, pas ici.
</small> </small>
<app-prerequisite-editor <app-prerequisite-editor
[prerequisites]="prerequisites" [prerequisites]="prerequisites"

View File

@@ -6,7 +6,14 @@
<lucide-icon *ngIf="chapter.icon" [img]="resolveCampaignIcon(chapter.icon)" [size]="22" class="title-icon"></lucide-icon> <lucide-icon *ngIf="chapter.icon" [img]="resolveCampaignIcon(chapter.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ chapter.name }} {{ chapter.name }}
</h1> </h1>
<p class="view-subtitle">{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }}</p> <p class="view-subtitle">
{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }}
<span class="cond-badge" *ngIf="(chapter.prerequisites?.length ?? 0) > 0"
title="Ce chapitre a des conditions de déblocage : il se débloque en Partie quand elles sont remplies.">
<lucide-icon [img]="Lock" [size]="12"></lucide-icon>
Conditionnel
</span>
</p>
</div> </div>
<div class="view-actions"> <div class="view-actions">
<button type="button" class="btn-secondary" (click)="openGraph()" <button type="button" class="btn-secondary" (click)="openGraph()"
@@ -25,8 +32,9 @@
</div> </div>
</header> </header>
<!-- Conditions de déblocage (donnée de scénario, read-only). Toujours visible si Arc HUB. --> <!-- Conditions de déblocage (scénario, read-only). Visible pour un arc HUB (quête)
<section class="view-section" *ngIf="parentArc?.type === 'HUB'"> OU dès qu'un chapitre linéaire porte des conditions. -->
<section class="view-section" *ngIf="parentArc?.type === 'HUB' || (chapter.prerequisites?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🔒</span> Conditions de déblocage</h2> <h2 class="view-section-title"><span class="view-section-icon">🔒</span> Conditions de déblocage</h2>
<ng-container *ngIf="(chapter.prerequisites?.length ?? 0) > 0; else noPrereqs"> <ng-container *ngIf="(chapter.prerequisites?.length ?? 0) > 0; else noPrereqs">
@@ -34,8 +42,9 @@
<li *ngFor="let p of chapter.prerequisites">{{ describePrerequisite(p) }}</li> <li *ngFor="let p of chapter.prerequisites">{{ describePrerequisite(p) }}</li>
</ul> </ul>
<small class="view-section-empty"> <small class="view-section-empty">
Pendant une partie, la quête se débloque dès que toutes ces conditions sont remplies. Pendant une partie, {{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} se débloque
Le toggle des faits se fait dans l'écran « Faits » de la Partie. dès que toutes ces conditions sont remplies. Le toggle des faits se fait dans l'écran
« Faits » de la Partie.
</small> </small>
</ng-container> </ng-container>

View File

@@ -1,5 +1,21 @@
// Styles partagés via styles/_view.scss // Styles partagés via styles/_view.scss
// Badge "Conditionnel" : chapitre porteur de conditions de déblocage.
.cond-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: 0.5rem;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
vertical-align: middle;
color: #f0c674;
background: rgba(240, 198, 116, 0.12);
border: 1px solid rgba(240, 198, 116, 0.35);
}
.quest-status-bar { .quest-status-bar {
border: 1px solid rgba(66, 133, 244, 0.25); border: 1px solid rgba(66, 133, 244, 0.25);
background: rgba(66, 133, 244, 0.04); background: rgba(66, 133, 244, 0.04);

View File

@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router'; import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular'; import { LucideAngularModule, Pencil, Network, Trash2, Lock } from 'lucide-angular';
import { resolveCampaignIcon } from '../../campaign-icons'; import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
@@ -32,6 +32,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil; readonly Pencil = Pencil;
readonly Network = Network; readonly Network = Network;
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly Lock = Lock;
readonly resolveCampaignIcon = resolveCampaignIcon; readonly resolveCampaignIcon = resolveCampaignIcon;
campaignId = ''; campaignId = '';

View File

@@ -6,12 +6,16 @@ import { Observable } from 'rxjs';
* Reflet de SettingsDTO cote Brain / SettingsController cote Core. * Reflet de SettingsDTO cote Brain / SettingsController cote Core.
* `onemin_api_key_set` indique si une cle est configuree, sans la reveler. * `onemin_api_key_set` indique si une cle est configuree, sans la reveler.
*/ */
export type LlmProvider = 'ollama' | 'onemin' | 'openrouter';
export interface AppSettings { export interface AppSettings {
llm_provider: 'ollama' | 'onemin'; llm_provider: LlmProvider;
ollama_base_url: string; ollama_base_url: string;
llm_model: string; llm_model: string;
onemin_model: string; onemin_model: string;
onemin_api_key_set: boolean; onemin_api_key_set: boolean;
openrouter_model: string;
openrouter_api_key_set: boolean;
llm_num_ctx: number; llm_num_ctx: number;
/** Taille cible d'un morceau (tokens) pour l'import de PDF (règles/campagne). */ /** Taille cible d'un morceau (tokens) pour l'import de PDF (règles/campagne). */
import_chunk_tokens: number; import_chunk_tokens: number;
@@ -24,11 +28,13 @@ export interface AppSettings {
* `onemin_api_key: ''` efface la cle, `null`/absent ne touche a rien. * `onemin_api_key: ''` efface la cle, `null`/absent ne touche a rien.
*/ */
export interface AppSettingsUpdate { export interface AppSettingsUpdate {
llm_provider?: 'ollama' | 'onemin'; llm_provider?: LlmProvider;
ollama_base_url?: string; ollama_base_url?: string;
llm_model?: string; llm_model?: string;
onemin_model?: string; onemin_model?: string;
onemin_api_key?: string; onemin_api_key?: string;
openrouter_model?: string;
openrouter_api_key?: string;
llm_num_ctx?: number; llm_num_ctx?: number;
import_chunk_tokens?: number; import_chunk_tokens?: number;
llm_timeout_seconds?: number; llm_timeout_seconds?: number;
@@ -72,6 +78,11 @@ export class SettingsService {
return this.http.get<{ groups: OneMinModelGroup[] }>(`${this.apiUrl}/models/onemin`, this.authOptions); return this.http.get<{ groups: OneMinModelGroup[] }>(`${this.apiUrl}/models/onemin`, this.authOptions);
} }
/** Catalogue dynamique des modeles OpenRouter (depuis leur API publique). */
listOpenRouterModels(): Observable<{ models: OpenRouterModel[] }> {
return this.http.get<{ models: OpenRouterModel[] }>(`${this.apiUrl}/models/openrouter`, this.authOptions);
}
/** /**
* Telecharge un modele Ollama et streame la progression au client. * Telecharge un modele Ollama et streame la progression au client.
* *
@@ -184,3 +195,13 @@ export interface OneMinModelGroup {
provider: string; provider: string;
models: string[]; models: string[];
} }
/** Un modele OpenRouter (catalogue dynamique). */
export interface OpenRouterModel {
id: string;
name: string;
/** Fenetre de contexte du modele (tokens). 0 si inconnue. */
context_length: number;
/** True si le modele est gratuit (id `:free` ou prix nul). */
free: boolean;
}

View File

@@ -32,6 +32,10 @@
<input type="radio" name="provider" value="onemin" [(ngModel)]="settings.llm_provider"> <input type="radio" name="provider" value="onemin" [(ngModel)]="settings.llm_provider">
<span>1min.ai (cloud)</span> <span>1min.ai (cloud)</span>
</label> </label>
<label class="radio">
<input type="radio" name="provider" value="openrouter" [(ngModel)]="settings.llm_provider">
<span>OpenRouter (cloud)</span>
</label>
</div> </div>
</div> </div>
</section> </section>
@@ -173,8 +177,45 @@
</div> </div>
</section> </section>
<!-- Bloc Fenetre de contexte --> <!-- Bloc OpenRouter -->
<section class="card" *ngIf="settings"> <section class="card" *ngIf="settings && settings.llm_provider === 'openrouter'">
<h2>Configuration OpenRouter</h2>
<div class="form-row">
<label for="openrouter-key">Cle API</label>
<input
id="openrouter-key"
type="password"
[(ngModel)]="openrouterApiKeyInput"
[placeholder]="settings.openrouter_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API (sk-or-...)'">
<label class="checkbox" *ngIf="settings.openrouter_api_key_set">
<input type="checkbox" [(ngModel)]="clearOpenrouterKey">
<span>Effacer la cle enregistree</span>
</label>
</div>
<div class="form-row">
<label for="openrouter-model">Modele</label>
<label class="checkbox">
<input type="checkbox" [(ngModel)]="openrouterFreeOnly">
<span>Gratuits seulement</span>
</label>
<select id="openrouter-model" [(ngModel)]="settings.openrouter_model">
<option *ngFor="let o of openrouterSelectOptions" [value]="o.id">{{ o.label }}</option>
</select>
<p class="hint">
Liste chargee automatiquement depuis OpenRouter. Pour rester <strong>gratuit</strong> :
un modele marque <code>· gratuit</code> ou le routeur <code>openrouter/free</code>
(choisit un modele gratuit tout seul). Pour les imports, prefere un gratuit a
<strong>grand contexte</strong> (valeur « ctx »).
</p>
</div>
</section>
<!-- Bloc Fenetre de contexte : seulement pour Ollama (num_ctx est un vrai
parametre du modele local). Pour le cloud (1min/OpenRouter), on ne fixe rien :
on utilise ce que le modele accepte, et la jauge du chat s'affiche sans max. -->
<section class="card" *ngIf="settings && settings.llm_provider === 'ollama'">
<h2>Fenetre de contexte</h2> <h2>Fenetre de contexte</h2>
<!-- Ollama : slider borne par le max du modele --> <!-- Ollama : slider borne par le max du modele -->
@@ -205,20 +246,6 @@
</p> </p>
</div> </div>
<!-- 1min.ai : saisie libre (pas d'introspection possible) -->
<div class="form-row" *ngIf="settings.llm_provider === 'onemin'">
<label for="llm-num-ctx-onemin">Fenetre de contexte (tokens)</label>
<input
id="llm-num-ctx-onemin"
type="number"
min="2048"
step="1024"
[(ngModel)]="settings.llm_num_ctx">
<p class="hint">
A regler selon la capacite du modele 1min.ai choisi (ex: 128 000 pour gpt-4o,
200 000 pour claude-sonnet). Sert de plafond a la jauge de contexte du chat.
</p>
</div>
</section> </section>
<!-- Bloc Import de PDF --> <!-- Bloc Import de PDF -->

View File

@@ -4,7 +4,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular';
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service'; import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OpenRouterModel, OllamaPullEvent } from '../services/settings.service';
import { UpdatesService, UpdateStatus } from '../services/updates.service'; import { UpdatesService, UpdateStatus } from '../services/updates.service';
import { ConfigService } from '../services/config.service'; import { ConfigService } from '../services/config.service';
import { LayoutService } from '../services/layout.service'; import { LayoutService } from '../services/layout.service';
@@ -103,6 +103,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
settings: AppSettings | null = null; settings: AppSettings | null = null;
ollamaModels: string[] = []; ollamaModels: string[] = [];
oneminGroups: OneMinModelGroup[] = []; oneminGroups: OneMinModelGroup[] = [];
/** Catalogue OpenRouter (chargé dynamiquement) + filtre "gratuits seulement". */
openrouterModels: OpenRouterModel[] = [];
openrouterFreeOnly = true;
/** Fournisseur 1min.ai actuellement selectionne (filtre la liste des modeles). */ /** Fournisseur 1min.ai actuellement selectionne (filtre la liste des modeles). */
oneminProvider: string = ''; oneminProvider: string = '';
@@ -128,6 +132,11 @@ export class SettingsComponent implements OnInit, OnDestroy {
/** True si l'utilisateur a coche "effacer la cle". */ /** True si l'utilisateur a coche "effacer la cle". */
clearApiKey = false; clearApiKey = false;
/** Cle OpenRouter saisie — vide = on ne touche pas a la cle persistee. */
openrouterApiKeyInput = '';
/** True si l'utilisateur a coche "effacer la cle OpenRouter". */
clearOpenrouterKey = false;
constructor( constructor(
private settingsService: SettingsService, private settingsService: SettingsService,
private router: Router, private router: Router,
@@ -457,6 +466,35 @@ export class SettingsComponent implements OnInit, OnDestroy {
}, },
error: () => this.oneminGroups = [] error: () => this.oneminGroups = []
}); });
this.settingsService.listOpenRouterModels().subscribe({
next: (r) => this.openrouterModels = r.models,
error: () => this.openrouterModels = []
});
}
/** Modeles OpenRouter, filtres (gratuits seulement par defaut). */
get filteredOpenrouterModels(): OpenRouterModel[] {
return this.openrouterFreeOnly
? this.openrouterModels.filter(m => m.free)
: this.openrouterModels;
}
/** Options du select OpenRouter : routeur auto + liste filtree + valeur courante. */
get openrouterSelectOptions(): { id: string; label: string }[] {
const options: { id: string; label: string }[] = [
{ id: 'openrouter/free', label: 'openrouter/free — routeur auto (gratuit)' }
];
for (const m of this.filteredOpenrouterModels) {
const ctx = m.context_length ? `${m.context_length.toLocaleString()} ctx` : '';
options.push({ id: m.id, label: `${m.id}${ctx}${m.free ? ' · gratuit' : ''}` });
}
// Garantit que le modele actuellement configure reste selectionnable.
const cur = this.settings?.openrouter_model;
if (cur && !options.some(o => o.id === cur)) {
options.splice(1, 0, { id: cur, label: `${cur} (actuel)` });
}
return options;
} }
/** Deduit le fournisseur a partir du modele actuellement configure. */ /** Deduit le fournisseur a partir du modele actuellement configure. */
@@ -518,6 +556,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
ollama_base_url: this.settings.ollama_base_url, ollama_base_url: this.settings.ollama_base_url,
llm_model: this.settings.llm_model, llm_model: this.settings.llm_model,
onemin_model: this.settings.onemin_model, onemin_model: this.settings.onemin_model,
openrouter_model: this.settings.openrouter_model,
llm_num_ctx: this.settings.llm_num_ctx, llm_num_ctx: this.settings.llm_num_ctx,
import_chunk_tokens: this.settings.import_chunk_tokens, import_chunk_tokens: this.settings.import_chunk_tokens,
llm_timeout_seconds: this.settings.llm_timeout_seconds llm_timeout_seconds: this.settings.llm_timeout_seconds
@@ -527,12 +566,19 @@ export class SettingsComponent implements OnInit, OnDestroy {
} else if (this.oneminApiKeyInput.trim()) { } else if (this.oneminApiKeyInput.trim()) {
patch.onemin_api_key = this.oneminApiKeyInput.trim(); patch.onemin_api_key = this.oneminApiKeyInput.trim();
} }
if (this.clearOpenrouterKey) {
patch.openrouter_api_key = '';
} else if (this.openrouterApiKeyInput.trim()) {
patch.openrouter_api_key = this.openrouterApiKeyInput.trim();
}
this.settingsService.updateSettings(patch).subscribe({ this.settingsService.updateSettings(patch).subscribe({
next: (s) => { next: (s) => {
this.settings = { ...s }; this.settings = { ...s };
this.oneminApiKeyInput = ''; this.oneminApiKeyInput = '';
this.clearApiKey = false; this.clearApiKey = false;
this.openrouterApiKeyInput = '';
this.clearOpenrouterKey = false;
this.successMessage = 'Parametres sauvegardes.'; this.successMessage = 'Parametres sauvegardes.';
this.saving = false; this.saving = false;
}, },

View File

@@ -97,13 +97,14 @@
</header> </header>
<div class="context-gauge" *ngIf="usage" [attr.data-level]="usageLevel" <div class="context-gauge" *ngIf="usage" [attr.data-level]="usageLevel"
[attr.title]="'System: ' + usage.system + ' · Historique: ' + usage.history + ' · Courant: ' + usage.current + ' / ' + usage.max + ' tokens'"> [attr.title]="'System: ' + usage.system + ' · Historique: ' + usage.history + ' · Courant: ' + usage.current + (usageHasMax ? ' / ' + usage.max : '') + ' tokens'">
<div class="gauge-bar"> <!-- Barre seulement si on connaît le plafond (Ollama). Cloud : compteur seul. -->
<div class="gauge-bar" *ngIf="usageHasMax">
<div class="gauge-fill" [style.width.%]="usagePercent"></div> <div class="gauge-fill" [style.width.%]="usagePercent"></div>
</div> </div>
<div class="gauge-label"> <div class="gauge-label">
<span class="gauge-text">Contexte : {{ usageTotal }} / {{ usage.max }} tokens</span> <span class="gauge-text">Contexte : {{ usageTotal }}<ng-container *ngIf="usageHasMax"> / {{ usage.max }}</ng-container> tokens</span>
<span class="gauge-percent">{{ usagePercent }}%</span> <span class="gauge-percent" *ngIf="usageHasMax">{{ usagePercent }}%</span>
</div> </div>
</div> </div>

View File

@@ -129,6 +129,10 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
if (!this.usage) return 0; if (!this.usage) return 0;
return this.usage.system + this.usage.history + this.usage.current; return this.usage.system + this.usage.history + this.usage.current;
} }
/** Vrai si on connaît la fenêtre max (Ollama) → affiche dénominateur + barre. */
get usageHasMax(): boolean {
return !!this.usage && this.usage.max > 0;
}
get usageRatio(): number { get usageRatio(): number {
if (!this.usage || this.usage.max <= 0) return 0; if (!this.usage || this.usage.max <= 0) return 0;
return Math.min(1, this.usageTotal / this.usage.max); return Math.min(1, this.usageTotal / this.usage.max);