Compare commits
5 Commits
v0.10.0-be
...
v0.10.4-be
| Author | SHA1 | Date | |
|---|---|---|---|
| 5eb15dc449 | |||
| da5b602f20 | |||
| 9ea43a1889 | |||
| 211e26dae1 | |||
| 53065c952b |
@@ -26,7 +26,9 @@ from app.domain.ports import LLMProvider, PdfTextExtractor
|
||||
|
||||
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.
|
||||
_DEFAULT_ARC_NAME = "Aventure principale"
|
||||
|
||||
@@ -23,7 +23,9 @@ from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 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
|
||||
# morceaux (sinon "Combat" / "Le combat" / "Règles de combat" se dispersent).
|
||||
|
||||
@@ -12,13 +12,45 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
from app.domain.ports import LLMProvider, LLMProviderError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ATTEMPTS = 3
|
||||
_BASE_DELAY_SECONDS = 2.0
|
||||
_ATTEMPTS = 4
|
||||
_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(
|
||||
@@ -28,7 +60,12 @@ async def generate_with_retry(
|
||||
output_format: str | None = None,
|
||||
temperature: float | None = None,
|
||||
) -> 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
|
||||
last_error: LLMProviderError | None = None
|
||||
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)
|
||||
except LLMProviderError as 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 _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(
|
||||
"Appel LLM échoué (tentative %s/%s) : %s — nouvelle tentative dans %ss.",
|
||||
attempt + 1, _ATTEMPTS, exc, delay,
|
||||
"Appel LLM échoué (tentative %s/%s)%s : %s — nouvelle tentative dans %ss.",
|
||||
attempt + 1, _ATTEMPTS, " [rate limit]" if _is_rate_limit(exc) else "",
|
||||
exc, wait,
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
await asyncio.sleep(wait)
|
||||
assert last_error is not None
|
||||
raise last_error
|
||||
|
||||
@@ -25,8 +25,8 @@ class Settings(BaseSettings):
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai (etage 2).
|
||||
llm_provider: Literal["ollama", "onemin"] = "ollama"
|
||||
# Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai ; "openrouter" = OpenRouter.
|
||||
llm_provider: Literal["ollama", "onemin", "openrouter"] = "ollama"
|
||||
|
||||
ollama_base_url: str = "http://localhost:11434"
|
||||
llm_model: str = "gemma4:26b"
|
||||
@@ -47,6 +47,12 @@ class Settings(BaseSettings):
|
||||
onemin_api_key: str = ""
|
||||
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).
|
||||
# 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.
|
||||
|
||||
@@ -29,6 +29,8 @@ _ALLOWED_KEYS = frozenset({
|
||||
"llm_num_ctx",
|
||||
"onemin_api_key",
|
||||
"onemin_model",
|
||||
"openrouter_api_key",
|
||||
"openrouter_model",
|
||||
"import_chunk_tokens",
|
||||
})
|
||||
|
||||
|
||||
139
brain/app/infrastructure/openrouter_adapter.py
Normal file
139
brain/app/infrastructure/openrouter_adapter.py
Normal 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}"
|
||||
@@ -45,12 +45,13 @@ from app.domain.models import (
|
||||
from app.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError
|
||||
from app.infrastructure.ollama_adapter import OllamaLLMProvider
|
||||
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
||||
from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider
|
||||
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
|
||||
|
||||
app = FastAPI(
|
||||
title="LoreMind Brain",
|
||||
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:
|
||||
if settings.llm_provider == "onemin":
|
||||
return OneMinAiLLMProvider(settings)
|
||||
if settings.llm_provider == "openrouter":
|
||||
return OpenRouterLLMProvider(settings)
|
||||
return OllamaLLMProvider(settings)
|
||||
except LLMProviderError as exc:
|
||||
# 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),
|
||||
"history": sum(_count_tokens(m.content) for m in history_msgs),
|
||||
"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]:
|
||||
@@ -885,12 +890,15 @@ class SettingsDTO(BaseModel):
|
||||
Les secrets (onemin_api_key) sont masques en lecture.
|
||||
"""
|
||||
|
||||
llm_provider: Literal["ollama", "onemin"]
|
||||
llm_provider: Literal["ollama", "onemin", "openrouter"]
|
||||
ollama_base_url: str
|
||||
llm_model: str
|
||||
onemin_model: str
|
||||
# True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme.
|
||||
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
|
||||
# aussi de plafond a la jauge de contexte UI.
|
||||
llm_num_ctx: int
|
||||
@@ -903,12 +911,14 @@ class SettingsDTO(BaseModel):
|
||||
class SettingsUpdateDTO(BaseModel):
|
||||
"""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
|
||||
llm_model: str | None = None
|
||||
onemin_model: str | None = None
|
||||
# Chaine vide => on efface la cle. None => pas de changement.
|
||||
onemin_api_key: str | None = None
|
||||
openrouter_model: str | None = None
|
||||
openrouter_api_key: str | None = None
|
||||
llm_num_ctx: int | None = None
|
||||
import_chunk_tokens: 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,
|
||||
onemin_model=s.onemin_model,
|
||||
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,
|
||||
import_chunk_tokens=s.import_chunk_tokens,
|
||||
llm_timeout_seconds=s.llm_timeout_seconds,
|
||||
@@ -1081,6 +1093,51 @@ async def delete_ollama_model(
|
||||
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")
|
||||
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
|
||||
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<groupId>com.loremind</groupId>
|
||||
<artifactId>loremind-core</artifactId>
|
||||
<version>0.10.0-beta</version>
|
||||
<version>0.10.3-beta</version>
|
||||
<name>LoreMind Core</name>
|
||||
<description>Backend Core - Architecture Hexagonale</description>
|
||||
|
||||
|
||||
@@ -174,10 +174,9 @@ public class SessionStructuralContextBuilder {
|
||||
.filter(a -> a.getId() != null)
|
||||
.collect(Collectors.toMap(Arc::getId, a -> a));
|
||||
|
||||
boolean anyHub = arcs.stream().anyMatch(a -> a.getType() == ArcType.HUB);
|
||||
if (!anyHub) {
|
||||
return new HubStatus(List.of(), List.of(), List.of(), activeFlags);
|
||||
}
|
||||
// On suit comme "quêtes" les chapitres CONDITIONNELS : ceux d'un arc HUB, ET
|
||||
// ceux d'un arc linéaire qui portent des prérequis. Un chapitre linéaire sans
|
||||
// condition reste hors du tableau (sinon tous les chapitres deviendraient des quêtes).
|
||||
|
||||
// Map chapterId -> ProgressionStatus pour ce Playthrough
|
||||
Map<String, ProgressionStatus> progressionByChapter = new HashMap<>();
|
||||
@@ -197,8 +196,10 @@ public class SessionStructuralContextBuilder {
|
||||
List<String> lockedTitles = new ArrayList<>();
|
||||
|
||||
for (Arc arc : arcs) {
|
||||
if (arc.getType() != ArcType.HUB) continue;
|
||||
boolean isHub = arc.getType() == ArcType.HUB;
|
||||
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);
|
||||
QuestStatus status = prerequisiteEvaluator.computeStatus(prog, c.getPrerequisites(), ctx);
|
||||
Arc parent = arcsById.get(c.getArcId());
|
||||
|
||||
@@ -140,6 +140,11 @@ public class SettingsController {
|
||||
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
|
||||
* appel. Suffisant pour notre cas d'usage : Map<String,Object> avec des
|
||||
|
||||
@@ -5,7 +5,9 @@ server {
|
||||
root /usr/share/nginx/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/ {
|
||||
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-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_read_timeout 300s;
|
||||
proxy_send_timeout 300s;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
}
|
||||
|
||||
# index.html : toujours revalide. Empeche un navigateur qui a precedemment
|
||||
|
||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.10.0-beta",
|
||||
"version": "0.10.3-beta",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "loremind-web",
|
||||
"version": "0.10.0-beta",
|
||||
"version": "0.10.3-beta",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^17.0.0",
|
||||
"@angular/common": "^17.0.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "loremind-web",
|
||||
"version": "0.10.0-beta",
|
||||
"version": "0.10.3-beta",
|
||||
"description": "LoreMind Frontend - Angular",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
|
||||
@@ -127,6 +127,8 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
||||
id: `chapter-${ch.id}`,
|
||||
label: ch.name,
|
||||
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,
|
||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`,
|
||||
createActions: [{
|
||||
|
||||
@@ -26,13 +26,17 @@
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
||||
|
||||
<!-- Section Hub : conditions de déblocage (scénario). Visible si l'arc parent est HUB. -->
|
||||
<div class="hub-section" *ngIf="parentArc?.type === 'HUB'">
|
||||
<h2 class="hub-section-title">🗺️ Conditions de déblocage de la quête</h2>
|
||||
<!-- Conditions de déblocage (scénario) — pour TOUT chapitre : arc HUB (= quête)
|
||||
comme arc linéaire (= chapitre conditionnel). Vide = disponible d'emblée. -->
|
||||
<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">
|
||||
Définition du scénario : toutes les conditions doivent être remplies (ET) pour
|
||||
que la quête soit débloquée dans une Partie. La progression en cours et l'état
|
||||
des faits se gèrent dans l'écran de la Partie, pas ici.
|
||||
Définition du scénario : toutes les conditions doivent être remplies (ET) pour que
|
||||
{{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} soit débloqué(e) dans une Partie.
|
||||
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>
|
||||
<app-prerequisite-editor
|
||||
[prerequisites]="prerequisites"
|
||||
|
||||
@@ -6,7 +6,14 @@
|
||||
<lucide-icon *ngIf="chapter.icon" [img]="resolveCampaignIcon(chapter.icon)" [size]="22" class="title-icon"></lucide-icon>
|
||||
{{ chapter.name }}
|
||||
</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 class="view-actions">
|
||||
<button type="button" class="btn-secondary" (click)="openGraph()"
|
||||
@@ -25,8 +32,9 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Conditions de déblocage (donnée de scénario, read-only). Toujours visible si Arc HUB. -->
|
||||
<section class="view-section" *ngIf="parentArc?.type === 'HUB'">
|
||||
<!-- Conditions de déblocage (scénario, read-only). Visible pour un arc HUB (quête)
|
||||
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>
|
||||
|
||||
<ng-container *ngIf="(chapter.prerequisites?.length ?? 0) > 0; else noPrereqs">
|
||||
@@ -34,8 +42,9 @@
|
||||
<li *ngFor="let p of chapter.prerequisites">{{ describePrerequisite(p) }}</li>
|
||||
</ul>
|
||||
<small class="view-section-empty">
|
||||
Pendant une partie, la quête se débloque dès que toutes ces conditions sont remplies.
|
||||
Le toggle des faits se fait dans l'écran « Faits » de la Partie.
|
||||
Pendant une partie, {{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} se débloque
|
||||
dès que toutes ces conditions sont remplies. Le toggle des faits se fait dans l'écran
|
||||
« Faits » de la Partie.
|
||||
</small>
|
||||
</ng-container>
|
||||
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
// 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 {
|
||||
border: 1px solid rgba(66, 133, 244, 0.25);
|
||||
background: rgba(66, 133, 244, 0.04);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||
import { forkJoin, of } from 'rxjs';
|
||||
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 { CampaignService } from '../../../services/campaign.service';
|
||||
import { CharacterService } from '../../../services/character.service';
|
||||
@@ -32,6 +32,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
||||
readonly Pencil = Pencil;
|
||||
readonly Network = Network;
|
||||
readonly Trash2 = Trash2;
|
||||
readonly Lock = Lock;
|
||||
readonly resolveCampaignIcon = resolveCampaignIcon;
|
||||
|
||||
campaignId = '';
|
||||
|
||||
@@ -6,12 +6,16 @@ import { Observable } from 'rxjs';
|
||||
* Reflet de SettingsDTO cote Brain / SettingsController cote Core.
|
||||
* `onemin_api_key_set` indique si une cle est configuree, sans la reveler.
|
||||
*/
|
||||
export type LlmProvider = 'ollama' | 'onemin' | 'openrouter';
|
||||
|
||||
export interface AppSettings {
|
||||
llm_provider: 'ollama' | 'onemin';
|
||||
llm_provider: LlmProvider;
|
||||
ollama_base_url: string;
|
||||
llm_model: string;
|
||||
onemin_model: string;
|
||||
onemin_api_key_set: boolean;
|
||||
openrouter_model: string;
|
||||
openrouter_api_key_set: boolean;
|
||||
llm_num_ctx: number;
|
||||
/** Taille cible d'un morceau (tokens) pour l'import de PDF (règles/campagne). */
|
||||
import_chunk_tokens: number;
|
||||
@@ -24,11 +28,13 @@ export interface AppSettings {
|
||||
* `onemin_api_key: ''` efface la cle, `null`/absent ne touche a rien.
|
||||
*/
|
||||
export interface AppSettingsUpdate {
|
||||
llm_provider?: 'ollama' | 'onemin';
|
||||
llm_provider?: LlmProvider;
|
||||
ollama_base_url?: string;
|
||||
llm_model?: string;
|
||||
onemin_model?: string;
|
||||
onemin_api_key?: string;
|
||||
openrouter_model?: string;
|
||||
openrouter_api_key?: string;
|
||||
llm_num_ctx?: number;
|
||||
import_chunk_tokens?: number;
|
||||
llm_timeout_seconds?: number;
|
||||
@@ -72,6 +78,11 @@ export class SettingsService {
|
||||
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.
|
||||
*
|
||||
@@ -184,3 +195,13 @@ export interface OneMinModelGroup {
|
||||
provider: 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;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
<input type="radio" name="provider" value="onemin" [(ngModel)]="settings.llm_provider">
|
||||
<span>1min.ai (cloud)</span>
|
||||
</label>
|
||||
<label class="radio">
|
||||
<input type="radio" name="provider" value="openrouter" [(ngModel)]="settings.llm_provider">
|
||||
<span>OpenRouter (cloud)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -173,8 +177,45 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Bloc Fenetre de contexte -->
|
||||
<section class="card" *ngIf="settings">
|
||||
<!-- Bloc OpenRouter -->
|
||||
<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>
|
||||
|
||||
<!-- Ollama : slider borne par le max du modele -->
|
||||
@@ -205,20 +246,6 @@
|
||||
</p>
|
||||
</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>
|
||||
|
||||
<!-- Bloc Import de PDF -->
|
||||
|
||||
@@ -4,7 +4,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
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 { ConfigService } from '../services/config.service';
|
||||
import { LayoutService } from '../services/layout.service';
|
||||
@@ -103,6 +103,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
settings: AppSettings | null = null;
|
||||
ollamaModels: string[] = [];
|
||||
oneminGroups: OneMinModelGroup[] = [];
|
||||
|
||||
/** Catalogue OpenRouter (chargé dynamiquement) + filtre "gratuits seulement". */
|
||||
openrouterModels: OpenRouterModel[] = [];
|
||||
openrouterFreeOnly = true;
|
||||
/** Fournisseur 1min.ai actuellement selectionne (filtre la liste des modeles). */
|
||||
oneminProvider: string = '';
|
||||
|
||||
@@ -128,6 +132,11 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
/** True si l'utilisateur a coche "effacer la cle". */
|
||||
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(
|
||||
private settingsService: SettingsService,
|
||||
private router: Router,
|
||||
@@ -457,6 +466,35 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
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. */
|
||||
@@ -518,6 +556,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
ollama_base_url: this.settings.ollama_base_url,
|
||||
llm_model: this.settings.llm_model,
|
||||
onemin_model: this.settings.onemin_model,
|
||||
openrouter_model: this.settings.openrouter_model,
|
||||
llm_num_ctx: this.settings.llm_num_ctx,
|
||||
import_chunk_tokens: this.settings.import_chunk_tokens,
|
||||
llm_timeout_seconds: this.settings.llm_timeout_seconds
|
||||
@@ -527,12 +566,19 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
||||
} else if (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({
|
||||
next: (s) => {
|
||||
this.settings = { ...s };
|
||||
this.oneminApiKeyInput = '';
|
||||
this.clearApiKey = false;
|
||||
this.openrouterApiKeyInput = '';
|
||||
this.clearOpenrouterKey = false;
|
||||
this.successMessage = 'Parametres sauvegardes.';
|
||||
this.saving = false;
|
||||
},
|
||||
|
||||
@@ -97,13 +97,14 @@
|
||||
</header>
|
||||
|
||||
<div class="context-gauge" *ngIf="usage" [attr.data-level]="usageLevel"
|
||||
[attr.title]="'System: ' + usage.system + ' · Historique: ' + usage.history + ' · Courant: ' + usage.current + ' / ' + usage.max + ' tokens'">
|
||||
<div class="gauge-bar">
|
||||
[attr.title]="'System: ' + usage.system + ' · Historique: ' + usage.history + ' · Courant: ' + usage.current + (usageHasMax ? ' / ' + usage.max : '') + ' tokens'">
|
||||
<!-- 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>
|
||||
<div class="gauge-label">
|
||||
<span class="gauge-text">Contexte : {{ usageTotal }} / {{ usage.max }} tokens</span>
|
||||
<span class="gauge-percent">{{ usagePercent }}%</span>
|
||||
<span class="gauge-text">Contexte : {{ usageTotal }}<ng-container *ngIf="usageHasMax"> / {{ usage.max }}</ng-container> tokens</span>
|
||||
<span class="gauge-percent" *ngIf="usageHasMax">{{ usagePercent }}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -129,6 +129,10 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
||||
if (!this.usage) return 0;
|
||||
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 {
|
||||
if (!this.usage || this.usage.max <= 0) return 0;
|
||||
return Math.min(1, this.usageTotal / this.usage.max);
|
||||
|
||||
Reference in New Issue
Block a user