Ajout d'open router en fournisseur IA ; ajout de la possibilité de mettre des conditions de déverouillage pour les chapitres optionnels
All checks were successful
All checks were successful
This commit is contained in:
@@ -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: [{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user