Mise en place docker + mise en place des settings (config ollama / 1min.ai)

This commit is contained in:
2026-04-21 06:51:41 +02:00
parent 67818f0d3d
commit 7a340285c5
27 changed files with 1301 additions and 36 deletions

30
.env.example Normal file
View File

@@ -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

View File

@@ -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 }}

7
.gitignore vendored
View File

@@ -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

64
INSTALL.md Normal file
View File

@@ -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
```

6
brain/.dockerignore Normal file
View File

@@ -0,0 +1,6 @@
data/
__pycache__/
*.pyc
.env
.venv/
venv/

16
brain/Dockerfile Normal file
View File

@@ -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"]

View File

@@ -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())

View File

@@ -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

View File

@@ -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: <cle>"
- 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)

View File

@@ -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-<family>-<version>`), 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

7
brain/data/settings.json Normal file
View File

@@ -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"
}

4
core/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
target/
.idea/
*.iml
.mvn/

12
core/Dockerfile Normal file
View File

@@ -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"]

View File

@@ -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);
}

View File

@@ -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<String,Object> 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<Map<String, Object>> getSettings() {
return forward(HttpMethod.GET, "/settings", null);
}
@PutMapping
public ResponseEntity<Map<String, Object>> updateSettings(@RequestBody Map<String, Object> patch) {
return forward(HttpMethod.PUT, "/settings", patch);
}
@GetMapping("/models/ollama")
public ResponseEntity<Map<String, Object>> listOllamaModels() {
return forward(HttpMethod.GET, "/models/ollama", null);
}
@GetMapping("/models/onemin")
public ResponseEntity<Map<String, Object>> listOneMinModels() {
return forward(HttpMethod.GET, "/models/onemin", null);
}
@SuppressWarnings({"rawtypes", "unchecked"})
private ResponseEntity<Map<String, Object>> forward(HttpMethod method, String path, Object body) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
HttpEntity<Object> entity = new HttpEntity<>(body, headers);
ResponseEntity<Map> response = restTemplate.exchange(
brainBaseUrl + path, method, entity, Map.class);
return ResponseEntity.status(response.getStatusCode()).body((Map<String, Object>) response.getBody());
}
}

View File

@@ -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

View File

@@ -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:

5
web/.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
.angular/
.cache/
.vscode/

17
web/Dockerfile Normal file
View File

@@ -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

View File

@@ -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",

26
web/nginx.conf Normal file
View File

@@ -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;
}
}

View File

@@ -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' }
];

View File

@@ -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<AppSettings> {
return this.http.get<AppSettings>(this.apiUrl);
}
updateSettings(patch: AppSettingsUpdate): Observable<AppSettings> {
return this.http.put<AppSettings>(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[];
}

View File

@@ -0,0 +1,103 @@
<div class="settings-page">
<header class="page-header">
<button class="btn-back" (click)="goBack()">
<lucide-icon [img]="ArrowLeft" [size]="16"></lucide-icon>
<span>Retour</span>
</button>
<h1>Parametres</h1>
</header>
<div *ngIf="errorMessage" class="alert alert-error">
<lucide-icon [img]="AlertCircle" [size]="16"></lucide-icon>
<span>{{ errorMessage }}</span>
</div>
<div *ngIf="successMessage" class="alert alert-success">
<lucide-icon [img]="Check" [size]="16"></lucide-icon>
<span>{{ successMessage }}</span>
</div>
<section class="card" *ngIf="settings">
<h2>Moteur IA</h2>
<p class="hint">Choix du fournisseur de modele de langage utilise par le chat et la generation de pages.</p>
<div class="form-row">
<label>Fournisseur</label>
<div class="radio-group">
<label class="radio">
<input type="radio" name="provider" value="ollama" [(ngModel)]="settings.llm_provider">
<span>Ollama (local)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="onemin" [(ngModel)]="settings.llm_provider">
<span>1min.ai (cloud)</span>
</label>
</div>
</div>
</section>
<!-- Bloc Ollama -->
<section class="card" *ngIf="settings && settings.llm_provider === 'ollama'">
<h2>Configuration Ollama</h2>
<div class="form-row">
<label for="ollama-url">URL du serveur Ollama</label>
<input id="ollama-url" type="text" [(ngModel)]="settings.ollama_base_url" placeholder="http://localhost:11434">
</div>
<div class="form-row">
<label for="ollama-model">Modele</label>
<div class="inline-select">
<select id="ollama-model" [(ngModel)]="settings.llm_model">
<option *ngIf="ollamaModels.length === 0" [value]="settings.llm_model">{{ settings.llm_model }}</option>
<option *ngFor="let m of ollamaModels" [value]="m">{{ m }}</option>
</select>
<button type="button" class="btn-secondary" (click)="refreshModels()" [disabled]="loadingModels">
<lucide-icon [img]="RefreshCw" [size]="14"></lucide-icon>
<span>{{ loadingModels ? 'Chargement...' : 'Actualiser' }}</span>
</button>
</div>
<p class="hint" *ngIf="ollamaModels.length === 0">Aucun modele detecte. Verifie que Ollama tourne et que l'URL est correcte.</p>
</div>
</section>
<!-- Bloc 1min.ai -->
<section class="card" *ngIf="settings && settings.llm_provider === 'onemin'">
<h2>Configuration 1min.ai</h2>
<div class="form-row">
<label for="onemin-key">Cle API</label>
<input
id="onemin-key"
type="password"
[(ngModel)]="oneminApiKeyInput"
[placeholder]="settings.onemin_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API'">
<label class="checkbox" *ngIf="settings.onemin_api_key_set">
<input type="checkbox" [(ngModel)]="clearApiKey">
<span>Effacer la cle enregistree</span>
</label>
</div>
<div class="form-row">
<label for="onemin-provider">Fournisseur</label>
<select id="onemin-provider" [(ngModel)]="oneminProvider" (ngModelChange)="onProviderChange()">
<option *ngFor="let g of oneminGroups" [value]="g.provider">{{ g.provider }}</option>
</select>
</div>
<div class="form-row">
<label for="onemin-model">Modele</label>
<select id="onemin-model" [(ngModel)]="settings.onemin_model">
<option *ngFor="let m of currentProviderModels" [value]="m">{{ m }}</option>
</select>
</div>
</section>
<div class="actions" *ngIf="settings">
<button class="btn-primary" (click)="save()" [disabled]="saving">
<lucide-icon [img]="Save" [size]="16"></lucide-icon>
<span>{{ saving ? 'Sauvegarde...' : 'Sauvegarder' }}</span>
</button>
</div>
</div>

View File

@@ -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; }

View File

@@ -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;
}
}

View File

@@ -53,7 +53,7 @@
<lucide-icon [img]="Download" [size]="16"></lucide-icon>
<span>Export VTT</span>
</button>
<button class="tool-btn">
<button class="tool-btn" [class.active]="currentRoute.startsWith('/settings')" (click)="navigateTo('/settings')">
<lucide-icon [img]="Settings" [size]="16"></lucide-icon>
<span>Paramètres</span>
</button>