import { Injectable, NgZone } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; /** * Reflet de SettingsDTO cote Brain / SettingsController cote Core. * `onemin_api_key_set` indique si une cle est configuree, sans la reveler. */ export interface AppSettings { llm_provider: 'ollama' | 'onemin'; ollama_base_url: string; llm_model: string; onemin_model: string; onemin_api_key_set: boolean; llm_num_ctx: number; } /** * Patch partiel — seuls les champs a modifier sont presents. * `onemin_api_key: ''` efface la cle, `null`/absent ne touche a rien. */ export interface AppSettingsUpdate { llm_provider?: 'ollama' | 'onemin'; ollama_base_url?: string; llm_model?: string; onemin_model?: string; onemin_api_key?: string; llm_num_ctx?: number; } /** Metadonnees d'un modele Ollama (issues de /api/show). */ export interface OllamaModelInfo { /** Fenetre de contexte max du modele (en tokens). 0 si inconnue. */ context_length: number; } @Injectable({ providedIn: 'root' }) export class SettingsService { private readonly apiUrl = '/api/settings'; // HTTP Basic : le browser gere le prompt natif de credentials au premier 401. // withCredentials=true pour que les creds soient renvoyees sur les appels // suivants en cross-origin (dev Angular sur :4200 -> core sur :8080). private readonly authOptions = { withCredentials: true }; constructor(private http: HttpClient, private zone: NgZone) {} getSettings(): Observable { return this.http.get(this.apiUrl, this.authOptions); } updateSettings(patch: AppSettingsUpdate): Observable { return this.http.put(this.apiUrl, patch, this.authOptions); } listOllamaModels(): Observable<{ models: string[] }> { return this.http.get<{ models: string[] }>(`${this.apiUrl}/models/ollama`, this.authOptions); } getOllamaModelInfo(name: string): Observable { return this.http.post( `${this.apiUrl}/models/ollama/info`, { name }, this.authOptions); } listOneMinModels(): Observable<{ groups: OneMinModelGroup[] }> { return this.http.get<{ groups: OneMinModelGroup[] }>(`${this.apiUrl}/models/onemin`, this.authOptions); } /** * Telecharge un modele Ollama et streame la progression au client. * * Le backend renvoie du NDJSON (un objet JSON par ligne) avec le format * Ollama natif : `{status, digest?, total?, completed?}`. On parse chaque * ligne au fur et a mesure et on emet via un Observable que le composant * peut consommer pour mettre a jour une barre de progression. * * On utilise `fetch` directement plutot que `HttpClient` car Angular * bufferise les reponses XHR, ce qui empeche le streaming en temps reel. */ pullOllamaModel(name: string): Observable { // fetch() s'execute hors zone Angular -> les emissions du subscriber // doivent etre forcees dans la zone via NgZone.run() pour que le change // detection se declenche et que la barre de progression se mette a jour // en temps reel. return new Observable((subscriber) => { const controller = new AbortController(); const emit = (ev: OllamaPullEvent) => this.zone.run(() => subscriber.next(ev)); const fail = (e: unknown) => this.zone.run(() => subscriber.error(e)); const done = () => this.zone.run(() => subscriber.complete()); (async () => { try { const response = await fetch(`${this.apiUrl}/models/ollama/pull`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ name }), signal: controller.signal, }); if (!response.ok) { const text = await response.text(); fail(new Error(`HTTP ${response.status} : ${text || 'reponse vide'}`)); return; } if (!response.body) { fail(new Error('Reponse sans corps : streaming non supporte par le navigateur')); return; } const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let eventsReceived = 0; while (true) { const { value, done: streamDone } = await reader.read(); if (streamDone) break; buffer += decoder.decode(value, { stream: true }); // Decoupage NDJSON : chaque ligne est un objet JSON complet. let nl: number; while ((nl = buffer.indexOf('\n')) >= 0) { const line = buffer.slice(0, nl).trim(); buffer = buffer.slice(nl + 1); if (!line) continue; try { emit(JSON.parse(line) as OllamaPullEvent); eventsReceived++; } catch { // Ligne non JSON (rare) : on l'ignore. } } } // Reste eventuel dans le buffer (derniere ligne sans '\n'). if (buffer.trim()) { try { emit(JSON.parse(buffer.trim()) as OllamaPullEvent); eventsReceived++; } catch { /* ignore */ } } if (eventsReceived === 0) { fail(new Error('Aucun evenement recu du serveur. Verifiez les logs du Brain (docker logs loremind-brain).')); return; } done(); } catch (err) { if ((err as Error).name !== 'AbortError') fail(err); } })(); return () => controller.abort(); }); } deleteOllamaModel(name: string): Observable<{ status: string; name: string }> { return this.http.delete<{ status: string; name: string }>( `${this.apiUrl}/models/ollama/${encodeURIComponent(name)}`, this.authOptions); } } /** * Format des evenements emis par Ollama pendant un pull. Les champs sont * optionnels car le serveur emet differents types de messages selon l'etape : * - `{status: "pulling manifest"}` * - `{status: "downloading", digest, total, completed}` * - `{status: "verifying sha256 digest"}` * - `{status: "writing manifest"}` * - `{status: "removing any unused layers"}` * - `{status: "success"}` * - `{error: "..."}` en cas d'erreur (modele inexistant, reseau, etc.) */ export interface OllamaPullEvent { status?: string; digest?: string; total?: number; completed?: number; error?: string; } /** Un groupe de modeles 1min.ai regroupes par fournisseur (Anthropic, OpenAI, ...). */ export interface OneMinModelGroup { provider: string; models: string[]; }