Files
LoreMind/web/src/app/services/notebook.service.ts
IETM_FIXE\ietm6 42c4b53ced
Some checks failed
Qualité & Sécurité / MegaLinter (PMD · Ruff · Bandit · gitleaks · hadolint) (push) Successful in 1m9s
Build & Push Images / build (brain) (push) Successful in 1m10s
Qualité & Sécurité / Trivy (CVE dépendances Maven / pip / npm) (push) Failing after 2m36s
Build & Push Images / build (web) (push) Successful in 1m48s
Build & Push Images / build-switcher (push) Successful in 20s
Build & Push Images / build (core) (push) Successful in 3m9s
Qualité & Sécurité / Web (ESLint · angular-eslint + sonarjs) (push) Successful in 38s
Stack qualité CI (MegaLinter, Trivy, ESLint) et purge du backlog lint web
CI (Gitea Actions) :
- Nouveau workflow quality.yml (séparé de ci.yml, non bloquant pour la release) :
  MegaLinter v9 (PMD, Ruff, Bandit, gitleaks, hadolint), ng lint (web) et
  Trivy (CVE des dépendances Maven/pip/npm), sur main + beta + PR
- Trivy : préchargement du cache Maven avant le scan — sans ça Maven Central
  rate-limite l'IP du runner (429) en résolvant les BOMs du parent Spring Boot
- upload-artifact v4 → v3 : l'API artifacts v4 n'est pas supportée par Gitea
  (corrige aussi l'upload du rapport Playwright de e2e.yml, cassé depuis toujours)
- Ruleset PMD projet (java-pmd-ruleset.xml, auto-détecté par MegaLinter) orienté
  bugs réels, sans le style Lombok-hostile du défaut : 11 337 → 65 findings ;
  PMD en mode rapport (JAVA_PMD_DISABLE_ERRORS) le temps de purger ce backlog

Web :
- angular-eslint 21 + eslint-plugin-sonarjs : les règles Sonar vivent désormais
  dans ESLint (config web/eslint.config.js, réglages justifiés en commentaire)
- tsconfig : skipLibCheck + types:[] + node_modules ré-exclu — répare les builds
  desktop Windows/Linux cassés par le conflit @types/eslint-scope vs ESLint 9
- Purge du backlog lint : 114 erreurs → 0 sur 66 fichiers (ngOnDestroy vides
  supprimés, any typés avec les vrais DTOs, outputs (close) → (closed), regex
  super-linéaires désamorcées, ternaires/fonctions imbriqués dépliés, intention
  documentée sur les catch/error volontairement vides)
- Bug latent corrigé au passage : license.service.disconnect() émettait
  undefined au lieu de true en cas de succès (masqué par un double cast)
2026-07-07 01:05:00 +02:00

134 lines
5.8 KiB
TypeScript

import { Injectable, NgZone } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { TranslateService } from '@ngx-translate/core';
import { Notebook, NotebookArchive, NotebookDetail, NotebookSource, NotebookChatEvent } from './notebook.model';
import { LanguageService } from './language.service';
import { parseSseStream } from '../shared/sse.util';
/** Normalise les sources renvoyées par le Brain (événement SSE `sources`). */
function mapSseSources(sources: { source_id?: string; page?: number | null; score?: number }[] | undefined) {
return (sources ?? []).map(s => ({
sourceId: s.source_id ?? '', page: s.page ?? null, score: s.score ?? 0
}));
}
/**
* Service des notebooks (atelier RAG) : CRUD, upload/indexation de sources,
* et chat ancré STREAMÉ (SSE via fetch, comme ai-chat.service).
*/
@Injectable({ providedIn: 'root' })
export class NotebookService {
private readonly apiUrl = '/api/notebooks';
constructor(private http: HttpClient, private zone: NgZone, private translate: TranslateService, private language: LanguageService) {}
listByCampaign(campaignId: string): Observable<Notebook[]> {
return this.http.get<Notebook[]>(`${this.apiUrl}/campaign/${campaignId}`);
}
get(id: string): Observable<NotebookDetail> {
return this.http.get<NotebookDetail>(`${this.apiUrl}/${id}`);
}
create(campaignId: string, name: string): Observable<Notebook> {
return this.http.post<Notebook>(this.apiUrl, { campaignId, name });
}
rename(id: string, name: string): Observable<Notebook> {
return this.http.put<Notebook>(`${this.apiUrl}/${id}`, { name });
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/${id}`);
}
/** Upload + indexation d'une source (multipart). Bloquant côté serveur (peut être long). */
addSource(notebookId: string, file: File): Observable<NotebookSource> {
const form = new FormData();
form.append('file', file, file.name);
return this.http.post<NotebookSource>(`${this.apiUrl}/${notebookId}/sources`, form);
}
deleteSource(sourceId: string): Observable<void> {
return this.http.delete<void>(`${this.apiUrl}/sources/${sourceId}`);
}
/** « Vider la conversation » : archive le fil actif (rien n'est supprimé). */
clearChat(notebookId: string): Observable<void> {
return this.http.post<void>(`${this.apiUrl}/${notebookId}/chat/clear`, {});
}
/** Conversations archivées, plus récentes d'abord. */
getArchives(notebookId: string): Observable<NotebookArchive[]> {
return this.http.get<NotebookArchive[]>(`${this.apiUrl}/${notebookId}/chat/archives`);
}
/**
* 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.
* `archiveIds` : archives de conversation cochées comme référence (clés archivedAt) ;
* leur contenu est injecté dans le contexte du prompt.
*/
streamChat(notebookId: string, message: string, deep = false, sourceIds?: string[], archiveIds?: string[]): Observable<NotebookChatEvent> {
return new Observable<NotebookChatEvent>((subscriber) => {
const controller = new AbortController();
const emit = (ev: NotebookChatEvent) => this.zone.run(() => subscriber.next(ev));
(async () => {
try {
const response = await fetch(`${this.apiUrl}/${notebookId}/chat/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'text/event-stream', 'X-User-Language': this.language.current },
credentials: 'include',
body: JSON.stringify({
message, deep,
sourceIds: sourceIds ?? null,
archiveIds: archiveIds?.length ? archiveIds : null
}),
signal: controller.signal,
});
if (!response.ok || !response.body) {
emit({ type: 'error', message: `HTTP ${response.status}` });
this.zone.run(() => subscriber.complete());
return;
}
await parseSseStream(response.body, ({ event, data }) => {
if (event === 'token') {
try { emit({ type: 'token', value: (JSON.parse(data) as { token?: string }).token ?? '' }); }
catch { /* ignore */ }
} else if (event === 'sources') {
try {
const o = JSON.parse(data) as { sources?: { source_id?: string; page?: number | null; score?: number }[] };
emit({ type: 'sources', sources: mapSseSources(o.sources) });
} catch { /* ignore */ }
} else if (event === 'progress') {
try {
const o = JSON.parse(data) as { current?: number; total?: number };
emit({ type: 'progress', current: o.current ?? 0, total: o.total ?? 0 });
} catch { /* ignore */ }
} else if (event === 'done') {
emit({ type: 'done' });
} else if (event === 'error') {
let msg = 'Erreur du chat.';
try { msg = (JSON.parse(data) as { message?: string }).message ?? msg; } catch { /* garde */ }
emit({ type: 'error', message: msg });
}
});
this.zone.run(() => subscriber.complete());
} catch (err) {
if ((err as Error).name !== 'AbortError') {
emit({ type: 'error', message: (err as Error).message || this.translate.instant('services.networkError') });
}
this.zone.run(() => subscriber.complete());
}
})();
return () => controller.abort();
});
}
}