import { Injectable, NgZone } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { Notebook, NotebookDetail, NotebookSource, NotebookChatEvent } from './notebook.model'; /** * 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) {} listByCampaign(campaignId: string): Observable { return this.http.get(`${this.apiUrl}/campaign/${campaignId}`); } get(id: string): Observable { return this.http.get(`${this.apiUrl}/${id}`); } create(campaignId: string, name: string): Observable { return this.http.post(this.apiUrl, { campaignId, name }); } rename(id: string, name: string): Observable { return this.http.put(`${this.apiUrl}/${id}`, { name }); } delete(id: string): Observable { return this.http.delete(`${this.apiUrl}/${id}`); } /** Upload + indexation d'une source (multipart). Bloquant côté serveur (peut être long). */ addSource(notebookId: string, file: File): Observable { const form = new FormData(); form.append('file', file, file.name); return this.http.post(`${this.apiUrl}/${notebookId}/sources`, form); } deleteSource(sourceId: string): Observable { return this.http.delete(`${this.apiUrl}/sources/${sourceId}`); } /** * Chat ancré streamé. fetch() + ReadableStream (HttpClient bufferise les SSE). * Émissions forcées dans la zone Angular pour la détection de changement. */ streamChat(notebookId: string, message: string, deep = false): Observable { return new Observable((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' }, credentials: 'include', body: JSON.stringify({ message, deep }), signal: controller.signal, }); if (!response.ok || !response.body) { emit({ type: 'error', message: `HTTP ${response.status}` }); this.zone.run(() => subscriber.complete()); return; } const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); let buffer = ''; let currentEvent: string | null = null; let currentData = ''; const dispatch = () => { const name = currentEvent ?? 'message'; if (name === 'token') { try { emit({ type: 'token', value: (JSON.parse(currentData) as { token?: string }).token ?? '' }); } catch { /* ignore */ } } else if (name === 'progress') { try { const o = JSON.parse(currentData) as { current?: number; total?: number }; emit({ type: 'progress', current: o.current ?? 0, total: o.total ?? 0 }); } catch { /* ignore */ } } else if (name === 'done') { emit({ type: 'done' }); } else if (name === 'error') { let msg = 'Erreur du chat.'; try { msg = (JSON.parse(currentData) as { message?: string }).message ?? msg; } catch { /* garde */ } emit({ type: 'error', message: msg }); } currentEvent = null; currentData = ''; }; while (true) { const { value, done } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); let idx: number; while ((idx = buffer.indexOf('\n')) >= 0) { const line = buffer.slice(0, idx).replace(/\r$/, ''); buffer = buffer.slice(idx + 1); if (line === '') { if (currentEvent !== null || currentData !== '') dispatch(); continue; } if (line.startsWith('event:')) currentEvent = line.slice(6).trim(); else if (line.startsWith('data:')) { const chunk = line.slice(5).replace(/^ /, ''); currentData = currentData ? `${currentData}\n${chunk}` : chunk; } } } if (currentEvent !== null || currentData !== '') dispatch(); this.zone.run(() => subscriber.complete()); } catch (err) { if ((err as Error).name !== 'AbortError') { emit({ type: 'error', message: (err as Error).message || 'Erreur réseau' }); } this.zone.run(() => subscriber.complete()); } })(); return () => controller.abort(); }); } }