Ajout d'open router en fournisseur IA ; ajout de la possibilité de mettre des conditions de déverouillage pour les chapitres optionnels
Some checks failed
Build & Push Images / build (brain) (push) Has been cancelled
Build & Push Images / build (core) (push) Has been cancelled
Build & Push Images / build (web) (push) Has been cancelled
Build & Push Images / build-switcher (push) Has been cancelled

This commit is contained in:
2026-06-05 00:23:19 +02:00
parent 211e26dae1
commit 9ea43a1889
19 changed files with 382 additions and 50 deletions

View File

@@ -127,6 +127,8 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
id: `chapter-${ch.id}`,
label: ch.name,
iconKey: ch.icon ?? undefined,
// Cadenas si le chapitre porte des conditions de déblocage (hub ou linéaire).
meta: (ch.prerequisites?.length ?? 0) > 0 ? '🔒' : undefined,
children: sceneItems,
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`,
createActions: [{

View File

@@ -26,13 +26,17 @@
<form [formGroup]="form" (ngSubmit)="submit()" class="edit-form">
<!-- Section Hub : conditions de déblocage (scénario). Visible si l'arc parent est HUB. -->
<div class="hub-section" *ngIf="parentArc?.type === 'HUB'">
<h2 class="hub-section-title">🗺️ Conditions de déblocage de la quête</h2>
<!-- Conditions de déblocage (scénario) — pour TOUT chapitre : arc HUB (= quête)
comme arc linéaire (= chapitre conditionnel). Vide = disponible d'emblée. -->
<div class="hub-section">
<h2 class="hub-section-title">
{{ parentArc?.type === 'HUB' ? '🗺️ Conditions de déblocage de la quête' : '🔒 Conditions de déblocage du chapitre' }}
</h2>
<small class="field-hint">
Définition du scénario : toutes les conditions doivent être remplies (ET) pour
que la quête soit débloquée dans une Partie. La progression en cours et l'état
des faits se gèrent dans l'écran de la Partie, pas ici.
Définition du scénario : toutes les conditions doivent être remplies (ET) pour que
{{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} soit débloqué(e) dans une Partie.
Laissez vide pour qu'il soit disponible dès le départ. La progression et l'état des
faits se gèrent dans l'écran de la Partie, pas ici.
</small>
<app-prerequisite-editor
[prerequisites]="prerequisites"

View File

@@ -6,7 +6,14 @@
<lucide-icon *ngIf="chapter.icon" [img]="resolveCampaignIcon(chapter.icon)" [size]="22" class="title-icon"></lucide-icon>
{{ chapter.name }}
</h1>
<p class="view-subtitle">{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }}</p>
<p class="view-subtitle">
{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }}
<span class="cond-badge" *ngIf="(chapter.prerequisites?.length ?? 0) > 0"
title="Ce chapitre a des conditions de déblocage : il se débloque en Partie quand elles sont remplies.">
<lucide-icon [img]="Lock" [size]="12"></lucide-icon>
Conditionnel
</span>
</p>
</div>
<div class="view-actions">
<button type="button" class="btn-secondary" (click)="openGraph()"
@@ -25,8 +32,9 @@
</div>
</header>
<!-- Conditions de déblocage (donnée de scénario, read-only). Toujours visible si Arc HUB. -->
<section class="view-section" *ngIf="parentArc?.type === 'HUB'">
<!-- Conditions de déblocage (scénario, read-only). Visible pour un arc HUB (quête)
OU dès qu'un chapitre linéaire porte des conditions. -->
<section class="view-section" *ngIf="parentArc?.type === 'HUB' || (chapter.prerequisites?.length ?? 0) > 0">
<h2 class="view-section-title"><span class="view-section-icon">🔒</span> Conditions de déblocage</h2>
<ng-container *ngIf="(chapter.prerequisites?.length ?? 0) > 0; else noPrereqs">
@@ -34,8 +42,9 @@
<li *ngFor="let p of chapter.prerequisites">{{ describePrerequisite(p) }}</li>
</ul>
<small class="view-section-empty">
Pendant une partie, la quête se débloque dès que toutes ces conditions sont remplies.
Le toggle des faits se fait dans l'écran « Faits » de la Partie.
Pendant une partie, {{ parentArc?.type === 'HUB' ? 'la quête' : 'ce chapitre' }} se débloque
dès que toutes ces conditions sont remplies. Le toggle des faits se fait dans l'écran
« Faits » de la Partie.
</small>
</ng-container>

View File

@@ -1,5 +1,21 @@
// Styles partagés via styles/_view.scss
// Badge "Conditionnel" : chapitre porteur de conditions de déblocage.
.cond-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
margin-left: 0.5rem;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
vertical-align: middle;
color: #f0c674;
background: rgba(240, 198, 116, 0.12);
border: 1px solid rgba(240, 198, 116, 0.35);
}
.quest-status-bar {
border: 1px solid rgba(66, 133, 244, 0.25);
background: rgba(66, 133, 244, 0.04);

View File

@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common';
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
import { LucideAngularModule, Pencil, Network, Trash2, Lock } from 'lucide-angular';
import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service';
@@ -32,6 +32,7 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil;
readonly Network = Network;
readonly Trash2 = Trash2;
readonly Lock = Lock;
readonly resolveCampaignIcon = resolveCampaignIcon;
campaignId = '';

View File

@@ -6,12 +6,16 @@ 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 type LlmProvider = 'ollama' | 'onemin' | 'openrouter';
export interface AppSettings {
llm_provider: 'ollama' | 'onemin';
llm_provider: LlmProvider;
ollama_base_url: string;
llm_model: string;
onemin_model: string;
onemin_api_key_set: boolean;
openrouter_model: string;
openrouter_api_key_set: boolean;
llm_num_ctx: number;
/** Taille cible d'un morceau (tokens) pour l'import de PDF (règles/campagne). */
import_chunk_tokens: number;
@@ -24,11 +28,13 @@ export interface AppSettings {
* `onemin_api_key: ''` efface la cle, `null`/absent ne touche a rien.
*/
export interface AppSettingsUpdate {
llm_provider?: 'ollama' | 'onemin';
llm_provider?: LlmProvider;
ollama_base_url?: string;
llm_model?: string;
onemin_model?: string;
onemin_api_key?: string;
openrouter_model?: string;
openrouter_api_key?: string;
llm_num_ctx?: number;
import_chunk_tokens?: number;
llm_timeout_seconds?: number;
@@ -72,6 +78,11 @@ export class SettingsService {
return this.http.get<{ groups: OneMinModelGroup[] }>(`${this.apiUrl}/models/onemin`, this.authOptions);
}
/** Catalogue dynamique des modeles OpenRouter (depuis leur API publique). */
listOpenRouterModels(): Observable<{ models: OpenRouterModel[] }> {
return this.http.get<{ models: OpenRouterModel[] }>(`${this.apiUrl}/models/openrouter`, this.authOptions);
}
/**
* Telecharge un modele Ollama et streame la progression au client.
*
@@ -184,3 +195,13 @@ export interface OneMinModelGroup {
provider: string;
models: string[];
}
/** Un modele OpenRouter (catalogue dynamique). */
export interface OpenRouterModel {
id: string;
name: string;
/** Fenetre de contexte du modele (tokens). 0 si inconnue. */
context_length: number;
/** True si le modele est gratuit (id `:free` ou prix nul). */
free: boolean;
}

View File

@@ -32,6 +32,10 @@
<input type="radio" name="provider" value="onemin" [(ngModel)]="settings.llm_provider">
<span>1min.ai (cloud)</span>
</label>
<label class="radio">
<input type="radio" name="provider" value="openrouter" [(ngModel)]="settings.llm_provider">
<span>OpenRouter (cloud)</span>
</label>
</div>
</div>
</section>
@@ -173,8 +177,45 @@
</div>
</section>
<!-- Bloc Fenetre de contexte -->
<section class="card" *ngIf="settings">
<!-- Bloc OpenRouter -->
<section class="card" *ngIf="settings && settings.llm_provider === 'openrouter'">
<h2>Configuration OpenRouter</h2>
<div class="form-row">
<label for="openrouter-key">Cle API</label>
<input
id="openrouter-key"
type="password"
[(ngModel)]="openrouterApiKeyInput"
[placeholder]="settings.openrouter_api_key_set ? 'Cle configuree (laisser vide pour ne pas changer)' : 'Saisir votre cle API (sk-or-...)'">
<label class="checkbox" *ngIf="settings.openrouter_api_key_set">
<input type="checkbox" [(ngModel)]="clearOpenrouterKey">
<span>Effacer la cle enregistree</span>
</label>
</div>
<div class="form-row">
<label for="openrouter-model">Modele</label>
<label class="checkbox">
<input type="checkbox" [(ngModel)]="openrouterFreeOnly">
<span>Gratuits seulement</span>
</label>
<select id="openrouter-model" [(ngModel)]="settings.openrouter_model">
<option *ngFor="let o of openrouterSelectOptions" [value]="o.id">{{ o.label }}</option>
</select>
<p class="hint">
Liste chargee automatiquement depuis OpenRouter. Pour rester <strong>gratuit</strong> :
un modele marque <code>· gratuit</code> ou le routeur <code>openrouter/free</code>
(choisit un modele gratuit tout seul). Pour les imports, prefere un gratuit a
<strong>grand contexte</strong> (valeur « ctx »).
</p>
</div>
</section>
<!-- Bloc Fenetre de contexte : seulement pour Ollama (num_ctx est un vrai
parametre du modele local). Pour le cloud (1min/OpenRouter), on ne fixe rien :
on utilise ce que le modele accepte, et la jauge du chat s'affiche sans max. -->
<section class="card" *ngIf="settings && settings.llm_provider === 'ollama'">
<h2>Fenetre de contexte</h2>
<!-- Ollama : slider borne par le max du modele -->
@@ -205,20 +246,6 @@
</p>
</div>
<!-- 1min.ai : saisie libre (pas d'introspection possible) -->
<div class="form-row" *ngIf="settings.llm_provider === 'onemin'">
<label for="llm-num-ctx-onemin">Fenetre de contexte (tokens)</label>
<input
id="llm-num-ctx-onemin"
type="number"
min="2048"
step="1024"
[(ngModel)]="settings.llm_num_ctx">
<p class="hint">
A regler selon la capacite du modele 1min.ai choisi (ex: 128 000 pour gpt-4o,
200 000 pour claude-sonnet). Sert de plafond a la jauge de contexte du chat.
</p>
</div>
</section>
<!-- Bloc Import de PDF -->

View File

@@ -4,7 +4,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular';
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service';
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OpenRouterModel, OllamaPullEvent } from '../services/settings.service';
import { UpdatesService, UpdateStatus } from '../services/updates.service';
import { ConfigService } from '../services/config.service';
import { LayoutService } from '../services/layout.service';
@@ -103,6 +103,10 @@ export class SettingsComponent implements OnInit, OnDestroy {
settings: AppSettings | null = null;
ollamaModels: string[] = [];
oneminGroups: OneMinModelGroup[] = [];
/** Catalogue OpenRouter (chargé dynamiquement) + filtre "gratuits seulement". */
openrouterModels: OpenRouterModel[] = [];
openrouterFreeOnly = true;
/** Fournisseur 1min.ai actuellement selectionne (filtre la liste des modeles). */
oneminProvider: string = '';
@@ -128,6 +132,11 @@ export class SettingsComponent implements OnInit, OnDestroy {
/** True si l'utilisateur a coche "effacer la cle". */
clearApiKey = false;
/** Cle OpenRouter saisie — vide = on ne touche pas a la cle persistee. */
openrouterApiKeyInput = '';
/** True si l'utilisateur a coche "effacer la cle OpenRouter". */
clearOpenrouterKey = false;
constructor(
private settingsService: SettingsService,
private router: Router,
@@ -457,6 +466,35 @@ export class SettingsComponent implements OnInit, OnDestroy {
},
error: () => this.oneminGroups = []
});
this.settingsService.listOpenRouterModels().subscribe({
next: (r) => this.openrouterModels = r.models,
error: () => this.openrouterModels = []
});
}
/** Modeles OpenRouter, filtres (gratuits seulement par defaut). */
get filteredOpenrouterModels(): OpenRouterModel[] {
return this.openrouterFreeOnly
? this.openrouterModels.filter(m => m.free)
: this.openrouterModels;
}
/** Options du select OpenRouter : routeur auto + liste filtree + valeur courante. */
get openrouterSelectOptions(): { id: string; label: string }[] {
const options: { id: string; label: string }[] = [
{ id: 'openrouter/free', label: 'openrouter/free — routeur auto (gratuit)' }
];
for (const m of this.filteredOpenrouterModels) {
const ctx = m.context_length ? `${m.context_length.toLocaleString()} ctx` : '';
options.push({ id: m.id, label: `${m.id}${ctx}${m.free ? ' · gratuit' : ''}` });
}
// Garantit que le modele actuellement configure reste selectionnable.
const cur = this.settings?.openrouter_model;
if (cur && !options.some(o => o.id === cur)) {
options.splice(1, 0, { id: cur, label: `${cur} (actuel)` });
}
return options;
}
/** Deduit le fournisseur a partir du modele actuellement configure. */
@@ -518,6 +556,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
ollama_base_url: this.settings.ollama_base_url,
llm_model: this.settings.llm_model,
onemin_model: this.settings.onemin_model,
openrouter_model: this.settings.openrouter_model,
llm_num_ctx: this.settings.llm_num_ctx,
import_chunk_tokens: this.settings.import_chunk_tokens,
llm_timeout_seconds: this.settings.llm_timeout_seconds
@@ -527,12 +566,19 @@ export class SettingsComponent implements OnInit, OnDestroy {
} else if (this.oneminApiKeyInput.trim()) {
patch.onemin_api_key = this.oneminApiKeyInput.trim();
}
if (this.clearOpenrouterKey) {
patch.openrouter_api_key = '';
} else if (this.openrouterApiKeyInput.trim()) {
patch.openrouter_api_key = this.openrouterApiKeyInput.trim();
}
this.settingsService.updateSettings(patch).subscribe({
next: (s) => {
this.settings = { ...s };
this.oneminApiKeyInput = '';
this.clearApiKey = false;
this.openrouterApiKeyInput = '';
this.clearOpenrouterKey = false;
this.successMessage = 'Parametres sauvegardes.';
this.saving = false;
},

View File

@@ -97,13 +97,14 @@
</header>
<div class="context-gauge" *ngIf="usage" [attr.data-level]="usageLevel"
[attr.title]="'System: ' + usage.system + ' · Historique: ' + usage.history + ' · Courant: ' + usage.current + ' / ' + usage.max + ' tokens'">
<div class="gauge-bar">
[attr.title]="'System: ' + usage.system + ' · Historique: ' + usage.history + ' · Courant: ' + usage.current + (usageHasMax ? ' / ' + usage.max : '') + ' tokens'">
<!-- Barre seulement si on connaît le plafond (Ollama). Cloud : compteur seul. -->
<div class="gauge-bar" *ngIf="usageHasMax">
<div class="gauge-fill" [style.width.%]="usagePercent"></div>
</div>
<div class="gauge-label">
<span class="gauge-text">Contexte : {{ usageTotal }} / {{ usage.max }} tokens</span>
<span class="gauge-percent">{{ usagePercent }}%</span>
<span class="gauge-text">Contexte : {{ usageTotal }}<ng-container *ngIf="usageHasMax"> / {{ usage.max }}</ng-container> tokens</span>
<span class="gauge-percent" *ngIf="usageHasMax">{{ usagePercent }}%</span>
</div>
</div>

View File

@@ -129,6 +129,10 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
if (!this.usage) return 0;
return this.usage.system + this.usage.history + this.usage.current;
}
/** Vrai si on connaît la fenêtre max (Ollama) → affiche dénominateur + barre. */
get usageHasMax(): boolean {
return !!this.usage && this.usage.max > 0;
}
get usageRatio(): number {
if (!this.usage || this.usage.max <= 0) return 0;
return Math.min(1, this.usageTotal / this.usage.max);