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

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>