From 9ea43a1889f5768331bb65145d31fc6df7f0ed1d Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Fri, 5 Jun 2026 00:23:19 +0200 Subject: [PATCH] =?UTF-8?q?Ajout=20d'open=20router=20en=20fournisseur=20IA?= =?UTF-8?q?=20;=20ajout=20de=20la=20possibilit=C3=A9=20de=20mettre=20des?= =?UTF-8?q?=20conditions=20de=20d=C3=A9verouillage=20pour=20les=20chapitre?= =?UTF-8?q?s=20optionnels?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- brain/app/core/config.py | 10 +- brain/app/core/settings_store.py | 2 + .../app/infrastructure/openrouter_adapter.py | 130 ++++++++++++++++++ brain/app/main.py | 65 ++++++++- core/pom.xml | 2 +- .../SessionStructuralContextBuilder.java | 11 +- .../web/controller/SettingsController.java | 5 + web/package-lock.json | 4 +- web/package.json | 2 +- web/src/app/campaigns/campaign-tree.helper.ts | 2 + .../chapter-edit/chapter-edit.component.html | 16 ++- .../chapter-view/chapter-view.component.html | 19 ++- .../chapter-view/chapter-view.component.scss | 16 +++ .../chapter-view/chapter-view.component.ts | 3 +- web/src/app/services/settings.service.ts | 25 +++- web/src/app/settings/settings.component.html | 59 +++++--- web/src/app/settings/settings.component.ts | 48 ++++++- .../ai-chat-drawer.component.html | 9 +- .../ai-chat-drawer.component.ts | 4 + 19 files changed, 382 insertions(+), 50 deletions(-) create mode 100644 brain/app/infrastructure/openrouter_adapter.py diff --git a/brain/app/core/config.py b/brain/app/core/config.py index b5ec820..a5039e0 100644 --- a/brain/app/core/config.py +++ b/brain/app/core/config.py @@ -25,8 +25,8 @@ class Settings(BaseSettings): extra="ignore", ) - # Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai (etage 2). - llm_provider: Literal["ollama", "onemin"] = "ollama" + # Provider LLM actif. "ollama" = local ; "onemin" = 1min.ai ; "openrouter" = OpenRouter. + llm_provider: Literal["ollama", "onemin", "openrouter"] = "ollama" ollama_base_url: str = "http://localhost:11434" llm_model: str = "gemma4:26b" @@ -47,6 +47,12 @@ class Settings(BaseSettings): onemin_api_key: str = "" 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). # 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. diff --git a/brain/app/core/settings_store.py b/brain/app/core/settings_store.py index 10d1031..3e5d534 100644 --- a/brain/app/core/settings_store.py +++ b/brain/app/core/settings_store.py @@ -29,6 +29,8 @@ _ALLOWED_KEYS = frozenset({ "llm_num_ctx", "onemin_api_key", "onemin_model", + "openrouter_api_key", + "openrouter_model", "import_chunk_tokens", }) diff --git a/brain/app/infrastructure/openrouter_adapter.py b/brain/app/infrastructure/openrouter_adapter.py new file mode 100644 index 0000000..631ddaa --- /dev/null +++ b/brain/app/infrastructure/openrouter_adapter.py @@ -0,0 +1,130 @@ +"""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: + response.raise_for_status() + 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}" diff --git a/brain/app/main.py b/brain/app/main.py index e0d3558..82fb6a5 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -45,12 +45,13 @@ from app.domain.models import ( from app.domain.ports import LLMProvider, LLMProviderError, PdfExtractionError from app.infrastructure.ollama_adapter import OllamaLLMProvider from app.infrastructure.onemin_adapter import OneMinAiLLMProvider +from app.infrastructure.openrouter_adapter import OpenRouterLLMProvider from app.infrastructure.pdf_extractor import PyMuPdfTextExtractor app = FastAPI( title="LoreMind Brain", description="Backend IA pour la génération de contenu narratif.", - version="0.10.1-beta", + version="0.10.2-beta", ) @@ -354,6 +355,8 @@ def get_llm_provider( try: if settings.llm_provider == "onemin": return OneMinAiLLMProvider(settings) + if settings.llm_provider == "openrouter": + return OpenRouterLLMProvider(settings) return OllamaLLMProvider(settings) except LLMProviderError as exc: # Ex : cle 1min.ai manquante. On renvoie du 400 plutot que du 500 @@ -688,7 +691,9 @@ async def chat_stream( "system": _count_tokens(system_prompt_preview), "history": sum(_count_tokens(m.content) for m in history_msgs), "current": _count_tokens(current_msg.content) if current_msg else 0, - "max": settings.llm_num_ctx, + # Plafond connu seulement pour Ollama (num_ctx). Pour le cloud (1min/OpenRouter) + # 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]: @@ -885,12 +890,15 @@ class SettingsDTO(BaseModel): Les secrets (onemin_api_key) sont masques en lecture. """ - llm_provider: Literal["ollama", "onemin"] + llm_provider: Literal["ollama", "onemin", "openrouter"] ollama_base_url: str llm_model: str onemin_model: str # True si une cle 1min.ai est deja configuree — pas de leak de la cle elle-meme. onemin_api_key_set: bool + 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 # aussi de plafond a la jauge de contexte UI. llm_num_ctx: int @@ -903,12 +911,14 @@ class SettingsDTO(BaseModel): class SettingsUpdateDTO(BaseModel): """Patch partiel des settings. Tous les champs sont optionnels.""" - llm_provider: Literal["ollama", "onemin"] | None = None + llm_provider: Literal["ollama", "onemin", "openrouter"] | None = None ollama_base_url: str | None = None llm_model: str | None = None onemin_model: str | None = None # Chaine vide => on efface la cle. None => pas de changement. onemin_api_key: str | None = None + openrouter_model: str | None = None + openrouter_api_key: str | None = None llm_num_ctx: int | None = None import_chunk_tokens: int | None = None llm_timeout_seconds: int | None = None @@ -921,6 +931,8 @@ def _to_settings_dto(s: Settings) -> SettingsDTO: llm_model=s.llm_model, onemin_model=s.onemin_model, 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, import_chunk_tokens=s.import_chunk_tokens, llm_timeout_seconds=s.llm_timeout_seconds, @@ -1081,6 +1093,51 @@ async def delete_ollama_model( 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") def list_onemin_models() -> dict[str, list[dict[str, object]]]: """Catalogue statique des modeles 1min.ai, groupes par fournisseur. diff --git a/core/pom.xml b/core/pom.xml index 3c56a3e..6bd466f 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -14,7 +14,7 @@ com.loremind loremind-core - 0.10.1-beta + 0.10.2-beta LoreMind Core Backend Core - Architecture Hexagonale diff --git a/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java b/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java index 1c600c6..2b97308 100644 --- a/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java +++ b/core/src/main/java/com/loremind/application/generationcontext/SessionStructuralContextBuilder.java @@ -174,10 +174,9 @@ public class SessionStructuralContextBuilder { .filter(a -> a.getId() != null) .collect(Collectors.toMap(Arc::getId, a -> a)); - boolean anyHub = arcs.stream().anyMatch(a -> a.getType() == ArcType.HUB); - if (!anyHub) { - return new HubStatus(List.of(), List.of(), List.of(), activeFlags); - } + // On suit comme "quêtes" les chapitres CONDITIONNELS : ceux d'un arc HUB, ET + // ceux d'un arc linéaire qui portent des prérequis. Un chapitre linéaire sans + // condition reste hors du tableau (sinon tous les chapitres deviendraient des quêtes). // Map chapterId -> ProgressionStatus pour ce Playthrough Map progressionByChapter = new HashMap<>(); @@ -197,8 +196,10 @@ public class SessionStructuralContextBuilder { List lockedTitles = new ArrayList<>(); for (Arc arc : arcs) { - if (arc.getType() != ArcType.HUB) continue; + boolean isHub = arc.getType() == ArcType.HUB; 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); QuestStatus status = prerequisiteEvaluator.computeStatus(prog, c.getPrerequisites(), ctx); Arc parent = arcsById.get(c.getArcId()); diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java index dbd2cce..f70155d 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/SettingsController.java @@ -140,6 +140,11 @@ public class SettingsController { return forward(HttpMethod.GET, "/models/onemin", null); } + @GetMapping("/models/openrouter") + public ResponseEntity> listOpenRouterModels() { + return forward(HttpMethod.GET, "/models/openrouter", null); + } + /** * Serialiseur JSON minimal pour eviter d'instancier ObjectMapper a chaque * appel. Suffisant pour notre cas d'usage : Map avec des diff --git a/web/package-lock.json b/web/package-lock.json index 1e74024..d3f2092 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "loremind-web", - "version": "0.10.1-beta", + "version": "0.10.2-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loremind-web", - "version": "0.10.1-beta", + "version": "0.10.2-beta", "dependencies": { "@angular/animations": "^17.0.0", "@angular/common": "^17.0.0", diff --git a/web/package.json b/web/package.json index ca067c9..fc1d9cb 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "loremind-web", - "version": "0.10.1-beta", + "version": "0.10.2-beta", "description": "LoreMind Frontend - Angular", "scripts": { "ng": "ng", diff --git a/web/src/app/campaigns/campaign-tree.helper.ts b/web/src/app/campaigns/campaign-tree.helper.ts index 521cceb..a3c3bbb 100644 --- a/web/src/app/campaigns/campaign-tree.helper.ts +++ b/web/src/app/campaigns/campaign-tree.helper.ts @@ -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: [{ diff --git a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html index 038fcbe..62f9fed 100644 --- a/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html +++ b/web/src/app/campaigns/chapter/chapter-edit/chapter-edit.component.html @@ -26,13 +26,17 @@
- -
-

🗺️ Conditions de déblocage de la quête

+ +
+

+ {{ parentArc?.type === 'HUB' ? '🗺️ Conditions de déblocage de la quête' : '🔒 Conditions de déblocage du chapitre' }} +

- 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. {{ chapter.name }} -

{{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }}

+

+ {{ parentArc?.type === 'HUB' ? 'Quête (Hub)' : 'Chapitre' }} + + + Conditionnel + +

@@ -173,8 +177,45 @@ - -
+ +
+

Configuration OpenRouter

+ +
+ + + +
+ +
+ + + +

+ Liste chargee automatiquement depuis OpenRouter. Pour rester gratuit : + un modele marque · gratuit ou le routeur openrouter/free + (choisit un modele gratuit tout seul). Pour les imports, prefere un gratuit a + grand contexte (valeur « ctx »). +

+
+
+ + +

Fenetre de contexte

@@ -205,20 +246,6 @@

- -
- - -

- 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. -

-
diff --git a/web/src/app/settings/settings.component.ts b/web/src/app/settings/settings.component.ts index d525fb7..63b5cdb 100644 --- a/web/src/app/settings/settings.component.ts +++ b/web/src/app/settings/settings.component.ts @@ -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; }, diff --git a/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.html b/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.html index 606a3c5..9d39af8 100644 --- a/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.html +++ b/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.html @@ -97,13 +97,14 @@
-
+ [attr.title]="'System: ' + usage.system + ' · Historique: ' + usage.history + ' · Courant: ' + usage.current + (usageHasMax ? ' / ' + usage.max : '') + ' tokens'"> + +
- Contexte : {{ usageTotal }} / {{ usage.max }} tokens - {{ usagePercent }}% + Contexte : {{ usageTotal }} / {{ usage.max }} tokens + {{ usagePercent }}%
diff --git a/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts b/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts index 42a8d4a..1b615c8 100644 --- a/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts +++ b/web/src/app/shared/ai-chat-drawer/ai-chat-drawer.component.ts @@ -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);