Compare commits
71 Commits
439f43875b
...
v0.10.2-be
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ea43a1889 | |||
| 211e26dae1 | |||
| 53065c952b | |||
| 6d00543a59 | |||
| 091de0daf7 | |||
| 504c4b7b6c | |||
| e3fc96a1bc | |||
| bc05b1536d | |||
| 0e350142de | |||
| 11cb708de9 | |||
| 53343c0a75 | |||
| 43f45d68a1 | |||
| 4bbc6333fc | |||
| dc908b8d93 | |||
| ae9455c244 | |||
| e346bb4b85 | |||
| a1c9e67013 | |||
| b0edfb5aab | |||
| 117334315f | |||
| 8e0c1c1017 | |||
| cb9eb14054 | |||
| fd14e1e572 | |||
| f3c445cfa1 | |||
| 1a5a87629d | |||
| e61bcd5dbb | |||
| bc8fb30398 | |||
| ec45104a80 | |||
| 3f8cdd177b | |||
| 4ed9104a53 | |||
| defbf2d1f7 | |||
| d2cf9f8c5c | |||
| a92e31b187 | |||
| 00627d1543 | |||
| 47a8fc529d | |||
| c524516b2a | |||
| 6a4ae5c326 | |||
| 4e01fdfa9c | |||
| b60a790c70 | |||
| 147d0701db | |||
| 98e54613ce | |||
| c47f987cef | |||
| 7cd0a8253a | |||
| 7b189acee9 | |||
| 08c91dd4c6 | |||
| dbf0b13189 | |||
| aba02ca9cf | |||
| 854110683b | |||
| 3ad8145572 | |||
| 0d27ea71b0 | |||
| f6b4e3ff82 | |||
| 2a438b79ce | |||
| 6ab7f64909 | |||
| 0ad1d09802 | |||
| c6a228a48f | |||
| 9eabd2abf8 | |||
| a23f82d635 | |||
| 280dced405 | |||
| 987187943e | |||
| 2e7d46cc63 | |||
| 93deb73ade | |||
| 4f1b8fdd55 | |||
| 035a2553d1 | |||
| 5bbae9e130 | |||
| 5121e2d2e3 | |||
| 5f5b2ecb87 | |||
| ceabf53c0a | |||
| 43e375e169 | |||
| 68fd1698b3 | |||
| 735dd2eee0 | |||
| 5ea3a5097f | |||
| 63a3c21bd2 |
@@ -25,8 +25,8 @@ class Settings(BaseSettings):
|
|||||||
extra="ignore",
|
extra="ignore",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai (etage 2).
|
# Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai ; "openrouter" = OpenRouter.
|
||||||
llm_provider: Literal["ollama", "onemin"] = "ollama"
|
llm_provider: Literal["ollama", "onemin", "openrouter"] = "ollama"
|
||||||
|
|
||||||
ollama_base_url: str = "http://localhost:11434"
|
ollama_base_url: str = "http://localhost:11434"
|
||||||
llm_model: str = "gemma4:26b"
|
llm_model: str = "gemma4:26b"
|
||||||
@@ -47,6 +47,12 @@ class Settings(BaseSettings):
|
|||||||
onemin_api_key: str = ""
|
onemin_api_key: str = ""
|
||||||
onemin_model: str = "gpt-4o-mini"
|
onemin_model: str = "gpt-4o-mini"
|
||||||
|
|
||||||
|
# OpenRouter (OpenAI-compatible). Cle + modele modifiables depuis l'UI.
|
||||||
|
# Defaut = routeur `openrouter/free` : choisit un modele GRATUIT (0 credit).
|
||||||
|
# Pour un modele precis gratuit : id finissant par `:free`.
|
||||||
|
openrouter_api_key: str = ""
|
||||||
|
openrouter_model: str = "openrouter/free"
|
||||||
|
|
||||||
# Taille cible d'un morceau (en tokens) pour l'import de PDF (regles/campagne).
|
# Taille cible d'un morceau (en tokens) pour l'import de PDF (regles/campagne).
|
||||||
# Plus c'est gros, moins il y a de morceaux => moins de fragmentation et un
|
# Plus c'est gros, moins il y a de morceaux => moins de fragmentation et un
|
||||||
# import plus rapide, MAIS il faut que ca tienne dans la fenetre du modele.
|
# import plus rapide, MAIS il faut que ca tienne dans la fenetre du modele.
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ _ALLOWED_KEYS = frozenset({
|
|||||||
"llm_num_ctx",
|
"llm_num_ctx",
|
||||||
"onemin_api_key",
|
"onemin_api_key",
|
||||||
"onemin_model",
|
"onemin_model",
|
||||||
|
"openrouter_api_key",
|
||||||
|
"openrouter_model",
|
||||||
"import_chunk_tokens",
|
"import_chunk_tokens",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
130
brain/app/infrastructure/openrouter_adapter.py
Normal file
130
brain/app/infrastructure/openrouter_adapter.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""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:
|
||||||
|
response.raise_for_status()
|
||||||
|
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.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError
|
||||||
from app.infrastructure.ollama_adapter import OllamaLLMProvider
|
from app.infrastructure.ollama_adapter import OllamaLLMProvider
|
||||||
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
|
||||||
|
from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider
|
||||||
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
|
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="LoreMind Brain",
|
title="LoreMind Brain",
|
||||||
description="Backend IA pour la génération de contenu narratif.",
|
description="Backend IA pour la génération de contenu narratif.",
|
||||||
version="0.10.0-beta",
|
version="0.10.2-beta",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -354,6 +355,8 @@ def get_llm_provider(
|
|||||||
try:
|
try:
|
||||||
if settings.llm_provider == "onemin":
|
if settings.llm_provider == "onemin":
|
||||||
return OneMinAiLLMProvider(settings)
|
return OneMinAiLLMProvider(settings)
|
||||||
|
if settings.llm_provider == "openrouter":
|
||||||
|
return OpenRouterLLMProvider(settings)
|
||||||
return OllamaLLMProvider(settings)
|
return OllamaLLMProvider(settings)
|
||||||
except LLMProviderError as exc:
|
except LLMProviderError as exc:
|
||||||
# Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500
|
# Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500
|
||||||
@@ -688,7 +691,9 @@ async def chat_stream(
|
|||||||
"system": _count_tokens(system_prompt_preview),
|
"system": _count_tokens(system_prompt_preview),
|
||||||
"history": sum(_count_tokens(m.content) for m in history_msgs),
|
"history": sum(_count_tokens(m.content) for m in history_msgs),
|
||||||
"current": _count_tokens(current_msg.content) if current_msg else 0,
|
"current": _count_tokens(current_msg.content) if current_msg else 0,
|
||||||
"max": settings.llm_num_ctx,
|
# Plafond connu seulement pour Ollama (num_ctx). Pour le cloud (1min/OpenRouter)
|
||||||
|
# on ne connaît pas la fenêtre réelle → 0 = "pas de max" (jauge sans dénominateur).
|
||||||
|
"max": settings.llm_num_ctx if settings.llm_provider == "ollama" else 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def event_stream() -> AsyncIterator[str]:
|
async def event_stream() -> AsyncIterator[str]:
|
||||||
@@ -885,12 +890,15 @@ class SettingsDTO(BaseModel):
|
|||||||
Les secrets (onemin_api_key) sont masques en lecture.
|
Les secrets (onemin_api_key) sont masques en lecture.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
llm_provider: Literal["ollama", "onemin"]
|
llm_provider: Literal["ollama", "onemin", "openrouter"]
|
||||||
ollama_base_url: str
|
ollama_base_url: str
|
||||||
llm_model: str
|
llm_model: str
|
||||||
onemin_model: str
|
onemin_model: str
|
||||||
# True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme.
|
# True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme.
|
||||||
onemin_api_key_set: bool
|
onemin_api_key_set: bool
|
||||||
|
openrouter_model: str
|
||||||
|
# True si une cle OpenRouter est deja configuree (cle elle-meme jamais renvoyee).
|
||||||
|
openrouter_api_key_set: bool
|
||||||
# Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert
|
# Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert
|
||||||
# aussi de plafond a la jauge de contexte UI.
|
# aussi de plafond a la jauge de contexte UI.
|
||||||
llm_num_ctx: int
|
llm_num_ctx: int
|
||||||
@@ -903,12 +911,14 @@ class SettingsDTO(BaseModel):
|
|||||||
class SettingsUpdateDTO(BaseModel):
|
class SettingsUpdateDTO(BaseModel):
|
||||||
"""Patch partiel des settings. Tous les champs sont optionnels."""
|
"""Patch partiel des settings. Tous les champs sont optionnels."""
|
||||||
|
|
||||||
llm_provider: Literal["ollama", "onemin"] | None = None
|
llm_provider: Literal["ollama", "onemin", "openrouter"] | None = None
|
||||||
ollama_base_url: str | None = None
|
ollama_base_url: str | None = None
|
||||||
llm_model: str | None = None
|
llm_model: str | None = None
|
||||||
onemin_model: str | None = None
|
onemin_model: str | None = None
|
||||||
# Chaine vide => on efface la cle. None => pas de changement.
|
# Chaine vide => on efface la cle. None => pas de changement.
|
||||||
onemin_api_key: str | None = None
|
onemin_api_key: str | None = None
|
||||||
|
openrouter_model: str | None = None
|
||||||
|
openrouter_api_key: str | None = None
|
||||||
llm_num_ctx: int | None = None
|
llm_num_ctx: int | None = None
|
||||||
import_chunk_tokens: int | None = None
|
import_chunk_tokens: int | None = None
|
||||||
llm_timeout_seconds: int | None = None
|
llm_timeout_seconds: int | None = None
|
||||||
@@ -921,6 +931,8 @@ def _to_settings_dto(s: Settings) -> SettingsDTO:
|
|||||||
llm_model=s.llm_model,
|
llm_model=s.llm_model,
|
||||||
onemin_model=s.onemin_model,
|
onemin_model=s.onemin_model,
|
||||||
onemin_api_key_set=bool(s.onemin_api_key),
|
onemin_api_key_set=bool(s.onemin_api_key),
|
||||||
|
openrouter_model=s.openrouter_model,
|
||||||
|
openrouter_api_key_set=bool(s.openrouter_api_key),
|
||||||
llm_num_ctx=s.llm_num_ctx,
|
llm_num_ctx=s.llm_num_ctx,
|
||||||
import_chunk_tokens=s.import_chunk_tokens,
|
import_chunk_tokens=s.import_chunk_tokens,
|
||||||
llm_timeout_seconds=s.llm_timeout_seconds,
|
llm_timeout_seconds=s.llm_timeout_seconds,
|
||||||
@@ -1081,6 +1093,51 @@ async def delete_ollama_model(
|
|||||||
return {"status": "deleted", "name": name}
|
return {"status": "deleted", "name": name}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/models/openrouter")
|
||||||
|
async def list_openrouter_models() -> dict[str, list[dict[str, object]]]:
|
||||||
|
"""Catalogue DYNAMIQUE des modeles OpenRouter (API publique, sans cle).
|
||||||
|
|
||||||
|
Renvoie {models: [{id, name, context_length, free}]}, trie gratuits d'abord
|
||||||
|
puis contexte decroissant. `free` = id finissant par ':free' OU prix nul.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=20) as client:
|
||||||
|
response = await client.get("https://openrouter.ai/api/v1/models")
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise HTTPException(status_code=502, detail=f"OpenRouter injoignable : {exc}")
|
||||||
|
|
||||||
|
def _is_zero(value: object) -> bool:
|
||||||
|
try:
|
||||||
|
return float(value) == 0.0 # type: ignore[arg-type]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
models: list[dict[str, object]] = []
|
||||||
|
for m in data.get("data", []) or []:
|
||||||
|
mid = str(m.get("id") or "")
|
||||||
|
if not mid:
|
||||||
|
continue
|
||||||
|
pricing = m.get("pricing") or {}
|
||||||
|
is_free = mid.endswith(":free") or (
|
||||||
|
_is_zero(pricing.get("prompt")) and _is_zero(pricing.get("completion"))
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
ctx = int(m.get("context_length") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
ctx = 0
|
||||||
|
models.append({
|
||||||
|
"id": mid,
|
||||||
|
"name": str(m.get("name") or mid),
|
||||||
|
"context_length": ctx,
|
||||||
|
"free": is_free,
|
||||||
|
})
|
||||||
|
|
||||||
|
models.sort(key=lambda x: (not x["free"], -int(x["context_length"]))) # type: ignore[index]
|
||||||
|
return {"models": models}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/models/onemin")
|
@app.get("/models/onemin")
|
||||||
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
|
def list_onemin_models() -> dict[str, list[dict[str, object]]]:
|
||||||
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.
|
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur.
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<groupId>com.loremind</groupId>
|
<groupId>com.loremind</groupId>
|
||||||
<artifactId>loremind-core</artifactId>
|
<artifactId>loremind-core</artifactId>
|
||||||
<version>0.10.0-beta</version>
|
<version>0.10.2-beta</version>
|
||||||
<name>LoreMind Core</name>
|
<name>LoreMind Core</name>
|
||||||
<description>Backend Core - Architecture Hexagonale</description>
|
<description>Backend Core - Architecture Hexagonale</description>
|
||||||
|
|
||||||
|
|||||||
@@ -174,10 +174,9 @@ public class SessionStructuralContextBuilder {
|
|||||||
.filter(a -> a.getId() != null)
|
.filter(a -> a.getId() != null)
|
||||||
.collect(Collectors.toMap(Arc::getId, a -> a));
|
.collect(Collectors.toMap(Arc::getId, a -> a));
|
||||||
|
|
||||||
boolean anyHub = arcs.stream().anyMatch(a -> a.getType() == ArcType.HUB);
|
// On suit comme "quêtes" les chapitres CONDITIONNELS : ceux d'un arc HUB, ET
|
||||||
if (!anyHub) {
|
// ceux d'un arc linéaire qui portent des prérequis. Un chapitre linéaire sans
|
||||||
return new HubStatus(List.of(), List.of(), List.of(), activeFlags);
|
// condition reste hors du tableau (sinon tous les chapitres deviendraient des quêtes).
|
||||||
}
|
|
||||||
|
|
||||||
// Map chapterId -> ProgressionStatus pour ce Playthrough
|
// Map chapterId -> ProgressionStatus pour ce Playthrough
|
||||||
Map<String, ProgressionStatus> progressionByChapter = new HashMap<>();
|
Map<String, ProgressionStatus> progressionByChapter = new HashMap<>();
|
||||||
@@ -197,8 +196,10 @@ public class SessionStructuralContextBuilder {
|
|||||||
List<String> lockedTitles = new ArrayList<>();
|
List<String> lockedTitles = new ArrayList<>();
|
||||||
|
|
||||||
for (Arc arc : arcs) {
|
for (Arc arc : arcs) {
|
||||||
if (arc.getType() != ArcType.HUB) continue;
|
boolean isHub = arc.getType() == ArcType.HUB;
|
||||||
for (Chapter c : chapterRepository.findByArcId(arc.getId())) {
|
for (Chapter c : chapterRepository.findByArcId(arc.getId())) {
|
||||||
|
boolean hasPrereqs = c.getPrerequisites() != null && !c.getPrerequisites().isEmpty();
|
||||||
|
if (!isHub && !hasPrereqs) continue; // chapitre linéaire sans condition : ignoré
|
||||||
ProgressionStatus prog = progressionByChapter.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
|
ProgressionStatus prog = progressionByChapter.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
|
||||||
QuestStatus status = prerequisiteEvaluator.computeStatus(prog, c.getPrerequisites(), ctx);
|
QuestStatus status = prerequisiteEvaluator.computeStatus(prog, c.getPrerequisites(), ctx);
|
||||||
Arc parent = arcsById.get(c.getArcId());
|
Arc parent = arcsById.get(c.getArcId());
|
||||||
|
|||||||
@@ -140,6 +140,11 @@ public class SettingsController {
|
|||||||
return forward(HttpMethod.GET, "/models/onemin", null);
|
return forward(HttpMethod.GET, "/models/onemin", null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/models/openrouter")
|
||||||
|
public ResponseEntity<Map<String, Object>> listOpenRouterModels() {
|
||||||
|
return forward(HttpMethod.GET, "/models/openrouter", null);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serialiseur JSON minimal pour eviter d'instancier ObjectMapper a chaque
|
* Serialiseur JSON minimal pour eviter d'instancier ObjectMapper a chaque
|
||||||
* appel. Suffisant pour notre cas d'usage : Map<String,Object> avec des
|
* appel. Suffisant pour notre cas d'usage : Map<String,Object> avec des
|
||||||
|
|||||||
@@ -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:ietm64}
|
spring.datasource.username=${SPRING_DATASOURCE_USERNAME:loremind_test}
|
||||||
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:M&Ipourlavie64}
|
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:loremind_test}
|
||||||
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.
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
client_max_body_size 10M;
|
# Upload max : aligné sur la limite multipart du Core (64 Mo). Couvre les
|
||||||
|
# imports/adaptations de PDF (livres de règles/campagne illustrés) et les images.
|
||||||
|
client_max_body_size 64M;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://core:8080/api/;
|
proxy_pass http://core:8080/api/;
|
||||||
@@ -15,9 +17,11 @@ server {
|
|||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Streaming SSE (chat, import, adaptation) : pas de buffering, et timeouts
|
||||||
|
# longs car une génération sur gros PDF peut tarder avant le 1er octet.
|
||||||
proxy_buffering off;
|
proxy_buffering off;
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 600s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 600s;
|
||||||
}
|
}
|
||||||
|
|
||||||
# index.html : toujours revalide. Empeche un navigateur qui a precedemment
|
# index.html : toujours revalide. Empeche un navigateur qui a precedemment
|
||||||
|
|||||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.10.0-beta",
|
"version": "0.10.2-beta",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "loremind-web",
|
"name": "loremind-web",
|
||||||
"version": "0.10.0-beta",
|
"version": "0.10.2-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.0-beta",
|
"version": "0.10.2-beta",
|
||||||
"description": "LoreMind Frontend - Angular",
|
"description": "LoreMind Frontend - Angular",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
|
|||||||
id: `chapter-${ch.id}`,
|
id: `chapter-${ch.id}`,
|
||||||
label: ch.name,
|
label: ch.name,
|
||||||
iconKey: ch.icon ?? undefined,
|
iconKey: ch.icon ?? undefined,
|
||||||
|
// Cadenas si le chapitre porte des conditions de déblocage (hub ou linéaire).
|
||||||
|
meta: (ch.prerequisites?.length ?? 0) > 0 ? '🔒' : undefined,
|
||||||
children: sceneItems,
|
children: sceneItems,
|
||||||
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`,
|
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`,
|
||||||
createActions: [{
|
createActions: [{
|
||||||
|
|||||||
@@ -26,13 +26,17 @@
|
|||||||
|
|
||||||
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
|
||||||
|
|
||||||
<!-- Section Hub : conditions de déblocage (scénario). Visible si l'arc parent est HUB. -->
|
<!-- Conditions de déblocage (scénario) — pour TOUT chapitre : arc HUB (= quête)
|
||||||
<div class="hub-section" *ngIf="parentArc?.type === 'HUB'">
|
comme arc linéaire (= chapitre conditionnel). Vide = disponible d'emblée. -->
|
||||||
<h2 class="hub-section-title">🗺️ Conditions de déblocage de la quête</h2>
|
<div class="hub-section">
|
||||||
|
<h2 class="hub-section-title">
|
||||||
|
{{ parentArc?.type === 'HUB' ? '🗺️ Conditions de déblocage de la quête' : '🔒 Conditions de déblocage du chapitre' }}
|
||||||
|
</h2>
|
||||||
<small class="field-hint">
|
<small class="field-hint">
|
||||||
Définition du scénario : toutes les conditions doivent être remplies (ET) pour
|
Définition du scénario : toutes les conditions doivent être remplies (ET) pour que
|
||||||
que la quête soit débloquée dans une Partie. La progression en cours et l'état
|
{{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} soit débloqué(e) dans une Partie.
|
||||||
des faits se gèrent dans l'écran de la Partie, pas ici.
|
Laissez vide pour qu'il soit disponible dès le départ. La progression et l'état des
|
||||||
|
faits se gèrent dans l'écran de la Partie, pas ici.
|
||||||
</small>
|
</small>
|
||||||
<app-prerequisite-editor
|
<app-prerequisite-editor
|
||||||
[prerequisites]="prerequisites"
|
[prerequisites]="prerequisites"
|
||||||
|
|||||||
@@ -6,7 +6,14 @@
|
|||||||
<lucide-icon *ngIf="chapter.icon" [img]="resolveCampaignIcon(chapter.icon)" [size]="22" class="title-icon"></lucide-icon>
|
<lucide-icon *ngIf="chapter.icon" [img]="resolveCampaignIcon(chapter.icon)" [size]="22" class="title-icon"></lucide-icon>
|
||||||
{{ chapter.name }}
|
{{ chapter.name }}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="view-subtitle">{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }}</p>
|
<p class="view-subtitle">
|
||||||
|
{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }}
|
||||||
|
<span class="cond-badge" *ngIf="(chapter.prerequisites?.length ?? 0) > 0"
|
||||||
|
title="Ce chapitre a des conditions de déblocage : il se débloque en Partie quand elles sont remplies.">
|
||||||
|
<lucide-icon [img]="Lock" [size]="12"></lucide-icon>
|
||||||
|
Conditionnel
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="view-actions">
|
<div class="view-actions">
|
||||||
<button type="button" class="btn-secondary" (click)="openGraph()"
|
<button type="button" class="btn-secondary" (click)="openGraph()"
|
||||||
@@ -25,8 +32,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- Conditions de déblocage (donnée de scénario, read-only). Toujours visible si Arc HUB. -->
|
<!-- Conditions de déblocage (scénario, read-only). Visible pour un arc HUB (quête)
|
||||||
<section class="view-section" *ngIf="parentArc?.type === 'HUB'">
|
OU dès qu'un chapitre linéaire porte des conditions. -->
|
||||||
|
<section class="view-section" *ngIf="parentArc?.type === 'HUB' || (chapter.prerequisites?.length ?? 0) > 0">
|
||||||
<h2 class="view-section-title"><span class="view-section-icon">🔒</span> Conditions de déblocage</h2>
|
<h2 class="view-section-title"><span class="view-section-icon">🔒</span> Conditions de déblocage</h2>
|
||||||
|
|
||||||
<ng-container *ngIf="(chapter.prerequisites?.length ?? 0) > 0; else noPrereqs">
|
<ng-container *ngIf="(chapter.prerequisites?.length ?? 0) > 0; else noPrereqs">
|
||||||
@@ -34,8 +42,9 @@
|
|||||||
<li *ngFor="let p of chapter.prerequisites">{{ describePrerequisite(p) }}</li>
|
<li *ngFor="let p of chapter.prerequisites">{{ describePrerequisite(p) }}</li>
|
||||||
</ul>
|
</ul>
|
||||||
<small class="view-section-empty">
|
<small class="view-section-empty">
|
||||||
Pendant une partie, la quête se débloque dès que toutes ces conditions sont remplies.
|
Pendant une partie, {{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} se débloque
|
||||||
Le toggle des faits se fait dans l'écran « Faits » de la Partie.
|
dès que toutes ces conditions sont remplies. Le toggle des faits se fait dans l'écran
|
||||||
|
« Faits » de la Partie.
|
||||||
</small>
|
</small>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
// Styles partagés via styles/_view.scss
|
// Styles partagés via styles/_view.scss
|
||||||
|
|
||||||
|
// Badge "Conditionnel" : chapitre porteur de conditions de déblocage.
|
||||||
|
.cond-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
padding: 0.1rem 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 600;
|
||||||
|
vertical-align: middle;
|
||||||
|
color: #f0c674;
|
||||||
|
background: rgba(240, 198, 116, 0.12);
|
||||||
|
border: 1px solid rgba(240, 198, 116, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
.quest-status-bar {
|
.quest-status-bar {
|
||||||
border: 1px solid rgba(66, 133, 244, 0.25);
|
border: 1px solid rgba(66, 133, 244, 0.25);
|
||||||
background: rgba(66, 133, 244, 0.04);
|
background: rgba(66, 133, 244, 0.04);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
|
||||||
import { forkJoin, of } from 'rxjs';
|
import { forkJoin, of } from 'rxjs';
|
||||||
import { switchMap } from 'rxjs/operators';
|
import { switchMap } from 'rxjs/operators';
|
||||||
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
|
import { LucideAngularModule, Pencil, Network, Trash2, Lock } from 'lucide-angular';
|
||||||
import { resolveCampaignIcon } from '../../campaign-icons';
|
import { resolveCampaignIcon } from '../../campaign-icons';
|
||||||
import { CampaignService } from '../../../services/campaign.service';
|
import { CampaignService } from '../../../services/campaign.service';
|
||||||
import { CharacterService } from '../../../services/character.service';
|
import { CharacterService } from '../../../services/character.service';
|
||||||
@@ -32,6 +32,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
|
|||||||
readonly Pencil = Pencil;
|
readonly Pencil = Pencil;
|
||||||
readonly Network = Network;
|
readonly Network = Network;
|
||||||
readonly Trash2 = Trash2;
|
readonly Trash2 = Trash2;
|
||||||
|
readonly Lock = Lock;
|
||||||
readonly resolveCampaignIcon = resolveCampaignIcon;
|
readonly resolveCampaignIcon = resolveCampaignIcon;
|
||||||
|
|
||||||
campaignId = '';
|
campaignId = '';
|
||||||
|
|||||||
@@ -6,12 +6,16 @@ import { Observable } from 'rxjs';
|
|||||||
* Reflet de SettingsDTO cote Brain / SettingsController cote Core.
|
* Reflet de SettingsDTO cote Brain / SettingsController cote Core.
|
||||||
* `onemin_api_key_set` indique si une cle est configuree, sans la reveler.
|
* `onemin_api_key_set` indique si une cle est configuree, sans la reveler.
|
||||||
*/
|
*/
|
||||||
|
export type LlmProvider = 'ollama' | 'onemin' | 'openrouter';
|
||||||
|
|
||||||
export interface AppSettings {
|
export interface AppSettings {
|
||||||
llm_provider: 'ollama' | 'onemin';
|
llm_provider: LlmProvider;
|
||||||
ollama_base_url: string;
|
ollama_base_url: string;
|
||||||
llm_model: string;
|
llm_model: string;
|
||||||
onemin_model: string;
|
onemin_model: string;
|
||||||
onemin_api_key_set: boolean;
|
onemin_api_key_set: boolean;
|
||||||
|
openrouter_model: string;
|
||||||
|
openrouter_api_key_set: boolean;
|
||||||
llm_num_ctx: number;
|
llm_num_ctx: number;
|
||||||
/** Taille cible d'un morceau (tokens) pour l'import de PDF (règles/campagne). */
|
/** Taille cible d'un morceau (tokens) pour l'import de PDF (règles/campagne). */
|
||||||
import_chunk_tokens: number;
|
import_chunk_tokens: number;
|
||||||
@@ -24,11 +28,13 @@ export interface AppSettings {
|
|||||||
* `onemin_api_key: ''` efface la cle, `null`/absent ne touche a rien.
|
* `onemin_api_key: ''` efface la cle, `null`/absent ne touche a rien.
|
||||||
*/
|
*/
|
||||||
export interface AppSettingsUpdate {
|
export interface AppSettingsUpdate {
|
||||||
llm_provider?: 'ollama' | 'onemin';
|
llm_provider?: LlmProvider;
|
||||||
ollama_base_url?: string;
|
ollama_base_url?: string;
|
||||||
llm_model?: string;
|
llm_model?: string;
|
||||||
onemin_model?: string;
|
onemin_model?: string;
|
||||||
onemin_api_key?: string;
|
onemin_api_key?: string;
|
||||||
|
openrouter_model?: string;
|
||||||
|
openrouter_api_key?: string;
|
||||||
llm_num_ctx?: number;
|
llm_num_ctx?: number;
|
||||||
import_chunk_tokens?: number;
|
import_chunk_tokens?: number;
|
||||||
llm_timeout_seconds?: number;
|
llm_timeout_seconds?: number;
|
||||||
@@ -72,6 +78,11 @@ export class SettingsService {
|
|||||||
return this.http.get<{ groups: OneMinModelGroup[] }>(`${this.apiUrl}/models/onemin`, this.authOptions);
|
return this.http.get<{ groups: OneMinModelGroup[] }>(`${this.apiUrl}/models/onemin`, this.authOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Catalogue dynamique des modeles OpenRouter (depuis leur API publique). */
|
||||||
|
listOpenRouterModels(): Observable<{ models: OpenRouterModel[] }> {
|
||||||
|
return this.http.get<{ models: OpenRouterModel[] }>(`${this.apiUrl}/models/openrouter`, this.authOptions);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Telecharge un modele Ollama et streame la progression au client.
|
* Telecharge un modele Ollama et streame la progression au client.
|
||||||
*
|
*
|
||||||
@@ -184,3 +195,13 @@ export interface OneMinModelGroup {
|
|||||||
provider: string;
|
provider: string;
|
||||||
models: string[];
|
models: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Un modele OpenRouter (catalogue dynamique). */
|
||||||
|
export interface OpenRouterModel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
/** Fenetre de contexte du modele (tokens). 0 si inconnue. */
|
||||||
|
context_length: number;
|
||||||
|
/** True si le modele est gratuit (id `:free` ou prix nul). */
|
||||||
|
free: boolean;
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,10 @@
|
|||||||
<input type="radio" name="provider" value="onemin" [(ngModel)]="settings.llm_provider">
|
<input type="radio" name="provider" value="onemin" [(ngModel)]="settings.llm_provider">
|
||||||
<span>1min.ai (cloud)</span>
|
<span>1min.ai (cloud)</span>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="radio">
|
||||||
|
<input type="radio" name="provider" value="openrouter" [(ngModel)]="settings.llm_provider">
|
||||||
|
<span>OpenRouter (cloud)</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -173,8 +177,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Bloc Fenetre de contexte -->
|
<!-- Bloc OpenRouter -->
|
||||||
<section class="card" *ngIf="settings">
|
<section class="card" *ngIf="settings && settings.llm_provider === 'openrouter'">
|
||||||
|
<h2>Configuration OpenRouter</h2>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="openrouter-key">Cle API</label>
|
||||||
|
<input
|
||||||
|
id="openrouter-key"
|
||||||
|
type="password"
|
||||||
|
[(ngModel)]="openrouterApiKeyInput"
|
||||||
|
[placeholder]="settings.openrouter_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API (sk-or-...)'">
|
||||||
|
<label class="checkbox" *ngIf="settings.openrouter_api_key_set">
|
||||||
|
<input type="checkbox" [(ngModel)]="clearOpenrouterKey">
|
||||||
|
<span>Effacer la cle enregistree</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-row">
|
||||||
|
<label for="openrouter-model">Modele</label>
|
||||||
|
<label class="checkbox">
|
||||||
|
<input type="checkbox" [(ngModel)]="openrouterFreeOnly">
|
||||||
|
<span>Gratuits seulement</span>
|
||||||
|
</label>
|
||||||
|
<select id="openrouter-model" [(ngModel)]="settings.openrouter_model">
|
||||||
|
<option *ngFor="let o of openrouterSelectOptions" [value]="o.id">{{ o.label }}</option>
|
||||||
|
</select>
|
||||||
|
<p class="hint">
|
||||||
|
Liste chargee automatiquement depuis OpenRouter. Pour rester <strong>gratuit</strong> :
|
||||||
|
un modele marque <code>· gratuit</code> ou le routeur <code>openrouter/free</code>
|
||||||
|
(choisit un modele gratuit tout seul). Pour les imports, prefere un gratuit a
|
||||||
|
<strong>grand contexte</strong> (valeur « ctx »).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Bloc Fenetre de contexte : seulement pour Ollama (num_ctx est un vrai
|
||||||
|
parametre du modele local). Pour le cloud (1min/OpenRouter), on ne fixe rien :
|
||||||
|
on utilise ce que le modele accepte, et la jauge du chat s'affiche sans max. -->
|
||||||
|
<section class="card" *ngIf="settings && settings.llm_provider === 'ollama'">
|
||||||
<h2>Fenetre de contexte</h2>
|
<h2>Fenetre de contexte</h2>
|
||||||
|
|
||||||
<!-- Ollama : slider borne par le max du modele -->
|
<!-- Ollama : slider borne par le max du modele -->
|
||||||
@@ -205,20 +246,6 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 1min.ai : saisie libre (pas d'introspection possible) -->
|
|
||||||
<div class="form-row" *ngIf="settings.llm_provider === 'onemin'">
|
|
||||||
<label for="llm-num-ctx-onemin">Fenetre de contexte (tokens)</label>
|
|
||||||
<input
|
|
||||||
id="llm-num-ctx-onemin"
|
|
||||||
type="number"
|
|
||||||
min="2048"
|
|
||||||
step="1024"
|
|
||||||
[(ngModel)]="settings.llm_num_ctx">
|
|
||||||
<p class="hint">
|
|
||||||
A regler selon la capacite du modele 1min.ai choisi (ex: 128 000 pour gpt-4o,
|
|
||||||
200 000 pour claude-sonnet). Sert de plafond a la jauge de contexte du chat.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Bloc Import de PDF -->
|
<!-- Bloc Import de PDF -->
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular';
|
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular';
|
||||||
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service';
|
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OpenRouterModel, OllamaPullEvent } from '../services/settings.service';
|
||||||
import { UpdatesService, UpdateStatus } from '../services/updates.service';
|
import { UpdatesService, UpdateStatus } from '../services/updates.service';
|
||||||
import { ConfigService } from '../services/config.service';
|
import { ConfigService } from '../services/config.service';
|
||||||
import { LayoutService } from '../services/layout.service';
|
import { LayoutService } from '../services/layout.service';
|
||||||
@@ -103,6 +103,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
|||||||
settings: AppSettings | null = null;
|
settings: AppSettings | null = null;
|
||||||
ollamaModels: string[] = [];
|
ollamaModels: string[] = [];
|
||||||
oneminGroups: OneMinModelGroup[] = [];
|
oneminGroups: OneMinModelGroup[] = [];
|
||||||
|
|
||||||
|
/** Catalogue OpenRouter (chargé dynamiquement) + filtre "gratuits seulement". */
|
||||||
|
openrouterModels: OpenRouterModel[] = [];
|
||||||
|
openrouterFreeOnly = true;
|
||||||
/** Fournisseur 1min.ai actuellement selectionne (filtre la liste des modeles). */
|
/** Fournisseur 1min.ai actuellement selectionne (filtre la liste des modeles). */
|
||||||
oneminProvider: string = '';
|
oneminProvider: string = '';
|
||||||
|
|
||||||
@@ -128,6 +132,11 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
|||||||
/** True si l'utilisateur a coche "effacer la cle". */
|
/** True si l'utilisateur a coche "effacer la cle". */
|
||||||
clearApiKey = false;
|
clearApiKey = false;
|
||||||
|
|
||||||
|
/** Cle OpenRouter saisie — vide = on ne touche pas a la cle persistee. */
|
||||||
|
openrouterApiKeyInput = '';
|
||||||
|
/** True si l'utilisateur a coche "effacer la cle OpenRouter". */
|
||||||
|
clearOpenrouterKey = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private settingsService: SettingsService,
|
private settingsService: SettingsService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
@@ -457,6 +466,35 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
error: () => this.oneminGroups = []
|
error: () => this.oneminGroups = []
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.settingsService.listOpenRouterModels().subscribe({
|
||||||
|
next: (r) => this.openrouterModels = r.models,
|
||||||
|
error: () => this.openrouterModels = []
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Modeles OpenRouter, filtres (gratuits seulement par defaut). */
|
||||||
|
get filteredOpenrouterModels(): OpenRouterModel[] {
|
||||||
|
return this.openrouterFreeOnly
|
||||||
|
? this.openrouterModels.filter(m => m.free)
|
||||||
|
: this.openrouterModels;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options du select OpenRouter : routeur auto + liste filtree + valeur courante. */
|
||||||
|
get openrouterSelectOptions(): { id: string; label: string }[] {
|
||||||
|
const options: { id: string; label: string }[] = [
|
||||||
|
{ id: 'openrouter/free', label: 'openrouter/free — routeur auto (gratuit)' }
|
||||||
|
];
|
||||||
|
for (const m of this.filteredOpenrouterModels) {
|
||||||
|
const ctx = m.context_length ? ` — ${m.context_length.toLocaleString()} ctx` : '';
|
||||||
|
options.push({ id: m.id, label: `${m.id}${ctx}${m.free ? ' · gratuit' : ''}` });
|
||||||
|
}
|
||||||
|
// Garantit que le modele actuellement configure reste selectionnable.
|
||||||
|
const cur = this.settings?.openrouter_model;
|
||||||
|
if (cur && !options.some(o => o.id === cur)) {
|
||||||
|
options.splice(1, 0, { id: cur, label: `${cur} (actuel)` });
|
||||||
|
}
|
||||||
|
return options;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Deduit le fournisseur a partir du modele actuellement configure. */
|
/** Deduit le fournisseur a partir du modele actuellement configure. */
|
||||||
@@ -518,6 +556,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
|||||||
ollama_base_url: this.settings.ollama_base_url,
|
ollama_base_url: this.settings.ollama_base_url,
|
||||||
llm_model: this.settings.llm_model,
|
llm_model: this.settings.llm_model,
|
||||||
onemin_model: this.settings.onemin_model,
|
onemin_model: this.settings.onemin_model,
|
||||||
|
openrouter_model: this.settings.openrouter_model,
|
||||||
llm_num_ctx: this.settings.llm_num_ctx,
|
llm_num_ctx: this.settings.llm_num_ctx,
|
||||||
import_chunk_tokens: this.settings.import_chunk_tokens,
|
import_chunk_tokens: this.settings.import_chunk_tokens,
|
||||||
llm_timeout_seconds: this.settings.llm_timeout_seconds
|
llm_timeout_seconds: this.settings.llm_timeout_seconds
|
||||||
@@ -527,12 +566,19 @@ export class SettingsComponent implements OnInit, OnDestroy {
|
|||||||
} else if (this.oneminApiKeyInput.trim()) {
|
} else if (this.oneminApiKeyInput.trim()) {
|
||||||
patch.onemin_api_key = this.oneminApiKeyInput.trim();
|
patch.onemin_api_key = this.oneminApiKeyInput.trim();
|
||||||
}
|
}
|
||||||
|
if (this.clearOpenrouterKey) {
|
||||||
|
patch.openrouter_api_key = '';
|
||||||
|
} else if (this.openrouterApiKeyInput.trim()) {
|
||||||
|
patch.openrouter_api_key = this.openrouterApiKeyInput.trim();
|
||||||
|
}
|
||||||
|
|
||||||
this.settingsService.updateSettings(patch).subscribe({
|
this.settingsService.updateSettings(patch).subscribe({
|
||||||
next: (s) => {
|
next: (s) => {
|
||||||
this.settings = { ...s };
|
this.settings = { ...s };
|
||||||
this.oneminApiKeyInput = '';
|
this.oneminApiKeyInput = '';
|
||||||
this.clearApiKey = false;
|
this.clearApiKey = false;
|
||||||
|
this.openrouterApiKeyInput = '';
|
||||||
|
this.clearOpenrouterKey = false;
|
||||||
this.successMessage = 'Parametres sauvegardes.';
|
this.successMessage = 'Parametres sauvegardes.';
|
||||||
this.saving = false;
|
this.saving = false;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -97,13 +97,14 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="context-gauge" *ngIf="usage" [attr.data-level]="usageLevel"
|
<div class="context-gauge" *ngIf="usage" [attr.data-level]="usageLevel"
|
||||||
[attr.title]="'System: ' + usage.system + ' · Historique: ' + usage.history + ' · Courant: ' + usage.current + ' / ' + usage.max + ' tokens'">
|
[attr.title]="'System: ' + usage.system + ' · Historique: ' + usage.history + ' · Courant: ' + usage.current + (usageHasMax ? ' / ' + usage.max : '') + ' tokens'">
|
||||||
<div class="gauge-bar">
|
<!-- Barre seulement si on connaît le plafond (Ollama). Cloud : compteur seul. -->
|
||||||
|
<div class="gauge-bar" *ngIf="usageHasMax">
|
||||||
<div class="gauge-fill" [style.width.%]="usagePercent"></div>
|
<div class="gauge-fill" [style.width.%]="usagePercent"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="gauge-label">
|
<div class="gauge-label">
|
||||||
<span class="gauge-text">Contexte : {{ usageTotal }} / {{ usage.max }} tokens</span>
|
<span class="gauge-text">Contexte : {{ usageTotal }}<ng-container *ngIf="usageHasMax"> / {{ usage.max }}</ng-container> tokens</span>
|
||||||
<span class="gauge-percent">{{ usagePercent }}%</span>
|
<span class="gauge-percent" *ngIf="usageHasMax">{{ usagePercent }}%</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
|
|||||||
if (!this.usage) return 0;
|
if (!this.usage) return 0;
|
||||||
return this.usage.system + this.usage.history + this.usage.current;
|
return this.usage.system + this.usage.history + this.usage.current;
|
||||||
}
|
}
|
||||||
|
/** Vrai si on connaît la fenêtre max (Ollama) → affiche dénominateur + barre. */
|
||||||
|
get usageHasMax(): boolean {
|
||||||
|
return !!this.usage && this.usage.max > 0;
|
||||||
|
}
|
||||||
get usageRatio(): number {
|
get usageRatio(): number {
|
||||||
if (!this.usage || this.usage.max <= 0) return 0;
|
if (!this.usage || this.usage.max <= 0) return 0;
|
||||||
return Math.min(1, this.usageTotal / this.usage.max);
|
return Math.min(1, this.usageTotal / this.usage.max);
|
||||||
|
|||||||
Reference in New Issue
Block a user