Compare commits
70 Commits
v0.10.4-be
...
bf91b75b5f
| Author | SHA1 | Date | |
|---|---|---|---|
| bf91b75b5f | |||
| 2fffad1b6b | |||
| 439f43875b | |||
| 79a68bc27b | |||
| c3d3394ec7 | |||
| 694f687fec | |||
| 87865338a0 | |||
| 586ddceff6 | |||
| 4b9b7f0995 | |||
| 3d73b1e6a7 | |||
| 759e47fc1f | |||
| f71bf3fcad | |||
| 0cd99dfb32 | |||
| f24ef0891e | |||
| 7c74c12f3e | |||
| 86836ad81c | |||
| 7c4a42327d | |||
| 52e389db24 | |||
| efaf5a3794 | |||
| 4fe93b5ff3 | |||
| 0f2d1b1efe | |||
| 5ff05242a8 | |||
| b06c77a1eb | |||
| 03bc669efe | |||
| c3873ddd84 | |||
| d7ceeac1b0 | |||
| cdbd3cd9b4 | |||
| a708c74425 | |||
| 9ad7651c44 | |||
| 389392fd1d | |||
| aaebeaa547 | |||
| 03ee3855f5 | |||
| 94a39cf3b4 | |||
| efe6f6c2b0 | |||
| 73a9d15786 | |||
| dfe05cf2d2 | |||
| fcba907438 | |||
| 5739602702 | |||
| addf78f01d | |||
| 5e04e84ee4 | |||
| 8d5c2e2b7f | |||
| 788d2c12f2 | |||
| b25a9746cf | |||
| 41fda9aeee | |||
| 550078268c | |||
| 0582690dca | |||
| 88278bd1dd | |||
| d24d6459a0 | |||
| 4b866e5212 | |||
| 6c6bd20f0d | |||
| 2764228abf | |||
| f95d69c915 | |||
| 70351e9d9a | |||
| ff4905126d | |||
| 0e5b5a7de4 | |||
| c8c032336b | |||
| dda27e55fc | |||
| 83ac67471e | |||
| e3c8232e38 | |||
| a4df9fc759 | |||
| f1989c1d77 | |||
| 8efdf5d0e0 | |||
| 96bc5de942 | |||
| 84ccdd53ad | |||
| 29978058ee | |||
| e510f64336 | |||
| f189f67aaf | |||
| 8efa148739 | |||
| 8f4dd3e9d6 | |||
| bf38b6695f |
@@ -26,9 +26,7 @@ from app.domain.ports import LLMProvider, PdfTextExtractor
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Très basse : structuration = recopie/réorganisation fidèle, pas de créativité.
|
_TEMPERATURE = 0.2
|
||||||
# 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"
|
||||||
|
|||||||
@@ -23,9 +23,7 @@ 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é.
|
||||||
# Très basse : structuration = recopie/réorganisation fidèle, pas de créativité.
|
_TEMPERATURE = 0.2
|
||||||
# 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).
|
||||||
|
|||||||
@@ -12,45 +12,13 @@ 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 = 4
|
_ATTEMPTS = 3
|
||||||
_BASE_DELAY_SECONDS = 3.0
|
_BASE_DELAY_SECONDS = 2.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(
|
||||||
@@ -60,12 +28,7 @@ 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).
|
"""Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff x2)."""
|
||||||
|
|
||||||
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):
|
||||||
@@ -73,27 +36,12 @@ 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 : %s — nouvelle tentative dans %ss.",
|
"Appel LLM échoué (tentative %s/%s) : %s — nouvelle tentative dans %ss.",
|
||||||
attempt + 1, _ATTEMPTS, " [rate limit]" if _is_rate_limit(exc) else "",
|
attempt + 1, _ATTEMPTS, exc, delay,
|
||||||
exc, wait,
|
|
||||||
)
|
)
|
||||||
await asyncio.sleep(wait)
|
await asyncio.sleep(delay)
|
||||||
|
delay *= 2
|
||||||
assert last_error is not None
|
assert last_error is not None
|
||||||
raise last_error
|
raise last_error
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ class Settings(BaseSettings):
|
|||||||
extra="ignore",
|
extra="ignore",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai ; "openrouter" = OpenRouter.
|
# Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai (etage 2).
|
||||||
llm_provider: Literal["ollama", "onemin", "openrouter"] = "ollama"
|
llm_provider: Literal["ollama", "onemin"] = "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,12 +47,6 @@ 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.
|
||||||
|
|||||||
@@ -29,8 +29,6 @@ _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",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
"""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,13 +45,12 @@ 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.3-beta",
|
version="0.10.1-beta",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -355,8 +354,6 @@ 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
|
||||||
@@ -691,9 +688,7 @@ 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,
|
||||||
# Plafond connu seulement pour Ollama (num_ctx). Pour le cloud (1min/OpenRouter)
|
"max": settings.llm_num_ctx,
|
||||||
# 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]:
|
||||||
@@ -890,15 +885,12 @@ 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", "openrouter"]
|
llm_provider: Literal["ollama", "onemin"]
|
||||||
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
|
||||||
@@ -911,14 +903,12 @@ 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", "openrouter"] | None = None
|
llm_provider: Literal["ollama", "onemin"] | 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
|
||||||
@@ -931,8 +921,6 @@ 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,
|
||||||
@@ -1093,51 +1081,6 @@ 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.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.10.3-beta</version>
|
<version>0.10.1-beta</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
|
|||||||
@@ -174,9 +174,10 @@ 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));
|
||||||
|
|
||||||
// On suit comme "quêtes" les chapitres CONDITIONNELS : ceux d'un arc HUB, ET
|
boolean anyHub = arcs.stream().anyMatch(a -> a.getType() == ArcType.HUB);
|
||||||
// ceux d'un arc linéaire qui portent des prérequis. Un chapitre linéaire sans
|
if (!anyHub) {
|
||||||
// condition reste hors du tableau (sinon tous les chapitres deviendraient des quêtes).
|
return new HubStatus(List.of(), List.of(), List.of(), activeFlags);
|
||||||
|
}
|
||||||
|
|
||||||
// Map chapterId -> ProgressionStatus pour ce Playthrough
|
// Map chapterId -> ProgressionStatus pour ce Playthrough
|
||||||
Map<String, ProgressionStatus> progressionByChapter = new HashMap<>();
|
Map<String, ProgressionStatus> progressionByChapter = new HashMap<>();
|
||||||
@@ -196,10 +197,8 @@ public class SessionStructuralContextBuilder {
|
|||||||
List<String> lockedTitles = new ArrayList<>();
|
List<String> lockedTitles = new ArrayList<>();
|
||||||
|
|
||||||
for (Arc arc : arcs) {
|
for (Arc arc : arcs) {
|
||||||
boolean isHub = arc.getType() == ArcType.HUB;
|
if (arc.getType() != ArcType.HUB) continue;
|
||||||
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());
|
||||||
|
|||||||
@@ -140,11 +140,6 @@ 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
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
# Surchargables via variables d'environnement (CI) :
|
# Surchargables via variables d'environnement (CI) :
|
||||||
# SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME, SPRING_DATASOURCE_PASSWORD
|
# SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME, SPRING_DATASOURCE_PASSWORD
|
||||||
spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/loremind_test}
|
spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/loremind_test}
|
||||||
spring.datasource.username=${SPRING_DATASOURCE_USERNAME:loremind_test}
|
spring.datasource.username=${SPRING_DATASOURCE_USERNAME:ietm64}
|
||||||
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:loremind_test}
|
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:M&Ipourlavie64}
|
||||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||||
|
|
||||||
# Configuration JPA pour les tests : schema recree a chaque run, pas de logs SQL.
|
# Configuration JPA pour les tests : schema recree a chaque run, pas de logs SQL.
|
||||||
|
|||||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.10.3-beta",
|
"version": "0.10.1-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.10.3-beta",
|
"version": "0.10.1-beta",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^17.0.0",
|
"@angular/animations": "^17.0.0",
|
||||||
"@angular/common": "^17.0.0",
|
"@angular/common": "^17.0.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.10.3-beta",
|
"version": "0.10.1-beta",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -127,8 +127,6 @@ 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: [{
|
||||||
|
|||||||
@@ -26,17 +26,13 @@
|
|||||||
|
|
||||||
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
||||||
|
|
||||||
<!-- Conditions de déblocage (scénario) — pour TOUT chapitre : arc HUB (= quête)
|
<!-- Section Hub : conditions de déblocage (scénario). Visible si l'arc parent est HUB. -->
|
||||||
comme arc linéaire (= chapitre conditionnel). Vide = disponible d'emblée. -->
|
<div class="hub-section" *ngIf="parentArc?.type === 'HUB'">
|
||||||
<div class="hub-section">
|
<h2 class="hub-section-title">🗺️ Conditions de déblocage de la quête</h2>
|
||||||
<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 que
|
Définition du scénario : toutes les conditions doivent être remplies (ET) pour
|
||||||
{{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} soit débloqué(e) dans une Partie.
|
que la quête soit débloquée dans une Partie. La progression en cours et l'état
|
||||||
Laissez vide pour qu'il soit disponible dès le départ. La progression et l'état des
|
des faits se gèrent dans l'écran de la Partie, pas ici.
|
||||||
faits se gèrent dans l'écran de la Partie, pas ici.
|
|
||||||
</small>
|
</small>
|
||||||
<app-prerequisite-editor
|
<app-prerequisite-editor
|
||||||
[prerequisites]="prerequisites"
|
[prerequisites]="prerequisites"
|
||||||
|
|||||||
@@ -6,14 +6,7 @@
|
|||||||
<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">
|
<p class="view-subtitle">{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }}</p>
|
||||||
{{ 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()"
|
||||||
@@ -32,9 +25,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Conditions de déblocage (scénario, read-only). Visible pour un arc HUB (quête)
|
<!-- Conditions de déblocage (donnée de scénario, read-only). Toujours visible si Arc HUB. -->
|
||||||
OU dès qu'un chapitre linéaire porte des conditions. -->
|
<section class="view-section" *ngIf="parentArc?.type === 'HUB'">
|
||||||
<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">
|
||||||
@@ -42,9 +34,8 @@
|
|||||||
<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, {{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} se débloque
|
Pendant une partie, la quête se débloque dès que toutes ces conditions sont remplies.
|
||||||
dès que toutes ces conditions sont remplies. Le toggle des faits se fait dans l'écran
|
Le toggle des faits se fait dans l'écran « Faits » de la Partie.
|
||||||
« Faits » de la Partie.
|
|
||||||
</small>
|
</small>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
|||||||
@@ -1,21 +1,5 @@
|
|||||||
// 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);
|
||||||
|
|||||||
@@ -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, Lock } from 'lucide-angular';
|
import { LucideAngularModule, Pencil, Network, Trash2 } 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,7 +32,6 @@ 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 = '';
|
||||||
|
|||||||
@@ -6,16 +6,12 @@ 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: LlmProvider;
|
llm_provider: 'ollama' | 'onemin';
|
||||||
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;
|
||||||
@@ -28,13 +24,11 @@ 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?: LlmProvider;
|
llm_provider?: 'ollama' | 'onemin';
|
||||||
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;
|
||||||
@@ -78,11 +72,6 @@ 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.
|
||||||
*
|
*
|
||||||
@@ -195,13 +184,3 @@ 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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -32,10 +32,6 @@
|
|||||||
<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>
|
||||||
@@ -177,45 +173,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Bloc OpenRouter -->
|
<!-- Bloc Fenetre de contexte -->
|
||||||
<section class="card" *ngIf="settings && settings.llm_provider === 'openrouter'">
|
<section class="card" *ngIf="settings">
|
||||||
<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 -->
|
||||||
@@ -246,6 +205,20 @@
|
|||||||
</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 -->
|
||||||
|
|||||||
@@ -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, OpenRouterModel, OllamaPullEvent } from '../services/settings.service';
|
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, 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,10 +103,6 @@ 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 = '';
|
||||||
|
|
||||||
@@ -132,11 +128,6 @@ 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,
|
||||||
@@ -466,35 +457,6 @@ 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. */
|
||||||
@@ -556,7 +518,6 @@ 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
|
||||||
@@ -566,19 +527,12 @@ 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;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -97,14 +97,13 @@
|
|||||||
</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 + (usageHasMax ? ' / ' + usage.max : '') + ' tokens'">
|
[attr.title]="'System: ' + usage.system + ' · Historique: ' + usage.history + ' · Courant: ' + usage.current + ' / ' + usage.max + ' tokens'">
|
||||||
<!-- Barre seulement si on connaît le plafond (Ollama). Cloud : compteur seul. -->
|
<div class="gauge-bar">
|
||||||
<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 }}<ng-container *ngIf="usageHasMax"> / {{ usage.max }}</ng-container> tokens</span>
|
<span class="gauge-text">Contexte : {{ usageTotal }} / {{ usage.max }} tokens</span>
|
||||||
<span class="gauge-percent" *ngIf="usageHasMax">{{ usagePercent }}%</span>
|
<span class="gauge-percent">{{ usagePercent }}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -129,10 +129,6 @@ 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user