@luanpoppe/ai 1.1.2 → 1.1.4
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/dist/@types/image.d.ts +4 -0
- package/dist/@types/image.d.ts.map +1 -0
- package/dist/@types/image.js +10 -0
- package/dist/@types/image.js.map +1 -0
- package/dist/ai.d.ts +1 -1
- package/dist/ai.d.ts.map +1 -1
- package/dist/ai.js +9 -3
- package/dist/ai.js.map +1 -1
- package/dist/index.d.ts +6 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -3
- package/dist/index.js.map +1 -1
- package/dist/langchain/memory.d.ts +96 -0
- package/dist/langchain/memory.d.ts.map +1 -0
- package/dist/langchain/memory.js +268 -0
- package/dist/langchain/memory.js.map +1 -0
- package/dist/langchain/messages.d.ts +17 -0
- package/dist/langchain/messages.d.ts.map +1 -1
- package/dist/langchain/messages.js +21 -0
- package/dist/langchain/messages.js.map +1 -1
- package/dist/langchain/models.d.ts +4 -0
- package/dist/langchain/models.d.ts.map +1 -1
- package/dist/langchain/models.js +14 -2
- package/dist/langchain/models.js.map +1 -1
- package/dist/utils/image-utils.d.ts +8 -0
- package/dist/utils/image-utils.d.ts.map +1 -0
- package/dist/utils/image-utils.js +91 -0
- package/dist/utils/image-utils.js.map +1 -0
- package/package.json +1 -1
- package/src/@types/image.ts +14 -0
- package/src/ai.ts +317 -306
- package/src/index.ts +7 -2
- package/src/langchain/{checkpointers.ts → memory.ts} +48 -22
- package/src/langchain/messages.ts +47 -0
- package/src/langchain/models.ts +19 -2
- package/src/utils/image-utils.ts +113 -0
- package/tests/e2e/@fixtures/imagem-teste-bateria.jpg +0 -0
- package/tests/e2e/ai-audio.test.ts +3 -3
- package/tests/e2e/ai-image.test.ts +173 -0
- package/tests/e2e/ai.test.ts +96 -4
- package/tests/unit/index.test.ts +98 -3
- package/tests/unit/langchain/checkpointers.test.ts +72 -5
- package/tests/unit/langchain/image-utils.test.ts +127 -0
- package/tests/unit/langchain/messages.test.ts +78 -0
- package/tests/unit/langchain/models.test.ts +16 -0
|
@@ -84,7 +84,7 @@ export class AIMemory {
|
|
|
84
84
|
*
|
|
85
85
|
* @param threadId - ID da thread/conversa
|
|
86
86
|
* @param graph - Opcional. Grafo compilado com checkpointer. Se não passado, usa o agent
|
|
87
|
-
* definido no construtor ou via setAgent
|
|
87
|
+
* definido no construtor ou via setAgent. Se nenhum disponível, usa o checkpointer.
|
|
88
88
|
* @returns Objeto com fullHistory (checkpoints) e messages (lista com role, createdAt, content)
|
|
89
89
|
*
|
|
90
90
|
* @example
|
|
@@ -96,6 +96,11 @@ export class AIMemory {
|
|
|
96
96
|
* // Passando graph explicitamente
|
|
97
97
|
* const { fullHistory, messages } = await memory.getHistory("1", agent);
|
|
98
98
|
*
|
|
99
|
+
* @example
|
|
100
|
+
* // Apenas com checkpointer (sem graph)
|
|
101
|
+
* const memory = new AIMemory({ type: "memory" });
|
|
102
|
+
* const { fullHistory, messages } = await memory.getHistory("1");
|
|
103
|
+
*
|
|
99
104
|
* @see https://docs.langchain.com/oss/javascript/langgraph/persistence#get-state-history
|
|
100
105
|
* @see https://docs.langchain.com/oss/javascript/langgraph/add-memory#manage-checkpoints
|
|
101
106
|
*/
|
|
@@ -104,21 +109,45 @@ export class AIMemory {
|
|
|
104
109
|
graph?: GraphWithStateHistory
|
|
105
110
|
): Promise<GetHistoryResult> {
|
|
106
111
|
const graphToUse = graph ?? this.agent;
|
|
107
|
-
if (!graphToUse) {
|
|
108
|
-
throw new Error(
|
|
109
|
-
"É necessário passar graph em getHistory ou definir o agent no construtor/setAgent do AIMemory."
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
const config = { configurable: { thread_id: threadId } };
|
|
113
112
|
const fullHistory: StateSnapshot[] = [];
|
|
114
|
-
|
|
115
|
-
|
|
113
|
+
|
|
114
|
+
if (graphToUse) {
|
|
115
|
+
const config = { configurable: { thread_id: threadId } };
|
|
116
|
+
for await (const snapshot of graphToUse.getStateHistory(config)) {
|
|
117
|
+
fullHistory.push(snapshot);
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
const checkpointer = await this.getCheckpointer();
|
|
121
|
+
const config = { configurable: { thread_id: threadId } };
|
|
122
|
+
for await (const tuple of checkpointer.list(config)) {
|
|
123
|
+
fullHistory.push(this.checkpointTupleToSnapshot(tuple));
|
|
124
|
+
}
|
|
116
125
|
}
|
|
117
126
|
|
|
118
127
|
const messages = this.extractMessagesFromHistory(fullHistory);
|
|
119
128
|
return { fullHistory, messages };
|
|
120
129
|
}
|
|
121
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Converte CheckpointTuple (do checkpointer.list) em StateSnapshot.
|
|
133
|
+
* Permite obter histórico usando apenas o checkpointer, sem o graph.
|
|
134
|
+
*/
|
|
135
|
+
private checkpointTupleToSnapshot(tuple: {
|
|
136
|
+
checkpoint: { channel_values: Record<string, unknown>; ts?: string };
|
|
137
|
+
config?: unknown;
|
|
138
|
+
metadata?: unknown;
|
|
139
|
+
}): StateSnapshot {
|
|
140
|
+
const { checkpoint } = tuple;
|
|
141
|
+
return {
|
|
142
|
+
values: checkpoint.channel_values ?? {},
|
|
143
|
+
createdAt: checkpoint.ts ?? new Date().toISOString(),
|
|
144
|
+
config: tuple.config ?? { configurable: {} },
|
|
145
|
+
metadata: tuple.metadata,
|
|
146
|
+
next: [],
|
|
147
|
+
tasks: [],
|
|
148
|
+
} as StateSnapshot;
|
|
149
|
+
}
|
|
150
|
+
|
|
122
151
|
/**
|
|
123
152
|
* Extrai lista de mensagens com role, createdAt e content a partir do histórico.
|
|
124
153
|
* Percorre os checkpoints do mais antigo ao mais recente para atribuir createdAt correto.
|
|
@@ -220,19 +249,17 @@ export class AIMemory {
|
|
|
220
249
|
checkpointId?: string
|
|
221
250
|
): Promise<StateSnapshot | null> {
|
|
222
251
|
const graphToUse = graph ?? this.agent;
|
|
223
|
-
if (
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return
|
|
252
|
+
if (graphToUse?.getState) {
|
|
253
|
+
const config = {
|
|
254
|
+
configurable: {
|
|
255
|
+
thread_id: threadId,
|
|
256
|
+
...(checkpointId && { checkpoint_id: checkpointId }),
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
return (await graphToUse.getState(config)) ?? null;
|
|
231
260
|
}
|
|
232
|
-
const
|
|
233
|
-
|
|
234
|
-
};
|
|
235
|
-
return (await graphToUse.getState(config)) ?? null;
|
|
261
|
+
const { fullHistory } = await this.getHistory(threadId, graphToUse ?? undefined);
|
|
262
|
+
return fullHistory[0] ?? null;
|
|
236
263
|
}
|
|
237
264
|
|
|
238
265
|
private async createCheckpointer(): Promise<BaseCheckpointSaver> {
|
|
@@ -328,4 +355,3 @@ export class AIMemory {
|
|
|
328
355
|
}
|
|
329
356
|
}
|
|
330
357
|
}
|
|
331
|
-
|
|
@@ -1,10 +1,29 @@
|
|
|
1
1
|
import { SystemMessage, HumanMessage, AIMessage } from "langchain";
|
|
2
2
|
import { AudioUtils } from "../utils/audio-utils";
|
|
3
|
+
import { ImageUtils } from "../utils/image-utils";
|
|
3
4
|
import type { AudioBuffer, AudioMimeType } from "../@types/audio";
|
|
5
|
+
import type { ImageBuffer, ImageMimeType } from "../@types/image";
|
|
4
6
|
import { AIAudioTranscription } from "./audio-transcription";
|
|
5
7
|
|
|
6
8
|
export type MessageInput = SystemMessage | HumanMessage | AIMessage;
|
|
7
9
|
|
|
10
|
+
export type ImageContentBlock = {
|
|
11
|
+
type: "image";
|
|
12
|
+
source_type: "base64";
|
|
13
|
+
data: string;
|
|
14
|
+
mime_type: ImageMimeType;
|
|
15
|
+
metadata?: Record<string, unknown>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type HumanMessageWithImageOptions = {
|
|
19
|
+
image: {
|
|
20
|
+
buffer: ImageBuffer;
|
|
21
|
+
mimeType?: ImageMimeType;
|
|
22
|
+
filename?: string;
|
|
23
|
+
};
|
|
24
|
+
text?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
8
27
|
export type AudioContentBlock = {
|
|
9
28
|
type: "audio";
|
|
10
29
|
source_type: "base64";
|
|
@@ -33,6 +52,34 @@ export class AIMessages {
|
|
|
33
52
|
return new HumanMessage(message);
|
|
34
53
|
}
|
|
35
54
|
|
|
55
|
+
static humanImage(options: HumanMessageWithImageOptions): HumanMessage {
|
|
56
|
+
const { image, text } = options;
|
|
57
|
+
const { buffer, mimeType, filename } = image;
|
|
58
|
+
|
|
59
|
+
const base64Data = ImageUtils.bufferToBase64(buffer);
|
|
60
|
+
const detectedMimeType =
|
|
61
|
+
mimeType ?? ImageUtils.detectImageMimeType(buffer, filename);
|
|
62
|
+
|
|
63
|
+
const content: Array<{ type: "text"; text: string } | ImageContentBlock> =
|
|
64
|
+
[];
|
|
65
|
+
|
|
66
|
+
if (text) {
|
|
67
|
+
content.push({ type: "text", text });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const imageBlock: ImageContentBlock = {
|
|
71
|
+
type: "image",
|
|
72
|
+
source_type: "base64",
|
|
73
|
+
data: base64Data,
|
|
74
|
+
mime_type: detectedMimeType,
|
|
75
|
+
};
|
|
76
|
+
content.push(imageBlock);
|
|
77
|
+
|
|
78
|
+
return new HumanMessage({
|
|
79
|
+
content: content as any,
|
|
80
|
+
} as any);
|
|
81
|
+
}
|
|
82
|
+
|
|
36
83
|
static async humanAudio(
|
|
37
84
|
options: HumanMessageWithAudioOptions
|
|
38
85
|
): Promise<HumanMessage> {
|
package/src/langchain/models.ts
CHANGED
|
@@ -4,16 +4,21 @@ import {
|
|
|
4
4
|
} from "@langchain/google-genai";
|
|
5
5
|
import { ChatOpenAI, ChatOpenAIFields } from "@langchain/openai";
|
|
6
6
|
|
|
7
|
+
/** Nível de esforço de raciocínio para modelos OpenAI (o1, gpt-5, etc.). Valores: "low" | "medium" | "high" */
|
|
8
|
+
export type ReasoningEffort = "low" | "medium" | "high";
|
|
9
|
+
|
|
7
10
|
export type LLMModelConfig = {
|
|
8
11
|
model: string;
|
|
9
12
|
apiKey?: string | undefined;
|
|
10
13
|
maxTokens?: number | undefined;
|
|
11
14
|
temperature?: number | undefined;
|
|
15
|
+
/** Nível de esforço de raciocínio (modelos OpenAI: o1, gpt-5, etc.) */
|
|
16
|
+
reasoningEffort?: ReasoningEffort | undefined;
|
|
12
17
|
};
|
|
13
18
|
|
|
14
19
|
export class AIModels {
|
|
15
20
|
static gpt(params: LLMModelConfig) {
|
|
16
|
-
const { model, apiKey, maxTokens, temperature } = params;
|
|
21
|
+
const { model, apiKey, maxTokens, temperature, reasoningEffort } = params;
|
|
17
22
|
if (!apiKey)
|
|
18
23
|
throw new Error("OpenAI API key is not passed in the model parameters");
|
|
19
24
|
|
|
@@ -24,6 +29,12 @@ export class AIModels {
|
|
|
24
29
|
|
|
25
30
|
if (maxTokens) options.maxTokens = maxTokens;
|
|
26
31
|
if (temperature) options.temperature = temperature;
|
|
32
|
+
if (reasoningEffort) {
|
|
33
|
+
options.modelKwargs = {
|
|
34
|
+
...(options.modelKwargs ?? {}),
|
|
35
|
+
reasoning_effort: reasoningEffort,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
27
38
|
|
|
28
39
|
return new ChatOpenAI(options);
|
|
29
40
|
}
|
|
@@ -48,7 +59,7 @@ export class AIModels {
|
|
|
48
59
|
}
|
|
49
60
|
|
|
50
61
|
static openrouter(params: LLMModelConfig) {
|
|
51
|
-
const { apiKey, maxTokens, model, temperature } = params;
|
|
62
|
+
const { apiKey, maxTokens, model, temperature, reasoningEffort } = params;
|
|
52
63
|
|
|
53
64
|
if (!apiKey)
|
|
54
65
|
throw new Error(
|
|
@@ -65,6 +76,12 @@ export class AIModels {
|
|
|
65
76
|
|
|
66
77
|
if (maxTokens) options.maxTokens = maxTokens;
|
|
67
78
|
if (temperature) options.temperature = temperature;
|
|
79
|
+
if (reasoningEffort) {
|
|
80
|
+
options.modelKwargs = {
|
|
81
|
+
...(options.modelKwargs ?? {}),
|
|
82
|
+
reasoning_effort: reasoningEffort,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
68
85
|
|
|
69
86
|
return new ChatOpenAI(options);
|
|
70
87
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { ImageBuffer, ImageMimeType } from "../@types/image";
|
|
2
|
+
|
|
3
|
+
export class ImageUtils {
|
|
4
|
+
static bufferToBase64(buffer: ImageBuffer): string {
|
|
5
|
+
if (buffer instanceof Buffer) {
|
|
6
|
+
return buffer.toString("base64");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
if (buffer instanceof ArrayBuffer) {
|
|
10
|
+
const uint8Array = new Uint8Array(buffer);
|
|
11
|
+
return Buffer.from(uint8Array).toString("base64");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (buffer instanceof Uint8Array) {
|
|
15
|
+
return Buffer.from(buffer).toString("base64");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
throw new Error("Tipo de buffer não suportado");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static detectMimeTypeFromExtension(filename?: string): ImageMimeType | null {
|
|
22
|
+
if (!filename) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const extension = filename.toLowerCase().split(".").pop();
|
|
27
|
+
|
|
28
|
+
const mimeTypeMap: Record<string, ImageMimeType> = {
|
|
29
|
+
jpg: "image/jpeg",
|
|
30
|
+
jpeg: "image/jpeg",
|
|
31
|
+
png: "image/png",
|
|
32
|
+
gif: "image/gif",
|
|
33
|
+
webp: "image/webp",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return extension ? mimeTypeMap[extension] ?? null : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
static detectMimeTypeFromBuffer(buffer: ImageBuffer): ImageMimeType | null {
|
|
40
|
+
let bytes: Uint8Array;
|
|
41
|
+
|
|
42
|
+
if (buffer instanceof Buffer) {
|
|
43
|
+
bytes = new Uint8Array(buffer.slice(0, 16));
|
|
44
|
+
} else if (buffer instanceof ArrayBuffer) {
|
|
45
|
+
bytes = new Uint8Array(buffer.slice(0, 16));
|
|
46
|
+
} else {
|
|
47
|
+
bytes = buffer.slice(0, 16);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// JPEG: FF D8 FF
|
|
51
|
+
if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
52
|
+
return "image/jpeg";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
|
56
|
+
if (
|
|
57
|
+
bytes[0] === 0x89 &&
|
|
58
|
+
bytes[1] === 0x50 &&
|
|
59
|
+
bytes[2] === 0x4e &&
|
|
60
|
+
bytes[3] === 0x47 &&
|
|
61
|
+
bytes[4] === 0x0d &&
|
|
62
|
+
bytes[5] === 0x0a &&
|
|
63
|
+
bytes[6] === 0x1a &&
|
|
64
|
+
bytes[7] === 0x0a
|
|
65
|
+
) {
|
|
66
|
+
return "image/png";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// GIF: 47 49 46 38 (GIF8)
|
|
70
|
+
if (
|
|
71
|
+
bytes[0] === 0x47 &&
|
|
72
|
+
bytes[1] === 0x49 &&
|
|
73
|
+
bytes[2] === 0x46 &&
|
|
74
|
+
bytes[3] === 0x38
|
|
75
|
+
) {
|
|
76
|
+
return "image/gif";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// WebP: 52 49 46 46 (RIFF) ... 57 45 42 50 (WEBP)
|
|
80
|
+
if (
|
|
81
|
+
bytes[0] === 0x52 &&
|
|
82
|
+
bytes[1] === 0x49 &&
|
|
83
|
+
bytes[2] === 0x46 &&
|
|
84
|
+
bytes[3] === 0x46 &&
|
|
85
|
+
bytes[8] === 0x57 &&
|
|
86
|
+
bytes[9] === 0x45 &&
|
|
87
|
+
bytes[10] === 0x42 &&
|
|
88
|
+
bytes[11] === 0x50
|
|
89
|
+
) {
|
|
90
|
+
return "image/webp";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
static detectImageMimeType(
|
|
97
|
+
buffer: ImageBuffer,
|
|
98
|
+
filename?: string
|
|
99
|
+
): ImageMimeType {
|
|
100
|
+
const mimeFromExtension =
|
|
101
|
+
ImageUtils.detectMimeTypeFromExtension(filename);
|
|
102
|
+
if (mimeFromExtension) {
|
|
103
|
+
return mimeFromExtension;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const mimeFromBuffer = ImageUtils.detectMimeTypeFromBuffer(buffer);
|
|
107
|
+
if (mimeFromBuffer) {
|
|
108
|
+
return mimeFromBuffer;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return "image/jpeg";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
Binary file
|
|
@@ -58,7 +58,7 @@ describe("AI Audio E2E Tests", () => {
|
|
|
58
58
|
}
|
|
59
59
|
);
|
|
60
60
|
|
|
61
|
-
it(
|
|
61
|
+
it.skip(
|
|
62
62
|
"deve processar áudio diretamente com Gemini (multimodal)",
|
|
63
63
|
{ timeout },
|
|
64
64
|
async () => {
|
|
@@ -132,7 +132,7 @@ describe("AI Audio E2E Tests", () => {
|
|
|
132
132
|
}
|
|
133
133
|
);
|
|
134
134
|
|
|
135
|
-
it(
|
|
135
|
+
it.skip(
|
|
136
136
|
"deve processar áudio com OpenRouter usando Gemini (multimodal)",
|
|
137
137
|
{ timeout },
|
|
138
138
|
async () => {
|
|
@@ -168,7 +168,7 @@ describe("AI Audio E2E Tests", () => {
|
|
|
168
168
|
}
|
|
169
169
|
);
|
|
170
170
|
|
|
171
|
-
it(
|
|
171
|
+
it.skip(
|
|
172
172
|
"deve processar áudio com OpenRouter usando GPT (transcrição prévia com Whisper)",
|
|
173
173
|
{ timeout },
|
|
174
174
|
async () => {
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { AI } from "../../src/index";
|
|
4
|
+
import { AIMessages } from "../../src/langchain/messages";
|
|
5
|
+
import "dotenv/config";
|
|
6
|
+
|
|
7
|
+
const IMAGE_FIXTURE_PATH = path.join(
|
|
8
|
+
process.cwd(),
|
|
9
|
+
"tests",
|
|
10
|
+
"e2e",
|
|
11
|
+
"@fixtures",
|
|
12
|
+
"imagem-teste-bateria.jpg"
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const PROMPT =
|
|
16
|
+
"Descreva brevemente o que há nesta imagem. Responda em uma frase.";
|
|
17
|
+
|
|
18
|
+
const EXPECTED_TERMS = [
|
|
19
|
+
"bateria",
|
|
20
|
+
"baterista",
|
|
21
|
+
"pratos",
|
|
22
|
+
"tambor",
|
|
23
|
+
"drum",
|
|
24
|
+
"cymbal",
|
|
25
|
+
"palco",
|
|
26
|
+
"stage",
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
function responseRelatesToImage(text: string): boolean {
|
|
30
|
+
const lower = text.toLowerCase();
|
|
31
|
+
return EXPECTED_TERMS.some((term) => lower.includes(term));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe("AI Image E2E Tests", () => {
|
|
35
|
+
const openAIApiKey = process.env.OPENAI_API_KEY;
|
|
36
|
+
const googleGeminiToken = process.env.GOOGLE_GEMINI_TOKEN;
|
|
37
|
+
const openRouterApiKey = process.env.OPENROUTER_API_KEY;
|
|
38
|
+
|
|
39
|
+
const timeout = 180 * 1000;
|
|
40
|
+
|
|
41
|
+
describe("Suporte a imagens", () => {
|
|
42
|
+
it(
|
|
43
|
+
"deve processar imagem com OpenAI (gpt-4o)",
|
|
44
|
+
{ timeout },
|
|
45
|
+
async () => {
|
|
46
|
+
if (!openAIApiKey) {
|
|
47
|
+
console.log("OPENAI_API_KEY não está configurada");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const ai = new AI({
|
|
51
|
+
openAIApiKey: openAIApiKey!,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const imageBuffer = fs.readFileSync(IMAGE_FIXTURE_PATH);
|
|
55
|
+
const message = AIMessages.humanImage({
|
|
56
|
+
image: {
|
|
57
|
+
buffer: imageBuffer,
|
|
58
|
+
filename: "imagem-teste-bateria.jpg",
|
|
59
|
+
},
|
|
60
|
+
text: PROMPT,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const result = await ai.call({
|
|
64
|
+
aiModel: "gpt-4o",
|
|
65
|
+
messages: [message],
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(result.text).toBeDefined();
|
|
69
|
+
expect(result.text.length).toBeGreaterThan(0);
|
|
70
|
+
expect(result.text).not.toContain("Model call failed");
|
|
71
|
+
expect(result.text).not.toContain("Invalid");
|
|
72
|
+
expect(responseRelatesToImage(result.text)).toBe(true);
|
|
73
|
+
}
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
it.skip(
|
|
77
|
+
"deve processar imagem com Gemini (gemini-2.5-flash)",
|
|
78
|
+
{ timeout },
|
|
79
|
+
async () => {
|
|
80
|
+
if (!googleGeminiToken) {
|
|
81
|
+
console.log("GOOGLE_GEMINI_TOKEN não está configurada");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
const ai = new AI({
|
|
85
|
+
googleGeminiToken: googleGeminiToken!,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const imageBuffer = fs.readFileSync(IMAGE_FIXTURE_PATH);
|
|
89
|
+
const message = AIMessages.humanImage({
|
|
90
|
+
image: {
|
|
91
|
+
buffer: imageBuffer,
|
|
92
|
+
filename: "imagem-teste-bateria.jpg",
|
|
93
|
+
},
|
|
94
|
+
text: PROMPT,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const result = await ai.call({
|
|
98
|
+
aiModel: "gemini-2.5-flash",
|
|
99
|
+
messages: [message],
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(result.text).toBeDefined();
|
|
103
|
+
expect(result.text.length).toBeGreaterThan(0);
|
|
104
|
+
expect(responseRelatesToImage(result.text)).toBe(true);
|
|
105
|
+
}
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
it.skip(
|
|
109
|
+
"deve processar imagem com OpenRouter (openrouter/openai/gpt-4o)",
|
|
110
|
+
{ timeout },
|
|
111
|
+
async () => {
|
|
112
|
+
if (!openRouterApiKey) {
|
|
113
|
+
console.log("OPENROUTER_API_KEY não está configurada");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
const ai = new AI({
|
|
117
|
+
openRouterApiKey: openRouterApiKey!,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const imageBuffer = fs.readFileSync(IMAGE_FIXTURE_PATH);
|
|
121
|
+
const message = AIMessages.humanImage({
|
|
122
|
+
image: {
|
|
123
|
+
buffer: imageBuffer,
|
|
124
|
+
filename: "imagem-teste-bateria.jpg",
|
|
125
|
+
},
|
|
126
|
+
text: PROMPT,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const result = await ai.call({
|
|
130
|
+
aiModel: "openrouter/openai/gpt-4o",
|
|
131
|
+
messages: [message],
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
expect(result.text).toBeDefined();
|
|
135
|
+
expect(result.text.length).toBeGreaterThan(0);
|
|
136
|
+
expect(result.text).not.toContain("Model call failed");
|
|
137
|
+
expect(responseRelatesToImage(result.text)).toBe(true);
|
|
138
|
+
}
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
it.skip(
|
|
142
|
+
"deve processar imagem com OpenRouter (openrouter/google/gemini-2.5-flash)",
|
|
143
|
+
{ timeout },
|
|
144
|
+
async () => {
|
|
145
|
+
if (!openRouterApiKey) {
|
|
146
|
+
console.log("OPENROUTER_API_KEY não está configurada");
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const ai = new AI({
|
|
150
|
+
openRouterApiKey: openRouterApiKey!,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const imageBuffer = fs.readFileSync(IMAGE_FIXTURE_PATH);
|
|
154
|
+
const message = AIMessages.humanImage({
|
|
155
|
+
image: {
|
|
156
|
+
buffer: imageBuffer,
|
|
157
|
+
filename: "imagem-teste-bateria.jpg",
|
|
158
|
+
},
|
|
159
|
+
text: PROMPT,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const result = await ai.call({
|
|
163
|
+
aiModel: "openrouter/google/gemini-2.5-flash",
|
|
164
|
+
messages: [message],
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(result.text).toBeDefined();
|
|
168
|
+
expect(result.text.length).toBeGreaterThan(0);
|
|
169
|
+
expect(responseRelatesToImage(result.text)).toBe(true);
|
|
170
|
+
}
|
|
171
|
+
);
|
|
172
|
+
});
|
|
173
|
+
});
|
package/tests/e2e/ai.test.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { createAgent } from "langchain";
|
|
1
2
|
import { AI } from "../../src/index";
|
|
3
|
+
import { AIMemory } from "../../src/langchain/memory";
|
|
4
|
+
import { AIModels } from "../../src/langchain/models";
|
|
2
5
|
import { AIMessages } from "../../src/langchain/messages";
|
|
3
6
|
import z from "zod";
|
|
4
7
|
import "dotenv/config";
|
|
@@ -42,7 +45,7 @@ describe("AI E2E Tests", () => {
|
|
|
42
45
|
},
|
|
43
46
|
);
|
|
44
47
|
|
|
45
|
-
it(
|
|
48
|
+
it.skip(
|
|
46
49
|
"deve fazer uma chamada real para Gemini e retornar resposta",
|
|
47
50
|
{ timeout },
|
|
48
51
|
async () => {
|
|
@@ -136,7 +139,7 @@ describe("AI E2E Tests", () => {
|
|
|
136
139
|
},
|
|
137
140
|
);
|
|
138
141
|
|
|
139
|
-
it(
|
|
142
|
+
it.skip(
|
|
140
143
|
"deve criar múltiplas mensagens e fazer chamada",
|
|
141
144
|
{ timeout },
|
|
142
145
|
async () => {
|
|
@@ -345,7 +348,7 @@ describe("AI E2E Tests", () => {
|
|
|
345
348
|
},
|
|
346
349
|
);
|
|
347
350
|
|
|
348
|
-
it(
|
|
351
|
+
it.skip(
|
|
349
352
|
"deve retornar resposta estruturada com Gemini",
|
|
350
353
|
{ timeout },
|
|
351
354
|
async () => {
|
|
@@ -481,8 +484,9 @@ describe("AI E2E Tests", () => {
|
|
|
481
484
|
AIMessages.human("Explique o que é TypeScript em uma frase curta."),
|
|
482
485
|
];
|
|
483
486
|
|
|
487
|
+
// gpt-4o suporta temperature; gpt-5-nano (reasoning) só aceita default
|
|
484
488
|
const result = await ai.call({
|
|
485
|
-
aiModel: "gpt-
|
|
489
|
+
aiModel: "gpt-4o",
|
|
486
490
|
messages,
|
|
487
491
|
modelConfig: {
|
|
488
492
|
maxTokens: 50,
|
|
@@ -496,6 +500,45 @@ describe("AI E2E Tests", () => {
|
|
|
496
500
|
},
|
|
497
501
|
);
|
|
498
502
|
|
|
503
|
+
// Níveis suportados pela API OpenAI (o1, gpt-5-nano, etc.): low, medium, high.
|
|
504
|
+
// Modelos mais recentes (gpt-5.2) podem suportar também: none, minimal, xhigh.
|
|
505
|
+
it.each([
|
|
506
|
+
{ reasoningEffort: "low" as const },
|
|
507
|
+
{ reasoningEffort: "medium" as const },
|
|
508
|
+
{ reasoningEffort: "high" as const },
|
|
509
|
+
])(
|
|
510
|
+
"deve usar reasoningEffort=$reasoningEffort em chamada real ao GPT",
|
|
511
|
+
{ timeout },
|
|
512
|
+
async ({ reasoningEffort }) => {
|
|
513
|
+
if (!openAIApiKey) {
|
|
514
|
+
console.log("OPENAI_API_KEY não está configurada");
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
const ai = new AI({
|
|
518
|
+
openAIApiKey: openAIApiKey!,
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
const messages = [
|
|
522
|
+
AIMessages.human(
|
|
523
|
+
"Responda apenas com a palavra 'OK' (modelo de reasoning).",
|
|
524
|
+
),
|
|
525
|
+
];
|
|
526
|
+
|
|
527
|
+
const result = await ai.call({
|
|
528
|
+
aiModel: "gpt-5-nano",
|
|
529
|
+
messages,
|
|
530
|
+
modelConfig: {
|
|
531
|
+
reasoningEffort,
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
expect(result.text).toBeDefined();
|
|
536
|
+
expect(result.text.length).toBeGreaterThan(0);
|
|
537
|
+
expect(result.messages).toBeDefined();
|
|
538
|
+
expect(result.messages.length).toBeGreaterThan(0);
|
|
539
|
+
},
|
|
540
|
+
);
|
|
541
|
+
|
|
499
542
|
it(
|
|
500
543
|
"deve usar maxTokens e temperature corretamente com OpenRouter",
|
|
501
544
|
{ timeout },
|
|
@@ -623,6 +666,55 @@ describe("AI E2E Tests", () => {
|
|
|
623
666
|
);
|
|
624
667
|
},
|
|
625
668
|
);
|
|
669
|
+
|
|
670
|
+
it(
|
|
671
|
+
"deve retornar histórico via getHistory usando apenas checkpointer - sem graph",
|
|
672
|
+
{ timeout },
|
|
673
|
+
async () => {
|
|
674
|
+
if (!openAIApiKey) {
|
|
675
|
+
console.log("OPENAI_API_KEY não está configurada");
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const memory = new AIMemory({ type: "memory" });
|
|
679
|
+
const checkpointer = await memory.getCheckpointer();
|
|
680
|
+
const model = AIModels.gpt({
|
|
681
|
+
apiKey: openAIApiKey!,
|
|
682
|
+
model: "gpt-5-nano",
|
|
683
|
+
});
|
|
684
|
+
const agent = createAgent({
|
|
685
|
+
model,
|
|
686
|
+
checkpointer,
|
|
687
|
+
systemPrompt: "",
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
const threadId = "e2e-checkpointer-only-thread";
|
|
691
|
+
|
|
692
|
+
await agent.invoke(
|
|
693
|
+
{ messages: [AIMessages.human("Responda apenas: recebido.")] },
|
|
694
|
+
{ configurable: { thread_id: threadId } },
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
await agent.invoke(
|
|
698
|
+
{ messages: [AIMessages.human("O que eu disse antes?")] },
|
|
699
|
+
{ configurable: { thread_id: threadId } },
|
|
700
|
+
);
|
|
701
|
+
|
|
702
|
+
const { fullHistory, messages } = await memory.getHistory(threadId);
|
|
703
|
+
|
|
704
|
+
console.log({ messages });
|
|
705
|
+
|
|
706
|
+
expect(fullHistory.length).toBeGreaterThan(0);
|
|
707
|
+
expect(messages.length).toBeGreaterThanOrEqual(2);
|
|
708
|
+
|
|
709
|
+
const allContent = messages.map((m) => m.content).join(" ");
|
|
710
|
+
expect(allContent).toContain("Responda apenas");
|
|
711
|
+
expect(allContent).toContain("O que eu disse");
|
|
712
|
+
|
|
713
|
+
expect(
|
|
714
|
+
messages.every((m) => ["human", "ai", "tool"].includes(m.role)),
|
|
715
|
+
).toBe(true);
|
|
716
|
+
},
|
|
717
|
+
);
|
|
626
718
|
});
|
|
627
719
|
|
|
628
720
|
describe("Múltiplas mensagens em conversa", () => {
|