Compare commits

..

68 Commits

Author SHA1 Message Date
439f43875b Ajout de 2 fonctionnalitées principales : import PDF que ce soit pour les règles ou les campagnes directement.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m28s
Build & Push Images / build (core) (push) Successful in 1m40s
Build & Push Images / build-switcher (push) Successful in 19s
Build & Push Images / build (web) (push) Successful in 1m46s
Fonctionnalité de comparaison PDF / campagne pour faire un mix et demander des conseils à l'IA
2026-06-04 13:55:27 +02:00
79a68bc27b Mise à jour des tests unitaires et passage en v0.9.2-beta en conséquence.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m36s
Build & Push Images / build (core) (push) Successful in 1m58s
Build & Push Images / build-switcher (push) Successful in 15s
Build & Push Images / build (web) (push) Successful in 3m1s
2026-06-03 15:56:26 +02:00
c3d3394ec7 Plusieurs ajouts :
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m47s
Build & Push Images / build (core) (push) Failing after 1m56s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Successful in 2m11s
- Possibilité de configurer des lieux dans une scène : permet de configurer un donjon par exemple avec les pièces, les trésors par pièce, la narration..... Mise en place également de conditions permettant de conditionner le déblocage des quêtes.
- Possibilité de transformé un arc en instance non linéaire afin de faire un hub. Permet de jouer de préparer des campagnes type Dragon of Icespire peak plus facilement.
- Configuration de partie : chaque partie va contenir les séances, ce qui permettra de suivre le déblocage des conditions pour les quêtes.

Passage en 0.9.1-beta
2026-06-03 15:44:48 +02:00
694f687fec Ajout d'un mode "jeu" (possibilité de lancer des sessions dans une campagne). Cela permet de faire de prendre des notes en live au cours d'une partie et d'avoir plusieurs outils sous la main pour aider le mj :
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m20s
Build & Push Images / build (core) (push) Successful in 1m50s
Build & Push Images / build-switcher (push) Successful in 18s
Build & Push Images / build (web) (push) Successful in 1m47s
- Possibilité de parler à une IA pour règle de jeu ou élément de lore / campagne au cours d'une partie comme aide mémoire
- Onglet dédié aux personnages de la campagne
- Onglet dédié aux scènes
- Onglet avec dès pour ceux qui souhaitent ;

Possibilité de rajouté une note en tant qu'évènement, jet de dès ou encore action du joueur par exemple. D'autres ajouts seront fait dans le futur (notamment des tables aléatoires pour PNJ en live).
2026-05-20 14:59:26 +02:00
87865338a0 Correction du bug de switch entre lore / campagne et la sidebar qui ne s'actualise pas en conséquence. Ajout d'un test playwright pour éviter toute régression à l'avenir 2026-05-19 19:15:00 +02:00
586ddceff6 mise à jour vers 0.8.7-beta
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m2s
Build & Push Images / build (core) (push) Successful in 1m38s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (web) (push) Successful in 1m33s
2026-05-19 18:45:56 +02:00
4b9b7f0995 Mise à jour du switcher pour régler le soucis de switch entre stable et bêta
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m5s
Build & Push Images / build (core) (push) Successful in 1m42s
Build & Push Images / build (web) (push) Successful in 1m38s
Build & Push Images / build-switcher (push) Successful in 1m48s
2026-05-19 18:36:00 +02:00
3d73b1e6a7 Mise à jour de la config du switcher pour prendre les crédit du GHCR + sh plus verbeux en cas de bugs
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 59s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build-switcher (push) Successful in 43s
Build & Push Images / build (web) (push) Successful in 1m36s
2026-05-19 18:25:54 +02:00
759e47fc1f Mise à jour vers 0.8.5 ; ajout de la bascule entre le canal bêta et le canal stable
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Build & Push Images / build (brain) (push) Successful in 1m0s
Build & Push Images / build (core) (push) Successful in 1m34s
Build & Push Images / build-switcher (push) Successful in 39s
Build & Push Images / build (web) (push) Successful in 1m40s
2026-05-19 18:05:17 +02:00
f71bf3fcad Ajout d'un globalExceptionHandler pour intercepter toutes les erreurs possibles et avoir un peu plus de détails.
All checks were successful
Build & Push Images / build (brain) (push) Successful in 2m27s
Build & Push Images / build (core) (push) Successful in 3m15s
Build & Push Images / build (web) (push) Successful in 2m49s
Suppression du détail de la mise à jour de chaque composant : l'utilisateur ce fiche de savoir composant x / y à jour car on fera la mise à jour pour tout à chaque fois
(même montée en version pour chaque composant même si composant y non touché par exemple... c'est la montée en version de l'appli qui compte)
2026-05-19 14:38:38 +02:00
0cd99dfb32 Mise à jour du pom coté pore pour intégrer une dépendance vers crypto.tink ; sinon la vérification de la licence patreon ne marche pas.
Some checks are pending
E2E Tests / e2e (push) Waiting to run
Build & Push Images / build (brain) (push) Successful in 1m51s
Build & Push Images / build (core) (push) Successful in 1m52s
Build & Push Images / build (web) (push) Successful in 1m39s
2026-05-19 14:06:57 +02:00
f24ef0891e Ajout de tests playwright et correction de tests non passant (pour les tests ajoutés : partie game system ).
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m36s
Build & Push Images / build (core) (push) Successful in 2m53s
Build & Push Images / build (web) (push) Successful in 2m36s
Correction de plusieurs anomalies : problème de switch entre 2 templates (par exemple si on était sur un template 1 et qu'on voulait passer directement au 2, ce dernier ne chargeait pas) ;
correction du soucis d'apparition de la sidebar à gauche qui disparaissait sans explication ; problème de redirection : lorsqu'on terminait de créer un PJ / PNJ ; on arrivait sur l'accueil de la campagne au lieu de voir le résultat de la création.
Problème de redirection également lors du clique sur un PNJ / PJ sur le coté : on arrivait sur l'édition au lieu de la présentation. Correction de la première lettre stylisée : tout est au même style comme ça plus de probleme de lecture.

Nouveautées : stylisation des modales (notamment suppression, warning.....) avec en prime l'ajout d'un warning lors du changement de système pour avertir que les fiches persos ne sont pas conservées.
Ajout d'une option pour créer un game system directement à la création d'une campagne afin de faciliter la mise en place de cette dernière.
Ajout d'un bouton pour créer un nouveau template directement lorsqu'on créer une page : ça permet de créer un template et de revenir sur la page qu'on était en train de créer sans perdre le titre.

Passage en bêta 0.8.4
2026-05-19 13:37:22 +02:00
7c74c12f3e Changement du readme
Some checks failed
E2E Tests / e2e (push) Failing after 58s
2026-05-17 18:04:24 +02:00
86836ad81c Refonte de toute la partie fiche de personnage avec mise en place d'un nouveau bloc de liste d'attribut (pour tout ce qui sera statistiques, compétences etc....)
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m5s
Build & Push Images / build (core) (push) Successful in 1m38s
Build & Push Images / build (web) (push) Successful in 1m36s
Passage V0.8.3
2026-04-30 15:53:38 +02:00
7c4a42327d Mise en place du picker d'image pour la partie header / illustration des fiches personnage
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m1s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m42s
Migration pour l'ancienne partie des fiches perso vers les nouvelles pages
Vue retravaillée pour les fiches perso
2026-04-30 10:54:27 +02:00
52e389db24 Refonte du système JDR + système de personnage joueurs / non joueurs :
Some checks failed
E2E Tests / e2e (push) Failing after 21s
- Système de templating dans le game system : en effet, les templates sont liés au game system car les fiches personnages ne sont pas forcément les même selon les jeux (perso Dnd possède + de compétences que Nimble par exemple)
- changement des fiches personnages pour adapter le templating au niveau des campagnes et remplir des pages de perso
2026-04-30 10:42:09 +02:00
efaf5a3794 Mise en place d'un composant permettant d'améliorer l'experience de mise à jour (via un rafraichissement de l'appli).
Some checks failed
E2E Tests / e2e (push) Has been cancelled
Build & Push Images / build (brain) (push) Successful in 1m0s
Build & Push Images / build (core) (push) Successful in 1m47s
Build & Push Images / build (web) (push) Successful in 1m38s
Modification de la partie web pour prendre la modification en compte
2026-04-29 14:39:30 +02:00
4fe93b5ff3 Correction problème mise à jour : l'application ne voyait pas les mises à jour quand on lançait docker après avoir push la dernière version.
Some checks failed
E2E Tests / e2e (push) Failing after 21s
Build & Push Images / build (brain) (push) Successful in 1m4s
Build & Push Images / build (core) (push) Successful in 1m31s
Build & Push Images / build (web) (push) Successful in 1m38s
Effectivement : au demarrage, docker ce mettait automatiquement sur la dernière version alors qu'il n'avait pas necessairement récupérer, ducoup comparaison faisait true et on arrivait pas à avoir la derniere version du code.
Push de la clé jwt publique : sinon pas incluse dans le jar finale et la section patreon n'apparaissait pas.
2026-04-29 10:56:37 +02:00
0f2d1b1efe Correction updateCheckServiceTest qui faisait planter le build gitea
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (core) (push) Successful in 1m27s
Build & Push Images / build (web) (push) Successful in 1m34s
Build & Push Images / build (brain) (push) Successful in 53s
2026-04-28 19:12:09 +02:00
5ff05242a8 Mise en place de la connexion au canal privé pour la bêta avec Patreon et passage en v0.8.0
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Failing after 48s
Build & Push Images / build (core) (push) Failing after 1m18s
Build & Push Images / build (web) (push) Successful in 1m35s
2026-04-28 19:04:11 +02:00
b06c77a1eb Autre patch dockerfile
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m30s
2026-04-27 22:11:43 +02:00
03bc669efe Patch dockerfile bookworm a lieu de alpine pour corriger le problème de build
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m39s
Build & Push Images / build (web) (push) Failing after 1m48s
2026-04-27 21:56:04 +02:00
c3873ddd84 Patch dockerfile pour ne plus que le build plante
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Successful in 1m8s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Failing after 1m42s
2026-04-27 21:43:13 +02:00
d7ceeac1b0 Correction package-lock
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 57s
Build & Push Images / build (core) (push) Successful in 1m34s
Build & Push Images / build (web) (push) Failing after 1m41s
2026-04-27 19:17:01 +02:00
cdbd3cd9b4 Modification lors de la création d'élément de campagne : quand on créer un nouvel élément, on arrive sur la modification et non le résumé de l'élément
Some checks failed
E2E Tests / e2e (push) Failing after 23s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m33s
Build & Push Images / build (web) (push) Failing after 1m39s
2026-04-27 19:03:58 +02:00
a708c74425 Correction du soucis de mise à jour via l'application
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 59s
Build & Push Images / build (core) (push) Successful in 1m39s
Build & Push Images / build (web) (push) Successful in 1m36s
2026-04-27 16:19:56 +02:00
9ad7651c44 Passage V0.7.0
Some checks failed
E2E Tests / e2e (push) Failing after 17s
Build & Push Images / build (brain) (push) Successful in 1m13s
Build & Push Images / build (core) (push) Successful in 1m37s
Build & Push Images / build (web) (push) Successful in 1m43s
2026-04-27 15:51:13 +02:00
389392fd1d Changement sur le Readme
Ajout d'une partie spécifique pour des PNJ dans la partie campagne
2026-04-27 15:48:04 +02:00
aaebeaa547 Mise à jour du readme d'accueil pour l'accès à la documentation
Some checks failed
E2E Tests / e2e (push) Failing after 17s
2026-04-27 08:27:38 +02:00
03ee3855f5 Passage version 0.6.14 + résolution d'un soucis sur l'updater depuis la migration sur git
Some checks failed
E2E Tests / e2e (push) Failing after 24s
Build & Push Images / build (brain) (push) Successful in 59s
Build & Push Images / build (core) (push) Successful in 1m36s
Build & Push Images / build (web) (push) Successful in 1m47s
2026-04-26 19:08:49 +02:00
94a39cf3b4 Mise en place de la pipeline pour github plutot que gitea ; mise en place des images docker sur GHCR plutôt que gitea
Some checks failed
E2E Tests / e2e (push) Failing after 22s
Build & Push Images / build (brain) (push) Successful in 58s
Build & Push Images / build (core) (push) Successful in 1m32s
Build & Push Images / build (web) (push) Successful in 1m40s
Passage version v0.6.13
2026-04-26 10:46:46 +02:00
efe6f6c2b0 Empêche la modale de ce fermer tant que le llm n'est pas télécharger
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m20s
Build & Push Images / build (web) (push) Successful in 1m30s
2026-04-26 09:12:36 +02:00
73a9d15786 Forçage HTTP/1.1 pour la partie python et passage en v0.6.11
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m17s
Build & Push Images / build (web) (push) Successful in 1m27s
2026-04-26 01:55:02 +02:00
dfe05cf2d2 Correction d'un bug lors de tentative de téléchargement de llm pour ollama
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 50s
Build & Push Images / build (core) (push) Successful in 1m22s
Build & Push Images / build (web) (push) Successful in 1m28s
2026-04-26 01:45:39 +02:00
fcba907438 Passage version 0.6.9
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 47s
Build & Push Images / build (core) (push) Successful in 1m18s
Build & Push Images / build (web) (push) Successful in 1m24s
2026-04-26 01:30:35 +02:00
5739602702 Changement du watchtower pour une version plus récente : projet originel abandonné, repris par un fork.
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Failing after 20s
Build & Push Images / build (web) (push) Failing after 21s
Build & Push Images / build (core) (push) Failing after 22s
2026-04-26 01:19:58 +02:00
addf78f01d Mise en place v0.6.8
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m20s
Build & Push Images / build (web) (push) Successful in 1m30s
Amélioration de l'installation automatique
Ajout de la possibilité de télécharger le llm que l'on veut à l'interieur de l'application en communicant avec ollama
2026-04-26 01:11:04 +02:00
5e04e84ee4 Mise à jour de la conf pour être sur que le cache angular est bien refresh
Some checks failed
E2E Tests / e2e (push) Failing after 20s
Mise à jour des installeurs
Mise en place de secure-host pour ne pas exposer Ollama à l'exterieur
2026-04-26 00:18:49 +02:00
8d5c2e2b7f Correction pour éviter que la fenêtre ce ferme sans qu'on voit le message d'erreur
Some checks failed
E2E Tests / e2e (push) Failing after 16s
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m18s
Build & Push Images / build (web) (push) Successful in 1m24s
2026-04-25 18:37:40 +02:00
788d2c12f2 Ajout d'un .bat pour l'exécution du .ps1
Some checks failed
E2E Tests / e2e (push) Failing after 16s
2026-04-25 18:34:52 +02:00
b25a9746cf Changement sur l'installation automatique : réduction des patterns suspects dans l'installation pour les antivirus (par exemple, monter automatiquement les privilèges en admin...),
Some checks failed
E2E Tests / e2e (push) Failing after 17s
afin d'éviter que l'appli ne soit détectée comme un virus
2026-04-25 18:24:44 +02:00
41fda9aeee Ajout d'un script pour installation automatique du produit
Some checks failed
E2E Tests / e2e (push) Failing after 19s
Build & Push Images / build (brain) (push) Successful in 45s
Build & Push Images / build (core) (push) Successful in 1m16s
Build & Push Images / build (web) (push) Successful in 1m26s
Ajout d'une partie mise à jour automatique : plus besoin de docker pull en ligne de commande ; on peut passer par l'interface
Refactoring partie Java pour respecter d'avantage le DDD : plus de jackson dans la partie domain

Passage version 0.6.6
2026-04-25 13:24:32 +02:00
550078268c Evolutions :
Some checks failed
Build & Push Images / build (brain) (push) Successful in 55s
Build & Push Images / build (core) (push) Successful in 1m35s
E2E Tests / e2e (push) Failing after 4m10s
Build & Push Images / build (web) (push) Successful in 2m0s
- Ajout d'icônes dans la scène, chapitre et arc
- Possibilité de bouger les cases dans la partie graphe et les textes associés si ces derniers ne sont pas visibles
- Changement sur le thème du graphe : mode sombre et plus blanc
- Barre d'action en haut, même pour la partie scène
- Mode sticky corrigé : plus de trou entre le haut du navigateur web et de la barre d'action

Passage version 0.6.5
2026-04-25 11:41:14 +02:00
0582690dca Correction d'un test unitaire
Some checks failed
E2E Tests / e2e (push) Failing after 3m43s
Ajout d'un champs image dans les templates par défaut en + du champs nom, description pour avoir un exemple
Correction du visuel du champs d'ajout lors de la modification d'un template (apparition ligne pleine au lieu de texte en pointillé)
Ajout d'un intercepteur pour la partie démo de l'application afin de bien rafraichir le cache angular lorsque le temps de démo est expiré
2026-04-25 09:23:56 +02:00
88278bd1dd Fix : problème d'ascenseur en bas de la page au niveau des templates
Some checks failed
Build & Push Images / build (brain) (push) Successful in 1m1s
Build & Push Images / build (core) (push) Successful in 1m32s
E2E Tests / e2e (push) Failing after 4m13s
Build & Push Images / build (web) (push) Successful in 1m53s
Sélection du template par défaut lors de la création d'une page en fonction du dossier
Passage v0.6.2
2026-04-25 01:39:05 +02:00
d24d6459a0 Ajout de test, correctif d'un problème d'horloge pour le workflow gitea actions pour le e2e
Some checks failed
E2E Tests / e2e (push) Failing after 3m33s
2026-04-25 00:51:32 +02:00
4b866e5212 Fix workflow gitea action pour e2e (tests automatisés via playwright) + correction d'une incohérence dans l'API coté java. Ajout d'autres tests utilisateur
Some checks failed
E2E Tests / e2e (push) Failing after 2m31s
2026-04-25 00:45:04 +02:00
6c6bd20f0d Mise en place de tests utilisateurs avec playwright pour la partie angular + corrections au niveau des labels avec for et id pour cliquer dessus
Some checks failed
E2E Tests / e2e (push) Failing after 5m30s
2026-04-25 00:25:53 +02:00
2764228abf Fix rate limit derriere Cloudflare + CORS sur POST demo 2026-04-24 08:55:40 +02:00
f95d69c915 Fix CORS 403 sur POST : passer APP_CORS_ALLOWED_ORIGINS au core démo 2026-04-24 08:46:26 +02:00
70351e9d9a Remplace docker SDK par appels HTTP directs (zero deps)
All checks were successful
Build & Push Images / build (brain) (push) Successful in 52s
Build & Push Images / build (core) (push) Successful in 1m28s
Build & Push Images / build (web) (push) Successful in 1m34s
2026-04-24 07:39:49 +02:00
ff4905126d Docker SDK v28 pour resoudre les conflits transitifs 2026-04-24 07:33:48 +02:00
0e5b5a7de4 Correction d'une dépendance go 2026-04-24 07:30:20 +02:00
c8c032336b Mise à jour du dockerfile suite à une dépendance trop ancienne sur go 2026-04-24 07:26:42 +02:00
dda27e55fc Orchestrateur go pour lancer la démo et mettre en place plusieurs instances docker 2026-04-23 17:49:26 +02:00
83ac67471e Changement dans la config pour éviter les url en dur + mise en place d'un mode démo 2026-04-23 17:15:08 +02:00
e3c8232e38 Version 0.6.1
All checks were successful
Build & Push Images / build (brain) (push) Successful in 48s
Build & Push Images / build (core) (push) Successful in 1m19s
Build & Push Images / build (web) (push) Successful in 1m31s
2026-04-23 14:36:09 +02:00
a4df9fc759 Ajout des personnage dans la sidebar de la campagne 2026-04-23 14:34:07 +02:00
f1989c1d77 Mutualisation de la version pour ne pas l'oublier dans le footer du front ;
All checks were successful
Build & Push Images / build (brain) (push) Successful in 50s
Build & Push Images / build (core) (push) Successful in 1m29s
Build & Push Images / build (web) (push) Successful in 1m24s
Passage en version v 0.6.0
2026-04-23 14:12:24 +02:00
8efdf5d0e0 Correction bug suppression complète coté lore (et suppression dans tout ce qui est campagne de la partie lore liée).
Améliorations ux :
- Bandeau en haut qui reste accessible lors de la création d'un élément (chapitre, page, scène etc...)
- Mise en place d'un surlignage pour voir su quel élément on est positionné
2026-04-23 14:06:50 +02:00
96bc5de942 Mise en place de la possibilité de supprimer des lores / campagnes d'un seul coup 2026-04-23 11:51:03 +02:00
84ccdd53ad Corrections d'ordre graphique / ergonomique :
- Lorsqu'on part de zéro : la création de dossier / page / template ce fait de manière plus fluide à la création d'un lore (par exemple création de page sans template et dossier : parcours facilité)
- Ajout d'un bouton "+" dans le header templates
- Harmonisation création / modification template

Correction de tests unitaires
2026-04-23 11:25:58 +02:00
29978058ee Correction d'un test unitaire
All checks were successful
Build & Push Images / build (brain) (push) Successful in 49s
Build & Push Images / build (core) (push) Successful in 1m24s
Build & Push Images / build (web) (push) Successful in 1m22s
2026-04-22 13:38:48 +02:00
e510f64336 Passage en v 0.5.0
Some checks failed
Build & Push Images / build (brain) (push) Successful in 47s
Build & Push Images / build (core) (push) Failing after 1m19s
Build & Push Images / build (web) (push) Successful in 1m23s
2026-04-22 13:33:47 +02:00
f189f67aaf Mise en place d'un bouton + au hover plutot qu'un affichage constant 2026-04-22 13:31:06 +02:00
8efa148739 Corrections visuel ; optimisation du chargement des pages (préchargement anticité, sinon temps de latence chaque fois qu'on visite un type de page une première fois) 2026-04-22 13:17:05 +02:00
8f4dd3e9d6 Ajout de la partie "Système de jeu" avec toute la partie stockage de règles de notre jeu.
Ajout de possibilité de stocker des fiches de personnages associés à une campagne également (personnages joueurs pour le moment)
2026-04-22 11:58:50 +02:00
bf38b6695f Ecriture de tests unitaires coté java pour améliorer la stabilité de l'application 2026-04-22 07:46:24 +02:00
24 changed files with 64 additions and 465 deletions

View File

@@ -26,9 +26,7 @@ from app.domain.ports import LLMProvider, PdfTextExtractor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Très basse : structuration = recopie/réorganisation fidèle, pas de créativité. _TEMPERATURE = 0.2
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
_TEMPERATURE = 0.1
# Nom de l'arc unique quand le livre n'est pas découpé en actes/parties. # Nom de l'arc unique quand le livre n'est pas découpé en actes/parties.
_DEFAULT_ARC_NAME = "Aventure principale" _DEFAULT_ARC_NAME = "Aventure principale"

View File

@@ -23,9 +23,7 @@ from app.domain.ports import LLMProvider, LLMProviderError, PdfTextExtractor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Température basse : tâche de tri/réécriture fidèle, pas de créativité. # Température basse : tâche de tri/réécriture fidèle, pas de créativité.
# Très basse : structuration = recopie/réorganisation fidèle, pas de créativité. _TEMPERATURE = 0.2
# Plus la valeur est haute, plus le modèle "brode" (invente du contenu absent).
_TEMPERATURE = 0.1
# Taxonomie canonique suggérée au modèle pour homogénéiser les titres entre # Taxonomie canonique suggérée au modèle pour homogénéiser les titres entre
# morceaux (sinon "Combat" / "Le combat" / "Règles de combat" se dispersent). # morceaux (sinon "Combat" / "Le combat" / "Règles de combat" se dispersent).

View File

@@ -12,45 +12,13 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import re
from app.domain.ports import LLMProvider, LLMProviderError from app.domain.ports import LLMProvider, LLMProviderError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_ATTEMPTS = 4 _ATTEMPTS = 3
_BASE_DELAY_SECONDS = 3.0 _BASE_DELAY_SECONDS = 2.0
# Un rate limit (429) "par minute" ne se libère pas en 2-3s : on attend plus
# longtemps pour ces erreurs-là (le free tier OpenRouter plafonne ~20 req/min).
_RATE_LIMIT_DELAYS = [10.0, 25.0, 45.0]
def _is_rate_limit(exc: LLMProviderError) -> bool:
msg = str(exc).lower()
return "429" in msg or "rate" in msg or "too many requests" in msg
def _is_daily_quota(exc: LLMProviderError) -> bool:
"""Limite PAR JOUR (vs par minute) : réessayer est inutile, elle ne se libère
qu'au reset quotidien. OpenRouter le précise dans le corps du 429."""
msg = str(exc).lower()
return "per-day" in msg or "per day" in msg or "free-models-per-day" in msg
# OpenRouter renvoie souvent le délai conseillé (saturation amont) :
# "retry_after_seconds": 8 ou "Retry-After": "8". On le respecte plutôt que
# d'attendre une durée fixe arbitraire.
_RETRY_AFTER_RE = re.compile(r'retry[_-]?after(?:_seconds)?"?\s*:\s*"?([0-9]+(?:\.[0-9]+)?)', re.IGNORECASE)
def _suggested_retry_after(exc: LLMProviderError) -> float | None:
match = _RETRY_AFTER_RE.search(str(exc))
if not match:
return None
try:
return float(match.group(1))
except ValueError:
return None
async def generate_with_retry( async def generate_with_retry(
@@ -60,12 +28,7 @@ async def generate_with_retry(
output_format: str | None = None, output_format: str | None = None,
temperature: float | None = None, temperature: float | None = None,
) -> str: ) -> str:
"""Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff). """Comme `llm.generate`, mais réessaie les erreurs transitoires (backoff x2)."""
Backoff plus long pour les 429 (rate limit) afin de laisser la fenêtre se
libérer. Nombre de tentatives borné : si le quota est durablement épuisé
(ex. limite/jour), l'erreur finit par remonter au lieu de boucler sans fin.
"""
delay = _BASE_DELAY_SECONDS delay = _BASE_DELAY_SECONDS
last_error: LLMProviderError | None = None last_error: LLMProviderError | None = None
for attempt in range(_ATTEMPTS): for attempt in range(_ATTEMPTS):
@@ -73,27 +36,12 @@ async def generate_with_retry(
return await llm.generate(prompt, output_format=output_format, temperature=temperature) return await llm.generate(prompt, output_format=output_format, temperature=temperature)
except LLMProviderError as exc: except LLMProviderError as exc:
last_error = exc last_error = exc
# Quota JOURNALIER épuisé : inutile d'insister, on remonte tout de suite
# (sinon on enchaîne des attentes longues pour rien, et on spamme l'API).
if _is_daily_quota(exc):
logger.warning("Quota journalier du fournisseur épuisé — abandon : %s", exc)
raise
if attempt < _ATTEMPTS - 1: if attempt < _ATTEMPTS - 1:
if _is_rate_limit(exc):
suggested = _suggested_retry_after(exc)
if suggested is not None:
# Indication serveur (saturation amont) + petite marge, plafonnée.
wait = min(suggested + 2.0, 60.0)
else:
wait = _RATE_LIMIT_DELAYS[min(attempt, len(_RATE_LIMIT_DELAYS) - 1)]
else:
wait = delay
delay *= 2
logger.warning( logger.warning(
"Appel LLM échoué (tentative %s/%s)%s : %s — nouvelle tentative dans %ss.", "Appel LLM échoué (tentative %s/%s) : %s — nouvelle tentative dans %ss.",
attempt + 1, _ATTEMPTS, " [rate limit]" if _is_rate_limit(exc) else "", attempt + 1, _ATTEMPTS, exc, delay,
exc, wait,
) )
await asyncio.sleep(wait) await asyncio.sleep(delay)
delay *= 2
assert last_error is not None assert last_error is not None
raise last_error raise last_error

View File

@@ -25,8 +25,8 @@ class Settings(BaseSettings):
extra="ignore", extra="ignore",
) )
# Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai ; "openrouter" = OpenRouter. # Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai (etage 2).
llm_provider: Literal["ollama", "onemin", "openrouter"] = "ollama" llm_provider: Literal["ollama", "onemin"] = "ollama"
ollama_base_url: str = "http://localhost:11434" ollama_base_url: str = "http://localhost:11434"
llm_model: str = "gemma4:26b" llm_model: str = "gemma4:26b"
@@ -47,12 +47,6 @@ class Settings(BaseSettings):
onemin_api_key: str = "" onemin_api_key: str = ""
onemin_model: str = "gpt-4o-mini" onemin_model: str = "gpt-4o-mini"
# OpenRouter (OpenAI-compatible). Cle + modele modifiables depuis l'UI.
# Defaut = routeur `openrouter/free` : choisit un modele GRATUIT (0 credit).
# Pour un modele precis gratuit : id finissant par `:free`.
openrouter_api_key: str = ""
openrouter_model: str = "openrouter/free"
# Taille cible d'un morceau (en tokens) pour l'import de PDF (regles/campagne). # Taille cible d'un morceau (en tokens) pour l'import de PDF (regles/campagne).
# Plus c'est gros, moins il y a de morceaux => moins de fragmentation et un # Plus c'est gros, moins il y a de morceaux => moins de fragmentation et un
# import plus rapide, MAIS il faut que ca tienne dans la fenetre du modele. # import plus rapide, MAIS il faut que ca tienne dans la fenetre du modele.

View File

@@ -29,8 +29,6 @@ _ALLOWED_KEYS = frozenset({
"llm_num_ctx", "llm_num_ctx",
"onemin_api_key", "onemin_api_key",
"onemin_model", "onemin_model",
"openrouter_api_key",
"openrouter_model",
"import_chunk_tokens", "import_chunk_tokens",
}) })

View File

@@ -1,139 +0,0 @@
"""Adapter OpenRouter — implémente les ports LLMProvider / LLMChatProvider.
OpenRouter expose l'API OpenAI standard (POST {base}/chat/completions, SSE), donc
cet adapter est en réalité un client "OpenAI-compatible". Le `generate` one-shot
passe lui aussi par le streaming (puis recollage) pour éviter les coupures de
passerelle sur les longues générations (cf. 1min.ai / Cloudflare 524).
Modèles GRATUITS : utiliser un id finissant par `:free` (ex.
`meta-llama/llama-3.3-70b-instruct:free`) ou le routeur `openrouter/free` (défaut)
qui choisit automatiquement un modèle gratuit — aucun crédit consommé.
"""
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_URL = "https://openrouter.ai/api/v1/chat/completions"
class OpenRouterLLMProvider:
"""Adapter OpenRouter (OpenAI-compatible) — satisfait LLMProvider et LLMChatProvider."""
def __init__(self, settings: Settings) -> None:
if not settings.openrouter_api_key:
raise LLMProviderError(
"Clé API OpenRouter manquante. Configure-la depuis l'écran Paramètres."
)
self._api_key = settings.openrouter_api_key
self._model = settings.openrouter_model
self._timeout = settings.llm_timeout_seconds
def _headers(self) -> dict[str, str]:
return {
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
# Attribution facultative (classement OpenRouter) — sans impact fonctionnel.
"HTTP-Referer": "https://loremind.app",
"X-Title": "LoreMind",
}
async def generate(
self,
prompt: str,
*,
output_format: str | None = None,
temperature: float | None = None,
) -> str:
"""One-shot via streaming (puis recollage) pour robustesse sur longues sorties."""
chunks: list[str] = []
async for token in self._stream([ChatMessage(role="user", content=prompt)], None, temperature):
chunks.append(token)
return "".join(chunks)
async def stream_chat(
self,
messages: list[ChatMessage],
*,
system_prompt: str | None = None,
temperature: float | None = None,
) -> AsyncIterator[str]:
async for token in self._stream(messages, system_prompt, temperature):
yield token
async def _stream(
self,
messages: list[ChatMessage],
system_prompt: str | None,
temperature: float | None,
) -> AsyncIterator[str]:
payload_messages: list[dict[str, str]] = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
for m in messages:
payload_messages.append({"role": m.role, "content": m.content})
body: dict[str, object] = {
"model": self._model,
"messages": payload_messages,
"stream": True,
}
if temperature is not None:
body["temperature"] = temperature
async with httpx.AsyncClient(timeout=self._timeout) as client:
try:
async with client.stream(
"POST", _API_URL, headers=self._headers(), json=body
) as response:
if response.status_code >= 400:
# En streaming, le corps n'est pas lu automatiquement : on le
# lit pour exposer le détail d'OpenRouter (ex. le 429 précise
# "free-models-per-day" vs "per-minute"), sinon on n'a que le
# code HTTP nu et le diagnostic est impossible.
detail = (await response.aread()).decode("utf-8", "replace").strip()
raise LLMProviderError(
f"Erreur OpenRouter (HTTP {response.status_code})"
+ (f" : {detail[:500]}" if detail else "")
)
async for token in self._parse_sse(response):
yield token
except httpx.HTTPError as exc:
raise LLMProviderError(self._format_http_error(exc)) from exc
@staticmethod
async def _parse_sse(response: httpx.Response) -> AsyncIterator[str]:
"""SSE OpenAI : lignes `data: {json}`, fin sur `data: [DONE]`."""
async for line in response.aiter_lines():
if not line or not line.startswith("data:"):
continue # lignes vides ou commentaires keep-alive (`: ...`)
data = line[len("data:"):].strip()
if data == "[DONE]":
return
try:
obj = json.loads(data)
except json.JSONDecodeError:
continue
choices = obj.get("choices")
if not choices:
continue
delta = choices[0].get("delta") or {}
content = delta.get("content")
if content:
yield content
def _format_http_error(self, exc: httpx.HTTPError) -> str:
"""Message lisible (timeout, quota 429, crédits 402, modèle inconnu…)."""
if isinstance(exc, httpx.TimeoutException):
return (
f"Erreur OpenRouter : délai dépassé (timeout {self._timeout}s). Le modèle a "
"mis trop de temps — réduis la taille des morceaux d'import ou augmente le timeout."
)
detail = str(exc) or exc.__class__.__name__
return f"Erreur OpenRouter ({exc.__class__.__name__}) : {detail}"

View File

@@ -45,13 +45,12 @@ from app.domain.models import (
from app.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError from app.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError
from app.infrastructure.ollama_adapter import OllamaLLMProvider from app.infrastructure.ollama_adapter import OllamaLLMProvider
from app.infrastructure.onemin_adapter import OneMinAiLLMProvider from app.infrastructure.onemin_adapter import OneMinAiLLMProvider
from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider
from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor
app = FastAPI( app = FastAPI(
title="LoreMind Brain", title="LoreMind Brain",
description="Backend IA pour la génération de contenu narratif.", description="Backend IA pour la génération de contenu narratif.",
version="0.10.3-beta", version="0.10.0-beta",
) )
@@ -355,8 +354,6 @@ def get_llm_provider(
try: try:
if settings.llm_provider == "onemin": if settings.llm_provider == "onemin":
return OneMinAiLLMProvider(settings) return OneMinAiLLMProvider(settings)
if settings.llm_provider == "openrouter":
return OpenRouterLLMProvider(settings)
return OllamaLLMProvider(settings) return OllamaLLMProvider(settings)
except LLMProviderError as exc: except LLMProviderError as exc:
# Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500 # Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500
@@ -691,9 +688,7 @@ async def chat_stream(
"system": _count_tokens(system_prompt_preview), "system": _count_tokens(system_prompt_preview),
"history": sum(_count_tokens(m.content) for m in history_msgs), "history": sum(_count_tokens(m.content) for m in history_msgs),
"current": _count_tokens(current_msg.content) if current_msg else 0, "current": _count_tokens(current_msg.content) if current_msg else 0,
# Plafond connu seulement pour Ollama (num_ctx). Pour le cloud (1min/OpenRouter) "max": settings.llm_num_ctx,
# on ne connaît pas la fenêtre réelle → 0 = "pas de max" (jauge sans dénominateur).
"max": settings.llm_num_ctx if settings.llm_provider == "ollama" else 0,
} }
async def event_stream() -> AsyncIterator[str]: async def event_stream() -> AsyncIterator[str]:
@@ -890,15 +885,12 @@ class SettingsDTO(BaseModel):
Les secrets (onemin_api_key) sont masques en lecture. Les secrets (onemin_api_key) sont masques en lecture.
""" """
llm_provider: Literal["ollama", "onemin", "openrouter"] llm_provider: Literal["ollama", "onemin"]
ollama_base_url: str ollama_base_url: str
llm_model: str llm_model: str
onemin_model: str onemin_model: str
# True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme. # True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme.
onemin_api_key_set: bool onemin_api_key_set: bool
openrouter_model: str
# True si une cle OpenRouter est deja configuree (cle elle-meme jamais renvoyee).
openrouter_api_key_set: bool
# Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert # Fenetre de contexte effective passee au modele (num_ctx Ollama) — sert
# aussi de plafond a la jauge de contexte UI. # aussi de plafond a la jauge de contexte UI.
llm_num_ctx: int llm_num_ctx: int
@@ -911,14 +903,12 @@ class SettingsDTO(BaseModel):
class SettingsUpdateDTO(BaseModel): class SettingsUpdateDTO(BaseModel):
"""Patch partiel des settings. Tous les champs sont optionnels.""" """Patch partiel des settings. Tous les champs sont optionnels."""
llm_provider: Literal["ollama", "onemin", "openrouter"] | None = None llm_provider: Literal["ollama", "onemin"] | None = None
ollama_base_url: str | None = None ollama_base_url: str | None = None
llm_model: str | None = None llm_model: str | None = None
onemin_model: str | None = None onemin_model: str | None = None
# Chaine vide => on efface la cle. None => pas de changement. # Chaine vide => on efface la cle. None => pas de changement.
onemin_api_key: str | None = None onemin_api_key: str | None = None
openrouter_model: str | None = None
openrouter_api_key: str | None = None
llm_num_ctx: int | None = None llm_num_ctx: int | None = None
import_chunk_tokens: int | None = None import_chunk_tokens: int | None = None
llm_timeout_seconds: int | None = None llm_timeout_seconds: int | None = None
@@ -931,8 +921,6 @@ def _to_settings_dto(s: Settings) -> SettingsDTO:
llm_model=s.llm_model, llm_model=s.llm_model,
onemin_model=s.onemin_model, onemin_model=s.onemin_model,
onemin_api_key_set=bool(s.onemin_api_key), onemin_api_key_set=bool(s.onemin_api_key),
openrouter_model=s.openrouter_model,
openrouter_api_key_set=bool(s.openrouter_api_key),
llm_num_ctx=s.llm_num_ctx, llm_num_ctx=s.llm_num_ctx,
import_chunk_tokens=s.import_chunk_tokens, import_chunk_tokens=s.import_chunk_tokens,
llm_timeout_seconds=s.llm_timeout_seconds, llm_timeout_seconds=s.llm_timeout_seconds,
@@ -1093,51 +1081,6 @@ async def delete_ollama_model(
return {"status": "deleted", "name": name} return {"status": "deleted", "name": name}
@app.get("/models/openrouter")
async def list_openrouter_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue DYNAMIQUE des modeles OpenRouter (API publique, sans cle).
Renvoie {models: [{id, name, context_length, free}]}, trie gratuits d'abord
puis contexte decroissant. `free` = id finissant par ':free' OU prix nul.
"""
try:
async with httpx.AsyncClient(timeout=20) as client:
response = await client.get("https://openrouter.ai/api/v1/models")
response.raise_for_status()
data = response.json()
except httpx.HTTPError as exc:
raise HTTPException(status_code=502, detail=f"OpenRouter injoignable : {exc}")
def _is_zero(value: object) -> bool:
try:
return float(value) == 0.0 # type: ignore[arg-type]
except (TypeError, ValueError):
return False
models: list[dict[str, object]] = []
for m in data.get("data", []) or []:
mid = str(m.get("id") or "")
if not mid:
continue
pricing = m.get("pricing") or {}
is_free = mid.endswith(":free") or (
_is_zero(pricing.get("prompt")) and _is_zero(pricing.get("completion"))
)
try:
ctx = int(m.get("context_length") or 0)
except (TypeError, ValueError):
ctx = 0
models.append({
"id": mid,
"name": str(m.get("name") or mid),
"context_length": ctx,
"free": is_free,
})
models.sort(key=lambda x: (not x["free"], -int(x["context_length"]))) # type: ignore[index]
return {"models": models}
@app.get("/models/onemin") @app.get("/models/onemin")
def list_onemin_models() -> dict[str, list[dict[str, object]]]: def list_onemin_models() -> dict[str, list[dict[str, object]]]:
"""Catalogue statique des modeles 1min.ai, groupes par fournisseur. """Catalogue statique des modeles 1min.ai, groupes par fournisseur.

View File

@@ -14,7 +14,7 @@
<groupId>com.loremind</groupId> <groupId>com.loremind</groupId>
<artifactId>loremind-core</artifactId> <artifactId>loremind-core</artifactId>
<version>0.10.3-beta</version> <version>0.10.0-beta</version>
<name>LoreMind Core</name> <name>LoreMind Core</name>
<description>Backend Core - Architecture Hexagonale</description> <description>Backend Core - Architecture Hexagonale</description>

View File

@@ -174,9 +174,10 @@ public class SessionStructuralContextBuilder {
.filter(a -> a.getId() != null) .filter(a -> a.getId() != null)
.collect(Collectors.toMap(Arc::getId, a -> a)); .collect(Collectors.toMap(Arc::getId, a -> a));
// On suit comme "quêtes" les chapitres CONDITIONNELS : ceux d'un arc HUB, ET boolean anyHub = arcs.stream().anyMatch(a -> a.getType() == ArcType.HUB);
// ceux d'un arc linéaire qui portent des prérequis. Un chapitre linéaire sans if (!anyHub) {
// condition reste hors du tableau (sinon tous les chapitres deviendraient des quêtes). return new HubStatus(List.of(), List.of(), List.of(), activeFlags);
}
// Map chapterId -> ProgressionStatus pour ce Playthrough // Map chapterId -> ProgressionStatus pour ce Playthrough
Map<String, ProgressionStatus> progressionByChapter = new HashMap<>(); Map<String, ProgressionStatus> progressionByChapter = new HashMap<>();
@@ -196,10 +197,8 @@ public class SessionStructuralContextBuilder {
List<String> lockedTitles = new ArrayList<>(); List<String> lockedTitles = new ArrayList<>();
for (Arc arc : arcs) { for (Arc arc : arcs) {
boolean isHub = arc.getType() == ArcType.HUB; if (arc.getType() != ArcType.HUB) continue;
for (Chapter c : chapterRepository.findByArcId(arc.getId())) { for (Chapter c : chapterRepository.findByArcId(arc.getId())) {
boolean hasPrereqs = c.getPrerequisites() != null && !c.getPrerequisites().isEmpty();
if (!isHub && !hasPrereqs) continue; // chapitre linéaire sans condition : ignoré
ProgressionStatus prog = progressionByChapter.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED); ProgressionStatus prog = progressionByChapter.getOrDefault(c.getId(), ProgressionStatus.NOT_STARTED);
QuestStatus status = prerequisiteEvaluator.computeStatus(prog, c.getPrerequisites(), ctx); QuestStatus status = prerequisiteEvaluator.computeStatus(prog, c.getPrerequisites(), ctx);
Arc parent = arcsById.get(c.getArcId()); Arc parent = arcsById.get(c.getArcId());

View File

@@ -140,11 +140,6 @@ public class SettingsController {
return forward(HttpMethod.GET, "/models/onemin", null); return forward(HttpMethod.GET, "/models/onemin", null);
} }
@GetMapping("/models/openrouter")
public ResponseEntity<Map<String, Object>> listOpenRouterModels() {
return forward(HttpMethod.GET, "/models/openrouter", null);
}
/** /**
* Serialiseur JSON minimal pour eviter d'instancier ObjectMapper a chaque * Serialiseur JSON minimal pour eviter d'instancier ObjectMapper a chaque
* appel. Suffisant pour notre cas d'usage : Map<String,Object> avec des * appel. Suffisant pour notre cas d'usage : Map<String,Object> avec des

View File

@@ -7,8 +7,8 @@
# Surchargables via variables d'environnement (CI) : # Surchargables via variables d'environnement (CI) :
# SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME, SPRING_DATASOURCE_PASSWORD # SPRING_DATASOURCE_URL, SPRING_DATASOURCE_USERNAME, SPRING_DATASOURCE_PASSWORD
spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/loremind_test} spring.datasource.url=${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/loremind_test}
spring.datasource.username=${SPRING_DATASOURCE_USERNAME:loremind_test} spring.datasource.username=${SPRING_DATASOURCE_USERNAME:ietm64}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:loremind_test} spring.datasource.password=${SPRING_DATASOURCE_PASSWORD:M&Ipourlavie64}
spring.datasource.driver-class-name=org.postgresql.Driver spring.datasource.driver-class-name=org.postgresql.Driver
# Configuration JPA pour les tests : schema recree a chaque run, pas de logs SQL. # Configuration JPA pour les tests : schema recree a chaque run, pas de logs SQL.

View File

@@ -5,9 +5,7 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# Upload max : aligné sur la limite multipart du Core (64 Mo). Couvre les client_max_body_size 10M;
# imports/adaptations de PDF (livres de règles/campagne illustrés) et les images.
client_max_body_size 64M;
location /api/ { location /api/ {
proxy_pass http://core:8080/api/; proxy_pass http://core:8080/api/;
@@ -17,11 +15,9 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# Streaming SSE (chat, import, adaptation) : pas de buffering, et timeouts
# longs car une génération sur gros PDF peut tarder avant le 1er octet.
proxy_buffering off; proxy_buffering off;
proxy_read_timeout 600s; proxy_read_timeout 300s;
proxy_send_timeout 600s; proxy_send_timeout 300s;
} }
# index.html : toujours revalide. Empeche un navigateur qui a precedemment # index.html : toujours revalide. Empeche un navigateur qui a precedemment

4
web/package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.10.3-beta", "version": "0.10.0-beta",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "loremind-web", "name": "loremind-web",
"version": "0.10.3-beta", "version": "0.10.0-beta",
"dependencies": { "dependencies": {
"@angular/animations": "^17.0.0", "@angular/animations": "^17.0.0",
"@angular/common": "^17.0.0", "@angular/common": "^17.0.0",

View File

@@ -1,6 +1,6 @@
{ {
"name": "loremind-web", "name": "loremind-web",
"version": "0.10.3-beta", "version": "0.10.0-beta",
"description": "LoreMind Frontend - Angular", "description": "LoreMind Frontend - Angular",
"scripts": { "scripts": {
"ng": "ng", "ng": "ng",

View File

@@ -127,8 +127,6 @@ export function buildCampaignTree(campaignId: string, data: CampaignTreeData): T
id: `chapter-${ch.id}`, id: `chapter-${ch.id}`,
label: ch.name, label: ch.name,
iconKey: ch.icon ?? undefined, 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, children: sceneItems,
route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`, route: `/campaigns/${campaignId}/arcs/${arc.id}/chapters/${ch.id}`,
createActions: [{ createActions: [{

View File

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

View File

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

View File

@@ -1,21 +1,5 @@
// Styles partagés via styles/_view.scss // 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 { .quest-status-bar {
border: 1px solid rgba(66, 133, 244, 0.25); border: 1px solid rgba(66, 133, 244, 0.25);
background: rgba(66, 133, 244, 0.04); 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 { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { forkJoin, of } from 'rxjs'; import { forkJoin, of } from 'rxjs';
import { switchMap } from 'rxjs/operators'; import { switchMap } from 'rxjs/operators';
import { LucideAngularModule, Pencil, Network, Trash2, Lock } from 'lucide-angular'; import { LucideAngularModule, Pencil, Network, Trash2 } from 'lucide-angular';
import { resolveCampaignIcon } from '../../campaign-icons'; import { resolveCampaignIcon } from '../../campaign-icons';
import { CampaignService } from '../../../services/campaign.service'; import { CampaignService } from '../../../services/campaign.service';
import { CharacterService } from '../../../services/character.service'; import { CharacterService } from '../../../services/character.service';
@@ -32,7 +32,6 @@ export class ChapterViewComponent implements OnInit, OnDestroy {
readonly Pencil = Pencil; readonly Pencil = Pencil;
readonly Network = Network; readonly Network = Network;
readonly Trash2 = Trash2; readonly Trash2 = Trash2;
readonly Lock = Lock;
readonly resolveCampaignIcon = resolveCampaignIcon; readonly resolveCampaignIcon = resolveCampaignIcon;
campaignId = ''; campaignId = '';

View File

@@ -6,16 +6,12 @@ import { Observable } from 'rxjs';
* Reflet de SettingsDTO cote Brain / SettingsController cote Core. * Reflet de SettingsDTO cote Brain / SettingsController cote Core.
* `onemin_api_key_set` indique si une cle est configuree, sans la reveler. * `onemin_api_key_set` indique si une cle est configuree, sans la reveler.
*/ */
export type LlmProvider = 'ollama' | 'onemin' | 'openrouter';
export interface AppSettings { export interface AppSettings {
llm_provider: LlmProvider; llm_provider: 'ollama' | 'onemin';
ollama_base_url: string; ollama_base_url: string;
llm_model: string; llm_model: string;
onemin_model: string; onemin_model: string;
onemin_api_key_set: boolean; onemin_api_key_set: boolean;
openrouter_model: string;
openrouter_api_key_set: boolean;
llm_num_ctx: number; llm_num_ctx: number;
/** Taille cible d'un morceau (tokens) pour l'import de PDF (règles/campagne). */ /** Taille cible d'un morceau (tokens) pour l'import de PDF (règles/campagne). */
import_chunk_tokens: number; import_chunk_tokens: number;
@@ -28,13 +24,11 @@ export interface AppSettings {
* `onemin_api_key: ''` efface la cle, `null`/absent ne touche a rien. * `onemin_api_key: ''` efface la cle, `null`/absent ne touche a rien.
*/ */
export interface AppSettingsUpdate { export interface AppSettingsUpdate {
llm_provider?: LlmProvider; llm_provider?: 'ollama' | 'onemin';
ollama_base_url?: string; ollama_base_url?: string;
llm_model?: string; llm_model?: string;
onemin_model?: string; onemin_model?: string;
onemin_api_key?: string; onemin_api_key?: string;
openrouter_model?: string;
openrouter_api_key?: string;
llm_num_ctx?: number; llm_num_ctx?: number;
import_chunk_tokens?: number; import_chunk_tokens?: number;
llm_timeout_seconds?: number; llm_timeout_seconds?: number;
@@ -78,11 +72,6 @@ export class SettingsService {
return this.http.get<{ groups: OneMinModelGroup[] }>(`${this.apiUrl}/models/onemin`, this.authOptions); 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. * Telecharge un modele Ollama et streame la progression au client.
* *
@@ -195,13 +184,3 @@ export interface OneMinModelGroup {
provider: string; provider: string;
models: 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,10 +32,6 @@
<input type="radio" name="provider" value="onemin" [(ngModel)]="settings.llm_provider"> <input type="radio" name="provider" value="onemin" [(ngModel)]="settings.llm_provider">
<span>1min.ai (cloud)</span> <span>1min.ai (cloud)</span>
</label> </label>
<label class="radio">
<input type="radio" name="provider" value="openrouter" [(ngModel)]="settings.llm_provider">
<span>OpenRouter (cloud)</span>
</label>
</div> </div>
</div> </div>
</section> </section>
@@ -177,45 +173,8 @@
</div> </div>
</section> </section>
<!-- Bloc OpenRouter --> <!-- Bloc Fenetre de contexte -->
<section class="card" *ngIf="settings && settings.llm_provider === 'openrouter'"> <section class="card" *ngIf="settings">
<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> <h2>Fenetre de contexte</h2>
<!-- Ollama : slider borne par le max du modele --> <!-- Ollama : slider borne par le max du modele -->
@@ -246,6 +205,20 @@
</p> </p>
</div> </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> </section>
<!-- Bloc Import de PDF --> <!-- Bloc Import de PDF -->

View File

@@ -4,7 +4,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router'; import { Router } from '@angular/router';
import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular'; import { LucideAngularModule, ArrowLeft, RefreshCw, Save, Check, AlertCircle, Download, Trash2, Plus, X, Heart, Link2, Unlink } from 'lucide-angular';
import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OpenRouterModel, OllamaPullEvent } from '../services/settings.service'; import { SettingsService, AppSettings, AppSettingsUpdate, OneMinModelGroup, OllamaPullEvent } from '../services/settings.service';
import { UpdatesService, UpdateStatus } from '../services/updates.service'; import { UpdatesService, UpdateStatus } from '../services/updates.service';
import { ConfigService } from '../services/config.service'; import { ConfigService } from '../services/config.service';
import { LayoutService } from '../services/layout.service'; import { LayoutService } from '../services/layout.service';
@@ -103,10 +103,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
settings: AppSettings | null = null; settings: AppSettings | null = null;
ollamaModels: string[] = []; ollamaModels: string[] = [];
oneminGroups: OneMinModelGroup[] = []; oneminGroups: OneMinModelGroup[] = [];
/** Catalogue OpenRouter (chargé dynamiquement) + filtre "gratuits seulement". */
openrouterModels: OpenRouterModel[] = [];
openrouterFreeOnly = true;
/** Fournisseur 1min.ai actuellement selectionne (filtre la liste des modeles). */ /** Fournisseur 1min.ai actuellement selectionne (filtre la liste des modeles). */
oneminProvider: string = ''; oneminProvider: string = '';
@@ -132,11 +128,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
/** True si l'utilisateur a coche "effacer la cle". */ /** True si l'utilisateur a coche "effacer la cle". */
clearApiKey = false; 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( constructor(
private settingsService: SettingsService, private settingsService: SettingsService,
private router: Router, private router: Router,
@@ -466,35 +457,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
}, },
error: () => this.oneminGroups = [] 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. */ /** Deduit le fournisseur a partir du modele actuellement configure. */
@@ -556,7 +518,6 @@ export class SettingsComponent implements OnInit, OnDestroy {
ollama_base_url: this.settings.ollama_base_url, ollama_base_url: this.settings.ollama_base_url,
llm_model: this.settings.llm_model, llm_model: this.settings.llm_model,
onemin_model: this.settings.onemin_model, onemin_model: this.settings.onemin_model,
openrouter_model: this.settings.openrouter_model,
llm_num_ctx: this.settings.llm_num_ctx, llm_num_ctx: this.settings.llm_num_ctx,
import_chunk_tokens: this.settings.import_chunk_tokens, import_chunk_tokens: this.settings.import_chunk_tokens,
llm_timeout_seconds: this.settings.llm_timeout_seconds llm_timeout_seconds: this.settings.llm_timeout_seconds
@@ -566,19 +527,12 @@ export class SettingsComponent implements OnInit, OnDestroy {
} else if (this.oneminApiKeyInput.trim()) { } else if (this.oneminApiKeyInput.trim()) {
patch.onemin_api_key = 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({ this.settingsService.updateSettings(patch).subscribe({
next: (s) => { next: (s) => {
this.settings = { ...s }; this.settings = { ...s };
this.oneminApiKeyInput = ''; this.oneminApiKeyInput = '';
this.clearApiKey = false; this.clearApiKey = false;
this.openrouterApiKeyInput = '';
this.clearOpenrouterKey = false;
this.successMessage = 'Parametres sauvegardes.'; this.successMessage = 'Parametres sauvegardes.';
this.saving = false; this.saving = false;
}, },

View File

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

View File

@@ -129,10 +129,6 @@ export class AiChatDrawerComponent implements OnInit, OnChanges, OnDestroy {
if (!this.usage) return 0; if (!this.usage) return 0;
return this.usage.system + this.usage.history + this.usage.current; 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 { get usageRatio(): number {
if (!this.usage || this.usage.max <= 0) return 0; if (!this.usage || this.usage.max <= 0) return 0;
return Math.min(1, this.usageTotal / this.usage.max); return Math.min(1, this.usageTotal / this.usage.max);