feat: Adiciona gerenciamento de chave de API para LLM e funcionalidades de webhook para Wuzapi, com scripts de teste e ajustes de método.

This commit is contained in:
Rodrigo Borba 2026-01-03 19:24:09 -03:00
parent ad308c4f11
commit c6ef97fc00
8 changed files with 206 additions and 37 deletions

View File

@ -76,6 +76,26 @@ class Api::V1::Accounts::Inboxes::WuzapiController < Api::V1::Accounts::BaseCont
render json: { error: e.message }, status: :internal_server_error render json: { error: e.message }, status: :internal_server_error
end end
def webhook_info
info = client.get_webhook(user_token)
render json: info
rescue Wuzapi::Client::Error => e
render json: { error: e.message }, status: :unprocessable_entity
rescue StandardError => e
render json: { error: e.message }, status: :internal_server_error
end
def update_webhook
# Re-calculate correct webhook URL from model
url = @inbox.channel.webhook_url
client.update_webhook(user_token, url)
render json: { success: true, message: 'Webhook updated successfully', webhook_url: url }
rescue Wuzapi::Client::Error => e
render json: { error: e.message }, status: :unprocessable_entity
rescue StandardError => e
render json: { error: e.message }, status: :internal_server_error
end
private private
def fetch_inbox def fetch_inbox

View File

@ -25,7 +25,7 @@ export default defineComponent({
// Get accountId reliably from global store (preferred) or inbox prop // Get accountId reliably from global store (preferred) or inbox prop
const accountId = computed(() => { const accountId = computed(() => {
return store.getters['getCurrentAccountId'] || props.inbox.account_id; return store.getters.getCurrentAccountId || props.inbox.account_id;
}); });
// Helper for API URL // Helper for API URL
@ -39,7 +39,6 @@ export default defineComponent({
try { try {
const response = await window.axios.get(getApiUrl('')); const response = await window.axios.get(getApiUrl(''));
console.log('Status Response:', response.data);
const data = response.data; const data = response.data;
// Wuzapi format: { data: { connected: true, jid: "...", details: "..." } } // Wuzapi format: { data: { connected: true, jid: "...", details: "..." } }
@ -58,12 +57,10 @@ export default defineComponent({
statusMessage.value = wuzapiData.details || legacyStatus || 'Unknown'; statusMessage.value = wuzapiData.details || legacyStatus || 'Unknown';
if (isConnected.value) { if (isConnected.value) {
console.log('✅ Wuzapi Connected! JID:', wuzapiData.jid);
qrCode.value = ''; qrCode.value = '';
stopPolling(); stopPolling();
} }
} catch (error) { } catch (error) {
console.error('Status Fetch Error:', error);
statusMessage.value = statusMessage.value =
error.response?.data?.error || error.message || 'Check failed'; error.response?.data?.error || error.message || 'Check failed';
} }
@ -71,9 +68,7 @@ export default defineComponent({
const fetchQrCode = async () => { const fetchQrCode = async () => {
try { try {
console.log('Fetching QR code...');
const response = await window.axios.get(getApiUrl('/qr')); const response = await window.axios.get(getApiUrl('/qr'));
console.log('QR Response Data:', response.data);
// Backend now normalizes to 'qrcode' in most cases, but we keep robust checks // Backend now normalizes to 'qrcode' in most cases, but we keep robust checks
const d = response.data; const d = response.data;
@ -86,13 +81,9 @@ export default defineComponent({
(typeof d.data === 'string' ? d.data : null); (typeof d.data === 'string' ? d.data : null);
if (qrcodeData && qrcodeData.length > 20) { if (qrcodeData && qrcodeData.length > 20) {
console.log('QR Code found, updating UI...');
qrCode.value = qrcodeData; qrCode.value = qrcodeData;
startPolling(); startPolling();
} else { } else {
console.warn(
'No QR code in response. Checking status as fallback...'
);
// Fallback: maybe we are already connected? // Fallback: maybe we are already connected?
await fetchStatus(); await fetchStatus();
if (!isConnected.value) { if (!isConnected.value) {
@ -100,16 +91,13 @@ export default defineComponent({
} }
} }
} catch (error) { } catch (error) {
console.error('QR Fetch Error:', error);
statusMessage.value = statusMessage.value =
error.response?.data?.error || 'Failed to load QR'; error.response?.data?.error || 'Failed to load QR';
} }
}; };
const handleConnect = async () => { const handleConnect = async () => {
console.log('Connect button clicked');
if (!accountId.value) { if (!accountId.value) {
console.error('Account ID missing');
useAlert('Error: Account ID missing'); useAlert('Error: Account ID missing');
return; return;
} }
@ -118,13 +106,10 @@ export default defineComponent({
try { try {
// 1. Call Connect // 1. Call Connect
const connectUrl = getApiUrl('/connect'); const connectUrl = getApiUrl('/connect');
console.log('Calling connect:', connectUrl);
await window.axios.post(connectUrl); await window.axios.post(connectUrl);
console.log('Connect successful, fetching QR...');
// 2. Fetch QR // 2. Fetch QR
await fetchQrCode(); await fetchQrCode();
} catch (error) { } catch (error) {
console.error('Connect failed:', error);
useAlert(error.response?.data?.error || 'Connection failed'); useAlert(error.response?.data?.error || 'Connection failed');
} finally { } finally {
isLoading.value = false; isLoading.value = false;
@ -146,7 +131,15 @@ export default defineComponent({
} }
}; };
const startPolling = () => { // Function hoisting allows use before definition
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
function startPolling() {
if (pollInterval) return; if (pollInterval) return;
// Poll every 5 seconds to check status AND refresh QR code // Poll every 5 seconds to check status AND refresh QR code
pollInterval = setInterval(async () => { pollInterval = setInterval(async () => {
@ -156,12 +149,37 @@ export default defineComponent({
await fetchQrCode(); await fetchQrCode();
} }
}, 5000); }, 5000);
}
const isLoadingWebhook = ref(false);
const webhookInfo = ref(null);
const fetchWebhookInfo = async () => {
isLoadingWebhook.value = true;
try {
const response = await window.axios.get(getApiUrl('/webhook_info'));
webhookInfo.value = response.data;
useAlert('Webhook info fetched successfully');
} catch (error) {
useAlert(error.response?.data?.error || 'Failed to fetch webhook info');
} finally {
isLoadingWebhook.value = false;
}
}; };
const stopPolling = () => { const updateWebhook = async () => {
if (pollInterval) { isLoadingWebhook.value = true;
clearInterval(pollInterval); try {
pollInterval = null; const response = await window.axios.put(getApiUrl('/update_webhook'));
webhookInfo.value = {
message: response.data.message,
url: response.data.webhook_url
};
useAlert('Webhook updated successfully');
} catch (error) {
useAlert(error.response?.data?.error || 'Failed to update webhook');
} finally {
isLoadingWebhook.value = false;
} }
}; };
@ -182,6 +200,10 @@ export default defineComponent({
disconnect, disconnect,
handleConnect, handleConnect,
accountId, accountId,
isLoadingWebhook,
webhookInfo,
fetchWebhookInfo,
updateWebhook,
}; };
}, },
}); });
@ -198,7 +220,7 @@ export default defineComponent({
<div v-if="accountId" class="flex flex-col items-center"> <div v-if="accountId" class="flex flex-col items-center">
<div v-if="isConnected" class="flex flex-col items-center"> <div v-if="isConnected" class="flex flex-col items-center">
<div class="text-green-600 font-bold mb-4 flex items-center gap-2"> <div class="text-green-600 font-bold mb-4 flex items-center gap-2">
<span class="i-woot-checkmark-circle text-2xl"></span> <span class="i-woot-checkmark-circle text-2xl" />
{{ $t('INBOX_MGMT.EDIT.WUZAPI.CONNECTED') }} {{ $t('INBOX_MGMT.EDIT.WUZAPI.CONNECTED') }}
</div> </div>
<p class="text-n-slate-11 mb-4"> <p class="text-n-slate-11 mb-4">
@ -250,6 +272,32 @@ export default defineComponent({
<div v-else class="text-red-600 p-4"> <div v-else class="text-red-600 p-4">
Error: Account ID not loaded. Please refresh the page. Error: Account ID not loaded. Please refresh the page.
</div> </div>
<div class="mt-8 pt-6 border-t border-n-weak w-full">
<h4 class="text-md font-medium text-n-slate-12 mb-4">
Webhook Configuration
</h4>
<div class="flex gap-4 mb-4">
<NextButton
icon="i-woot-refresh"
:is-loading="isLoadingWebhook"
label="Get Webhook Info"
@click="fetchWebhookInfo"
/>
<NextButton
icon="i-woot-upload"
:is-loading="isLoadingWebhook"
label="Update Webhook Connection"
@click="updateWebhook"
/>
</div>
<div
v-if="webhookInfo"
class="bg-n-alpha-1 p-4 rounded text-sm font-mono overflow-auto"
>
<pre>{{ JSON.stringify(webhookInfo, null, 2) }}</pre>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@ -236,6 +236,8 @@ Rails.application.routes.draw do
get :qr get :qr
post :connect post :connect
post :disconnect post :disconnect
get :webhook_info
put :update_webhook
end end
end end

22
enterprise/app/helpers/captain/chat_helper.rb Executable file → Normal file
View File

@ -20,7 +20,7 @@ module Captain::ChatHelper
private private
def build_chat def build_chat
llm_chat = chat(model: @model, temperature: temperature) llm_chat = chat(model: @model, temperature: temperature, api_key: api_key)
llm_chat = llm_chat.with_params(response_format: { type: 'json_object' }) llm_chat = llm_chat.with_params(response_format: { type: 'json_object' })
llm_chat = setup_tools(llm_chat) llm_chat = setup_tools(llm_chat)
@ -37,7 +37,9 @@ module Captain::ChatHelper
def setup_system_instructions(chat) def setup_system_instructions(chat)
system_messages = @messages.select { |m| m[:role] == 'system' || m[:role] == :system } system_messages = @messages.select { |m| m[:role] == 'system' || m[:role] == :system }
combined_instructions = system_messages.pluck(:content).join("\n\n") combined_instructions = system_messages.pluck(:content).join("
")
chat.with_instructions(combined_instructions) chat.with_instructions(combined_instructions)
end end
@ -95,24 +97,20 @@ module Captain::ChatHelper
@account&.id || @assistant&.account_id @account&.id || @assistant&.account_id
end end
# Ensures all LLM calls and tool executions within an agentic loop def api_key
# are grouped under a single trace/session in Langfuse. @assistant&.config&.[]('openai_api_key').presence || ENV.fetch('OPENAI_API_KEY', nil) || ENV.fetch('GEMINI_API_KEY', nil)
# end
# Without this guard, each recursive call to request_chat_completion
# (triggered by tool calls) would create a separate trace instead of def with_agent_session(&block)
# nesting within the existing session span.
def with_agent_session(&)
already_active = @agent_session_active already_active = @agent_session_active
return yield if already_active return yield if already_active
@agent_session_active = true @agent_session_active = true
instrument_agent_session(instrumentation_params, &) instrument_agent_session(instrumentation_params, &block)
ensure ensure
@agent_session_active = false unless already_active @agent_session_active = false unless already_active
end end
# Must be implemented by including class to identify the feature for instrumentation.
# Used for Langfuse tagging and span naming.
def feature_name def feature_name
raise NotImplementedError, "#{self.class.name} must implement #feature_name" raise NotImplementedError, "#{self.class.name} must implement #feature_name"
end end

View File

@ -14,8 +14,10 @@ class Llm::BaseAiService
setup_temperature setup_temperature
end end
def chat(model: @model, temperature: @temperature) def chat(model: @model, temperature: @temperature, api_key: nil)
RubyLLM.chat(model: model).with_temperature(temperature) client = RubyLLM.chat(model: model)
client = client.with_api_key(api_key) if api_key.present?
client.with_temperature(temperature)
end end
private private

View File

@ -53,7 +53,7 @@ module Wuzapi
end end
def session_disconnect(user_token) def session_disconnect(user_token)
request(:get, '/session/disconnect', nil, user_auth_headers(user_token)) request(:post, '/session/disconnect', nil, user_auth_headers(user_token))
end end
def session_logout(user_token) def session_logout(user_token)
@ -66,6 +66,15 @@ module Wuzapi
request(:post, '/webhook', payload, user_auth_headers(user_token)) request(:post, '/webhook', payload, user_auth_headers(user_token))
end end
def update_webhook(user_token, webhook_url)
payload = { 'WebhookURL' => webhook_url, 'Events' => ['All'] }
request(:put, '/webhook', payload, user_auth_headers(user_token))
end
def get_webhook(user_token)
request(:get, '/webhook', nil, user_auth_headers(user_token))
end
private private
def normalize_url(url) def normalize_url(url)
@ -94,6 +103,8 @@ module Wuzapi
Net::HTTP::Get.new(uri.request_uri) Net::HTTP::Get.new(uri.request_uri)
when :post when :post
Net::HTTP::Post.new(uri.request_uri) Net::HTTP::Post.new(uri.request_uri)
when :put
Net::HTTP::Put.new(uri.request_uri)
when :delete when :delete
Net::HTTP::Delete.new(uri.request_uri) Net::HTTP::Delete.new(uri.request_uri)
end end

49
local_test_ai.rb Normal file
View File

@ -0,0 +1,49 @@
# local_test_ai.rb
# 1. Check Env Vars
puts "\n--- [DIAGNÓSTICO IA / JASMINE] ---"
puts 'Checking Environment Variables...'
openai_key = ENV.fetch('OPENAI_API_KEY', nil)
gemini_key = ENV.fetch('GEMINI_API_KEY', nil)
if openai_key.present?
puts "✅ OPENAI_API_KEY found: #{openai_key[0..5]}...#{openai_key[-4..-1]}"
else
puts '❌ OPENAI_API_KEY NOT found'
end
if gemini_key.present?
puts "✅ GEMINI_API_KEY found: #{gemini_key[0..5]}...#{gemini_key[-4..-1]}"
else
puts '⚠️ GEMINI_API_KEY NOT found (Optional if using OpenAI)'
end
# 2. Check RubyLLM Config
puts "\nChecking RubyLLM Configuration..."
begin
# Force re-configure just to be sure we are using the env vars
RubyLLM.configure do |config|
config.openai_api_key = openai_key if openai_key.present?
config.gemini_api_key = gemini_key if gemini_key.present?
end
puts '✅ RubyLLM Configured'
rescue StandardError => e
puts "❌ RubyLLM Configuration Error: #{e.message}"
end
# 3. Test Call
puts "\nTesting Simple LLM Call (Hello World)..."
begin
# Try to use GPT-4o-mini or fallback to gpt-3.5-turbo or gemini
model = openai_key.present? ? 'gpt-4o-mini' : 'gemini-pro'
puts "Using model: #{model}"
client = RubyLLM.chat(model: model)
response = client.ask("Responda apenas com: 'IA Funcionando!'")
puts "\n>>> RESPOSTA DA IA: #{response.content}"
puts '✅ CONEXÃO BEM SUCEDIDA!'
rescue StandardError => e
puts "\n❌ ERRO NA CHAMADA DA IA: #{e.message}"
puts e.backtrace.first(5)
end

39
reproduce_key_error.rb Normal file
View File

@ -0,0 +1,39 @@
# reproduce_key_error.rb
begin
puts "Testing RubyLLM.chat(api_key: 'test') arguments..."
# Attempt 1: Check signature if possible (inspection)
# puts RubyLLM.method(:chat).parameters
# Attempt 2: Try to call with explicit key (even if dummy)
# If it raises ArgumentError, it's not supported.
# If it raises ConfigurationError (missing key) despite passing one, it's ignored.
puts "1. Calling RubyLLM.chat(model: 'gpt-3.5-turbo', api_key: 'sk-test-123')"
begin
client = RubyLLM.chat(model: 'gpt-3.5-turbo', api_key: 'sk-test-123')
puts '✅ RubyLLM.chat accepted api_key argument!'
puts "Client class: #{client.class}"
rescue ArgumentError => e
puts "❌ RubyLLM.chat raised ArgumentError: #{e.message}"
rescue StandardError => e
puts "⚠️ RubyLLM.chat raised #{e.class}: #{e.message}"
end
puts "\n2. Calling RubyLLM.chat(model: 'gpt-3.5-turbo').with_api_key('sk-test-123') (Guessing method)"
begin
client = RubyLLM.chat(model: 'gpt-3.5-turbo')
if client.respond_to?(:with_api_key)
client.with_api_key('sk-test-123')
puts '✅ Client supports .with_api_key'
else
puts '❌ Client usually does NOT support .with_api_key (respond_to? false)'
end
rescue StandardError => e
puts "⚠️ Error in attempt 2: #{e.message}"
end
rescue StandardError => e
puts "FATAL: #{e.message}"
puts e.backtrace
end