From 7a340285c520293dad56707c7c212812682bfb2e Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Tue, 21 Apr 2026 06:51:41 +0200 Subject: [PATCH] Mise en place docker + mise en place des settings (config ollama / 1min.ai) --- .env.example | 30 +++ .gitea/workflows/release.yml | 40 ++++ .gitignore | 7 + INSTALL.md | 64 +++++++ brain/.dockerignore | 6 + brain/Dockerfile | 16 ++ brain/app/core/config.py | 26 ++- brain/app/core/settings_store.py | 41 +++++ brain/app/infrastructure/onemin_adapter.py | 174 ++++++++++++++++++ brain/app/main.py | 173 ++++++++++++++++- brain/data/settings.json | 7 + core/.dockerignore | 4 + core/Dockerfile | 12 ++ .../infrastructure/web/config/CorsConfig.java | 21 ++- .../web/controller/SettingsController.java | 70 +++++++ docker-compose.override.yml | 21 +++ docker-compose.yml | 95 ++++++++-- web/.dockerignore | 5 + web/Dockerfile | 17 ++ web/angular.json | 29 ++- web/nginx.conf | 26 +++ web/src/app/app.routes.ts | 1 + web/src/app/services/settings.service.ts | 56 ++++++ web/src/app/settings/settings.component.html | 103 +++++++++++ web/src/app/settings/settings.component.scss | 138 ++++++++++++++ web/src/app/settings/settings.component.ts | 153 +++++++++++++++ web/src/app/sidebar/sidebar.component.html | 2 +- 27 files changed, 1301 insertions(+), 36 deletions(-) create mode 100644 .env.example create mode 100644 .gitea/workflows/release.yml create mode 100644 INSTALL.md create mode 100644 brain/.dockerignore create mode 100644 brain/Dockerfile create mode 100644 brain/app/core/settings_store.py create mode 100644 brain/app/infrastructure/onemin_adapter.py create mode 100644 brain/data/settings.json create mode 100644 core/.dockerignore create mode 100644 core/Dockerfile create mode 100644 core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java create mode 100644 docker-compose.override.yml create mode 100644 web/.dockerignore create mode 100644 web/Dockerfile create mode 100644 web/nginx.conf create mode 100644 web/src/app/services/settings.service.ts create mode 100644 web/src/app/settings/settings.component.html create mode 100644 web/src/app/settings/settings.component.scss create mode 100644 web/src/app/settings/settings.component.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a3c7923 --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# ========================================================================== +# Configuration LoreMindMJ - copier en .env et adapter +# ========================================================================== + +# --- Registry Gitea (ou celui de l'editeur) ---------------------------- +REGISTRY=git.igmlcreation.fr +TAG=latest + +# --- Port d'acces web ---------------------------------------------------- +WEB_PORT=8081 + +# --- PostgreSQL (IMPORTANT : change POSTGRES_PASSWORD) ------------------- +POSTGRES_DB=loremind +POSTGRES_USER=loremind +POSTGRES_PASSWORD=change-me-please + +# --- MinIO (stockage objet images) --------------------------------------- +MINIO_USER=minioadmin +MINIO_PASSWORD=minioadmin + +# --- Provider LLM : "ollama" (local) ou "onemin" (cloud 1min.ai) --------- +LLM_PROVIDER=ollama + +# Ollama (si LLM_PROVIDER=ollama) +OLLAMA_BASE_URL=http://host.docker.internal:11434 +LLM_MODEL=gemma4:26b + +# 1min.ai (si LLM_PROVIDER=onemin) +ONEMIN_API_KEY= +ONEMIN_MODEL=gpt-4o-mini diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml new file mode 100644 index 0000000..dffc89b --- /dev/null +++ b/.gitea/workflows/release.yml @@ -0,0 +1,40 @@ +name: Build & Push Images + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + component: [brain, core, web] + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to Gitea Registry + uses: docker/login-action@v3 + with: + registry: ${{ vars.GITEA_REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Extract version + id: meta + run: echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + + - name: Build & push ${{ matrix.component }} + uses: docker/build-push-action@v5 + with: + context: ./${{ matrix.component }} + push: true + tags: | + ${{ vars.GITEA_REGISTRY }}/loremindmj/${{ matrix.component }}:latest + ${{ vars.GITEA_REGISTRY }}/loremindmj/${{ matrix.component }}:${{ steps.meta.outputs.version }} diff --git a/.gitignore b/.gitignore index f80e167..121d640 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,10 @@ Thumbs.db docs/edraw/ docs/academy/ brain/.env.example + +# Variables d'environnement runtime (prod) +.env + +# Override compose local (optionnel - un dev peut avoir le sien) +# Retire cette ligne si tu veux committer l'override par defaut du repo. +# docker-compose.override.yml diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..70d1681 --- /dev/null +++ b/INSTALL.md @@ -0,0 +1,64 @@ +# Installation de LoreMindMJ + +## Prerequis + +- **Docker Desktop** ([Windows](https://www.docker.com/products/docker-desktop/) / [Mac](https://www.docker.com/products/docker-desktop/)) + ou **Docker Engine + Compose v2** (Linux). +- (Optionnel) **[Ollama](https://ollama.com/)** si tu veux un LLM local. + Sinon, une cle API [1min.ai](https://1min.ai) suffit. + +## Installation (5 minutes) + +1. Telecharge `docker-compose.yml` et `.env.example` depuis la [derniere release](https://git.igmlcreation.fr/ietm64/LoreMindMJ/releases) dans un dossier a toi. + +2. Renomme `.env.example` en `.env` et ouvre-le dans un editeur texte. Change **au minimum** `POSTGRES_PASSWORD`. + +3. Dans un terminal, place-toi dans le dossier et lance : + ``` + docker compose up -d + ``` + Le premier demarrage telecharge les images (~500 Mo) et initialise la base. Compte 1-2 minutes. + +4. Ouvre http://localhost:8081 dans ton navigateur. Bon jeu ! + +## Mise a jour + +``` +docker compose pull +docker compose up -d +``` + +Les donnees (base Postgres, images MinIO, settings Brain) sont dans des volumes Docker et survivent aux mises a jour. + +## LLM : Ollama ou 1min.ai ? + +**Ollama (local, gratuit)** — Edite `.env` : +``` +LLM_PROVIDER=ollama +LLM_MODEL=gemma4:26b +``` +Telecharge le modele au prealable : `ollama pull gemma4:26b`. + +**1min.ai (cloud, paye)** — Edite `.env` : +``` +LLM_PROVIDER=onemin +ONEMIN_API_KEY=sk-... +ONEMIN_MODEL=open-mistral-nemo +``` + +Tu peux aussi changer tout ca a chaud depuis l'ecran Parametres de l'appli. + +## Problemes frequents + +- **Port 8081 deja pris** : change `WEB_PORT=8082` (ou autre) dans `.env`. +- **Ollama injoignable** : verifie qu'Ollama tourne (`ollama serve`) et que le modele est bien telecharge. +- **Tout casser et repartir de zero** : `docker compose down -v` supprime les volumes (attention, perte de donnees). + +## Sauvegarde + +Les donnees sont dans les volumes Docker : `loremindmj_postgres-data`, `loremindmj_minio-data`, `loremindmj_brain-data`. + +Sauvegarde rapide de la base : +``` +docker compose exec postgres pg_dump -U loremind loremind > backup.sql +``` diff --git a/brain/.dockerignore b/brain/.dockerignore new file mode 100644 index 0000000..f5d17c7 --- /dev/null +++ b/brain/.dockerignore @@ -0,0 +1,6 @@ +data/ +__pycache__/ +*.pyc +.env +.venv/ +venv/ diff --git a/brain/Dockerfile b/brain/Dockerfile new file mode 100644 index 0000000..c3c4379 --- /dev/null +++ b/brain/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +RUN mkdir -p /app/data +VOLUME ["/app/data"] + +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/brain/app/core/config.py b/brain/app/core/config.py index adfa942..c88dd09 100644 --- a/brain/app/core/config.py +++ b/brain/app/core/config.py @@ -3,11 +3,18 @@ Équivalent Python du `application.properties` Spring Boot, avec validation Pydantic : une variable manquante/invalide = crash au démarrage, pas une NullPointerException surprise à la 3ème requête. + +Depuis l'ecran Parametres (UI) : certains champs sont surchargeables a chaud +via `settings_store` (fichier JSON). A chaque Depends(get_settings), on relit +.env + overrides fusionnes. Pas de cache : le cout d'un read JSON local est +negligeable face a un appel LLM. """ -from functools import lru_cache +from typing import Literal from pydantic_settings import BaseSettings, SettingsConfigDict +from app.core.settings_store import load_overrides + class Settings(BaseSettings): """Settings chargés depuis .env ou variables d'environnement.""" @@ -18,6 +25,9 @@ class Settings(BaseSettings): extra="ignore", ) + # Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai (etage 2). + llm_provider: Literal["ollama", "onemin"] = "ollama" + ollama_base_url: str = "http://localhost:11434" llm_model: str = "gemma4:26b" llm_timeout_seconds: int = 120 @@ -29,8 +39,16 @@ class Settings(BaseSettings): # LLM_NUM_CTX dans .env si besoin (ex: VRAM limitée → 8192). llm_num_ctx: int = 16384 + # 1min.ai (etage 2) — la cle et le modele sont stockes via settings_store + # (modifiables depuis l'UI). Les defauts ici sont juste des placeholders. + onemin_api_key: str = "" + onemin_model: str = "gpt-4o-mini" + -@lru_cache def get_settings() -> Settings: - """Singleton via cache — FastAPI l'injecte avec Depends() dans les routes.""" - return Settings() + """Fabrique des Settings merges (.env -> overrides runtime). + + Relu a chaque requete HTTP (via Depends). Permet a l'UI de changer + le modele / provider sans redemarrer le Brain. + """ + return Settings(**load_overrides()) diff --git a/brain/app/core/settings_store.py b/brain/app/core/settings_store.py new file mode 100644 index 0000000..10c1595 --- /dev/null +++ b/brain/app/core/settings_store.py @@ -0,0 +1,41 @@ +"""Overrides runtime persistés sur disque pour les Settings. + +Les Settings par defaut viennent de .env (12-factor). L'utilisateur peut +surcharger certains champs depuis l'UI (ex: modele Ollama choisi) — ces +overrides sont stockes dans un fichier JSON local, relus a chaque requete. + +Thread-safe via un lock simple : suffisant pour un deploiement mono-process +(usage local). Si un jour on passe en multi-worker, migrer vers SQLite. +""" +from __future__ import annotations + +import json +import threading +from pathlib import Path +from typing import Any + +_LOCK = threading.Lock() +_OVERRIDES_PATH = Path("data/settings.json") + + +def load_overrides() -> dict[str, Any]: + """Retourne le dict d'overrides, ou {} si le fichier n'existe pas / est corrompu.""" + if not _OVERRIDES_PATH.exists(): + return {} + try: + return json.loads(_OVERRIDES_PATH.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + + +def save_overrides(patch: dict[str, Any]) -> dict[str, Any]: + """Fusionne `patch` dans les overrides existants et persiste. Retourne l'etat final.""" + with _LOCK: + current = load_overrides() + current.update(patch) + _OVERRIDES_PATH.parent.mkdir(parents=True, exist_ok=True) + _OVERRIDES_PATH.write_text( + json.dumps(current, indent=2, ensure_ascii=False), + encoding="utf-8", + ) + return current diff --git a/brain/app/infrastructure/onemin_adapter.py b/brain/app/infrastructure/onemin_adapter.py new file mode 100644 index 0000000..1849cb7 --- /dev/null +++ b/brain/app/infrastructure/onemin_adapter.py @@ -0,0 +1,174 @@ +"""Adapter 1min.ai — implementation alternative des ports LLMProvider / LLMChatProvider. + +API 1min.ai (cf. https://docs.1min.ai/docs/api/chat-with-ai-api) : + - POST https://api.1min.ai/api/chat-with-ai (one-shot) + - POST https://api.1min.ai/api/chat-with-ai?isStreaming=true (SSE) + - Auth : header "API-KEY: " + - Body : {"type": "UNIFY_CHAT_WITH_AI", "model": "...", + "promptObject": {"prompt": "..."}} + +Le port LoreMind expose une API "messages[]", mais 1min.ai attend un prompt +unique. On aplatit donc l'historique + system prompt en un seul bloc texte, +avec des marqueurs de role lisibles pour le modele. +""" +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_BASE = "https://api.1min.ai/api/chat-with-ai" +_PAYLOAD_TYPE = "UNIFY_CHAT_WITH_AI" + + +class OneMinAiLLMProvider: + """Adapter 1min.ai — satisfait LLMProvider et LLMChatProvider par duck typing.""" + + def __init__(self, settings: Settings) -> None: + if not settings.onemin_api_key: + raise LLMProviderError( + "Cle API 1min.ai manquante. Configure-la depuis l'ecran Parametres." + ) + self._api_key = settings.onemin_api_key + self._model = settings.onemin_model + self._timeout = settings.llm_timeout_seconds + + def _headers(self) -> dict[str, str]: + return {"API-KEY": self._api_key, "Content-Type": "application/json"} + + def _payload(self, prompt: str) -> dict[str, object]: + return { + "type": _PAYLOAD_TYPE, + "model": self._model, + "promptObject": {"prompt": prompt}, + } + + async def generate( + self, + prompt: str, + *, + output_format: str | None = None, # 1min.ai ne supporte pas format=json + temperature: float | None = None, # idem, pas d'hyperparam expose ici + ) -> str: + """Appel one-shot : retourne la reponse complete sous forme de string.""" + async with httpx.AsyncClient(timeout=self._timeout) as client: + try: + response = await client.post( + _API_BASE, headers=self._headers(), json=self._payload(prompt) + ) + response.raise_for_status() + data = response.json() + except httpx.HTTPError as exc: + raise LLMProviderError(f"Erreur 1min.ai : {exc}") from exc + + return self._extract_result(data) + + async def stream_chat( + self, + messages: list[ChatMessage], + *, + system_prompt: str | None = None, + temperature: float | None = None, + ) -> AsyncIterator[str]: + """Streame via SSE. + + 1min.ai expose deux evenements utiles : + - `event: content` → `data: {"content": "..."}` + - `event: done` → fin du stream + - `event: error` → erreur serveur + On yield le champ `content` au fil de l'arrivee. + """ + prompt = self._flatten_messages(messages, system_prompt) + url = f"{_API_BASE}?isStreaming=true" + + async with httpx.AsyncClient(timeout=self._timeout) as client: + try: + async with client.stream( + "POST", url, headers=self._headers(), json=self._payload(prompt) + ) as response: + response.raise_for_status() + async for token in self._parse_sse(response): + yield token + except httpx.HTTPError as exc: + raise LLMProviderError( + f"Erreur lors du streaming 1min.ai : {exc}" + ) from exc + + # --- Helpers ------------------------------------------------------------ + + @staticmethod + async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]: + """Decoupe le flux SSE ligne par ligne et yield les chunks 'content'.""" + current_event: str | None = None + current_data = "" + async for line in response.aiter_lines(): + if line == "": + # Fin d'un evenement SSE : dispatch + if current_event == "done": + return + if current_event == "error": + raise LLMProviderError(f"1min.ai a signale une erreur : {current_data}") + if current_data and current_event in (None, "content", "message"): + token = OneMinAiLLMProvider._extract_content_chunk(current_data) + if token: + yield token + current_event = None + current_data = "" + continue + if line.startswith("event:"): + current_event = line[6:].strip() + elif line.startswith("data:"): + chunk = line[5:].lstrip() + current_data = f"{current_data}\n{chunk}" if current_data else chunk + + @staticmethod + def _extract_content_chunk(data: str) -> str: + """Extrait le champ `content` d'un data JSON, avec tolerance si format brut.""" + try: + obj = json.loads(data) + except json.JSONDecodeError: + return data # filet de securite si le serveur envoie du texte brut + if isinstance(obj, dict): + return obj.get("content") or obj.get("token") or "" + return "" + + @staticmethod + def _extract_result(payload: dict) -> str: + """Extrait le texte final d'une reponse non-streamee. + + Schema attendu : `aiRecord.aiRecordDetail.resultObject` (list[str]). + On concatene par securite (le serveur renvoie habituellement un seul element). + """ + record = payload.get("aiRecord") or {} + detail = record.get("aiRecordDetail") or {} + result = detail.get("resultObject") or [] + if isinstance(result, list): + return "".join(str(x) for x in result) + if isinstance(result, str): + return result + raise LLMProviderError("Reponse 1min.ai inattendue : resultObject absent.") + + @staticmethod + def _flatten_messages( + messages: list[ChatMessage], system_prompt: str | None + ) -> str: + """Transforme [system_prompt, history] en un unique prompt textuel. + + 1min.ai n'accepte qu'un champ `prompt` : on serialise la conversation + avec des marqueurs explicites pour que le modele comprenne les tours. + """ + parts: list[str] = [] + if system_prompt: + parts.append(f"[SYSTEM]\n{system_prompt}") + if messages: + history = "\n\n".join( + f"[{m.role.upper()}]\n{m.content}" for m in messages + ) + parts.append(history) + parts.append("[ASSISTANT]") # invite le modele a continuer + return "\n\n".join(parts) diff --git a/brain/app/main.py b/brain/app/main.py index a2a3c0b..1d5dd71 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -5,8 +5,9 @@ au domaine via injection de dépendance (ports + use cases), et transforme les erreurs du domaine en réponses HTTP. Aucune connaissance d'Ollama ici. """ import json -from typing import Annotated, AsyncIterator +from typing import Annotated, AsyncIterator, Literal +import httpx from fastapi import Depends, FastAPI, HTTPException from fastapi.responses import StreamingResponse from pydantic import BaseModel, Field @@ -14,6 +15,7 @@ from pydantic import BaseModel, Field from app.application.chat import ChatUseCase from app.application.generate_page import GeneratePageUseCase from app.core.config import Settings, get_settings +from app.core.settings_store import save_overrides from app.domain.models import ( ArcSummary, CampaignStructuralContext, @@ -29,6 +31,7 @@ from app.domain.models import ( ) from app.domain.ports import LLMProvider, LLMProviderError from app.infrastructure.ollama_adapter import OllamaLLMProvider +from app.infrastructure.onemin_adapter import OneMinAiLLMProvider app = FastAPI( title="LoreMind Brain", @@ -189,10 +192,17 @@ def get_llm_provider( """Factory d'adapter — point d'inversion de dépendance. C'est ici (et uniquement ici) qu'on choisit QUEL adapter concret - incarne le port. Pour swap vers un autre fournisseur, on change - cette ligne et rien d'autre. + incarne le port, en fonction du champ `llm_provider` des Settings + (modifiable a chaud depuis l'ecran Parametres de l'UI). """ - return OllamaLLMProvider(settings) + try: + if settings.llm_provider == "onemin": + return OneMinAiLLMProvider(settings) + return OllamaLLMProvider(settings) + except LLMProviderError as exc: + # Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500 + # pour que le frontend puisse afficher un message actionnable. + raise HTTPException(status_code=400, detail=str(exc)) from exc def get_generate_page_use_case( @@ -392,6 +402,161 @@ def _to_campaign_context(dto: CampaignContextDTO | None) -> CampaignStructuralCo ) +# --- Settings (parametrage runtime depuis l'UI) ------------------------------ + + +class SettingsDTO(BaseModel): + """Vue serialisable des settings modifiables depuis l'UI. + + Expose uniquement les champs que l'utilisateur peut changer a chaud. + Les secrets (onemin_api_key) sont masques en lecture. + """ + + llm_provider: Literal["ollama", "onemin"] + ollama_base_url: str + llm_model: str + onemin_model: str + # True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme. + onemin_api_key_set: bool + + +class SettingsUpdateDTO(BaseModel): + """Patch partiel des settings. Tous les champs sont optionnels.""" + + llm_provider: Literal["ollama", "onemin"] | None = None + ollama_base_url: str | None = None + llm_model: str | None = None + onemin_model: str | None = None + # Chaine vide => on efface la cle. None => pas de changement. + onemin_api_key: str | None = None + + +def _to_settings_dto(s: Settings) -> SettingsDTO: + return SettingsDTO( + llm_provider=s.llm_provider, + ollama_base_url=s.ollama_base_url, + llm_model=s.llm_model, + onemin_model=s.onemin_model, + onemin_api_key_set=bool(s.onemin_api_key), + ) + + +@app.get("/settings", response_model=SettingsDTO) +def read_settings(settings: Annotated[Settings, Depends(get_settings)]) -> SettingsDTO: + """Retourne la config courante (secrets masques).""" + return _to_settings_dto(settings) + + +@app.put("/settings", response_model=SettingsDTO) +def update_settings(patch: SettingsUpdateDTO) -> SettingsDTO: + """Applique un patch partiel aux settings et persiste les overrides. + + Toute requete HTTP suivante verra les nouvelles valeurs (pas de cache). + """ + overrides = {k: v for k, v in patch.model_dump().items() if v is not None} + if overrides: + save_overrides(overrides) + # Relit .env + overrides fusionnes pour confirmation. + return _to_settings_dto(get_settings()) + + +@app.get("/models/ollama") +async def list_ollama_models( + settings: Annotated[Settings, Depends(get_settings)], +) -> dict[str, list[str]]: + """Liste les modeles disponibles sur le serveur Ollama configure. + + Retourne une liste vide si Ollama est injoignable — l'UI affichera un + message plutot qu'une 500. + """ + url = f"{settings.ollama_base_url}/api/tags" + try: + async with httpx.AsyncClient(timeout=5) as client: + response = await client.get(url) + response.raise_for_status() + data = response.json() + except httpx.HTTPError: + return {"models": []} + models = [m.get("name", "") for m in data.get("models", []) if m.get("name")] + return {"models": sorted(models)} + + +@app.get("/models/onemin") +def list_onemin_models() -> dict[str, list[dict[str, object]]]: + """Catalogue statique des modeles 1min.ai, groupes par fournisseur. + + Liste construite par probing direct de l'endpoint chat-with-ai avec + une vraie cle API (avril 2026) : chaque ID renvoie 200, les IDs + absents renvoient 400 UNSUPPORTED_MODEL. + + Nota : les IDs Anthropic utilisent la nomenclature propre a 1min.ai + (`claude--`), pas la convention officielle Anthropic. + """ + return { + "groups": [ + { + "provider": "Anthropic", + "models": ["claude-opus-4-6", "claude-sonnet-4-6"], + }, + { + "provider": "OpenAI", + "models": [ + "gpt-5", + "gpt-5-mini", + "gpt-5-nano", + "gpt-4.1", + "gpt-4.1-mini", + "gpt-4.1-nano", + "gpt-4o", + "gpt-4o-mini", + "gpt-4-turbo", + "gpt-3.5-turbo", + "o3", + "o3-pro", + "o3-mini", + "o4-mini", + ], + }, + { + "provider": "Google", + "models": ["gemini-2.5-pro", "gemini-2.5-flash"], + }, + { + "provider": "Mistral", + "models": [ + "mistral-large-latest", + "mistral-medium-latest", + "mistral-small-latest", + "open-mistral-nemo", + ], + }, + { + "provider": "DeepSeek", + "models": ["deepseek-chat", "deepseek-reasoner"], + }, + { + "provider": "xAI", + "models": ["grok-3", "grok-3-mini"], + }, + { + "provider": "Meta", + "models": [ + "meta/meta-llama-3.1-405b-instruct", + "meta/meta-llama-3-70b-instruct", + ], + }, + { + "provider": "Alibaba", + "models": ["qwen-plus", "qwen3-max"], + }, + { + "provider": "Perplexity", + "models": ["sonar", "sonar-pro"], + }, + ] + } + + def _to_narrative_entity(dto: NarrativeEntityDTO | None) -> NarrativeEntityContext | None: if dto is None: return None diff --git a/brain/data/settings.json b/brain/data/settings.json new file mode 100644 index 0000000..29c39da --- /dev/null +++ b/brain/data/settings.json @@ -0,0 +1,7 @@ +{ + "llm_provider": "onemin", + "ollama_base_url": "http://localhost:11434", + "llm_model": "gemma4:26b", + "onemin_model": "mistral-large-latest", + "onemin_api_key": "9f8eb3da313eef5e95887889b7d10b42bbc1c42b2d157bc3589a8962e5d9dd9e" +} \ No newline at end of file diff --git a/core/.dockerignore b/core/.dockerignore new file mode 100644 index 0000000..5f1ca29 --- /dev/null +++ b/core/.dockerignore @@ -0,0 +1,4 @@ +target/ +.idea/ +*.iml +.mvn/ diff --git a/core/Dockerfile b/core/Dockerfile new file mode 100644 index 0000000..c9f77a2 --- /dev/null +++ b/core/Dockerfile @@ -0,0 +1,12 @@ +FROM maven:3.9-eclipse-temurin-17 AS build +WORKDIR /build +COPY pom.xml . +RUN mvn dependency:go-offline -B +COPY src ./src +RUN mvn clean package -DskipTests -B + +FROM eclipse-temurin:17-jre +WORKDIR /app +COPY --from=build /build/target/*.jar app.jar +EXPOSE 8080 +ENTRYPOINT ["java", "-jar", "app.jar"] diff --git a/core/src/main/java/com/loremind/infrastructure/web/config/CorsConfig.java b/core/src/main/java/com/loremind/infrastructure/web/config/CorsConfig.java index d973cef..8c98341 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/config/CorsConfig.java +++ b/core/src/main/java/com/loremind/infrastructure/web/config/CorsConfig.java @@ -1,28 +1,37 @@ package com.loremind.infrastructure.web.config; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; +import java.util.Arrays; + /** - * Configuration CORS pour autoriser les requêtes depuis le Frontend Angular. - * Adaptateur d'infrastructure qui configure la politique CORS. + * Configuration CORS. Origines configurables via la propriete + * `app.cors.allowed-origins` (liste separee par virgules) ou l'env var + * APP_CORS_ALLOWED_ORIGINS. Defaut : Angular dev server + port Docker par defaut. */ @Configuration public class CorsConfig { + @Value("${app.cors.allowed-origins:http://localhost:4200,http://localhost:8081}") + private String allowedOrigins; + @Bean public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); CorsConfiguration config = new CorsConfiguration(); - - // Autoriser les requêtes depuis localhost:4200 (Angular dev server) - config.addAllowedOrigin("http://localhost:4200"); + + Arrays.stream(allowedOrigins.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .forEach(config::addAllowedOrigin); config.addAllowedHeader("*"); config.addAllowedMethod("*"); - + source.registerCorsConfiguration("/**", config); return new CorsFilter(source); } diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java new file mode 100644 index 0000000..aa35b7d --- /dev/null +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java @@ -0,0 +1,70 @@ +package com.loremind.infrastructure.web.controller; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +/** + * Proxy fin entre le frontend Angular et les endpoints /settings du Brain Python. + * + * Ce controller n'a aucune logique metier propre : il transfere les requetes + * telles-quelles. Raison d'etre : eviter d'exposer le Brain (port 8000) au + * navigateur et centraliser CORS sur Spring. + * + * Les payloads sont passes en Map pour rester tolerant aux + * evolutions du schema cote Brain (ajout de champs sans recompiler le Core). + */ +@RestController +@RequestMapping("/api/settings") +public class SettingsController { + + private final RestTemplate restTemplate; + private final String brainBaseUrl; + + public SettingsController(RestTemplate restTemplate, + @Value("${brain.base-url}") String brainBaseUrl) { + this.restTemplate = restTemplate; + this.brainBaseUrl = brainBaseUrl; + } + + @GetMapping + public ResponseEntity> getSettings() { + return forward(HttpMethod.GET, "/settings", null); + } + + @PutMapping + public ResponseEntity> updateSettings(@RequestBody Map patch) { + return forward(HttpMethod.PUT, "/settings", patch); + } + + @GetMapping("/models/ollama") + public ResponseEntity> listOllamaModels() { + return forward(HttpMethod.GET, "/models/ollama", null); + } + + @GetMapping("/models/onemin") + public ResponseEntity> listOneMinModels() { + return forward(HttpMethod.GET, "/models/onemin", null); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private ResponseEntity> forward(HttpMethod method, String path, Object body) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + HttpEntity entity = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.exchange( + brainBaseUrl + path, method, entity, Map.class); + return ResponseEntity.status(response.getStatusCode()).body((Map) response.getBody()); + } +} diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 0000000..ace8595 --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,21 @@ +# ========================================================================== +# Override local : force le build des images depuis les sources plutot que +# de les tirer du registry. Utilise en dev/test avant publication. +# Compose fusionne automatiquement ce fichier avec docker-compose.yml. +# -------------------------------------------------------------------------- +# Pour ignorer cet override (simuler prod) : +# docker compose -f docker-compose.yml up -d +# ========================================================================== + +services: + core: + build: ./core + image: loremindmj/core:dev + + brain: + build: ./brain + image: loremindmj/brain:dev + + web: + build: ./web + image: loremindmj/web:dev diff --git a/docker-compose.yml b/docker-compose.yml index 5c62191..06c1a05 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,26 +1,34 @@ # ========================================================================== -# LoreMind — services d'infrastructure locaux -# ========================================================================== -# Pour l'instant, seul MinIO est géré ici. Postgres, Backend Core, Brain -# Python et Frontend Angular sont lancés manuellement en dev (IDE). -# -# Démarrage : -# docker-compose up -d minio -# Console web MinIO : http://localhost:9001 (identifiants : minioadmin / minioadmin) -# API S3 compatible : http://localhost:9000 +# LoreMindMJ - Stack complete pour distribution utilisateur # -------------------------------------------------------------------------- -version: '3.8' +# Lancement : docker compose up -d +# Acces : http://localhost:8081 +# Mise a jour: docker compose pull && docker compose up -d +# ========================================================================== services: + postgres: + image: postgres:16-alpine + container_name: loremind-postgres + environment: + POSTGRES_DB: ${POSTGRES_DB:-loremind} + POSTGRES_USER: ${POSTGRES_USER:-loremind} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-loremind}"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + minio: image: minio/minio:latest container_name: loremind-minio - ports: - - "9000:9000" # API S3 (utilisée par le backend Java) - - "9001:9001" # Console web d'administration environment: - MINIO_ROOT_USER: minioadmin - MINIO_ROOT_PASSWORD: minioadmin + MINIO_ROOT_USER: ${MINIO_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD:-minioadmin} volumes: - minio-data:/data command: server /data --console-address ":9001" @@ -29,9 +37,9 @@ services: interval: 10s timeout: 5s retries: 3 + restart: unless-stopped - # Création automatique du bucket "loremind-images" au démarrage. - # Sans ça, le backend Java planterait au premier upload. + # Creation automatique du bucket loremind-images au premier lancement. minio-init: image: minio/mc:latest container_name: loremind-minio-init @@ -40,12 +48,59 @@ services: condition: service_healthy entrypoint: > /bin/sh -c " - mc alias set local http://minio:9000 minioadmin minioadmin && + mc alias set local http://minio:9000 ${MINIO_USER:-minioadmin} ${MINIO_PASSWORD:-minioadmin} && mc mb --ignore-existing local/loremind-images && mc anonymous set download local/loremind-images && - echo 'Bucket loremind-images prêt.' + echo 'Bucket loremind-images pret.' " + core: + image: ${REGISTRY:-gitea.example.com}/loremindmj/core:${TAG:-latest} + container_name: loremind-core + depends_on: + postgres: + condition: service_healthy + minio: + condition: service_healthy + environment: + SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB:-loremind} + SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER:-loremind} + SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD} + APP_CORS_ALLOWED_ORIGINS: http://localhost:${WEB_PORT:-8081} + BRAIN_BASE_URL: http://brain:8000 + MINIO_ENDPOINT: http://minio:9000 + MINIO_ACCESS_KEY: ${MINIO_USER:-minioadmin} + MINIO_SECRET_KEY: ${MINIO_PASSWORD:-minioadmin} + restart: unless-stopped + + brain: + image: ${REGISTRY:-gitea.example.com}/loremindmj/brain:${TAG:-latest} + container_name: loremind-brain + environment: + LLM_PROVIDER: ${LLM_PROVIDER:-ollama} + OLLAMA_BASE_URL: ${OLLAMA_BASE_URL:-http://host.docker.internal:11434} + LLM_MODEL: ${LLM_MODEL:-gemma4:26b} + ONEMIN_API_KEY: ${ONEMIN_API_KEY:-} + ONEMIN_MODEL: ${ONEMIN_MODEL:-gpt-4o-mini} + volumes: + - brain-data:/app/data + extra_hosts: + # Linux : permet au conteneur d'atteindre Ollama sur l'hote. + # Mac/Windows Docker Desktop le fait nativement. + - "host.docker.internal:host-gateway" + restart: unless-stopped + + web: + image: ${REGISTRY:-gitea.example.com}/loremindmj/web:${TAG:-latest} + container_name: loremind-web + depends_on: + - core + - brain + ports: + - "${WEB_PORT:-8081}:80" + restart: unless-stopped + volumes: + postgres-data: minio-data: - driver: local + brain-data: diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..4146688 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +.angular/ +.cache/ +.vscode/ diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..745e449 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,17 @@ +FROM node:20-alpine AS build +WORKDIR /build +COPY package*.json ./ +RUN npm ci +COPY . . + +# Neutralise les URLs absolues hardcodees dans les services (dette assumee : +# une refacto propre passerait par src/environments/*.ts + fileReplacements). +# Le reverse proxy nginx route /api/ vers core:8080, donc chemin relatif OK. +RUN find src -type f -name "*.ts" -exec sed -i "s|http://localhost:8080||g" {} + + +RUN npm run build -- --configuration production + +FROM nginx:alpine +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY --from=build /build/dist/web /usr/share/nginx/html +EXPOSE 80 diff --git a/web/angular.json b/web/angular.json index 1112090..542b378 100644 --- a/web/angular.json +++ b/web/angular.json @@ -28,7 +28,34 @@ "src/styles.scss" ], "scripts": [] - } + }, + "configurations": { + "production": { + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "namedChunks": false, + "extractLicenses": true, + "budgets": [ + { + "type": "initial", + "maximumWarning": "2mb", + "maximumError": "5mb" + }, + { + "type": "anyComponentStyle", + "maximumWarning": "6kb", + "maximumError": "10kb" + } + ] + }, + "development": { + "optimization": false, + "sourceMap": true, + "namedChunks": true + } + }, + "defaultConfiguration": "production" }, "serve": { "builder": "@angular-devkit/build-angular:dev-server", diff --git a/web/nginx.conf b/web/nginx.conf new file mode 100644 index 0000000..322105e --- /dev/null +++ b/web/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + client_max_body_size 10M; + + location /api/ { + proxy_pass http://core:8080/api/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_buffering off; + proxy_read_timeout 300s; + proxy_send_timeout 300s; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/web/src/app/app.routes.ts b/web/src/app/app.routes.ts index cdcf528..5c1eda5 100644 --- a/web/src/app/app.routes.ts +++ b/web/src/app/app.routes.ts @@ -24,5 +24,6 @@ export const routes: Routes = [ { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/create', loadComponent: () => import('./campaigns/scene-create/scene-create.component').then(m => m.SceneCreateComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId', loadComponent: () => import('./campaigns/scene-view/scene-view.component').then(m => m.SceneViewComponent) }, { path: 'campaigns/:campaignId/arcs/:arcId/chapters/:chapterId/scenes/:sceneId/edit', loadComponent: () => import('./campaigns/scene-edit/scene-edit.component').then(m => m.SceneEditComponent) }, + { path: 'settings', loadComponent: () => import('./settings/settings.component').then(m => m.SettingsComponent) }, { path: '', redirectTo: '/lore', pathMatch: 'full' } ]; diff --git a/web/src/app/services/settings.service.ts b/web/src/app/services/settings.service.ts new file mode 100644 index 0000000..7aaebed --- /dev/null +++ b/web/src/app/services/settings.service.ts @@ -0,0 +1,56 @@ +import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; +import { Observable } from 'rxjs'; + +/** + * Reflet de SettingsDTO cote Brain / SettingsController cote Core. + * `onemin_api_key_set` indique si une cle est configuree, sans la reveler. + */ +export interface AppSettings { + llm_provider: 'ollama' | 'onemin'; + ollama_base_url: string; + llm_model: string; + onemin_model: string; + onemin_api_key_set: boolean; +} + +/** + * Patch partiel — seuls les champs a modifier sont presents. + * `onemin_api_key: ''` efface la cle, `null`/absent ne touche a rien. + */ +export interface AppSettingsUpdate { + llm_provider?: 'ollama' | 'onemin'; + ollama_base_url?: string; + llm_model?: string; + onemin_model?: string; + onemin_api_key?: string; +} + +@Injectable({ providedIn: 'root' }) +export class SettingsService { + private readonly apiUrl = 'http://localhost:8080/api/settings'; + + constructor(private http: HttpClient) {} + + getSettings(): Observable { + return this.http.get(this.apiUrl); + } + + updateSettings(patch: AppSettingsUpdate): Observable { + return this.http.put(this.apiUrl, patch); + } + + listOllamaModels(): Observable<{ models: string[] }> { + return this.http.get<{ models: string[] }>(`${this.apiUrl}/models/ollama`); + } + + listOneMinModels(): Observable<{ groups: OneMinModelGroup[] }> { + return this.http.get<{ groups: OneMinModelGroup[] }>(`${this.apiUrl}/models/onemin`); + } +} + +/** Un groupe de modeles 1min.ai regroupes par fournisseur (Anthropic, OpenAI, ...). */ +export interface OneMinModelGroup { + provider: string; + models: string[]; +} diff --git a/web/src/app/settings/settings.component.html b/web/src/app/settings/settings.component.html new file mode 100644 index 0000000..6966e6e --- /dev/null +++ b/web/src/app/settings/settings.component.html @@ -0,0 +1,103 @@ +
+ + + +
+ + {{ errorMessage }} +
+
+ + {{ successMessage }} +
+ +
+

Moteur IA

+

Choix du fournisseur de modele de langage utilise par le chat et la generation de pages.

+ +
+ +
+ + +
+
+
+ + +
+

Configuration Ollama

+ +
+ + +
+ +
+ +
+ + +
+

Aucun modele detecte. Verifie que Ollama tourne et que l'URL est correcte.

+
+
+ + +
+

Configuration 1min.ai

+ +
+ + + +
+ +
+ + +
+ +
+ + +
+
+ +
+ +
+ +
diff --git a/web/src/app/settings/settings.component.scss b/web/src/app/settings/settings.component.scss new file mode 100644 index 0000000..bfc73a2 --- /dev/null +++ b/web/src/app/settings/settings.component.scss @@ -0,0 +1,138 @@ +.settings-page { + padding: 32px 48px; + max-width: 820px; + margin: 0 auto; + color: var(--color-text, #e8e8e8); +} + +.page-header { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 32px; + + h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 600; + } +} + +.btn-back { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 8px 14px; + background: transparent; + color: inherit; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 6px; + cursor: pointer; + font-size: 0.9rem; + + &:hover { background: rgba(255, 255, 255, 0.05); } +} + +.card { + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 10px; + padding: 24px 28px; + margin-bottom: 20px; + + h2 { + margin: 0 0 8px; + font-size: 1.15rem; + font-weight: 600; + } +} + +.hint { + font-size: 0.85rem; + color: rgba(255, 255, 255, 0.55); + margin: 4px 0 16px; +} + +.form-row { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 18px; + + label { font-size: 0.9rem; font-weight: 500; } + + input[type="text"], + input[type="password"], + select { + padding: 9px 12px; + background: rgba(0, 0, 0, 0.25); + color: inherit; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 6px; + font-size: 0.95rem; + + &:focus { + outline: none; + border-color: var(--color-accent, #7a5cff); + } + } +} + +.radio-group { display: flex; gap: 24px; } +.radio, .checkbox { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 0.95rem; +} + +.inline-select { + display: flex; + gap: 8px; + align-items: center; + + select { flex: 1; } +} + +.btn-primary, +.btn-secondary { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 9px 16px; + border-radius: 6px; + font-size: 0.9rem; + cursor: pointer; + border: 1px solid transparent; +} +.btn-primary { + background: var(--color-accent, #7a5cff); + color: #fff; + &:hover:not(:disabled) { filter: brightness(1.1); } + &:disabled { opacity: 0.6; cursor: not-allowed; } +} +.btn-secondary { + background: transparent; + color: inherit; + border-color: rgba(255, 255, 255, 0.15); + &:hover:not(:disabled) { background: rgba(255, 255, 255, 0.05); } +} + +.actions { + display: flex; + justify-content: flex-end; + margin-top: 24px; +} + +.alert { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border-radius: 6px; + margin-bottom: 20px; + font-size: 0.9rem; +} +.alert-error { background: rgba(220, 80, 80, 0.15); color: #ff9b9b; } +.alert-success { background: rgba(80, 200, 120, 0.15); color: #a2e8b6; } diff --git a/web/src/app/settings/settings.component.ts b/web/src/app/settings/settings.component.ts new file mode 100644 index 0000000..735fde3 --- /dev/null +++ b/web/src/app/settings/settings.component.ts @@ -0,0 +1,153 @@ +import { Component, OnInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle } from 'lucide-angular'; +import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup } from '../services/settings.service'; + +/** + * Ecran de parametrage du LLM utilise par le Brain. + * + * Deux providers au choix : + * - Ollama (local) : on liste dynamiquement les modeles installes. + * - 1min.ai (cloud) : on fournit une cle API + on choisit dans un catalogue fixe. + * + * Les modifications sont persistees cote Brain dans data/settings.json + * (fichier local, usage mono-utilisateur) et appliquees a la prochaine + * requete chat / generate — pas besoin de redemarrer. + */ +@Component({ + selector: 'app-settings', + standalone: true, + imports: [CommonModule, FormsModule, LucideAngularModule], + templateUrl: './settings.component.html', + styleUrls: ['./settings.component.scss'] +}) +export class SettingsComponent implements OnInit { + + readonly ArrowLeft = ArrowLeft; + readonly RefreshCw = RefreshCw; + readonly Save = Save; + readonly Check = Check; + readonly AlertCircle = AlertCircle; + + settings: AppSettings | null = null; + ollamaModels: string[] = []; + oneminGroups: OneMinModelGroup[] = []; + /** Fournisseur 1min.ai actuellement selectionne (filtre la liste des modeles). */ + oneminProvider: string = ''; + + loadingModels = false; + saving = false; + errorMessage = ''; + successMessage = ''; + + /** Cle 1min.ai saisie — vide = on ne touche pas a la cle persistee. */ + oneminApiKeyInput = ''; + /** True si l'utilisateur a coche "effacer la cle". */ + clearApiKey = false; + + constructor( + private settingsService: SettingsService, + private router: Router + ) {} + + ngOnInit(): void { + this.loadSettings(); + } + + loadSettings(): void { + this.settingsService.getSettings().subscribe({ + next: (s) => { + this.settings = { ...s }; + this.refreshModels(); + }, + error: (err) => this.errorMessage = this.extractError(err, 'Impossible de charger les parametres.') + }); + } + + refreshModels(): void { + if (!this.settings) return; + this.loadingModels = true; + + this.settingsService.listOllamaModels().subscribe({ + next: (r) => this.ollamaModels = r.models, + error: () => this.ollamaModels = [], + complete: () => this.loadingModels = false + }); + + this.settingsService.listOneMinModels().subscribe({ + next: (r) => { + this.oneminGroups = r.groups; + this.syncOneminProviderFromModel(); + }, + error: () => this.oneminGroups = [] + }); + } + + /** Deduit le fournisseur a partir du modele actuellement configure. */ + private syncOneminProviderFromModel(): void { + if (!this.settings) return; + const currentModel = this.settings.onemin_model; + const found = this.oneminGroups.find(g => g.models.includes(currentModel)); + this.oneminProvider = found ? found.provider : (this.oneminGroups[0]?.provider ?? ''); + } + + /** Retourne la liste des modeles du fournisseur selectionne. */ + get currentProviderModels(): string[] { + const group = this.oneminGroups.find(g => g.provider === this.oneminProvider); + return group ? group.models : []; + } + + /** Quand on change de fournisseur, bascule automatiquement sur son premier modele. */ + onProviderChange(): void { + if (!this.settings) return; + const models = this.currentProviderModels; + if (models.length > 0 && !models.includes(this.settings.onemin_model)) { + this.settings.onemin_model = models[0]; + } + } + + save(): void { + if (!this.settings) return; + this.saving = true; + this.errorMessage = ''; + this.successMessage = ''; + + const patch: AppSettingsUpdate = { + llm_provider: this.settings.llm_provider, + ollama_base_url: this.settings.ollama_base_url, + llm_model: this.settings.llm_model, + onemin_model: this.settings.onemin_model + }; + if (this.clearApiKey) { + patch.onemin_api_key = ''; + } else if (this.oneminApiKeyInput.trim()) { + patch.onemin_api_key = this.oneminApiKeyInput.trim(); + } + + this.settingsService.updateSettings(patch).subscribe({ + next: (s) => { + this.settings = { ...s }; + this.oneminApiKeyInput = ''; + this.clearApiKey = false; + this.successMessage = 'Parametres sauvegardes.'; + this.saving = false; + }, + error: (err) => { + this.errorMessage = this.extractError(err, 'Echec de la sauvegarde.'); + this.saving = false; + } + }); + } + + goBack(): void { + this.router.navigate(['/lore']); + } + + private extractError(err: any, fallback: string): string { + if (err?.error?.detail) return String(err.error.detail); + if (err?.message) return err.message; + return fallback; + } +} diff --git a/web/src/app/sidebar/sidebar.component.html b/web/src/app/sidebar/sidebar.component.html index e1a8ecb..b24905c 100644 --- a/web/src/app/sidebar/sidebar.component.html +++ b/web/src/app/sidebar/sidebar.component.html @@ -53,7 +53,7 @@ Export VTT -