@oliverlessa/gemini-agent-lib
Version:
Uma biblioteca NodeJS para criar agentes de IA com Gemini LLM
306 lines (265 loc) • 16.1 kB
JavaScript
const { GoogleGenerativeAI, HarmCategory, HarmBlockThreshold } = require("@google/generative-ai");
const debug = require('./debug').llm; // Assumindo que o debug pode ser útil
class GenerativeAILLM {
/**
* Construtor para a classe GenerativeAILLM.
* @param {object} options - Opções de configuração.
* @param {string} options.apiKey - Chave da API do Google AI Studio (Gemini). Obrigatório.
* @param {string} [options.projectId] - ID do Projeto Google Cloud (para compatibilidade de interface com VertexAILLM, não usado).
* @param {string} [options.location] - Localização do Google Cloud (para compatibilidade de interface com VertexAILLM, não usado).
* @param {string} [options.modelName="gemini-2.0-flash-001"] - Nome do modelo Gemini a ser usado. Padrão: gemini-2.0-flash-001.
* @param {string} [options.mode="oneshot"] - Modo de operação: 'oneshot' ou 'chat'.
* @param {object} [options.generationConfig={}] - Configurações de geração para o modelo.
* @param {Array} [options.safetySettings] - Configurações de segurança personalizadas. Se não fornecido, usa padrões.
*/
constructor({ apiKey, projectId, location, modelName = "gemini-2.0-flash-001", mode = "oneshot", generationConfig = {}, safetySettings }) { // CORRIGIDO: modelName padrão
if (!apiKey) {
throw new Error("API Key do Gemini não fornecida.");
}
// projectId e location são para compatibilidade de interface com VertexAILLM, não usados aqui.
this.apiKey = apiKey;
this.modelName = modelName;
this.mode = mode; // 'oneshot' ou 'chat'
this.generationConfig = generationConfig;
// Configurações de segurança padrão (iguais ao VertexAILLM)
const defaultSafetySettings = [
{ category: HarmCategory.HARM_CATEGORY_HARASSMENT, threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE },
{ category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE },
{ category: HarmCategory.HARM_CATEGORY_SEXUALLY_EXPLICIT, threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE },
{ category: HarmCategory.HARM_CATEGORY_DANGEROUS_CONTENT, threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE },
];
this.safetySettings = safetySettings || defaultSafetySettings;
// Inicializa o SDK do GoogleGenerativeAI
this.genAI = new GoogleGenerativeAI(this.apiKey);
// Inicializa o modelo generativo com as configurações
this.model = this.genAI.getGenerativeModel({
model: this.modelName,
generationConfig: this.generationConfig,
safetySettings: this.safetySettings // Passa as configurações de segurança
});
// Inicializa variáveis de estado para o modo chat
if (this.mode === 'chat') {
this.chat = null; // Armazenará a instância do chat do SDK (@google/generative-ai) - Pode não ser usado diretamente se usarmos generateContent
this._currentTools = null; // Rastreia as ferramentas da sessão atual
this._currentContext = null; // Rastreia o contexto (systemInstruction) da sessão atual
}
}
/**
* Verifica se o chat deve ser reinicializado com base em mudanças nas ferramentas ou no contexto.
* @param {Array|undefined} tools - Ferramentas a serem utilizadas.
* @param {string|undefined} context - Instruções do sistema (contexto).
* @returns {boolean} - Verdadeiro se o chat deve ser reinicializado.
*/
shouldReinitializeChat(tools, context) {
// Compara a representação JSON das ferramentas atuais e novas
const toolsChanged = JSON.stringify(tools) !== JSON.stringify(this._currentTools);
// Compara o contexto (string) atual e novo
const contextChanged = context !== this._currentContext;
// Retorna true se qualquer um deles mudou
return toolsChanged || contextChanged;
}
/**
* Gera conteúdo usando a API Gemini, suportando modos 'oneshot' e 'chat'.
* @param {object} options - Opções para geração de conteúdo.
* @param {string} options.prompt - O prompt do usuário.
* @param {Array} [options.tools] - Ferramentas (function declarations) a serem disponibilizadas para o modelo. Incluir `{ googleSearch: {} }` para ativar grounding.
* @param {string} [options.context] - Instrução do sistema (system instruction).
* @param {Array} [options.history] - Histórico da conversa (usado principalmente no modo 'chat').
* @returns {Promise<object>} - Promessa resolvida com o objeto de resposta { text: string, functionCall?: object }.
*/
async generateContent({ prompt, tools, context, history }) {
try {
// Prepara a requisição base
const request = {
safetySettings: this.safetySettings,
generationConfig: this.generationConfig
};
// Adiciona tools se fornecidas (incluindo grounding se presente)
if (tools && tools.length > 0) {
request.tools = tools;
debug("Ferramentas enviadas para Generative AI: %o", tools);
}
// Adiciona systemInstruction se context for fornecido
if (context) {
request.systemInstruction = { role: 'system', parts: [{ text: context }] };
}
// Prepara o conteúdo (prompt e histórico)
const userMessage = { role: 'user', parts: [{ text: typeof prompt === 'string' ? prompt : JSON.stringify(prompt) }] };
request.contents = [...(history || []), userMessage];
// Log para debug
debug(`[${this.constructor.name}] Enviando request para ${this.modelName} (mode: ${this.mode}): %o`, {
contentsLength: request.contents.length,
hasTools: !!request.tools,
hasSystemInstruction: !!request.systemInstruction
});
// Chama a API generateContent (usada tanto para oneshot quanto para chat)
const result = await this.model.generateContent(request);
// Log detalhado da resposta bruta para depuração
debug(`[${this.constructor.name}] Resposta bruta da API Gemini: %o`, result.response);
// Processa a resposta unificada
return this._processResponse(result.response);
} catch (error) {
console.error(`Erro na chamada à API Gemini (${this.modelName}, mode: ${this.mode}):`, error);
debug(`Erro na chamada à API Gemini (${this.modelName}, mode: ${this.mode}): %o`, error);
// Retorna um objeto de resposta similar ao LLMMock em caso de erro
return { text: `Erro ao comunicar com a API Gemini (${this.modelName}, mode: ${this.mode}): ${error.message}` };
}
}
/**
* Processa a resposta bruta da API Gemini para o formato esperado.
* @param {object} response - O objeto response retornado pela API Gemini.
* @returns {object} - Objeto com { text: string, functionCall?: object }
* @private
*/
_processResponse(response) {
// Validação básica da resposta
if (!response || !response.candidates || response.candidates.length === 0) {
console.warn("Resposta da API Gemini vazia ou inválida:", response);
debug("Resposta da API Gemini vazia ou inválida: %o", response);
return { text: "Resposta vazia ou inválida da API Gemini." };
}
const candidate = response.candidates[0];
// Extrair texto combinado das partes
let text = "";
if (candidate.content && candidate.content.parts) {
text = candidate.content.parts
.filter(part => typeof part.text === 'string') // Garante que part.text existe e é string
.map(part => part.text)
.join('\n'); // Junta múltiplos textos com nova linha
} else {
console.warn("Candidato da API Gemini sem 'content' ou 'parts':", candidate);
debug("Candidato da API Gemini sem 'content' ou 'parts': %o", candidate);
}
// Extrair functionCalls (pode haver múltiplos em paralelo)
// Nota: Usamos o 'response' bruto da API aqui, não apenas 'candidate'.
// O SDK @google/generative-ai já coloca functionCalls no nível da resposta.
const functionCalls = response.functionCalls || undefined;
// Verificar também se há functionCall no formato do VertexAILLM (para compatibilidade)
let vertexStyleFunctionCall = null;
if (candidate.content && candidate.content.parts) {
const functionCallPart = candidate.content.parts.find(part => part.functionCall);
if (functionCallPart && functionCallPart.functionCall) {
vertexStyleFunctionCall = functionCallPart.functionCall;
debug("Encontrado functionCall no formato VertexAILLM: %o", vertexStyleFunctionCall);
}
}
// --- Início: Tratamento de Grounding ---
// A ativação é feita incluindo { googleSearch: {} } no array `tools`.
// A estrutura de `candidate.groundingMetadata` é compatível com VertexAILLM.
if (candidate.groundingMetadata) {
debug("GroundingMetadata encontrado: %o", candidate.groundingMetadata);
try {
const fontesFormatadas = this._formatGroundingMetadata(candidate.groundingMetadata);
if (fontesFormatadas) {
text += fontesFormatadas;
}
} catch (e) {
console.error("Erro ao formatar metadados de grounding:", e);
debug("Erro ao formatar metadados de grounding: %o", e);
}
}
// --- Fim: Tratamento de Grounding ---
// Construir o objeto de resultado final
const responseResult = { text: text };
// Priorizar functionCalls do SDK, mas usar vertexStyleFunctionCall como fallback
if (functionCalls && functionCalls.length > 0) {
if (functionCalls.length > 1) {
console.warn(`[${this.constructor.name}] Múltiplas chamadas de função (${functionCalls.length}) recebidas, retornando apenas a primeira:`, functionCalls);
debug(`[${this.constructor.name}] Múltiplas chamadas de função (${functionCalls.length}) recebidas, retornando apenas a primeira: %o`, functionCalls);
}
responseResult.functionCall = functionCalls[0]; // Retorna a primeira chamada
} else if (vertexStyleFunctionCall) {
// Usar o formato do VertexAILLM como fallback
debug(`[${this.constructor.name}] Usando functionCall no formato VertexAILLM como fallback`);
responseResult.functionCall = vertexStyleFunctionCall;
}
debug(`[${this.constructor.name}] Resposta processada: %o`, responseResult);
return responseResult;
}
/**
* Formata os metadados de grounding para exibição.
* (Lógica adaptada de VertexAILLM)
* @param {object} groundingMetadata - O objeto groundingMetadata da resposta.
* @returns {string|null} - String formatada das fontes ou null.
* @private
*/
_formatGroundingMetadata(groundingMetadata) {
// Verifica se existem groundingChunks (fontes) e groundingSupports (trechos citados)
if (groundingMetadata && groundingMetadata.groundingChunks && groundingMetadata.groundingSupports) {
const chunks = groundingMetadata.groundingChunks;
const supports = groundingMetadata.groundingSupports;
// Mapa para armazenar as fontes únicas com seus trechos
const fontesMap = new Map();
// Processa cada support (trecho citado) e associa à sua fonte
supports.forEach(support => {
// Obtém o texto do trecho
const trechoTexto = support.segment?.text || "";
// Para cada índice de chunk referenciado por este trecho
if (support.groundingChunkIndices) {
support.groundingChunkIndices.forEach(chunkIndex => {
if (chunkIndex >= 0 && chunkIndex < chunks.length) {
const chunk = chunks[chunkIndex];
// Extrai informações da fonte (web ou retrievedContext)
const uri = chunk.web?.uri || chunk.retrievedContext?.uri || "";
const titulo = chunk.web?.title || chunk.retrievedContext?.title || "";
// Tenta obter um domínio se for web
const dominio = uri.startsWith('http') ? new URL(uri).hostname : "";
// Cria uma chave única para a fonte
const fonteKey = uri;
if (uri) { // Só adiciona se tiver uma URI
// Adiciona ou atualiza a entrada no mapa
if (!fontesMap.has(fonteKey)) {
fontesMap.set(fonteKey, {
uri: uri,
titulo: titulo,
dominio: dominio,
trechos: new Set() // Usa Set para evitar trechos duplicados por fonte
});
}
// Adiciona o trecho à fonte correspondente se não for vazio
if (trechoTexto) {
fontesMap.get(fonteKey).trechos.add(trechoTexto);
}
}
}
});
}
});
// Formata as fontes para exibição
if (fontesMap.size > 0) {
let fontesFormatadas = "\n\n**Fontes:**\n";
// Converte o mapa em array para facilitar a formatação
const fontesArray = Array.from(fontesMap.values());
// Formata cada fonte com seus trechos
fontesArray.forEach((fonte, index) => {
fontesFormatadas += `\n[${index + 1}] [${fonte.titulo || fonte.dominio || 'Fonte Desconhecida'}](${fonte.uri})\n`;
// Adiciona os trechos citados desta fonte
if (fonte.trechos.size > 0) {
fonte.trechos.forEach(trecho => {
// Limita o tamanho do trecho para não ficar muito extenso
const trechoResumido = trecho.length > 150
? trecho.substring(0, 147) + "..."
: trecho;
fontesFormatadas += ` * "${trechoResumido}"\n`; // Adiciona aspas para clareza
});
}
});
return fontesFormatadas;
}
}
// Fallback para o método antigo se não encontrar a estrutura esperada (menos provável com @google/generative-ai)
else if (groundingMetadata && groundingMetadata.webSearchRetrievalResults) {
debug("Usando fallback para webSearchRetrievalResults em groundingMetadata.");
// Extrai as fontes dos metadados de grounding (método antigo)
const fontes = groundingMetadata.webSearchRetrievalResults.map((resultItem, index) => {
return `[${index + 1}] ${resultItem.uri}: ${resultItem.title}`;
});
// Adiciona as fontes à resposta final
if (fontes.length > 0) {
return `\n\n**Fontes:**\n${fontes.join('\n')}`;
}
}
return null; // Retorna null se não houver metadados válidos para formatar
}
}
module.exports = GenerativeAILLM;