Plusieurs gros ajouts :
- Possibilité de discuter avec un PDF ; RAG ou analyse approfondie. Enlèvement de l'autre outil PDF de discussion qui analysait d'abord un PDF en proposant directement une intégration sans attendre qu'on pose de question - Mise en place de l'import directement dans les outils dans la sidebar - Mise en place d'un outil pour créer des tables aléatoires avec possibilité d'utiliser pendant la partie - Mise en place d'un outil pour mettre en place des PNJ, scènes, chapitre.... directement à partir de la discussion avec le PDF - Mise en place RAG avec mistal-embeding ou nomic si on utilise ollama - Mise en place mistral, google en fournisseurs alternatifs pour l'IA dans le cloud - version 0.11.0-bêta
This commit is contained in:
@@ -1,108 +0,0 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
/** Évènements du flux SSE de conseils d'adaptation. */
|
||||
export type AdaptStreamEvent =
|
||||
| { type: 'token'; value: string }
|
||||
| { type: 'done' }
|
||||
| { type: 'error'; message: string };
|
||||
|
||||
/** Message de la conversation d'adaptation. */
|
||||
export interface AdaptMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service : adaptation conversationnelle d'un PDF à une campagne (streamée).
|
||||
* fetch() + SSE (POST multipart impossible avec EventSource), décodage ligne à ligne.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CampaignAdaptService {
|
||||
|
||||
adviseStream(campaignId: string, file: File, messages: AdaptMessage[]): Observable<AdaptStreamEvent> {
|
||||
return new Observable<AdaptStreamEvent>((subscriber) => {
|
||||
const controller = new AbortController();
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
form.append('messages', JSON.stringify(messages ?? []));
|
||||
|
||||
fetch(`/api/campaigns/${campaignId}/adapt-pdf/stream`, {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'text/event-stream' },
|
||||
body: form,
|
||||
signal: controller.signal
|
||||
})
|
||||
.then(async (response) => {
|
||||
if (!response.ok || !response.body) {
|
||||
subscriber.error(new Error(`HTTP ${response.status}`));
|
||||
return;
|
||||
}
|
||||
await this.consume(response.body, subscriber);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (controller.signal.aborted) return;
|
||||
subscriber.error(err);
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
});
|
||||
}
|
||||
|
||||
private async consume(
|
||||
body: ReadableStream<Uint8Array>,
|
||||
subscriber: { next: (e: AdaptStreamEvent) => void; error: (e: unknown) => void; complete: () => void }
|
||||
): Promise<void> {
|
||||
const reader = 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 === 'error') {
|
||||
let message = "Échec de l'adaptation.";
|
||||
try { message = (JSON.parse(currentData) as { message?: string }).message ?? message; } catch { /* défaut */ }
|
||||
subscriber.error(new Error(message));
|
||||
} else if (name === 'done') {
|
||||
subscriber.next({ type: 'done' });
|
||||
subscriber.complete();
|
||||
} else if (name === 'token') {
|
||||
try {
|
||||
const tok = (JSON.parse(currentData) as { token?: string }).token;
|
||||
if (tok) subscriber.next({ type: 'token', value: tok });
|
||||
} catch { /* fragment ignoré */ }
|
||||
}
|
||||
currentEvent = null;
|
||||
currentData = '';
|
||||
};
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = 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();
|
||||
subscriber.complete();
|
||||
} catch (err) {
|
||||
subscriber.error(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { forkJoin, Subscription } from 'rxjs';
|
||||
import { CampaignService } from './campaign.service';
|
||||
import { CharacterService } from './character.service';
|
||||
import { NpcService } from './npc.service';
|
||||
import { RandomTableService } from './random-table.service';
|
||||
import { LayoutService } from './layout.service';
|
||||
import { loadCampaignTreeData, buildCampaignSidebarConfig } from '../campaigns/campaign-tree.helper';
|
||||
|
||||
@@ -26,6 +27,7 @@ export class CampaignSidebarService {
|
||||
private campaignService: CampaignService,
|
||||
private characterService: CharacterService,
|
||||
private npcService: NpcService,
|
||||
private randomTableService: RandomTableService,
|
||||
private layoutService: LayoutService
|
||||
) {}
|
||||
|
||||
@@ -42,7 +44,8 @@ export class CampaignSidebarService {
|
||||
this.campaignService,
|
||||
campaignId,
|
||||
this.characterService,
|
||||
this.npcService
|
||||
this.npcService,
|
||||
this.randomTableService
|
||||
)
|
||||
}).subscribe(({ campaign, allCampaigns, treeData }) => {
|
||||
this.layoutService.show(buildCampaignSidebarConfig(campaign, allCampaigns, treeData, campaignId));
|
||||
|
||||
49
web/src/app/services/notebook-action.model.ts
Normal file
49
web/src/app/services/notebook-action.model.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Protocole d'« actions » proposées par l'IA dans le chat d'un atelier : des blocs
|
||||
* ```loremind-action {json} ``` que le front transforme en cartes « Créer dans la
|
||||
* campagne ». Ce module définit les types + le parseur (extraction + texte nettoyé).
|
||||
*/
|
||||
|
||||
export interface NotebookActionEntry {
|
||||
minRoll: number;
|
||||
maxRoll: number;
|
||||
label: string;
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface NotebookAction {
|
||||
type: 'npc' | 'scene' | 'chapter' | 'arc' | 'table';
|
||||
name: string;
|
||||
description?: string;
|
||||
content?: string;
|
||||
arcType?: 'LINEAR' | 'HUB';
|
||||
diceFormula?: string;
|
||||
entries?: NotebookActionEntry[];
|
||||
}
|
||||
|
||||
const ACTION_RE = /```loremind-action\s*([\s\S]*?)```/g;
|
||||
const VALID_TYPES = new Set(['npc', 'scene', 'chapter', 'arc', 'table']);
|
||||
|
||||
/**
|
||||
* Extrait les blocs d'action COMPLETS d'un message et renvoie le texte sans ces
|
||||
* blocs. Les blocs encore en cours de streaming (clôture absente) ne matchent pas
|
||||
* → ils restent affichés tels quels brièvement, puis deviennent une carte.
|
||||
*/
|
||||
export function parseNotebookActions(content: string): { text: string; actions: NotebookAction[] } {
|
||||
const actions: NotebookAction[] = [];
|
||||
if (!content) return { text: '', actions };
|
||||
let match: RegExpExecArray | null;
|
||||
ACTION_RE.lastIndex = 0;
|
||||
while ((match = ACTION_RE.exec(content)) !== null) {
|
||||
try {
|
||||
const obj = JSON.parse(match[1].trim());
|
||||
if (obj && typeof obj.type === 'string' && VALID_TYPES.has(obj.type) && typeof obj.name === 'string') {
|
||||
actions.push(obj as NotebookAction);
|
||||
}
|
||||
} catch {
|
||||
/* bloc JSON invalide : ignoré */
|
||||
}
|
||||
}
|
||||
const text = content.replace(ACTION_RE, '').trim();
|
||||
return { text, actions };
|
||||
}
|
||||
40
web/src/app/services/notebook.model.ts
Normal file
40
web/src/app/services/notebook.model.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/** Reflet des entités notebook côté Core (atelier RAG). */
|
||||
|
||||
export interface Notebook {
|
||||
id: string;
|
||||
name: string;
|
||||
campaignId: string;
|
||||
}
|
||||
|
||||
export interface NotebookSource {
|
||||
id: string;
|
||||
notebookId: string;
|
||||
filename: string;
|
||||
/** INDEXING | READY | FAILED */
|
||||
status: string;
|
||||
chunkCount: number;
|
||||
pageCount: number;
|
||||
}
|
||||
|
||||
export interface NotebookMessage {
|
||||
id?: string;
|
||||
notebookId?: string;
|
||||
/** "user" | "assistant" */
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface NotebookDetail {
|
||||
id: string;
|
||||
name: string;
|
||||
campaignId: string;
|
||||
sources: NotebookSource[];
|
||||
messages: NotebookMessage[];
|
||||
}
|
||||
|
||||
/** Évènements du chat ancré streamé. */
|
||||
export type NotebookChatEvent =
|
||||
| { type: 'token'; value: string }
|
||||
| { type: 'progress'; current: number; total: number }
|
||||
| { type: 'done' }
|
||||
| { type: 'error'; message: string };
|
||||
126
web/src/app/services/notebook.service.ts
Normal file
126
web/src/app/services/notebook.service.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
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<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}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<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' },
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
34
web/src/app/services/random-table.model.ts
Normal file
34
web/src/app/services/random-table.model.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Reflet du RandomTableDTO côté Core. Une table aléatoire campagne + ses entrées.
|
||||
*/
|
||||
export interface RandomTableEntry {
|
||||
/** Borne basse du jet (incluse). */
|
||||
minRoll: number;
|
||||
/** Borne haute du jet (incluse). */
|
||||
maxRoll: number;
|
||||
/** Résultat court. */
|
||||
label: string;
|
||||
/** Détail markdown (« ce que c'est »). */
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
export interface RandomTable {
|
||||
id?: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
/** Formule du dé : "1d20", "2d6", "d100"… */
|
||||
diceFormula: string;
|
||||
icon?: string;
|
||||
campaignId: string;
|
||||
order?: number;
|
||||
entries: RandomTableEntry[];
|
||||
}
|
||||
|
||||
export interface RandomTableCreate {
|
||||
name: string;
|
||||
description?: string;
|
||||
diceFormula: string;
|
||||
icon?: string;
|
||||
campaignId: string;
|
||||
entries: RandomTableEntry[];
|
||||
}
|
||||
49
web/src/app/services/random-table.service.ts
Normal file
49
web/src/app/services/random-table.service.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { RandomTable, RandomTableCreate } from './random-table.model';
|
||||
|
||||
/**
|
||||
* CRUD des tables aléatoires (campagne). Mirroir de NpcService.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class RandomTableService {
|
||||
private apiUrl = '/api/random-tables';
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
getByCampaign(campaignId: string): Observable<RandomTable[]> {
|
||||
return this.http.get<RandomTable[]>(`${this.apiUrl}/campaign/${campaignId}`);
|
||||
}
|
||||
|
||||
getById(id: string): Observable<RandomTable> {
|
||||
return this.http.get<RandomTable>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
create(payload: RandomTableCreate): Observable<RandomTable> {
|
||||
return this.http.post<RandomTable>(this.apiUrl, payload);
|
||||
}
|
||||
|
||||
update(id: string, payload: RandomTable): Observable<RandomTable> {
|
||||
return this.http.put<RandomTable>(`${this.apiUrl}/${id}`, payload);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete<void>(`${this.apiUrl}/${id}`);
|
||||
}
|
||||
|
||||
/** Génère une PROPOSITION de table via l'IA (non persistée) à préremplir dans le formulaire. */
|
||||
generate(campaignId: string, description: string, diceFormula: string): Observable<RandomTable> {
|
||||
return this.http.post<RandomTable>(`${this.apiUrl}/generate`, { campaignId, description, diceFormula });
|
||||
}
|
||||
|
||||
/** Improvisation IA d'un court récit sur un résultat tiré (en partie). */
|
||||
improvise(
|
||||
campaignId: string, tableName: string, resultLabel: string, resultDetail: string
|
||||
): Observable<{ narration: string }> {
|
||||
return this.http.post<{ narration: string }>(
|
||||
`${this.apiUrl}/improvise`,
|
||||
{ campaignId, tableName, resultLabel, resultDetail }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ 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 type LlmProvider = 'ollama' | 'onemin' | 'openrouter';
|
||||
export type LlmProvider = 'ollama' | 'onemin' | 'openrouter' | 'mistral' | 'gemini';
|
||||
|
||||
export interface AppSettings {
|
||||
llm_provider: LlmProvider;
|
||||
@@ -16,6 +16,17 @@ export interface AppSettings {
|
||||
onemin_api_key_set: boolean;
|
||||
openrouter_model: string;
|
||||
openrouter_api_key_set: boolean;
|
||||
mistral_model: string;
|
||||
mistral_api_key_set: boolean;
|
||||
gemini_model: string;
|
||||
gemini_api_key_set: boolean;
|
||||
/** Embeddings (RAG des ateliers). */
|
||||
embedding_provider: 'ollama' | 'mistral';
|
||||
ollama_embedding_model: string;
|
||||
mistral_embedding_model: string;
|
||||
auto_pull_embedding_model: boolean;
|
||||
/** Nombre d'extraits récupérés par question (RAG). */
|
||||
rag_top_k: number;
|
||||
llm_num_ctx: number;
|
||||
/** Taille cible d'un morceau (tokens) pour l'import de PDF (règles/campagne). */
|
||||
import_chunk_tokens: number;
|
||||
@@ -35,6 +46,15 @@ export interface AppSettingsUpdate {
|
||||
onemin_api_key?: string;
|
||||
openrouter_model?: string;
|
||||
openrouter_api_key?: string;
|
||||
mistral_model?: string;
|
||||
mistral_api_key?: string;
|
||||
gemini_model?: string;
|
||||
gemini_api_key?: string;
|
||||
embedding_provider?: 'ollama' | 'mistral';
|
||||
ollama_embedding_model?: string;
|
||||
mistral_embedding_model?: string;
|
||||
auto_pull_embedding_model?: boolean;
|
||||
rag_top_k?: number;
|
||||
llm_num_ctx?: number;
|
||||
import_chunk_tokens?: number;
|
||||
llm_timeout_seconds?: number;
|
||||
@@ -83,6 +103,16 @@ export class SettingsService {
|
||||
return this.http.get<{ models: OpenRouterModel[] }>(`${this.apiUrl}/models/openrouter`, this.authOptions);
|
||||
}
|
||||
|
||||
/** Catalogue des modeles Mistral (dynamique si cle configuree, repli statique sinon). */
|
||||
listMistralModels(): Observable<{ models: MistralModel[] }> {
|
||||
return this.http.get<{ models: MistralModel[] }>(`${this.apiUrl}/models/mistral`, this.authOptions);
|
||||
}
|
||||
|
||||
/** Catalogue des modeles Gemini (dynamique si cle configuree, repli statique sinon). */
|
||||
listGeminiModels(): Observable<{ models: GeminiModel[] }> {
|
||||
return this.http.get<{ models: GeminiModel[] }>(`${this.apiUrl}/models/gemini`, this.authOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Telecharge un modele Ollama et streame la progression au client.
|
||||
*
|
||||
@@ -205,3 +235,13 @@ export interface OpenRouterModel {
|
||||
/** True si le modele est gratuit (id `:free` ou prix nul). */
|
||||
free: boolean;
|
||||
}
|
||||
|
||||
/** Un modele Mistral (catalogue dynamique ou repli statique). */
|
||||
export interface MistralModel {
|
||||
id: string;
|
||||
}
|
||||
|
||||
/** Un modele Gemini (catalogue dynamique ou repli statique). */
|
||||
export interface GeminiModel {
|
||||
id: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user