diff --git a/brain/app/main.py b/brain/app/main.py index 4856d19..8ff1e15 100644 --- a/brain/app/main.py +++ b/brain/app/main.py @@ -26,7 +26,7 @@ from app.infrastructure.ollama_model_installer import ensure_ollama_embedding_mo app = FastAPI( title="LoreMind Brain", description="Backend IA pour la génération de contenu narratif.", - version="0.12.4-beta", + version="0.12.5-beta", ) logger = logging.getLogger(__name__) diff --git a/core/pom.xml b/core/pom.xml index 57f5b95..2e4962d 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -14,7 +14,7 @@ com.loremind loremind-core - 0.12.4-beta + 0.12.5-beta LoreMind Core Backend Core - Architecture Hexagonale diff --git a/core/src/main/java/com/loremind/infrastructure/web/controller/NotebookController.java b/core/src/main/java/com/loremind/infrastructure/web/controller/NotebookController.java index b72fd46..06af118 100644 --- a/core/src/main/java/com/loremind/infrastructure/web/controller/NotebookController.java +++ b/core/src/main/java/com/loremind/infrastructure/web/controller/NotebookController.java @@ -125,6 +125,13 @@ public class NotebookController { .map(m -> new NotebookChatStreamer.Msg(m.getRole(), m.getContent())) .toList(); List sourceIds = service.readySourceIds(id); + if (req.sourceIds() != null) { + // Sélection de l'UI (cases cochées) : on ne garde que les sources qui + // appartiennent bien à CE notebook et sont prêtes — un id étranger est + // ignoré. Limite le coût (ex. analyse approfondie sur 1 PDF au lieu de 5). + var wanted = new java.util.HashSet<>(req.sourceIds()); + sourceIds = sourceIds.stream().filter(wanted::contains).toList(); + } String context = service.buildContext(nb.getCampaignId()); boolean deep = req.deep() != null && req.deep(); @@ -220,5 +227,10 @@ public class NotebookController { public record CreateRequest(String campaignId, String name) {} public record RenameRequest(String name) {} - public record ChatRequest(String message, Boolean deep) {} + /** + * @param sourceIds Optionnel : sous-ensemble de sources à utiliser pour ce tour + * (cases cochées dans l'UI). Null = toutes les sources prêtes. + * Toujours intersecté avec les sources du notebook (sécurité). + */ + public record ChatRequest(String message, Boolean deep, List sourceIds) {} } diff --git a/web/package-lock.json b/web/package-lock.json index d3dfe14..1b61336 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "loremind-web", - "version": "0.12.4-beta", + "version": "0.12.5-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "loremind-web", - "version": "0.12.4-beta", + "version": "0.12.5-beta", "dependencies": { "@angular/animations": "^21.2.16", "@angular/common": "^21.2.16", diff --git a/web/package.json b/web/package.json index 8d3d99e..0d29401 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "loremind-web", - "version": "0.12.4-beta", + "version": "0.12.5-beta", "description": "LoreMind Frontend - Angular", "scripts": { "ng": "ng", diff --git a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.html b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.html index a2a6f45..94df349 100644 --- a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.html +++ b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.html @@ -20,13 +20,30 @@ @if (uploadError) {

{{ uploadError }}

} + @if (readySourceCount > 1) { +

+ {{ selectedSourceIds.size }}/{{ readySourceCount }} source(s) utilisée(s) par le chat et l'analyse approfondie +

+ } @for (s of sources; track s) { -
- - +
+ @if (s.status === 'READY') { + + } @else { + + + }
{{ s.filename }}
diff --git a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.scss b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.scss index c02f795..1328361 100644 --- a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.scss +++ b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.scss @@ -39,12 +39,36 @@ } .nbd-upload-error { color: #e88; font-size: 0.8rem; margin: 0.2rem 0; } +// Compteur de sources actives — ambre quand une partie est décochée, pour +// rappeler que le chat ne voit pas tout. +.nbd-sources-hint { + font-size: 0.72rem; + color: var(--color-text-muted, #9aa0aa); + margin: 0.1rem 0 0.2rem; + + &.partial { color: #fbbf24; } +} + .nbd-source { display: flex; align-items: flex-start; gap: 0.5rem; padding: 0.5rem; border-radius: 7px; background: rgba(255,255,255,0.03); lucide-icon.ok { color: #6bd08a; } lucide-icon.fail { color: #e88; } lucide-icon.busy { color: #e0c074; } + + // Case « source utilisée par le chat » (sources prêtes uniquement). + .nbd-source-check { + margin: 2px 0 0; + accent-color: #6bd08a; + cursor: pointer; + flex-shrink: 0; + } + + // Source décochée : grisée = hors du périmètre du chat et de l'analyse. + &.nbd-source--off { + opacity: 0.5; + } + .nbd-source-info { flex: 1; min-width: 0; } .nbd-source-name { font-size: 0.85rem; font-weight: 500; word-break: break-word; } .nbd-source-meta { font-size: 0.72rem; color: var(--color-text-muted, #9aa0aa); } diff --git a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts index eff1f12..f979059 100644 --- a/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts +++ b/web/src/app/campaigns/notebook/notebook-detail/notebook-detail.component.ts @@ -137,13 +137,54 @@ export class NotebookDetailComponent implements OnInit { this.detail = d; this.sources = d.sources ?? []; this.messages = d.messages ?? []; + this.syncSelection(); }, error: () => this.back() }); } reloadSources(): void { - this.service.get(this.notebookId).subscribe({ next: (d) => this.sources = d.sources ?? [] }); + this.service.get(this.notebookId).subscribe({ next: (d) => { + this.sources = d.sources ?? []; + this.syncSelection(); + } }); + } + + // --- Sélection des sources utilisées par le chat ------------------------- + // Par défaut tout est coché ; décocher permet de limiter une question (et + // surtout une analyse approfondie, coûteuse en requêtes) à certains PDF. + + /** IDs des sources cochées (sous-ensemble des sources READY). */ + selectedSourceIds = new Set(); + /** IDs déjà vus — pour ne cocher par défaut que les NOUVELLES sources. */ + private knownSourceIds = new Set(); + + /** Aligne la sélection sur la liste courante : nouvelles sources cochées par + * défaut, sources supprimées retirées, choix de l'utilisateur préservés. */ + private syncSelection(): void { + const readyIds = new Set(this.sources.filter(s => s.status === 'READY').map(s => s.id)); + for (const id of readyIds) { + if (!this.knownSourceIds.has(id)) { + this.selectedSourceIds.add(id); + this.knownSourceIds.add(id); + } + } + for (const id of [...this.selectedSourceIds]) { + if (!readyIds.has(id)) this.selectedSourceIds.delete(id); + } + } + + isSelected(s: NotebookSource): boolean { + return this.selectedSourceIds.has(s.id); + } + + toggleSource(s: NotebookSource): void { + if (this.selectedSourceIds.has(s.id)) this.selectedSourceIds.delete(s.id); + else this.selectedSourceIds.add(s.id); + } + + get readySourceCount(): number { + return this.sources.filter(s => s.status === 'READY').length; } // --- Sources --- @@ -181,7 +222,7 @@ export class NotebookDetailComponent implements OnInit { this.messages.push(assistant); this.sending = true; - this.service.streamChat(this.notebookId, text, deep).subscribe({ + this.service.streamChat(this.notebookId, text, deep, [...this.selectedSourceIds]).subscribe({ next: (ev) => { if (ev.type === 'token') { this.deepProgress = null; assistant.content += ev.value; } else if (ev.type === 'sources') assistant.sources = ev.sources; diff --git a/web/src/app/services/notebook.service.ts b/web/src/app/services/notebook.service.ts index 951a739..b4d3f34 100644 --- a/web/src/app/services/notebook.service.ts +++ b/web/src/app/services/notebook.service.ts @@ -47,8 +47,11 @@ export class NotebookService { /** * Chat ancré streamé. fetch() + ReadableStream (HttpClient bufferise les SSE). * Émissions forcées dans la zone Angular pour la détection de changement. + * + * `sourceIds` : sous-ensemble de sources à utiliser pour ce tour (cases cochées) ; + * undefined = toutes les sources prêtes du notebook. */ - streamChat(notebookId: string, message: string, deep = false): Observable { + streamChat(notebookId: string, message: string, deep = false, sourceIds?: string[]): Observable { return new Observable((subscriber) => { const controller = new AbortController(); const emit = (ev: NotebookChatEvent) => this.zone.run(() => subscriber.next(ev)); @@ -59,7 +62,7 @@ export class NotebookService { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream' }, credentials: 'include', - body: JSON.stringify({ message, deep }), + body: JSON.stringify({ message, deep, sourceIds: sourceIds ?? null }), signal: controller.signal, }); if (!response.ok || !response.body) {