Mise en place docker + mise en place des settings (config ollama / 1min.ai)
This commit is contained in:
30
.env.example
Normal file
30
.env.example
Normal 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
|
||||
40
.gitea/workflows/release.yml
Normal file
40
.gitea/workflows/release.yml
Normal 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
7
.gitignore
vendored
@@ -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
64
INSTALL.md
Normal 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
6
brain/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
||||
data/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.env
|
||||
.venv/
|
||||
venv/
|
||||
16
brain/Dockerfile
Normal file
16
brain/Dockerfile
Normal 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"]
|
||||
@@ -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())
|
||||
|
||||
41
brain/app/core/settings_store.py
Normal file
41
brain/app/core/settings_store.py
Normal 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
|
||||
174
brain/app/infrastructure/onemin_adapter.py
Normal file
174
brain/app/infrastructure/onemin_adapter.py
Normal 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)
|
||||
@@ -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
7
brain/data/settings.json
Normal 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
4
core/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
target/
|
||||
.idea/
|
||||
*.iml
|
||||
.mvn/
|
||||
12
core/Dockerfile
Normal file
12
core/Dockerfile
Normal 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"]
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
21
docker-compose.override.yml
Normal file
21
docker-compose.override.yml
Normal 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
|
||||
@@ -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
5
web/.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.angular/
|
||||
.cache/
|
||||
.vscode/
|
||||
17
web/Dockerfile
Normal file
17
web/Dockerfile
Normal 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
|
||||
@@ -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
26
web/nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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' }
|
||||
];
|
||||
|
||||
56
web/src/app/services/settings.service.ts
Normal file
56
web/src/app/services/settings.service.ts
Normal 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[];
|
||||
}
|
||||
103
web/src/app/settings/settings.component.html
Normal file
103
web/src/app/settings/settings.component.html
Normal 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>
|
||||
138
web/src/app/settings/settings.component.scss
Normal file
138
web/src/app/settings/settings.component.scss
Normal 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; }
|
||||
153
web/src/app/settings/settings.component.ts
Normal file
153
web/src/app/settings/settings.component.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user