goblin-agents 2.1.0 → 2.2.2
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/lib/llm/aiAgent.js +85 -22
- package/lib/llm/providers/ollama.js +97 -0
- package/lib/llm/providers/opanAi.js +101 -0
- package/lib/llm/providers/provider.js +36 -0
- package/lib/llm/providers.js +5 -214
- package/lib/llm/utils.js +36 -10
- package/lib/logger.js +104 -0
- package/lib/report/item.html +2 -0
- package/lib/report/message.html +8 -0
- package/lib/report/report.html +10 -0
- package/package.json +1 -1
package/lib/llm/aiAgent.js
CHANGED
|
@@ -12,8 +12,9 @@ const {
|
|
|
12
12
|
record,
|
|
13
13
|
} = require('xcraft-core-stones');
|
|
14
14
|
const {getMutex} = require('xcraft-core-utils/lib/locks.js');
|
|
15
|
-
const
|
|
15
|
+
const LLMProviders = require('./providers.js');
|
|
16
16
|
const {OCR_SYSTEM_PROMPT, getPDFImages, splitThinkTags} = require('./utils.js');
|
|
17
|
+
const Logger = require('../logger.js');
|
|
17
18
|
|
|
18
19
|
class MessageShape {
|
|
19
20
|
role = enumeration('assistant', 'user', 'tool');
|
|
@@ -129,6 +130,17 @@ function vectorToSqlLiteralInt8(vec) {
|
|
|
129
130
|
return hex;
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
function cleanText(text) {
|
|
134
|
+
if (typeof text !== 'string') {
|
|
135
|
+
return text;
|
|
136
|
+
}
|
|
137
|
+
const {thinks, cleanText} = splitThinkTags(text);
|
|
138
|
+
for (const think of thinks) {
|
|
139
|
+
this.log.dbg(`🧐 ${think}`);
|
|
140
|
+
}
|
|
141
|
+
return cleanText;
|
|
142
|
+
}
|
|
143
|
+
|
|
132
144
|
class AiAgentShape {
|
|
133
145
|
id = id('aiAgent');
|
|
134
146
|
version = number;
|
|
@@ -294,7 +306,7 @@ class AiAgent extends Elf {
|
|
|
294
306
|
|
|
295
307
|
_provider;
|
|
296
308
|
_conf;
|
|
297
|
-
|
|
309
|
+
_mutex = getMutex;
|
|
298
310
|
|
|
299
311
|
/**
|
|
300
312
|
* @param {string} id
|
|
@@ -341,7 +353,7 @@ class AiAgent extends Elf {
|
|
|
341
353
|
return;
|
|
342
354
|
}
|
|
343
355
|
|
|
344
|
-
const Instance =
|
|
356
|
+
const Instance = LLMProviders.get(this.state.provider);
|
|
345
357
|
this._provider = new Instance(this.state.host, this.state.headers.toJS());
|
|
346
358
|
this._conf = {provider, host, headersStr};
|
|
347
359
|
}
|
|
@@ -367,27 +379,45 @@ class AiAgent extends Elf {
|
|
|
367
379
|
await this.persist();
|
|
368
380
|
}
|
|
369
381
|
|
|
370
|
-
async gen(prompt) {
|
|
382
|
+
async gen(prompt, contextId = 'default') {
|
|
383
|
+
const logger = Logger.get(contextId, this);
|
|
384
|
+
|
|
385
|
+
await this._mutex.lock(this.id);
|
|
386
|
+
this.quest.defer(() => this._mutex.unlock(this.id));
|
|
387
|
+
|
|
371
388
|
await this._loadProvider();
|
|
372
389
|
|
|
373
390
|
let options;
|
|
374
391
|
if (this.state.options) {
|
|
375
392
|
options = this.state.options.toJS();
|
|
376
393
|
}
|
|
377
|
-
const
|
|
394
|
+
const format = this.state.format
|
|
395
|
+
? typeof this.state.format === 'string'
|
|
396
|
+
? this.state.format
|
|
397
|
+
: this.state.format.toJS()
|
|
398
|
+
: undefined;
|
|
399
|
+
let res = await this._provider.gen({
|
|
378
400
|
model: this.state.model,
|
|
379
401
|
reasoning: this.state.reasoning,
|
|
380
402
|
prompt,
|
|
381
403
|
system: this.state.prompt,
|
|
382
|
-
format
|
|
404
|
+
format,
|
|
383
405
|
options,
|
|
384
406
|
stream: false,
|
|
385
407
|
});
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
this.
|
|
389
|
-
|
|
390
|
-
|
|
408
|
+
|
|
409
|
+
logger.push('aiAgent.gen', {
|
|
410
|
+
id: this.id,
|
|
411
|
+
model: this.state.model,
|
|
412
|
+
options: Object.entries(options).reduce(
|
|
413
|
+
(out, opt) => `${out} ${opt[0]}=${opt[1]}`,
|
|
414
|
+
''
|
|
415
|
+
),
|
|
416
|
+
reasoning: this.state.reasoning,
|
|
417
|
+
format: format || 'N/A',
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
return cleanText(res);
|
|
391
421
|
}
|
|
392
422
|
|
|
393
423
|
async reset(contextId, save = false) {
|
|
@@ -404,6 +434,9 @@ class AiAgent extends Elf {
|
|
|
404
434
|
}
|
|
405
435
|
|
|
406
436
|
async readPDFpages(pdfPath) {
|
|
437
|
+
await this._mutex.lock(this.id);
|
|
438
|
+
this.quest.defer(() => this._mutex.unlock(this.id));
|
|
439
|
+
|
|
407
440
|
await this._loadProvider();
|
|
408
441
|
|
|
409
442
|
const images = await getPDFImages(pdfPath);
|
|
@@ -440,6 +473,10 @@ class AiAgent extends Elf {
|
|
|
440
473
|
if (!rawText) {
|
|
441
474
|
return null;
|
|
442
475
|
}
|
|
476
|
+
|
|
477
|
+
await this._mutex.lock(this.id);
|
|
478
|
+
this.quest.defer(() => this._mutex.unlock(this.id));
|
|
479
|
+
|
|
443
480
|
await this._loadProvider();
|
|
444
481
|
const vectors = await this._provider.embed({
|
|
445
482
|
model: this.state.model,
|
|
@@ -457,8 +494,8 @@ class AiAgent extends Elf {
|
|
|
457
494
|
return [];
|
|
458
495
|
}
|
|
459
496
|
|
|
460
|
-
await this.
|
|
461
|
-
this.quest.defer(() => this.
|
|
497
|
+
await this._mutex.lock(this.id);
|
|
498
|
+
this.quest.defer(() => this._mutex.unlock(this.id));
|
|
462
499
|
|
|
463
500
|
await this._loadProvider();
|
|
464
501
|
const res = await this._provider.embedInBatch({
|
|
@@ -477,6 +514,11 @@ class AiAgent extends Elf {
|
|
|
477
514
|
}
|
|
478
515
|
|
|
479
516
|
async chat(contextId, question, userDesktopId) {
|
|
517
|
+
const logger = Logger.get(contextId, this);
|
|
518
|
+
|
|
519
|
+
await this._mutex.lock(this.id);
|
|
520
|
+
this.quest.defer(() => this._mutex.unlock(this.id));
|
|
521
|
+
|
|
480
522
|
await this._loadProvider();
|
|
481
523
|
const contextMessages = this.state.messages[contextId] || [];
|
|
482
524
|
const messages = [
|
|
@@ -509,6 +551,18 @@ class AiAgent extends Elf {
|
|
|
509
551
|
|
|
510
552
|
const result = await this._provider.chat(chatCompletionRequest);
|
|
511
553
|
|
|
554
|
+
logger.push('aiAgent.chat', {
|
|
555
|
+
id: this.id,
|
|
556
|
+
model: chatCompletionRequest.model,
|
|
557
|
+
options: Object.entries(chatCompletionRequest.options).reduce(
|
|
558
|
+
(out, opt) => `${out} ${opt[0]}=${opt[1]}`,
|
|
559
|
+
''
|
|
560
|
+
),
|
|
561
|
+
question,
|
|
562
|
+
reasoning: result.message?.reasoning,
|
|
563
|
+
callTools: !!result.message.tool_calls?.length,
|
|
564
|
+
});
|
|
565
|
+
|
|
512
566
|
if (this.state.toolServiceId && this.state.tools) {
|
|
513
567
|
const used = await this.callTools(
|
|
514
568
|
contextId,
|
|
@@ -522,23 +576,22 @@ class AiAgent extends Elf {
|
|
|
522
576
|
}
|
|
523
577
|
}
|
|
524
578
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
this.log.dbg(`🧐 ${think}`);
|
|
579
|
+
const res = cleanText(result.message.content);
|
|
580
|
+
if (typeof res === 'string') {
|
|
581
|
+
//keep agent response
|
|
582
|
+
this.logic.chat(contextId, 'assistant', cleanText);
|
|
530
583
|
}
|
|
531
|
-
this.log.dbg(`🐈 Chat session ended : ${contextId}`);
|
|
532
584
|
|
|
533
|
-
|
|
585
|
+
this.log.dbg(`🐈 Chat session ended : ${contextId}`);
|
|
586
|
+
return res;
|
|
534
587
|
}
|
|
535
588
|
|
|
536
589
|
async callTools(contextId, message, userDesktopId) {
|
|
537
|
-
const {tool_calls
|
|
590
|
+
const {tool_calls} = message;
|
|
538
591
|
if (!tool_calls || !userDesktopId) {
|
|
539
592
|
return false;
|
|
540
593
|
}
|
|
541
|
-
this.logic.callTools(contextId,
|
|
594
|
+
this.logic.callTools(contextId, tool_calls);
|
|
542
595
|
for (const tool of tool_calls) {
|
|
543
596
|
const {
|
|
544
597
|
id,
|
|
@@ -585,6 +638,10 @@ class AiAgent extends Elf {
|
|
|
585
638
|
if (this.state.provider !== 'ollama') {
|
|
586
639
|
throw new Error('Streaming API not impl.');
|
|
587
640
|
}
|
|
641
|
+
|
|
642
|
+
await this._mutex.lock(this.id);
|
|
643
|
+
this.quest.defer(() => this._mutex.unlock(this.id));
|
|
644
|
+
|
|
588
645
|
await this._loadProvider();
|
|
589
646
|
|
|
590
647
|
const contextMessages = this.state.messages[contextId] || [];
|
|
@@ -619,6 +676,9 @@ class AiAgent extends Elf {
|
|
|
619
676
|
}
|
|
620
677
|
|
|
621
678
|
async resumeExchange(resumePrompt, contextId) {
|
|
679
|
+
await this._mutex.lock(this.id);
|
|
680
|
+
this.quest.defer(() => this._mutex.unlock(this.id));
|
|
681
|
+
|
|
622
682
|
await this._loadProvider();
|
|
623
683
|
const contextMessages = this.state.messages[contextId] || [];
|
|
624
684
|
|
|
@@ -643,6 +703,9 @@ class AiAgent extends Elf {
|
|
|
643
703
|
}
|
|
644
704
|
|
|
645
705
|
async react(reactPrompt, contextId, question) {
|
|
706
|
+
await this._mutex.lock(this.id);
|
|
707
|
+
this.quest.defer(() => this._mutex.unlock(this.id));
|
|
708
|
+
|
|
646
709
|
await this._loadProvider();
|
|
647
710
|
const feedId = await this.newQuestFeed();
|
|
648
711
|
const contextMessages = this.state.messages[contextId] || [];
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
const {Ollama} = require('ollama');
|
|
3
|
+
const {Semaphore} = require('xcraft-core-utils/lib/locks.js');
|
|
4
|
+
const LLMProvider = require('./provider.js');
|
|
5
|
+
|
|
6
|
+
class OllamaProvider extends LLMProvider {
|
|
7
|
+
#semEmbed = new Semaphore(4);
|
|
8
|
+
#ollama;
|
|
9
|
+
|
|
10
|
+
constructor(host, headers) {
|
|
11
|
+
super();
|
|
12
|
+
this.#ollama = new Ollama({
|
|
13
|
+
host,
|
|
14
|
+
headers,
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async chat(chatRequest) {
|
|
19
|
+
const {
|
|
20
|
+
model,
|
|
21
|
+
messages,
|
|
22
|
+
options,
|
|
23
|
+
format,
|
|
24
|
+
tools,
|
|
25
|
+
stream,
|
|
26
|
+
reasoning,
|
|
27
|
+
} = chatRequest;
|
|
28
|
+
const useFormat = !!format;
|
|
29
|
+
const chat = await this.#ollama.chat({
|
|
30
|
+
model,
|
|
31
|
+
messages,
|
|
32
|
+
options,
|
|
33
|
+
format,
|
|
34
|
+
tools,
|
|
35
|
+
stream,
|
|
36
|
+
think: reasoning,
|
|
37
|
+
});
|
|
38
|
+
if (useFormat) {
|
|
39
|
+
chat.message.content = JSON.parse(chat.message.content);
|
|
40
|
+
return chat;
|
|
41
|
+
} else {
|
|
42
|
+
return chat;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async gen(genRequest) {
|
|
47
|
+
const {
|
|
48
|
+
model,
|
|
49
|
+
system,
|
|
50
|
+
prompt,
|
|
51
|
+
format,
|
|
52
|
+
options,
|
|
53
|
+
stream,
|
|
54
|
+
reasoning,
|
|
55
|
+
} = genRequest;
|
|
56
|
+
const useFormat = !!format;
|
|
57
|
+
const gen = await this.#ollama.generate({
|
|
58
|
+
model,
|
|
59
|
+
prompt,
|
|
60
|
+
system,
|
|
61
|
+
format,
|
|
62
|
+
options,
|
|
63
|
+
stream,
|
|
64
|
+
think: reasoning,
|
|
65
|
+
});
|
|
66
|
+
if (useFormat) {
|
|
67
|
+
return JSON.parse(gen.response);
|
|
68
|
+
} else {
|
|
69
|
+
return gen.response;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async embed(embedRequest) {
|
|
74
|
+
try {
|
|
75
|
+
await this.#semEmbed.wait();
|
|
76
|
+
const {model, input} = embedRequest;
|
|
77
|
+
const {embeddings} = await this.#ollama.embed({
|
|
78
|
+
model,
|
|
79
|
+
input,
|
|
80
|
+
});
|
|
81
|
+
return embeddings[0];
|
|
82
|
+
} finally {
|
|
83
|
+
this.#semEmbed.signal();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async embedInBatch(embedRequest) {
|
|
88
|
+
const {model, input} = embedRequest;
|
|
89
|
+
const {embeddings} = await this.#ollama.embed({
|
|
90
|
+
model,
|
|
91
|
+
input,
|
|
92
|
+
});
|
|
93
|
+
return embeddings;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = OllamaProvider;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
const {RestAPI} = require('xcraft-core-utils');
|
|
3
|
+
const LLMProvider = require('./provider.js');
|
|
4
|
+
|
|
5
|
+
class OpenAIProvider extends LLMProvider {
|
|
6
|
+
#baseUrl;
|
|
7
|
+
#openAI;
|
|
8
|
+
|
|
9
|
+
constructor(host = 'https://openrouter.ai/api/v1', headers) {
|
|
10
|
+
super();
|
|
11
|
+
this.#baseUrl = host;
|
|
12
|
+
this.#openAI = new RestAPI(300000, headers);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async chat(chatRequest) {
|
|
16
|
+
delete chatRequest.reasoning; //not yet supported
|
|
17
|
+
if (chatRequest.options) {
|
|
18
|
+
chatRequest = {...chatRequest, ...chatRequest.options};
|
|
19
|
+
delete chatRequest.options;
|
|
20
|
+
}
|
|
21
|
+
if (!chatRequest.tools) {
|
|
22
|
+
delete chatRequest.tools;
|
|
23
|
+
}
|
|
24
|
+
if (!chatRequest.format) {
|
|
25
|
+
delete chatRequest.format;
|
|
26
|
+
}
|
|
27
|
+
const useFormat = chatRequest?.format !== undefined;
|
|
28
|
+
if (useFormat) {
|
|
29
|
+
chatRequest.response_format = {
|
|
30
|
+
type: 'json_schema',
|
|
31
|
+
json_schema: {
|
|
32
|
+
name: 'default',
|
|
33
|
+
schema: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
properties: {
|
|
36
|
+
results: {...chatRequest.format},
|
|
37
|
+
},
|
|
38
|
+
required: ['results'],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
delete chatRequest.format;
|
|
44
|
+
}
|
|
45
|
+
const query = `${this.#baseUrl}/chat/completions`;
|
|
46
|
+
const chat = await this.#openAI._post(query, chatRequest, {
|
|
47
|
+
retry: {
|
|
48
|
+
methods: ['POST'],
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
const {choices} = chat;
|
|
52
|
+
if (chat.error) {
|
|
53
|
+
const err = new Error(
|
|
54
|
+
chat.error?.metadata
|
|
55
|
+
? JSON.stringify(chat.error?.metadata)
|
|
56
|
+
: chat.error.message
|
|
57
|
+
);
|
|
58
|
+
err.code = chat.error.code;
|
|
59
|
+
throw err;
|
|
60
|
+
}
|
|
61
|
+
if (useFormat) {
|
|
62
|
+
choices[0].message.content = JSON.parse(
|
|
63
|
+
choices[0].message.content
|
|
64
|
+
).results;
|
|
65
|
+
return choices[0];
|
|
66
|
+
} else {
|
|
67
|
+
return choices[0];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async gen(genRequest) {
|
|
72
|
+
const {model, system, prompt, format, tools, options, stream} = genRequest;
|
|
73
|
+
const chatRequest = {
|
|
74
|
+
model,
|
|
75
|
+
messages: [
|
|
76
|
+
{role: 'system', content: system},
|
|
77
|
+
{role: 'user', content: prompt},
|
|
78
|
+
],
|
|
79
|
+
options,
|
|
80
|
+
format,
|
|
81
|
+
tools,
|
|
82
|
+
stream,
|
|
83
|
+
};
|
|
84
|
+
const chat = await this.chat(chatRequest);
|
|
85
|
+
return chat.message.content;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async embed(embedRequest) {
|
|
89
|
+
const query = `${this.#baseUrl}/v1/embeddings`;
|
|
90
|
+
const {data} = await this.#openAI._post(query, embedRequest);
|
|
91
|
+
return data[0].embedding;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async embedInBatch(embedRequest) {
|
|
95
|
+
const query = `${this.#baseUrl}/v1/embeddings`;
|
|
96
|
+
const {data} = await this.#openAI._post(query, embedRequest);
|
|
97
|
+
return data;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = OpenAIProvider;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
class LLMProvider {
|
|
4
|
+
constructor() {
|
|
5
|
+
if (new.target === LLMProvider) {
|
|
6
|
+
throw new Error('Cannot instantiate an abstract class.');
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async chatStream() {
|
|
11
|
+
throw new Error('not impl.');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @returns {Promise<*>}
|
|
16
|
+
*/
|
|
17
|
+
async chat() {
|
|
18
|
+
throw new Error('not impl.');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @returns {Promise<*>}
|
|
23
|
+
*/
|
|
24
|
+
async gen() {
|
|
25
|
+
throw new Error('not impl.');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @returns {Promise<*>}
|
|
30
|
+
*/
|
|
31
|
+
async embed() {
|
|
32
|
+
throw new Error('not impl.');
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = LLMProvider;
|
package/lib/llm/providers.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
|
|
2
|
-
const
|
|
3
|
-
const
|
|
1
|
+
// @ts-check
|
|
2
|
+
const OllamaProvider = require('./providers/ollama.js');
|
|
3
|
+
const OpenAIProvider = require('./providers/opanAi.js');
|
|
4
4
|
|
|
5
|
-
class
|
|
5
|
+
class LLMProviders {
|
|
6
6
|
static get(name) {
|
|
7
7
|
switch (name) {
|
|
8
8
|
default:
|
|
@@ -12,215 +12,6 @@ class LLMProvider {
|
|
|
12
12
|
return OpenAIProvider;
|
|
13
13
|
}
|
|
14
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 {
|
|
53
|
-
model,
|
|
54
|
-
messages,
|
|
55
|
-
options,
|
|
56
|
-
format,
|
|
57
|
-
tools,
|
|
58
|
-
stream,
|
|
59
|
-
reasoning,
|
|
60
|
-
} = chatRequest;
|
|
61
|
-
const useFormat = !!format;
|
|
62
|
-
const chat = await this.#ollama.chat({
|
|
63
|
-
model,
|
|
64
|
-
messages,
|
|
65
|
-
options,
|
|
66
|
-
format,
|
|
67
|
-
tools,
|
|
68
|
-
stream,
|
|
69
|
-
think: reasoning,
|
|
70
|
-
});
|
|
71
|
-
if (useFormat) {
|
|
72
|
-
chat.message.content = JSON.parse(chat.message.content);
|
|
73
|
-
return chat;
|
|
74
|
-
} else {
|
|
75
|
-
return chat;
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
async gen(genRequest) {
|
|
80
|
-
const {
|
|
81
|
-
model,
|
|
82
|
-
system,
|
|
83
|
-
prompt,
|
|
84
|
-
format,
|
|
85
|
-
options,
|
|
86
|
-
stream,
|
|
87
|
-
reasoning,
|
|
88
|
-
} = genRequest;
|
|
89
|
-
const useFormat = !!format;
|
|
90
|
-
const gen = await this.#ollama.generate({
|
|
91
|
-
model,
|
|
92
|
-
prompt,
|
|
93
|
-
system,
|
|
94
|
-
format,
|
|
95
|
-
options,
|
|
96
|
-
stream,
|
|
97
|
-
think: reasoning,
|
|
98
|
-
});
|
|
99
|
-
if (useFormat) {
|
|
100
|
-
return JSON.parse(gen.response);
|
|
101
|
-
} else {
|
|
102
|
-
return gen.response;
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
async embed(embedRequest) {
|
|
107
|
-
try {
|
|
108
|
-
await this.#semEmbed.wait();
|
|
109
|
-
const {model, input} = embedRequest;
|
|
110
|
-
const {embeddings} = await this.#ollama.embed({
|
|
111
|
-
model,
|
|
112
|
-
input,
|
|
113
|
-
});
|
|
114
|
-
return embeddings[0];
|
|
115
|
-
} finally {
|
|
116
|
-
this.#semEmbed.signal();
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
async embedInBatch(embedRequest) {
|
|
121
|
-
const {model, input} = embedRequest;
|
|
122
|
-
const {embeddings} = await this.#ollama.embed({
|
|
123
|
-
model,
|
|
124
|
-
input,
|
|
125
|
-
});
|
|
126
|
-
return embeddings;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
class OpenAIProvider extends LLMProvider {
|
|
131
|
-
#baseUrl;
|
|
132
|
-
#openAI;
|
|
133
|
-
|
|
134
|
-
constructor(host = 'https://openrouter.ai/api/v1', headers) {
|
|
135
|
-
super();
|
|
136
|
-
this.#baseUrl = host;
|
|
137
|
-
this.#openAI = new RestAPI(300000, headers);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
async chat(chatRequest) {
|
|
141
|
-
delete chatRequest.reasoning; //not yet supported
|
|
142
|
-
if (chatRequest.options) {
|
|
143
|
-
chatRequest = {...chatRequest, ...chatRequest.options};
|
|
144
|
-
delete chatRequest.options;
|
|
145
|
-
}
|
|
146
|
-
if (!chatRequest.tools) {
|
|
147
|
-
delete chatRequest.tools;
|
|
148
|
-
}
|
|
149
|
-
if (!chatRequest.format) {
|
|
150
|
-
delete chatRequest.format;
|
|
151
|
-
}
|
|
152
|
-
const useFormat = chatRequest?.format !== undefined;
|
|
153
|
-
if (useFormat) {
|
|
154
|
-
chatRequest.response_format = {
|
|
155
|
-
type: 'json_schema',
|
|
156
|
-
json_schema: {
|
|
157
|
-
name: 'default',
|
|
158
|
-
schema: {
|
|
159
|
-
type: 'object',
|
|
160
|
-
properties: {
|
|
161
|
-
results: {...chatRequest.format},
|
|
162
|
-
},
|
|
163
|
-
required: ['results'],
|
|
164
|
-
},
|
|
165
|
-
},
|
|
166
|
-
};
|
|
167
|
-
|
|
168
|
-
delete chatRequest.format;
|
|
169
|
-
}
|
|
170
|
-
const query = `${this.#baseUrl}/chat/completions`;
|
|
171
|
-
const chat = await this.#openAI._post(query, chatRequest, {
|
|
172
|
-
retry: {
|
|
173
|
-
methods: ['POST'],
|
|
174
|
-
},
|
|
175
|
-
});
|
|
176
|
-
const {choices} = chat;
|
|
177
|
-
if (chat.error) {
|
|
178
|
-
const err = new Error(
|
|
179
|
-
chat.error?.metadata
|
|
180
|
-
? JSON.stringify(chat.error?.metadata)
|
|
181
|
-
: chat.error.message
|
|
182
|
-
);
|
|
183
|
-
err.code = chat.error.code;
|
|
184
|
-
throw err;
|
|
185
|
-
}
|
|
186
|
-
if (useFormat) {
|
|
187
|
-
choices[0].message.content = JSON.parse(
|
|
188
|
-
choices[0].message.content
|
|
189
|
-
).results;
|
|
190
|
-
return choices[0];
|
|
191
|
-
} else {
|
|
192
|
-
return choices[0];
|
|
193
|
-
}
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
async gen(genRequest) {
|
|
197
|
-
const {model, system, prompt, format, tools, options, stream} = genRequest;
|
|
198
|
-
const chatRequest = {
|
|
199
|
-
model,
|
|
200
|
-
messages: [
|
|
201
|
-
{role: 'system', content: system},
|
|
202
|
-
{role: 'user', content: prompt},
|
|
203
|
-
],
|
|
204
|
-
options,
|
|
205
|
-
format,
|
|
206
|
-
tools,
|
|
207
|
-
stream,
|
|
208
|
-
};
|
|
209
|
-
const chat = await this.chat(chatRequest);
|
|
210
|
-
return chat.message.content;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
async embed(embedRequest) {
|
|
214
|
-
const query = `${this.#baseUrl}/v1/embeddings`;
|
|
215
|
-
const {data} = await this.#openAI._post(query, embedRequest);
|
|
216
|
-
return data[0].embedding;
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
async embedInBatch(embedRequest) {
|
|
220
|
-
const query = `${this.#baseUrl}/v1/embeddings`;
|
|
221
|
-
const {data} = await this.#openAI._post(query, embedRequest);
|
|
222
|
-
return data;
|
|
223
|
-
}
|
|
224
15
|
}
|
|
225
16
|
|
|
226
|
-
module.exports =
|
|
17
|
+
module.exports = LLMProviders;
|
package/lib/llm/utils.js
CHANGED
|
@@ -236,8 +236,23 @@ function groupSentences(text, maxSize) {
|
|
|
236
236
|
return chunks;
|
|
237
237
|
}
|
|
238
238
|
|
|
239
|
+
/**
|
|
240
|
+
* Construit un prompt à partir d'une liste d'articles, avec gestion du format et de la limite de tokens.
|
|
241
|
+
*
|
|
242
|
+
* @param {ArticleShape[]} articles - Liste des articles sources utilisés pour générer le prompt.
|
|
243
|
+
* @param {"text"|"json"} [format="text"] - Format de sortie attendu :
|
|
244
|
+
* - `"text"` → prompt en texte brut
|
|
245
|
+
* - `"json"` → prompt structuré en JSON
|
|
246
|
+
* @param {number} [maxTotalTokens] - Nombre maximal de tokens autorisés dans le prompt.
|
|
247
|
+
* Si non défini, aucun contrôle n'est appliqué.
|
|
248
|
+
* @param {function} [dbg=() => null] - Fonction de débogage optionnelle appelée
|
|
249
|
+
* avec des informations intermédiaires (signature `(info: any) => void`).
|
|
250
|
+
* @returns {string} - Le prompt généré, soit en texte brut (string),
|
|
251
|
+
* soit en objet JSON stringifié selon le format choisi.
|
|
252
|
+
*/
|
|
239
253
|
function buildPromptFromArticlesMulti(
|
|
240
254
|
articles,
|
|
255
|
+
format = 'text',
|
|
241
256
|
maxTotalTokens,
|
|
242
257
|
dbg = () => null
|
|
243
258
|
) {
|
|
@@ -247,15 +262,23 @@ function buildPromptFromArticlesMulti(
|
|
|
247
262
|
let sections = [];
|
|
248
263
|
let sectionTokenCount = 0;
|
|
249
264
|
for (const article of articles) {
|
|
250
|
-
const {kind, title, sourceId,
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
265
|
+
const {kind, title, sourceId, description, finalScore} = article;
|
|
266
|
+
|
|
267
|
+
let articleBlock = '';
|
|
268
|
+
if (format === 'json') {
|
|
269
|
+
articleBlock = JSON.stringify({
|
|
270
|
+
kind,
|
|
271
|
+
title,
|
|
272
|
+
url: sourceId.startsWith('http') ? sourceId : null,
|
|
273
|
+
finalScore,
|
|
274
|
+
description,
|
|
275
|
+
});
|
|
276
|
+
} else {
|
|
277
|
+
articleBlock += `# ${title}\n\n`;
|
|
278
|
+
articleBlock += `**score :** ${finalScore}\n\n`;
|
|
279
|
+
articleBlock += `\`\`\`html\n${description}\n\`\`\`\n\n`;
|
|
280
|
+
}
|
|
281
|
+
|
|
259
282
|
dbg(articleBlock);
|
|
260
283
|
const tokensForArticle = estimateTokenCount(articleBlock);
|
|
261
284
|
|
|
@@ -268,7 +291,10 @@ function buildPromptFromArticlesMulti(
|
|
|
268
291
|
sectionTokenCount += tokensForArticle;
|
|
269
292
|
}
|
|
270
293
|
|
|
271
|
-
|
|
294
|
+
if (format === 'json') {
|
|
295
|
+
return `[${sections.join(',')}]`;
|
|
296
|
+
}
|
|
297
|
+
return sections.join('---\n\n');
|
|
272
298
|
}
|
|
273
299
|
|
|
274
300
|
/**
|
package/lib/logger.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const fse = require('fs-extra');
|
|
4
|
+
const {string, Sculpt, dateTime, object} = require('xcraft-core-stones');
|
|
5
|
+
const xConfig = require('xcraft-core-etc')().load('xcraft');
|
|
6
|
+
|
|
7
|
+
class MessageShape {
|
|
8
|
+
timestamp = dateTime;
|
|
9
|
+
step = string;
|
|
10
|
+
payload = object;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class Message extends Sculpt(MessageShape) {}
|
|
14
|
+
|
|
15
|
+
class Logger {
|
|
16
|
+
#id;
|
|
17
|
+
|
|
18
|
+
/** @type {Set<Message>} */
|
|
19
|
+
#messages = new Set();
|
|
20
|
+
|
|
21
|
+
/** @type {Map<string, Logger>} */
|
|
22
|
+
static #loggers = new Map();
|
|
23
|
+
static #logDir = path.join(xConfig.xcraftRoot, 'var/log/ai');
|
|
24
|
+
|
|
25
|
+
constructor(id) {
|
|
26
|
+
this.#id = id;
|
|
27
|
+
fse.ensureDirSync(Logger.#logDir);
|
|
28
|
+
Logger.#loggers.set(id, this);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} step
|
|
33
|
+
* @param {object} payload
|
|
34
|
+
*/
|
|
35
|
+
push(step, payload) {
|
|
36
|
+
const timestamp = new Date().toISOString();
|
|
37
|
+
const msg = new Message({timestamp, step, payload});
|
|
38
|
+
this.#messages.add(msg);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async #report() {
|
|
42
|
+
let timestamp = new Date().toISOString();
|
|
43
|
+
let messages = '';
|
|
44
|
+
|
|
45
|
+
for (const message of this.#messages) {
|
|
46
|
+
timestamp = message.timestamp;
|
|
47
|
+
|
|
48
|
+
const messageFile = path.join(__dirname, 'report/message.html');
|
|
49
|
+
let reportMessage = await fse.readFile(messageFile, 'utf8');
|
|
50
|
+
|
|
51
|
+
let payload = '';
|
|
52
|
+
for (const [key, value] of Object.entries(message.payload)) {
|
|
53
|
+
const itemFile = path.join(__dirname, 'report/item.html');
|
|
54
|
+
let reportItem = await fse.readFile(itemFile, 'utf8');
|
|
55
|
+
payload += reportItem
|
|
56
|
+
.replace('${KEY}', key)
|
|
57
|
+
.replace('${VALUE}', `${value}`.replaceAll('\n', '<br/>'));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
messages += reportMessage
|
|
61
|
+
.replace('${TIMESTAMP}', message.timestamp)
|
|
62
|
+
.replace('${STEP}', message.step)
|
|
63
|
+
.replace('${PAYLOAD}', payload);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const reportFile = path.join(__dirname, 'report/report.html');
|
|
67
|
+
let report = await fse.readFile(reportFile, 'utf8');
|
|
68
|
+
report = report
|
|
69
|
+
.replace('${MESSAGES}', messages)
|
|
70
|
+
.replace('${TITLE}', `AI reporting for ${this.#id}`);
|
|
71
|
+
|
|
72
|
+
const ts = timestamp.split(':').slice(0, 2).join('').split('-').join('');
|
|
73
|
+
const reportOutput = path.join(Logger.#logDir, `${this.#id}_${ts}Z.html`);
|
|
74
|
+
await fse.writeFile(reportOutput, report, 'utf8');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async dispose() {
|
|
78
|
+
if (this.#id && this.#id !== 'default') {
|
|
79
|
+
await this.#report();
|
|
80
|
+
}
|
|
81
|
+
Logger.#loggers.delete(this.#id);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
static get(id, quest) {
|
|
85
|
+
if (quest.quest) {
|
|
86
|
+
quest = quest.quest;
|
|
87
|
+
}
|
|
88
|
+
if (Logger.#loggers.has(id)) {
|
|
89
|
+
const logger = Logger.#loggers.get(id);
|
|
90
|
+
if (logger) {
|
|
91
|
+
return logger;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const logger = new Logger(id);
|
|
95
|
+
quest.defer(async () => await logger.dispose());
|
|
96
|
+
return logger;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
static get logDir() {
|
|
100
|
+
return Logger.#logDir;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = Logger;
|