goblin-agents 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.editorconfig +9 -0
- package/.zou-flow +2 -0
- package/aiAgent.js +4 -0
- package/config.js +34 -0
- package/lib/llm/aiAgent.js +677 -0
- package/lib/llm/providers.js +198 -0
- package/lib/llm/utils.js +291 -0
- package/package.json +31 -0
package/.editorconfig
ADDED
package/.zou-flow
ADDED
package/aiAgent.js
ADDED
package/config.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
module.exports = [
|
|
4
|
+
{
|
|
5
|
+
type: 'input',
|
|
6
|
+
name: 'version',
|
|
7
|
+
message: 'Agents version',
|
|
8
|
+
default: 2,
|
|
9
|
+
},
|
|
10
|
+
{
|
|
11
|
+
type: 'input',
|
|
12
|
+
name: 'defaultProfile',
|
|
13
|
+
message: 'Agents default profile',
|
|
14
|
+
default: null,
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
type: 'input',
|
|
18
|
+
name: 'defaultSettings',
|
|
19
|
+
message: 'Agents default setting',
|
|
20
|
+
default: null,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
type: 'input',
|
|
24
|
+
name: 'profiles',
|
|
25
|
+
message: 'Agents profiles for overriding settings',
|
|
26
|
+
default: {},
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
type: 'input',
|
|
30
|
+
name: 'settings',
|
|
31
|
+
message: 'Settings by name',
|
|
32
|
+
default: {},
|
|
33
|
+
},
|
|
34
|
+
];
|
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
const {Elf} = require('xcraft-core-goblin');
|
|
3
|
+
const {id, MetaShape} = require('xcraft-core-goblin/lib/types.js');
|
|
4
|
+
const {
|
|
5
|
+
string,
|
|
6
|
+
array,
|
|
7
|
+
enumeration,
|
|
8
|
+
option,
|
|
9
|
+
object,
|
|
10
|
+
boolean,
|
|
11
|
+
number,
|
|
12
|
+
record,
|
|
13
|
+
} = require('xcraft-core-stones');
|
|
14
|
+
const LLMProvider = require('./providers.js');
|
|
15
|
+
const {OCR_SYSTEM_PROMPT, getPDFImages} = require('./utils.js');
|
|
16
|
+
|
|
17
|
+
class MessageShape {
|
|
18
|
+
role = enumeration('assistant', 'user', 'tool');
|
|
19
|
+
content = string;
|
|
20
|
+
images = option(array(string));
|
|
21
|
+
tool_calls = option(array(object));
|
|
22
|
+
tool_call_id = option(string);
|
|
23
|
+
name = option(string);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
class OptionsShape {
|
|
27
|
+
// Active NUMA (Non-Uniform Memory Access) pour optimiser la gestion mémoire sur les architectures multi-sockets.
|
|
28
|
+
numa = option(boolean);
|
|
29
|
+
// Détermine la taille maximale du contexte utilisé par le modèle (en tokens).
|
|
30
|
+
num_ctx = option(number);
|
|
31
|
+
// Nombre de requêtes traitées simultanément par lots (batch processing).
|
|
32
|
+
num_batch = option(number);
|
|
33
|
+
// Nombre de GPU à utiliser pour l'inférence (si pris en charge).
|
|
34
|
+
num_gpu = option(number);
|
|
35
|
+
// Identifiant du GPU principal utilisé pour l'inférence.
|
|
36
|
+
main_gpu = option(number);
|
|
37
|
+
// Active le mode faible consommation de mémoire vidéo (utile pour les GPU avec peu de VRAM).
|
|
38
|
+
low_vram = option(boolean);
|
|
39
|
+
// Utilise des valeurs en float16 pour les clés et valeurs des caches d'attention (optimisation mémoire et performance).
|
|
40
|
+
f16_kv = option(boolean);
|
|
41
|
+
// Active la sortie des logits pour chaque token généré (utile pour le débogage et certaines analyses).
|
|
42
|
+
logits_all = option(boolean);
|
|
43
|
+
// Charge uniquement le vocabulaire du modèle, sans les poids (utile pour l'analyse des tokens).
|
|
44
|
+
vocab_only = option(boolean);
|
|
45
|
+
// Utilise mmap pour mapper les poids du modèle en mémoire au lieu de les charger en RAM (économie de mémoire).
|
|
46
|
+
use_mmap = option(boolean);
|
|
47
|
+
// Utilise mlock pour empêcher la mise en cache des poids en swap (utile pour les systèmes à faible latence).
|
|
48
|
+
use_mlock = option(boolean);
|
|
49
|
+
// Ne génère pas de texte, mais renvoie uniquement les embeddings des tokens (pour des cas d'usage spécifiques comme la recherche vectorielle).
|
|
50
|
+
embedding_only = option(boolean);
|
|
51
|
+
// Nombre de threads utilisés pour l'inférence sur CPU.
|
|
52
|
+
num_thread = option(number);
|
|
53
|
+
// Nombre de tokens à conserver avant le début d'une nouvelle génération.
|
|
54
|
+
num_keep = option(number);
|
|
55
|
+
// Graine pour la génération aléatoire, permet de reproduire les mêmes résultats.
|
|
56
|
+
seed = option(number);
|
|
57
|
+
// Nombre de tokens à prédire lors de la génération.
|
|
58
|
+
num_predict = option(number);
|
|
59
|
+
// Filtre les tokens les moins probables en ne conservant que les `top_k` plus probables.
|
|
60
|
+
top_k = option(number);
|
|
61
|
+
// Filtre les tokens en fonction d'une probabilité cumulative `top_p` (nucleus sampling).
|
|
62
|
+
top_p = option(number);
|
|
63
|
+
// Facteur de filtrage basé sur la fonction TFS-z pour la diversité dans la génération.
|
|
64
|
+
tfs_z = option(number);
|
|
65
|
+
// Probabilité typique utilisée pour équilibrer la diversité et la cohérence de la génération.
|
|
66
|
+
typical_p = option(number);
|
|
67
|
+
// Nombre de tokens récents pris en compte pour éviter la répétition dans la génération.
|
|
68
|
+
repeat_last_n = option(number);
|
|
69
|
+
// Contrôle la température du modèle (plus haut = génération plus créative, plus bas = plus déterministe).
|
|
70
|
+
temperature = option(number);
|
|
71
|
+
// Applique une pénalité sur la répétition de mots/tokens déjà générés.
|
|
72
|
+
repeat_penalty = option(number);
|
|
73
|
+
// Applique une pénalité sur les tokens déjà générés pour encourager la diversité.
|
|
74
|
+
presence_penalty = option(number);
|
|
75
|
+
// Applique une pénalité en fonction de la fréquence d’apparition des tokens (évite les répétitions excessives).
|
|
76
|
+
frequency_penalty = option(number);
|
|
77
|
+
// Active le mode Mirostat, un algorithme adaptatif pour le contrôle de la perplexité.
|
|
78
|
+
mirostat = option(number);
|
|
79
|
+
// Paramètre `tau` du mode Mirostat, ajuste le niveau de perplexité cible.
|
|
80
|
+
mirostat_tau = option(number);
|
|
81
|
+
// Paramètre `eta` du mode Mirostat, contrôle la vitesse d'adaptation.
|
|
82
|
+
mirostat_eta = option(number);
|
|
83
|
+
// Applique une pénalisation spécifique aux nouvelles lignes pour limiter leur apparition.
|
|
84
|
+
penalize_newline = option(boolean);
|
|
85
|
+
// Liste de tokens ou mots qui stoppent immédiatement la génération lorsqu’ils sont rencontrés.
|
|
86
|
+
stop = option(array(string));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
class AiAgentShape {
|
|
90
|
+
id = id('aiAgent');
|
|
91
|
+
version = number;
|
|
92
|
+
name = string; //agent name
|
|
93
|
+
role = string; //agent role
|
|
94
|
+
prompt = string; //default prompt
|
|
95
|
+
provider = enumeration('ollama', 'open-ai');
|
|
96
|
+
model = string;
|
|
97
|
+
host = string;
|
|
98
|
+
headers = record(string, string);
|
|
99
|
+
messages = record(string, array(MessageShape)); //for keeping chat history by context
|
|
100
|
+
options = option(OptionsShape); //for tweeking
|
|
101
|
+
format = option(object);
|
|
102
|
+
tools = option(array(object));
|
|
103
|
+
toolServiceId = option(string);
|
|
104
|
+
usability = enumeration('disabled', 'experimental', 'stable', 'deprecated');
|
|
105
|
+
meta = MetaShape;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
class AiAgentState extends Elf.Sculpt(AiAgentShape) {}
|
|
109
|
+
|
|
110
|
+
/** @type {Partial<AiAgentState>} */
|
|
111
|
+
const defaultValues = {
|
|
112
|
+
name: 'agent',
|
|
113
|
+
role: 'assistant',
|
|
114
|
+
prompt: '',
|
|
115
|
+
provider: 'ollama',
|
|
116
|
+
model: 'mistral-small',
|
|
117
|
+
options: {},
|
|
118
|
+
host: 'http://127.0.0.1:11434',
|
|
119
|
+
headers: {},
|
|
120
|
+
format: null,
|
|
121
|
+
tools: null,
|
|
122
|
+
usability: 'disabled',
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
class AiAgentLogic extends Elf.Archetype {
|
|
126
|
+
static db = 'agents';
|
|
127
|
+
state = new AiAgentState({
|
|
128
|
+
id: undefined,
|
|
129
|
+
version: 1,
|
|
130
|
+
name: defaultValues.name,
|
|
131
|
+
role: defaultValues.role,
|
|
132
|
+
prompt: defaultValues.prompt,
|
|
133
|
+
provider: defaultValues.provider,
|
|
134
|
+
model: defaultValues.model,
|
|
135
|
+
messages: {},
|
|
136
|
+
options: defaultValues.options,
|
|
137
|
+
host: defaultValues.host,
|
|
138
|
+
headers: defaultValues.headers,
|
|
139
|
+
format: defaultValues.format,
|
|
140
|
+
tools: defaultValues.tools,
|
|
141
|
+
usability: defaultValues.usability,
|
|
142
|
+
meta: {
|
|
143
|
+
status: 'published',
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
create(id, agentState) {
|
|
148
|
+
const {state} = this;
|
|
149
|
+
state.id = id;
|
|
150
|
+
this.patch(agentState);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
patch(patch) {
|
|
154
|
+
const {state} = this;
|
|
155
|
+
for (const [k, v] of Object.entries(defaultValues)) {
|
|
156
|
+
state[k] = patch[k] ? patch[k] : v;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
upgrade(version, patch) {
|
|
161
|
+
const {state} = this;
|
|
162
|
+
this.patch(patch);
|
|
163
|
+
state.version = version;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
reset(contextId) {
|
|
167
|
+
const {state} = this;
|
|
168
|
+
state.messages[contextId] = [];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
set(contextId, messages) {
|
|
172
|
+
const {state} = this;
|
|
173
|
+
state.messages[contextId] = messages;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
chat(contextId, role, content) {
|
|
177
|
+
const {state} = this;
|
|
178
|
+
if (!state.messages[contextId]) {
|
|
179
|
+
state.messages[contextId] = [];
|
|
180
|
+
}
|
|
181
|
+
state.messages[contextId].push({role, content});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
ask(contextId, question, answer) {
|
|
185
|
+
const {state} = this;
|
|
186
|
+
if (!state.messages[contextId]) {
|
|
187
|
+
state.messages[contextId] = [];
|
|
188
|
+
}
|
|
189
|
+
state.messages[contextId].push({role: 'user', content: question});
|
|
190
|
+
state.messages[contextId].push({role: 'assistant', content: answer});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
addAssistantMessage(contextId, answer) {
|
|
194
|
+
const {state} = this;
|
|
195
|
+
if (!state.messages[contextId]) {
|
|
196
|
+
state.messages[contextId] = [];
|
|
197
|
+
}
|
|
198
|
+
state.messages[contextId].push({role: 'assistant', content: answer});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
callTools(contextId, toolCalls) {
|
|
202
|
+
const {state} = this;
|
|
203
|
+
if (!state.messages[contextId]) {
|
|
204
|
+
state.messages[contextId] = [];
|
|
205
|
+
}
|
|
206
|
+
state.messages[contextId].push({
|
|
207
|
+
role: 'assistant',
|
|
208
|
+
tool_calls: toolCalls,
|
|
209
|
+
content: '',
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
addToolMessage(contextId, toolCallId, toolName, answer) {
|
|
214
|
+
const {state} = this;
|
|
215
|
+
if (!state.messages[contextId]) {
|
|
216
|
+
state.messages[contextId] = [];
|
|
217
|
+
}
|
|
218
|
+
state.messages[contextId].push({
|
|
219
|
+
role: 'tool',
|
|
220
|
+
tool_call_id: toolCallId,
|
|
221
|
+
name: toolName,
|
|
222
|
+
content: answer,
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
change(path, newValue) {
|
|
227
|
+
const {state} = this;
|
|
228
|
+
state._state.set(path, newValue);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
trash() {
|
|
232
|
+
const {state} = this;
|
|
233
|
+
state.meta.status = 'trashed';
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
class AiAgent extends Elf {
|
|
238
|
+
logic = Elf.getLogic(AiAgentLogic);
|
|
239
|
+
state = new AiAgentState();
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* @param {string} id
|
|
243
|
+
* @param {string} desktopId
|
|
244
|
+
* @param {Partial<AiAgentState>} [agentState]
|
|
245
|
+
* @returns {Promise<this>}
|
|
246
|
+
*/
|
|
247
|
+
async create(id, desktopId, agentState) {
|
|
248
|
+
this.logic.create(id, agentState);
|
|
249
|
+
await this.persist();
|
|
250
|
+
return this;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Restore the last known 'persist' when the changes provided by the client
|
|
255
|
+
* are using a different version.
|
|
256
|
+
*/
|
|
257
|
+
async beforePersistOnServer() {
|
|
258
|
+
const {state} = this;
|
|
259
|
+
const {version} = require('xcraft-core-etc')().load('goblin-agents');
|
|
260
|
+
if (state.version === version) {
|
|
261
|
+
return; /* It's fine, we keep the changes */
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/* Restore the last 'persist' */
|
|
265
|
+
const prev = await this.cryo.getState(
|
|
266
|
+
AiAgentLogic.db,
|
|
267
|
+
this.id,
|
|
268
|
+
AiAgentShape
|
|
269
|
+
);
|
|
270
|
+
this.logic.patch(prev);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* @param {Partial<AiAgentState>} [agentState]
|
|
275
|
+
*/
|
|
276
|
+
async patch(agentState) {
|
|
277
|
+
this.logic.patch(agentState);
|
|
278
|
+
await this.persist();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* @param {number} version
|
|
283
|
+
* @param {Partial<AiAgentState>} [agentState]
|
|
284
|
+
*/
|
|
285
|
+
async upgrade(version, agentState) {
|
|
286
|
+
const {state} = this;
|
|
287
|
+
if (state.version >= version) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
this.logic.upgrade(version, agentState);
|
|
291
|
+
await this.persist();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async gen(prompt) {
|
|
295
|
+
const Instance = LLMProvider.get(this.state.provider);
|
|
296
|
+
const provider = new Instance(this.state.host, this.state.headers);
|
|
297
|
+
|
|
298
|
+
let options;
|
|
299
|
+
if (this.state.options) {
|
|
300
|
+
options = this.state.options.toJS();
|
|
301
|
+
}
|
|
302
|
+
return await provider.gen({
|
|
303
|
+
model: this.state.model,
|
|
304
|
+
prompt,
|
|
305
|
+
system: this.state.prompt,
|
|
306
|
+
format: this.state.format?.toJS(),
|
|
307
|
+
options,
|
|
308
|
+
stream: false,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async reset(contextId, save = false) {
|
|
313
|
+
if (!contextId) {
|
|
314
|
+
for (const contextId of Object.keys(this.state.messages)) {
|
|
315
|
+
await this.reset(contextId);
|
|
316
|
+
}
|
|
317
|
+
} else {
|
|
318
|
+
this.logic.reset(contextId);
|
|
319
|
+
}
|
|
320
|
+
if (save) {
|
|
321
|
+
await this.persist();
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
async readPDFpages(pdfPath) {
|
|
326
|
+
const Instance = LLMProvider.get(this.state.provider);
|
|
327
|
+
const provider = new Instance(this.state.host, this.state.headers);
|
|
328
|
+
|
|
329
|
+
const images = await getPDFImages(pdfPath);
|
|
330
|
+
const descriptions = [];
|
|
331
|
+
for (const image of images) {
|
|
332
|
+
const messages = [
|
|
333
|
+
{
|
|
334
|
+
role: 'user',
|
|
335
|
+
content: [
|
|
336
|
+
{
|
|
337
|
+
type: 'text',
|
|
338
|
+
text: OCR_SYSTEM_PROMPT,
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
type: 'image_url',
|
|
342
|
+
image_url: {
|
|
343
|
+
url: image,
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
],
|
|
347
|
+
},
|
|
348
|
+
];
|
|
349
|
+
const result = await provider.chat({
|
|
350
|
+
model: this.state.model,
|
|
351
|
+
messages,
|
|
352
|
+
});
|
|
353
|
+
descriptions.push(result.message.content);
|
|
354
|
+
}
|
|
355
|
+
return descriptions;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async embed(rawText) {
|
|
359
|
+
if (!rawText) {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
const Instance = LLMProvider.get(this.state.provider);
|
|
363
|
+
const provider = new Instance(this.state.host, this.state.headers);
|
|
364
|
+
return await provider.embed({
|
|
365
|
+
model: this.state.model,
|
|
366
|
+
input: rawText,
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
async embedInBatch(rawTexts = []) {
|
|
371
|
+
if (rawTexts.length === 0) {
|
|
372
|
+
return [];
|
|
373
|
+
}
|
|
374
|
+
const Instance = LLMProvider.get(this.state.provider);
|
|
375
|
+
const provider = new Instance(this.state.host, this.state.headers);
|
|
376
|
+
return await provider.embedInBatch({
|
|
377
|
+
model: this.state.model,
|
|
378
|
+
input: rawTexts,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async set(contextId, messages) {
|
|
383
|
+
this.logic.set(contextId, messages);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async chat(contextId, question, userDesktopId, sessionId = null) {
|
|
387
|
+
const Instance = LLMProvider.get(this.state.provider);
|
|
388
|
+
const provider = new Instance(this.state.host, this.state.headers);
|
|
389
|
+
const contextMessages = this.state.messages[contextId] || [];
|
|
390
|
+
const messages = [
|
|
391
|
+
{role: 'system', content: this.state.prompt},
|
|
392
|
+
...Array.from(contextMessages).map((m) => m._state.toJS()), //history
|
|
393
|
+
];
|
|
394
|
+
//add new question
|
|
395
|
+
messages.push({role: 'user', content: question});
|
|
396
|
+
|
|
397
|
+
let options;
|
|
398
|
+
if (this.state.options) {
|
|
399
|
+
const pState = this.state._state.toJS();
|
|
400
|
+
options = pState?.options;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
if (!sessionId) {
|
|
404
|
+
this.logic.chat(contextId, 'user', question);
|
|
405
|
+
sessionId = Elf.uuid();
|
|
406
|
+
this.log.dbg(`🐈 Chat session started : ${sessionId}`);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
const result = await provider.chat({
|
|
410
|
+
model: this.state.model,
|
|
411
|
+
messages,
|
|
412
|
+
options,
|
|
413
|
+
format: this.state.format?.toJS(),
|
|
414
|
+
tools: this.state.tools?.toJS(),
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
if (this.state.toolServiceId && this.state.tools) {
|
|
418
|
+
const used = await this.callTools(
|
|
419
|
+
contextId,
|
|
420
|
+
result.message,
|
|
421
|
+
userDesktopId,
|
|
422
|
+
sessionId
|
|
423
|
+
);
|
|
424
|
+
if (used) {
|
|
425
|
+
return await this.chat(contextId, question, userDesktopId, sessionId);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
//keep agent response
|
|
430
|
+
this.logic.chat(contextId, 'assistant', result.message.content);
|
|
431
|
+
|
|
432
|
+
return result.message.content;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async callTools(contextId, message, userDesktopId, sessionId) {
|
|
436
|
+
const {tool_calls} = message;
|
|
437
|
+
if (!tool_calls || !userDesktopId) {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
this.logic.callTools(contextId, tool_calls);
|
|
441
|
+
for (const tool of tool_calls) {
|
|
442
|
+
const {
|
|
443
|
+
id,
|
|
444
|
+
function: {name},
|
|
445
|
+
} = tool;
|
|
446
|
+
let result = '';
|
|
447
|
+
try {
|
|
448
|
+
const payload = {
|
|
449
|
+
id: this.state.toolServiceId,
|
|
450
|
+
desktopId: userDesktopId,
|
|
451
|
+
...JSON.parse(tool.function.arguments),
|
|
452
|
+
};
|
|
453
|
+
const toolNamespace = this.state.toolServiceId?.split('@', 1)[0];
|
|
454
|
+
const toolCmd = `${toolNamespace}.${name}`;
|
|
455
|
+
this.log.dbg(
|
|
456
|
+
`🔧 Tool call: ${toolCmd}(${JSON.stringify(
|
|
457
|
+
payload
|
|
458
|
+
)}) sid: ${sessionId}`
|
|
459
|
+
);
|
|
460
|
+
result = await this.quest.cmd(toolCmd, payload);
|
|
461
|
+
await this.addToolMessage(contextId, id, name, result);
|
|
462
|
+
} catch (err) {
|
|
463
|
+
this.log.dbg(`🔧 Tool error : ${err} sid: ${sessionId}`);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async addToolMessage(contextId, toolId, toolName, answer) {
|
|
470
|
+
this.logic.addToolMessage(contextId, toolId, toolName, answer);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
async ask(contextId, question, questionId) {
|
|
474
|
+
if (this.state.provider !== 'ollama') {
|
|
475
|
+
throw new Error('Streaming API not impl.');
|
|
476
|
+
}
|
|
477
|
+
const Instance = LLMProvider.get(this.state.provider);
|
|
478
|
+
const provider = new Instance(this.state.host, this.state.headers);
|
|
479
|
+
const feedId = await this.newQuestFeed();
|
|
480
|
+
|
|
481
|
+
const contextMessages = this.state.messages[contextId] || [];
|
|
482
|
+
const messages = [
|
|
483
|
+
{role: 'system', content: this.state.prompt},
|
|
484
|
+
...Array.from(contextMessages).map((m) => m._state.toJS()), //history
|
|
485
|
+
];
|
|
486
|
+
//add new question
|
|
487
|
+
messages.push({role: 'user', content: question});
|
|
488
|
+
|
|
489
|
+
let options;
|
|
490
|
+
if (this.state.options) {
|
|
491
|
+
const pState = this.state._state.toJS();
|
|
492
|
+
options = pState?.options;
|
|
493
|
+
}
|
|
494
|
+
const response = await provider.chat({
|
|
495
|
+
model: this.state.model,
|
|
496
|
+
messages,
|
|
497
|
+
options,
|
|
498
|
+
stream: true,
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
let agentResponse = '';
|
|
502
|
+
for await (const part of response) {
|
|
503
|
+
agentResponse += part.message.content;
|
|
504
|
+
this.quest.evt(`<${questionId}>`, {part: part.message.content});
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
//keep agent response
|
|
508
|
+
this.logic.ask(contextId, question, agentResponse);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
async resumeExchange(resumePrompt, contextId) {
|
|
512
|
+
const Instance = LLMProvider.get(this.state.provider);
|
|
513
|
+
const provider = new Instance(this.state.host, this.state.headers);
|
|
514
|
+
const feedId = await this.newQuestFeed();
|
|
515
|
+
const contextMessages = this.state.messages[contextId] || [];
|
|
516
|
+
|
|
517
|
+
let options;
|
|
518
|
+
if (this.state.options) {
|
|
519
|
+
options = this.state.options.toJS();
|
|
520
|
+
}
|
|
521
|
+
const prompt = Array.from(contextMessages)
|
|
522
|
+
.map((m) => m._state.toJS())
|
|
523
|
+
.filter((m) => m.role !== 'system')
|
|
524
|
+
.map((m) => `${m.role}: ${m.content}`)
|
|
525
|
+
.join('\n');
|
|
526
|
+
|
|
527
|
+
return await provider.gen({
|
|
528
|
+
model: this.state.model,
|
|
529
|
+
prompt,
|
|
530
|
+
system: resumePrompt,
|
|
531
|
+
options,
|
|
532
|
+
stream: false,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
async react(reactPrompt, contextId, question) {
|
|
537
|
+
const Instance = LLMProvider.get(this.state.provider);
|
|
538
|
+
const provider = new Instance(this.state.host, this.state.headers);
|
|
539
|
+
const feedId = await this.newQuestFeed();
|
|
540
|
+
const contextMessages = this.state.messages[contextId] || [];
|
|
541
|
+
const messages = [
|
|
542
|
+
{role: 'system', content: reactPrompt},
|
|
543
|
+
...Array.from(contextMessages).map((m) => m._state.toJS()), //history
|
|
544
|
+
{role: 'user', content: question},
|
|
545
|
+
];
|
|
546
|
+
this.log.dbg('ReAct Question:', question);
|
|
547
|
+
|
|
548
|
+
let options;
|
|
549
|
+
if (this.state.options) {
|
|
550
|
+
options = this.state.options.toJS();
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const answer = await provider.chat({
|
|
554
|
+
model: this.state.model,
|
|
555
|
+
messages,
|
|
556
|
+
options,
|
|
557
|
+
format: {
|
|
558
|
+
type: 'object',
|
|
559
|
+
properties: {
|
|
560
|
+
observation: {type: 'string'},
|
|
561
|
+
thought: {type: 'string'},
|
|
562
|
+
action: {type: 'string'},
|
|
563
|
+
agentId: {type: 'string'},
|
|
564
|
+
result: {type: 'string'},
|
|
565
|
+
},
|
|
566
|
+
required: ['observation', 'thought', 'action', 'agentId', 'result'],
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
if (!answer.message.content) {
|
|
570
|
+
await this.addAssistantMessage(
|
|
571
|
+
contextId,
|
|
572
|
+
'Génération stoppée. Impossible de répondre.'
|
|
573
|
+
);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
const {agentId, action, result, observation} = answer.message.content;
|
|
577
|
+
this.log.dbg('ReAct Observation:', observation);
|
|
578
|
+
this.log.dbg('ReAct Action:', action);
|
|
579
|
+
// Ajoute l'observation
|
|
580
|
+
await this.addAssistantMessage(contextId, observation);
|
|
581
|
+
// Si un agent est nécessaire, appel de l'agent externe
|
|
582
|
+
if (agentId) {
|
|
583
|
+
const result = await this.callAgent(
|
|
584
|
+
contextId,
|
|
585
|
+
agentId,
|
|
586
|
+
`${observation} -> ${action}`,
|
|
587
|
+
feedId
|
|
588
|
+
);
|
|
589
|
+
return {agentId, action, result, observation};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return {agentId, action, result, observation};
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
async callAgent(contextId, agentId, action, feedId) {
|
|
596
|
+
this.log.dbg(`🥸 calling "${agentId}" with action: "${action}"`);
|
|
597
|
+
const exist = await this.cryo.isPersisted(AiAgentLogic.db, agentId);
|
|
598
|
+
if (!exist) {
|
|
599
|
+
this.log.dbg(`🥸 "${agentId}" is not available, stopping"`);
|
|
600
|
+
await this.addAssistantMessage(contextId, 'Agent non disponible.');
|
|
601
|
+
return null;
|
|
602
|
+
}
|
|
603
|
+
const toolAgent = await new AiAgent(this).create(agentId, feedId);
|
|
604
|
+
const agentResponse = await toolAgent.chat(contextId, action);
|
|
605
|
+
if (typeof agentResponse !== 'string') {
|
|
606
|
+
await this.addAssistantMessage(
|
|
607
|
+
contextId,
|
|
608
|
+
'```json' + JSON.stringify(agentResponse, null, 2) + '```'
|
|
609
|
+
);
|
|
610
|
+
} else {
|
|
611
|
+
await this.addAssistantMessage(contextId, agentResponse);
|
|
612
|
+
}
|
|
613
|
+
return agentResponse;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async addAssistantMessage(contextId, message) {
|
|
617
|
+
this.logic.addAssistantMessage(contextId, message);
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
async save() {
|
|
621
|
+
await this.persist();
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* @template {keyof this['state']} K
|
|
626
|
+
* @param {K} path
|
|
627
|
+
* @param {this['state'][K]} newValue
|
|
628
|
+
*/
|
|
629
|
+
async change(path, newValue) {
|
|
630
|
+
if (path === 'options.temperature') {
|
|
631
|
+
newValue = parseFloat(newValue);
|
|
632
|
+
}
|
|
633
|
+
if (path === 'options.num_ctx') {
|
|
634
|
+
newValue = parseInt(newValue);
|
|
635
|
+
}
|
|
636
|
+
this.logic.change(path, newValue);
|
|
637
|
+
await this.persist();
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
async getUserChatContextHistory(contextId, filterTool = true) {
|
|
641
|
+
const contextMessages = this.state.messages[contextId] || [];
|
|
642
|
+
return [
|
|
643
|
+
...Array.from(contextMessages)
|
|
644
|
+
.map((m) => m._state.toJS())
|
|
645
|
+
.filter((m) => {
|
|
646
|
+
if (!filterTool) {
|
|
647
|
+
return m;
|
|
648
|
+
}
|
|
649
|
+
if (m.role !== 'tool' && !m.tool_calls) {
|
|
650
|
+
return m;
|
|
651
|
+
}
|
|
652
|
+
}),
|
|
653
|
+
];
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
async getBaseSettings() {
|
|
657
|
+
const {provider, host, headers, model} = this.state.toJS();
|
|
658
|
+
return {provider, host, headers, model};
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async getUsability() {
|
|
662
|
+
return this.state.usability;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async trash() {
|
|
666
|
+
this.logic.trash();
|
|
667
|
+
await this.persist();
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
delete() {}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
module.exports = {
|
|
674
|
+
AiAgent,
|
|
675
|
+
AiAgentLogic,
|
|
676
|
+
AiAgentShape,
|
|
677
|
+
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
const {Ollama} = require('ollama');
|
|
2
|
+
const {RestAPI} = require('xcraft-core-utils');
|
|
3
|
+
const {Semaphore} = require('xcraft-core-utils/lib/locks.js');
|
|
4
|
+
|
|
5
|
+
class LLMProvider {
|
|
6
|
+
static get(name) {
|
|
7
|
+
switch (name) {
|
|
8
|
+
default:
|
|
9
|
+
case 'ollama':
|
|
10
|
+
return OllamaProvider;
|
|
11
|
+
case 'open-ai':
|
|
12
|
+
return OpenAIProvider;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
constructor() {
|
|
17
|
+
if (new.target === LLMProvider) {
|
|
18
|
+
throw new Error('Cannot instantiate an abstract class.');
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async chatStream() {
|
|
23
|
+
throw new Error('not impl.');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async chat() {
|
|
27
|
+
throw new Error('not impl.');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async gen() {
|
|
31
|
+
throw new Error('not impl.');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async embed() {
|
|
35
|
+
throw new Error('not impl.');
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class OllamaProvider extends LLMProvider {
|
|
40
|
+
#semEmbed = new Semaphore(4);
|
|
41
|
+
#ollama;
|
|
42
|
+
|
|
43
|
+
constructor(host, headers) {
|
|
44
|
+
super();
|
|
45
|
+
this.#ollama = new Ollama({
|
|
46
|
+
host,
|
|
47
|
+
headers,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async chat(chatRequest) {
|
|
52
|
+
const {model, messages, options, format, tools, stream} = chatRequest;
|
|
53
|
+
const useFormat = !!format;
|
|
54
|
+
const chat = await this.#ollama.chat({
|
|
55
|
+
model,
|
|
56
|
+
messages,
|
|
57
|
+
options,
|
|
58
|
+
format,
|
|
59
|
+
tools,
|
|
60
|
+
stream,
|
|
61
|
+
});
|
|
62
|
+
if (useFormat) {
|
|
63
|
+
chat.message.content = JSON.parse(chat.message.content);
|
|
64
|
+
return chat;
|
|
65
|
+
} else {
|
|
66
|
+
return chat;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async gen(genRequest) {
|
|
71
|
+
const {model, system, prompt, format, options, stream} = genRequest;
|
|
72
|
+
const useFormat = !!format;
|
|
73
|
+
const gen = await this.#ollama.generate({
|
|
74
|
+
model,
|
|
75
|
+
prompt,
|
|
76
|
+
system,
|
|
77
|
+
format,
|
|
78
|
+
options,
|
|
79
|
+
stream,
|
|
80
|
+
});
|
|
81
|
+
if (useFormat) {
|
|
82
|
+
return JSON.parse(gen.response);
|
|
83
|
+
} else {
|
|
84
|
+
return gen.response;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async embed(embedRequest) {
|
|
89
|
+
try {
|
|
90
|
+
await this.#semEmbed.wait();
|
|
91
|
+
const {model, input} = embedRequest;
|
|
92
|
+
const {embeddings} = await this.#ollama.embed({
|
|
93
|
+
model,
|
|
94
|
+
input,
|
|
95
|
+
});
|
|
96
|
+
return embeddings[0];
|
|
97
|
+
} finally {
|
|
98
|
+
this.#semEmbed.signal();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async embedInBatch(embedRequest) {
|
|
103
|
+
const {model, input} = embedRequest;
|
|
104
|
+
const {embeddings} = await this.#ollama.embed({
|
|
105
|
+
model,
|
|
106
|
+
input,
|
|
107
|
+
});
|
|
108
|
+
return embeddings;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
class OpenAIProvider extends LLMProvider {
|
|
113
|
+
#baseUrl;
|
|
114
|
+
#openAI;
|
|
115
|
+
|
|
116
|
+
constructor(host = 'https://openrouter.ai/api/v1', headers) {
|
|
117
|
+
super();
|
|
118
|
+
this.#baseUrl = host;
|
|
119
|
+
this.#openAI = new RestAPI(300000, headers);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async chat(chatRequest) {
|
|
123
|
+
if (chatRequest.options) {
|
|
124
|
+
chatRequest = {...chatRequest, ...chatRequest.options};
|
|
125
|
+
delete chatRequest.options;
|
|
126
|
+
}
|
|
127
|
+
if (!chatRequest.tools) {
|
|
128
|
+
delete chatRequest.tools;
|
|
129
|
+
}
|
|
130
|
+
if (!chatRequest.format) {
|
|
131
|
+
delete chatRequest.format;
|
|
132
|
+
}
|
|
133
|
+
const useFormat = chatRequest?.format !== undefined;
|
|
134
|
+
if (useFormat) {
|
|
135
|
+
chatRequest.response_format = {
|
|
136
|
+
type: 'json_schema',
|
|
137
|
+
json_schema: {
|
|
138
|
+
name: 'default',
|
|
139
|
+
schema: {
|
|
140
|
+
type: 'object',
|
|
141
|
+
properties: {
|
|
142
|
+
results: {...chatRequest.format},
|
|
143
|
+
},
|
|
144
|
+
required: ['results'],
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
delete chatRequest.format;
|
|
149
|
+
}
|
|
150
|
+
const query = `${this.#baseUrl}/chat/completions`;
|
|
151
|
+
const chat = await this.#openAI._post(query, chatRequest);
|
|
152
|
+
const {choices} = chat;
|
|
153
|
+
if (chat.error) {
|
|
154
|
+
const err = new Error(chat.error.message);
|
|
155
|
+
err.code = chat.error.code;
|
|
156
|
+
throw err;
|
|
157
|
+
}
|
|
158
|
+
if (useFormat) {
|
|
159
|
+
choices[0].message.content = JSON.parse(
|
|
160
|
+
choices[0].message.content
|
|
161
|
+
).results;
|
|
162
|
+
return choices[0];
|
|
163
|
+
} else {
|
|
164
|
+
return choices[0];
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async gen(genRequest) {
|
|
169
|
+
const {model, system, prompt, format, tools, options, stream} = genRequest;
|
|
170
|
+
const chatRequest = {
|
|
171
|
+
model,
|
|
172
|
+
messages: [
|
|
173
|
+
{role: 'system', content: system},
|
|
174
|
+
{role: 'user', content: prompt},
|
|
175
|
+
],
|
|
176
|
+
options,
|
|
177
|
+
format,
|
|
178
|
+
tools,
|
|
179
|
+
stream,
|
|
180
|
+
};
|
|
181
|
+
const chat = await this.chat(chatRequest);
|
|
182
|
+
return chat.message.content;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async embed(embedRequest) {
|
|
186
|
+
const query = `${this.#baseUrl}/v1/embeddings`;
|
|
187
|
+
const {data} = await this.#openAI._post(query, embedRequest);
|
|
188
|
+
return data[0].embedding;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async embedInBatch(embedRequest) {
|
|
192
|
+
const query = `${this.#baseUrl}/v1/embeddings`;
|
|
193
|
+
const {data} = await this.#openAI._post(query, embedRequest);
|
|
194
|
+
return data;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = LLMProvider;
|
package/lib/llm/utils.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
const HtmlEntities = require('html-entities');
|
|
2
|
+
const sanitizeHtml = require('sanitize-html');
|
|
3
|
+
const {RecursiveCharacterTextSplitter} = require('@langchain/textsplitters');
|
|
4
|
+
|
|
5
|
+
const MAX_SEGMENT_LENGTH = 500;
|
|
6
|
+
|
|
7
|
+
const OCR_SYSTEM_PROMPT = `Effectue une reconnaissance optique de caractères (OCR) sur cette image et extraits-en le texte lisible.
|
|
8
|
+
Respecte la mise en page autant que possible.
|
|
9
|
+
Si le texte contient des tableaux ou des colonnes, préserve leur structure.
|
|
10
|
+
Ignore les artefacts visuels inutiles.
|
|
11
|
+
Corrige les erreurs évidentes et restitue un texte propre et fidèle à l'original.
|
|
12
|
+
Ta réponse contient uniquement le texte lisible.`;
|
|
13
|
+
|
|
14
|
+
const getPDFImages = async (pdfPath) => {
|
|
15
|
+
const {pdfToPng} = require('pdf-to-png-converter');
|
|
16
|
+
// The function accepts PDF file path or a Buffer
|
|
17
|
+
const pages = await pdfToPng(pdfPath, {
|
|
18
|
+
disableFontFace: true, // When `false`, fonts will be rendered using a built-in font renderer that constructs the glyphs with primitive path commands. Default value is true.
|
|
19
|
+
useSystemFonts: true, // When `true`, fonts that aren't embedded in the PDF document will fallback to a system font. Default value is false.
|
|
20
|
+
enableXfa: true, // Render Xfa forms if any. Default value is false.
|
|
21
|
+
viewportScale: 0.8, // The desired scale of PNG viewport. Default value is 1.0 which means to display page on the existing canvas with 100% scale.
|
|
22
|
+
//outputFolder: 'output/folder', // Folder to write output PNG files. If not specified, PNG output will be available only as a Buffer content, without saving to a file.
|
|
23
|
+
//outputFileMaskFunc: (pageNumber) => `page_${pageNumber}.png`, // Output filename mask function. Example: (pageNumber) => `page_${pageNumber}.png`
|
|
24
|
+
//pdfFilePassword: password, // Password for encrypted PDF.
|
|
25
|
+
//pagesToProcess: [1, 3, 11], // Subset of pages to convert (first page = 1), other pages will be skipped if specified.
|
|
26
|
+
//strictPagesToProcess: false, // When `true`, will throw an error if specified page number in pagesToProcess is invalid, otherwise will skip invalid page. Default value is false.
|
|
27
|
+
verbosityLevel: 0, // Verbosity level. ERRORS: 0, WARNINGS: 1, INFOS: 5. Default value is 0.
|
|
28
|
+
});
|
|
29
|
+
const images = [];
|
|
30
|
+
for (const page of pages) {
|
|
31
|
+
const {content} = page;
|
|
32
|
+
const base64Image = content.toString('base64');
|
|
33
|
+
images.push(`data:image/png;base64,${base64Image}`);
|
|
34
|
+
}
|
|
35
|
+
return images;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const cleanHtmlContent = (html) => {
|
|
39
|
+
const decodedHtml = HtmlEntities.decode(html);
|
|
40
|
+
const textOnly = decodedHtml.replace(/<[^>]+>/g, '');
|
|
41
|
+
const cleanedText = textOnly.replace(/\s+/g, ' ').trim();
|
|
42
|
+
return cleanedText;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const simplifyHtml = (html) => {
|
|
46
|
+
return sanitizeHtml(HtmlEntities.decode(html));
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const splitLongText = (text, maxLength, baseId) => {
|
|
50
|
+
let segments = [];
|
|
51
|
+
let words = text.split(/\s+/); // Découper en mots
|
|
52
|
+
let currentSegment = [];
|
|
53
|
+
let currentLength = 0;
|
|
54
|
+
let partIndex = 1;
|
|
55
|
+
|
|
56
|
+
words.forEach((word) => {
|
|
57
|
+
if (currentLength + word.length + 1 > maxLength) {
|
|
58
|
+
segments.push({
|
|
59
|
+
id: `${baseId}_part${partIndex}`,
|
|
60
|
+
content: currentSegment.join(' '),
|
|
61
|
+
});
|
|
62
|
+
currentSegment = [];
|
|
63
|
+
currentLength = 0;
|
|
64
|
+
partIndex++;
|
|
65
|
+
}
|
|
66
|
+
currentSegment.push(word);
|
|
67
|
+
currentLength += word.length + 1;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (currentSegment.length > 0) {
|
|
71
|
+
segments.push({
|
|
72
|
+
id: `${baseId}_part${partIndex}`,
|
|
73
|
+
content: currentSegment.join(' '),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return segments;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
const html2chunks = async (html, chunkSize = 1000, chunkOverlap = 0) => {
|
|
81
|
+
const textSplitter = RecursiveCharacterTextSplitter.fromLanguage('html', {
|
|
82
|
+
chunkSize,
|
|
83
|
+
chunkOverlap,
|
|
84
|
+
separators: RecursiveCharacterTextSplitter.getSeparatorsForLanguage('html'),
|
|
85
|
+
});
|
|
86
|
+
return await textSplitter.splitText(html);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const extractTitlesAndContent = (html) => {
|
|
90
|
+
const cleanHtml = simplifyHtml(html);
|
|
91
|
+
|
|
92
|
+
const titleRegex = /<h([1-6])>(.*?)<\/h\1>/g;
|
|
93
|
+
const paragraphRegex = /<p>(.*?)<\/p>/g;
|
|
94
|
+
|
|
95
|
+
let result = [];
|
|
96
|
+
let match;
|
|
97
|
+
let lastIndex = 0;
|
|
98
|
+
let segmentId = 1;
|
|
99
|
+
|
|
100
|
+
while ((match = titleRegex.exec(cleanHtml)) !== null) {
|
|
101
|
+
const titleText = match[2].trim();
|
|
102
|
+
const titleStartIndex = match.index;
|
|
103
|
+
|
|
104
|
+
const contentBeforeTitle = cleanHtml.slice(lastIndex, titleStartIndex);
|
|
105
|
+
if (contentBeforeTitle && contentBeforeTitle.length > 3) {
|
|
106
|
+
result.push(
|
|
107
|
+
...splitLongText(
|
|
108
|
+
contentBeforeTitle,
|
|
109
|
+
MAX_SEGMENT_LENGTH,
|
|
110
|
+
`segment_${segmentId}`
|
|
111
|
+
)
|
|
112
|
+
);
|
|
113
|
+
segmentId++;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
result.push({
|
|
117
|
+
id: `segment_${segmentId}`,
|
|
118
|
+
content: titleText,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
lastIndex = titleRegex.lastIndex;
|
|
122
|
+
segmentId++;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
while ((match = paragraphRegex.exec(cleanHtml)) !== null) {
|
|
126
|
+
const paragraphText = match[1].trim();
|
|
127
|
+
if (paragraphText.length > 3) {
|
|
128
|
+
if (paragraphText.length > MAX_SEGMENT_LENGTH) {
|
|
129
|
+
result.push(
|
|
130
|
+
...splitLongText(
|
|
131
|
+
paragraphText,
|
|
132
|
+
MAX_SEGMENT_LENGTH,
|
|
133
|
+
`segment_${segmentId}`
|
|
134
|
+
)
|
|
135
|
+
);
|
|
136
|
+
} else {
|
|
137
|
+
result.push({
|
|
138
|
+
id: `segment_${segmentId}`,
|
|
139
|
+
content: paragraphText,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
segmentId++;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const remainingContent = cleanHtml.slice(lastIndex).trim();
|
|
148
|
+
if (remainingContent && remainingContent.length > 3) {
|
|
149
|
+
result.push(
|
|
150
|
+
...splitLongText(
|
|
151
|
+
remainingContent,
|
|
152
|
+
MAX_SEGMENT_LENGTH,
|
|
153
|
+
`segment_${segmentId}`
|
|
154
|
+
)
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return result;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const estimateTokenCount = (text) => {
|
|
162
|
+
return Math.ceil(text.split(/\s+/).length * 1.33);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
function sliceText(text, chunkSize) {
|
|
166
|
+
const chunks = [];
|
|
167
|
+
let currentChunk = '';
|
|
168
|
+
|
|
169
|
+
const sentences = text.match(/[^.!?]+[.!?]*/g) || [text]; // Découpe en phrases
|
|
170
|
+
|
|
171
|
+
for (const sentence of sentences) {
|
|
172
|
+
if ((currentChunk + sentence).length <= chunkSize) {
|
|
173
|
+
currentChunk += sentence;
|
|
174
|
+
} else {
|
|
175
|
+
if (currentChunk) chunks.push(currentChunk.trim());
|
|
176
|
+
currentChunk = sentence;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (currentChunk) chunks.push(currentChunk.trim());
|
|
181
|
+
|
|
182
|
+
return chunks;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function groupSentences(text, maxSize) {
|
|
186
|
+
const chunks = [];
|
|
187
|
+
let currentChunk = '';
|
|
188
|
+
|
|
189
|
+
// Découpe le texte en phrases en gardant la ponctuation
|
|
190
|
+
const sentences = text.match(/[^.!?]+[.!?]*/g) || [text];
|
|
191
|
+
|
|
192
|
+
for (const sentence of sentences) {
|
|
193
|
+
if ((currentChunk + ' ' + sentence).trim().length > maxSize) {
|
|
194
|
+
// Si la phrase est trop longue, on découpe par mot
|
|
195
|
+
if (sentence.length > maxSize) {
|
|
196
|
+
const words = sentence.split(' ');
|
|
197
|
+
let tempChunk = '';
|
|
198
|
+
|
|
199
|
+
for (const word of words) {
|
|
200
|
+
if ((tempChunk + ' ' + word).trim().length > maxSize) {
|
|
201
|
+
chunks.push(tempChunk.trim());
|
|
202
|
+
tempChunk = word;
|
|
203
|
+
} else {
|
|
204
|
+
tempChunk += (tempChunk ? ' ' : '') + word;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (tempChunk) chunks.push(tempChunk.trim());
|
|
209
|
+
currentChunk = ''; // Réinitialiser après un découpage forcé
|
|
210
|
+
} else {
|
|
211
|
+
// Ajouter le chunk en attente et commencer un nouveau
|
|
212
|
+
if (currentChunk) chunks.push(currentChunk.trim());
|
|
213
|
+
currentChunk = sentence.trim();
|
|
214
|
+
}
|
|
215
|
+
} else {
|
|
216
|
+
currentChunk += (currentChunk ? ' ' : '') + sentence.trim();
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Ajoute le dernier bloc s'il contient du texte
|
|
221
|
+
if (currentChunk) chunks.push(currentChunk.trim());
|
|
222
|
+
|
|
223
|
+
return chunks;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const buildPromptFromArticles = (articles, question, maxTokens) => {
|
|
227
|
+
let context = '[CONTEXT]\n\n';
|
|
228
|
+
let currentTokenCount = estimateTokenCount(context);
|
|
229
|
+
|
|
230
|
+
articles.forEach((article) => {
|
|
231
|
+
const {kind, title, link, locale} = article;
|
|
232
|
+
const articleContext = `# ${kind}: ${title} #\nLien: ${link}\n Langue: ${locale}\n`;
|
|
233
|
+
|
|
234
|
+
context += articleContext;
|
|
235
|
+
|
|
236
|
+
if (article.segments && article.segments.length > 0) {
|
|
237
|
+
context += '## Segments pertinents: ##\n';
|
|
238
|
+
article.segments
|
|
239
|
+
.sort((a, b) => b.score - a.score) // Trier par pertinence
|
|
240
|
+
.forEach((s, i) => {
|
|
241
|
+
let segmentContext = ` ${i + 1}. ${s.segment} (Pertinence: ${
|
|
242
|
+
s.score
|
|
243
|
+
})\n`;
|
|
244
|
+
currentTokenCount += estimateTokenCount(segmentContext);
|
|
245
|
+
|
|
246
|
+
if (currentTokenCount > maxTokens) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
context += segmentContext;
|
|
251
|
+
|
|
252
|
+
// Ajouter du contexte avant et après le segment
|
|
253
|
+
const beforeContext = article.content.substring(
|
|
254
|
+
Math.max(0, s.start - 50),
|
|
255
|
+
s.start
|
|
256
|
+
);
|
|
257
|
+
const afterContext = article.content.substring(
|
|
258
|
+
s.end,
|
|
259
|
+
Math.min(article.content.length, s.end + 50)
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const additionalContext = `### Contexte avant: ###\n${beforeContext}\n### Contexte après: ###\n${afterContext}\n`;
|
|
263
|
+
currentTokenCount += estimateTokenCount(additionalContext);
|
|
264
|
+
|
|
265
|
+
if (currentTokenCount > maxTokens) {
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
context += additionalContext;
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
const questionContext = '[QUESTION]\n\n' + question;
|
|
275
|
+
context += questionContext;
|
|
276
|
+
return context.trim();
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
module.exports = {
|
|
280
|
+
extractTitlesAndContent,
|
|
281
|
+
html2chunks,
|
|
282
|
+
splitLongText,
|
|
283
|
+
cleanHtmlContent,
|
|
284
|
+
simplifyHtml,
|
|
285
|
+
estimateTokenCount,
|
|
286
|
+
OCR_SYSTEM_PROMPT,
|
|
287
|
+
getPDFImages,
|
|
288
|
+
sliceText,
|
|
289
|
+
groupSentences,
|
|
290
|
+
buildPromptFromArticles,
|
|
291
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "goblin-agents",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "AI Agents framework",
|
|
5
|
+
"author": "Samuel Loup",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"config": {
|
|
8
|
+
"xcraft": {
|
|
9
|
+
"commands": true
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"test": "echo \"Error: no test specified\" && exit 1"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"@langchain/core": "^0.3.42",
|
|
17
|
+
"@langchain/textsplitters": "^0.1.0",
|
|
18
|
+
"fs-extra": "^9.1.0",
|
|
19
|
+
"html-entities": "^2.4.0",
|
|
20
|
+
"ollama": "^0.5.10",
|
|
21
|
+
"pdf-to-png-converter": "^3.6.5",
|
|
22
|
+
"sanitize-html": "^2.12.1",
|
|
23
|
+
"xcraft-core-goblin": "^5.8.0",
|
|
24
|
+
"xcraft-core-utils": "^4.14.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"xcraft-dev-prettier": "^2.0.0",
|
|
28
|
+
"xcraft-dev-rules": "^4.4.2"
|
|
29
|
+
},
|
|
30
|
+
"prettier": "xcraft-dev-prettier"
|
|
31
|
+
}
|