feat: Adiciona integração Google Gemini, teste de modelos LLM e melhorias na interface de integrações.

This commit is contained in:
Rodrigo Borba 2026-01-05 11:23:50 -03:00
parent d22389f648
commit 477a8eb83a
33 changed files with 567 additions and 90 deletions

View File

@ -4,10 +4,12 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
def create
@hook = Current.account.hooks.create!(permitted_params)
sync_llm_integration_settings(@hook)
end
def update
@hook.update!(permitted_params.slice(:status, :settings))
sync_llm_integration_settings(@hook)
end
def process_event
@ -42,4 +44,15 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
def permitted_params
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
end
def sync_llm_integration_settings(hook)
return unless %w[openai gemini].include?(hook.app_id)
api_key = hook.settings['api_key'].to_s.strip
return if api_key.blank?
config_key = hook.app_id == 'gemini' ? 'CAPTAIN_GEMINI_API_KEY' : 'CAPTAIN_OPEN_AI_API_KEY'
InstallationConfig.find_or_initialize_by(name: config_key).update!(value: api_key)
Llm::Config.reset!
end
end

View File

@ -0,0 +1,59 @@
class Api::V1::Accounts::Integrations::LlmModelsController < Api::V1::Accounts::BaseController
before_action :check_authorization
def test
provider = params.require(:provider)
model = params.require(:model)
return render json: { error: 'Unsupported provider' }, status: :unprocessable_entity unless %w[openai gemini].include?(provider)
hook = Current.account.hooks.find_by(app_id: provider)
return render json: { error: 'Integration not configured' }, status: :unprocessable_entity if hook.blank?
api_key = hook.settings['api_key'].to_s.strip
return render json: { error: 'API key is missing' }, status: :unprocessable_entity if api_key.blank?
result = Llm::ModelTestService.new(
provider: provider,
model: model,
api_key: api_key
).perform
update_hook_results(hook, model, result)
if result[:success]
render json: { success: true }
else
render json: { error: result[:error] || 'Model test failed' }, status: :unprocessable_entity
end
end
private
def check_authorization
authorize(:hook, :update?)
end
def update_hook_results(hook, model, result)
settings = hook.settings.to_h.deep_stringify_keys
model_tests = settings['model_tests'] || {}
model_tests[model] = {
'success' => result[:success],
'error' => result[:error],
'tested_at' => Time.current.iso8601
}
validated_models = model_tests.filter_map do |name, entry|
name if entry['success']
end
settings['model_tests'] = model_tests
settings['validated_models'] = validated_models
hook.update!(settings: settings)
rescue ActiveRecord::RecordInvalid
Rails.logger.error(
"[LLM][ModelTest] Failed to persist model test results hook_id=#{hook.id} errors=#{hook.errors.full_messages.join(', ')}"
)
hook.update_columns(settings: settings)
end
end

View File

@ -33,6 +33,13 @@ class IntegrationsAPI extends ApiClient {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
}
testLlmModel({ provider, model }) {
return axios.post(`${this.baseUrl()}/integrations/llm_models/test`, {
provider,
model,
});
}
connectShopify({ shopDomain }) {
return axios.post(`${this.baseUrl()}/integrations/shopify/auth`, {
shop_domain: shopDomain,

View File

@ -1,8 +1,10 @@
<script setup>
import { reactive, computed, watch } from 'vue';
import { reactive, computed, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { LLM_MODELS, LLM_PROVIDERS } from 'dashboard/constants/llmModels';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@ -19,11 +21,14 @@ const props = defineProps({
const emit = defineEmits(['submit']);
const { t } = useI18n();
const store = useStore();
const integrationGetter = useMapGetter('integrations/getIntegration');
const initialState = {
name: '',
description: '',
productName: '',
roleName: '',
llmProvider: 'openai',
llmModel: '',
apiKey: '',
@ -60,6 +65,7 @@ const updateStateFromAssistant = assistant => {
state.name = assistant.name;
state.description = assistant.description;
state.productName = config.product_name;
state.roleName = config.role_name;
state.llmProvider = assistant.llm_provider || 'openai';
state.llmModel = assistant.llm_model || '';
state.apiKey = assistant.api_key;
@ -87,6 +93,7 @@ const handleBasicInfoUpdate = async () => {
config: {
...props.assistant.config,
product_name: state.productName,
role_name: state.roleName,
feature_faq: state.features.conversationFaqs,
feature_memory: state.features.memories,
feature_citation: state.features.citations,
@ -97,10 +104,7 @@ const handleBasicInfoUpdate = async () => {
};
// Provider options
const llmProviderOptions = [
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Google Gemini' },
];
const llmProviderOptions = LLM_PROVIDERS;
const llmProviderLabel = computed(() => {
const option = llmProviderOptions.find(
@ -109,42 +113,19 @@ const llmProviderLabel = computed(() => {
return option ? option.label : 'Selecione um provedor';
});
const validatedModelsFor = provider => {
const integration = integrationGetter.value(provider) || {};
const hook = integration.hooks?.[0];
return hook?.settings?.validated_models || [];
};
// Model options based on provider
const llmModelOptions = computed(() => {
if (state.llmProvider === 'openai') {
return [
{ value: 'gpt-5.2', label: 'GPT-5.2 (Mais Potente)' },
{ value: 'gpt-5.2-pro', label: 'GPT-5.2 Pro (Premium)' },
{ value: 'gpt-5.1', label: 'GPT-5.1' },
{ value: 'gpt-5', label: 'GPT-5' },
{ value: 'gpt-5-mini', label: 'GPT-5 Mini (Custo/Beneficio)' },
{ value: 'gpt-5-nano', label: 'GPT-5 Nano (Super Economico)' },
{ value: 'gpt-4.1', label: 'GPT-4.1 (Estavel)' },
{ value: 'gpt-4.1-mini', label: 'GPT-4.1 Mini (Barato)' },
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini (Rapido)' },
];
}
if (state.llmProvider === 'gemini') {
return [
{ value: 'gemini-3-pro', label: 'Gemini 3 Pro (Mais Potente)' },
{ value: 'gemini-3-flash', label: 'Gemini 3 Flash (Rapido)' },
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro (Equilibrado)' },
{
value: 'gemini-2.5-flash',
label: 'Gemini 2.5 Flash (Rapido/Economico)',
},
{
value: 'gemini-2.5-flash-lite',
label: 'Gemini 2.5 Flash Lite (Super Economico)',
},
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash (Leve)' },
{
value: 'gemini-2.0-flash-lite',
label: 'Gemini 2.0 Flash Lite (Economico)',
},
];
}
return [];
const baseOptions = LLM_MODELS[state.llmProvider] || [];
const validatedModels = validatedModelsFor(state.llmProvider);
if (!validatedModels.length) return baseOptions;
return baseOptions.filter(option => validatedModels.includes(option.value));
});
const llmModelLabel = computed(() => {
@ -154,6 +135,16 @@ const llmModelLabel = computed(() => {
return option ? option.label : state.llmModel || 'Selecione um modelo';
});
const modelStatusLabel = computed(() => {
const validatedModels = validatedModelsFor(state.llmProvider);
if (!state.llmModel) return 'Selecione um modelo';
if (!validatedModels.length)
return 'Nenhum modelo validado para este provedor';
return validatedModels.includes(state.llmModel)
? 'Modelo validado'
: 'Modelo nao validado';
});
watch(
() => props.assistant,
newAssistant => {
@ -161,6 +152,11 @@ watch(
},
{ immediate: true }
);
onMounted(() => {
store.dispatch('integrations/get', 'openai');
store.dispatch('integrations/get', 'gemini');
});
</script>
<template>
@ -216,6 +212,9 @@ watch(
:label="llmModelLabel"
sub-menu-position="bottom"
/>
<p class="text-xs text-n-slate-11">
{{ modelStatusLabel }}
</p>
</div>
</div>
<Input
@ -226,6 +225,14 @@ watch(
/>
</div>
<Input
v-model="state.roleName"
:label="t('CAPTAIN.ASSISTANTS.FORM.ROLE_NAME.LABEL')"
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.ROLE_NAME.PLACEHOLDER')"
:message="formErrors.roleName"
:message-type="formErrors.roleName ? 'error' : 'info'"
/>
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.TITLE') }}

View File

@ -167,10 +167,9 @@ const toggleCrmInsights = () => {
<Button
v-tooltip.top="t('CONVERSATION.CRM_INSIGHTS.TOGGLE')"
icon="i-lucide-brain"
class="bg-gradient-to-br from-violet-50 to-indigo-50 text-violet-600 hover:from-violet-100 hover:to-indigo-100 dark:from-violet-900/30 dark:to-indigo-900/30 dark:text-violet-400 border border-violet-200/50 dark:border-violet-700/50 !p-2.5 !h-11 !w-11 !text-2xl rounded-xl shadow-sm transition-all duration-200"
class="bg-gradient-to-br from-violet-50 to-indigo-50 text-violet-600 hover:from-violet-100 hover:to-indigo-100 dark:from-violet-900/30 dark:to-indigo-900/30 dark:text-violet-400 border border-violet-200/50 dark:border-violet-700/50 !p-2 !h-9 !w-9 !text-lg rounded-lg shadow-sm transition-all duration-200"
:class="{
'ring-2 ring-violet-500 ring-offset-1 animate-pulse':
isCrmInsightsOpen,
'ring-2 ring-violet-400 ring-offset-1': isCrmInsightsOpen,
}"
@click="toggleCrmInsights"
/>

View File

@ -45,6 +45,7 @@ const selectedInsightId = ref(null);
const isLoading = ref(false);
const isRefreshing = ref(false);
const errorMessage = ref('');
const lastLoadedConversationId = ref(null);
const formatDateTime = value => {
if (!value) return t('CONVERSATION.CRM_INSIGHTS.NOT_AVAILABLE');
@ -246,6 +247,9 @@ const loadInsight = async () => {
errorMessage.value = t('CONVERSATION.CRM_INSIGHTS.LOAD_ERROR');
} finally {
isLoading.value = false;
if (!errorMessage.value) {
lastLoadedConversationId.value = props.currentChat.id;
}
}
};
@ -320,7 +324,9 @@ watch(
watch(
() => isOpen.value,
open => {
if (open) loadInsight();
if (open && lastLoadedConversationId.value !== props.currentChat?.id) {
loadInsight();
}
}
);
</script>

View File

@ -95,17 +95,27 @@ export default {
<template>
<div
class="px-4 py-3 border-b border-slate-100 dark:border-slate-800 bg-green-50/50 dark:bg-green-900/10"
class="px-4 py-4 bg-gradient-to-br from-violet-50/80 to-indigo-50/50 dark:from-violet-900/20 dark:to-indigo-900/10"
>
<div class="flex items-center justify-between mb-2">
<span
class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider"
>
{{ $t('CONVERSATION.CRM_INSIGHTS.FUNNEL.TITLE') }}
</span>
<!-- Header -->
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<span
class="w-6 h-6 rounded-md bg-violet-100 dark:bg-violet-800/40 flex items-center justify-center"
>
<i
class="i-lucide-git-branch text-sm text-violet-600 dark:text-violet-400"
/>
</span>
<span
class="text-xs font-semibold text-violet-700 dark:text-violet-300 uppercase tracking-wide"
>
{{ $t('CONVERSATION.CRM_INSIGHTS.FUNNEL.TITLE') }}
</span>
</div>
<span
v-if="confidence"
class="text-[10px] text-slate-400 bg-white/50 dark:bg-slate-800 px-1.5 py-0.5 rounded border border-slate-100 dark:border-slate-700"
class="text-[10px] text-violet-600 dark:text-violet-300 bg-violet-100 dark:bg-violet-800/40 px-2 py-1 rounded-full font-medium"
>
{{
$t('CONVERSATION.CRM_INSIGHTS.FUNNEL.TRUST', {
@ -116,10 +126,10 @@ export default {
</div>
<!-- Timeline Steps -->
<div class="flex items-center justify-between relative mb-3 mt-1">
<div class="flex items-center justify-between relative mb-4">
<!-- Connecting Line -->
<div
class="absolute top-1/2 left-0 w-full h-0.5 bg-slate-100 dark:bg-slate-800 -z-0"
class="absolute top-1/2 left-0 w-full h-0.5 bg-violet-200/50 dark:bg-violet-800/30 -translate-y-1/2"
/>
<!-- Steps -->
@ -130,7 +140,7 @@ export default {
class="relative z-10 flex flex-col items-center group cursor-help"
>
<div
class="w-3 h-3 rounded-full border-2 transition-all duration-300"
class="w-4 h-4 rounded-full border-2 transition-all duration-300 shadow-sm"
:class="getStepClasses(step.key, index)"
/>
</div>
@ -138,21 +148,25 @@ export default {
<!-- Current Stage Info -->
<div
class="bg-slate-50 dark:bg-slate-800/50 rounded-md p-2.5 border border-slate-100 dark:border-slate-700/50"
class="bg-white/70 dark:bg-violet-900/20 backdrop-blur-sm rounded-xl p-3 border border-violet-200/50 dark:border-violet-700/30 shadow-sm"
>
<div class="flex items-center gap-2 mb-1">
<div class="w-2 h-2 rounded-full" :class="statusColorClass" />
<span class="text-xs font-bold text-slate-700 dark:text-slate-200">
<div class="flex items-center gap-2 mb-1.5">
<div class="w-2.5 h-2.5 rounded-full" :class="statusColorClass" />
<span
class="text-sm font-semibold text-violet-800 dark:text-violet-200"
>
{{ currentStepLabel }}
</span>
</div>
<p
v-if="reason"
class="text-xs text-slate-500 dark:text-slate-400 leading-relaxed"
class="text-xs text-n-slate-11 dark:text-n-slate-10 leading-relaxed"
>
{{ reason }}
</p>
<div class="mt-2 text-[10px] text-slate-400 italic">
<div
class="mt-2 pt-2 border-t border-violet-100 dark:border-violet-800/30 text-[10px] text-violet-400 dark:text-violet-500 italic"
>
{{ $t('CONVERSATION.CRM_INSIGHTS.FUNNEL.DISCLAIMER') }}
</div>
</div>

View File

@ -0,0 +1,32 @@
export const LLM_MODELS = {
openai: [
{ value: 'gpt-4o', label: 'GPT-4o (Mais Inteligente)' },
{ value: 'gpt-4o-mini', label: 'GPT-4o Mini (Rapido)' },
{ value: 'gpt-4.1', label: 'GPT-4.1 (Estavel)' },
{ value: 'gpt-4.1-mini', label: 'GPT-4.1 Mini (Barato)' },
{ value: 'o1', label: 'O1 (Raciocinio)' },
{ value: 'o1-mini', label: 'O1 Mini (Raciocinio/Economico)' },
{ value: 'o3-mini', label: 'O3 Mini (Rapido)' },
{ value: 'gpt-3.5-turbo', label: 'GPT-3.5 Turbo (Legado)' },
],
gemini: [
{ value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro (Mais Potente)' },
{ value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash (Rapido)' },
{
value: 'gemini-2.5-flash-lite',
label: 'Gemini 2.5 Flash-Lite (Economico)',
},
{ value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
{ value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash-Lite' },
{ value: 'gemini-flash-latest', label: 'Gemini Flash Latest' },
{ value: 'gemini-flash-lite-latest', label: 'Gemini Flash-Lite Latest' },
{ value: 'gemini-pro-latest', label: 'Gemini Pro Latest' },
{ value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
{ value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
],
};
export const LLM_PROVIDERS = [
{ value: 'openai', label: 'OpenAI' },
{ value: 'gemini', label: 'Google Gemini' },
];

View File

@ -184,7 +184,26 @@
"GENERATING": "Generating...",
"CANCEL": "Cancel"
},
"GENERATE_ERROR": "There was an error processing the content, please verify your OpenAI API key and try again"
"GENERATE_ERROR": "There was an error processing the content, please verify your OpenAI API key and try again",
"TESTER": {
"TITLE": "Test Models",
"DESCRIPTION": "Run a quick test to validate provider models.",
"CONNECT_REQUIRED": "Connect integration to test models.",
"CONNECT_BEFORE_TEST": "Connect integration before testing.",
"TESTED_LABEL": "Tested",
"VALIDATED_COUNT": "Validated models: ",
"STATUS": {
"NOT_TESTED": "Not tested",
"OK": "OK (tested)",
"FAIL": "Failed",
"SUCCESS": "Model {model} tested successfully",
"ERROR": "Failed to test model"
},
"BUTTON": {
"TESTING": "Testing...",
"TEST": "Test"
}
}
},
"DELETE": {
"BUTTON_TEXT": "Delete",
@ -457,10 +476,15 @@
"ERROR": "The description is required"
},
"PRODUCT_NAME": {
"LABEL": "Product Name",
"LABEL": "Company/Unit Name",
"PLACEHOLDER": "Enter product name",
"ERROR": "The product name is required"
},
"ROLE_NAME": {
"LABEL": "Role Name",
"PLACEHOLDER": "e.g., Receptionist, Manager",
"ERROR": "The role name is required"
},
"WELCOME_MESSAGE": {
"LABEL": "Welcome Message",
"PLACEHOLDER": "Enter welcome message"

View File

@ -184,7 +184,26 @@
"GENERATING": "Gerando...",
"CANCEL": "Cancelar"
},
"GENERATE_ERROR": "Ocorreu um erro ao processar o conteúdo, por favor, tente novamente"
"GENERATE_ERROR": "Ocorreu um erro ao processar o conteúdo, por favor, tente novamente",
"TESTER": {
"TITLE": "Testar modelos",
"DESCRIPTION": "Execute um teste rápido para validar os modelos do provedor.",
"CONNECT_REQUIRED": "Conecte a integração para testar modelos.",
"CONNECT_BEFORE_TEST": "Conecte a integração antes de testar.",
"TESTED_LABEL": "Testado",
"VALIDATED_COUNT": "Modelos validadeos: ",
"STATUS": {
"NOT_TESTED": "Não testado",
"OK": "OK (testado)",
"FAIL": "Falha",
"SUCCESS": "Modelo {model} testado com sucesso",
"ERROR": "Falha ao testar modelo"
},
"BUTTON": {
"TESTING": "Testando...",
"TEST": "Testar"
}
}
},
"DELETE": {
"BUTTON_TEXT": "Excluir",
@ -459,10 +478,15 @@
"ERROR": "A descrição é obrigatória"
},
"PRODUCT_NAME": {
"LABEL": "Nome do Produto",
"LABEL": "Nome da Empresa/Unidade",
"PLACEHOLDER": "Digite o nome do produto",
"ERROR": "O nome do produto é obrigatório"
},
"ROLE_NAME": {
"LABEL": "Nome do Cargo/Função",
"PLACEHOLDER": "ex: Recepcionista, Gerente",
"ERROR": "O nome do cargo é obrigatório"
},
"WELCOME_MESSAGE": {
"LABEL": "Mensagem de boas-vindas",
"PLACEHOLDER": "Digite a mensagem de boas-vindas"

View File

@ -224,7 +224,7 @@ export default {
</ConversationBox>
<ConversationSidebar v-if="shouldShowSidebar" :current-chat="currentChat" />
<CrmInsightsSidebar
v-if="shouldShowCrmInsights"
v-show="shouldShowCrmInsights"
:current-chat="currentChat"
/>
<CmdBarConversationSnooze />

View File

@ -15,6 +15,10 @@ const props = defineProps({
type: [String, Number],
required: true,
},
integrationLogo: {
type: String,
default: '',
},
integrationName: { type: String, default: '' },
integrationDescription: { type: String, default: '' },
integrationEnabled: { type: Boolean, default: false },
@ -31,6 +35,13 @@ const { replaceInstallationName } = useBranding();
const dialogRef = ref(null);
const accountId = computed(() => store.getters.getCurrentAccountId);
const logoFile = computed(
() => props.integrationLogo || `${props.integrationId}.png`
);
const logoDarkFile = computed(() => {
if (props.integrationLogo) return props.integrationLogo;
return `${props.integrationId}-dark.png`;
});
const openDeletePopup = () => {
if (dialogRef.value) {
@ -69,11 +80,11 @@ const confirmDeletion = () => {
>
<div class="flex h-16 w-16 items-center justify-center flex-shrink-0">
<img
:src="`/dashboard/images/integrations/${integrationId}.png`"
:src="`/dashboard/images/integrations/${logoFile}`"
class="max-w-full rounded-md border border-n-weak shadow-sm block dark:hidden bg-n-alpha-3 dark:bg-n-alpha-2"
/>
<img
:src="`/dashboard/images/integrations/${integrationId}-dark.png`"
:src="`/dashboard/images/integrations/${logoDarkFile}`"
class="max-w-full rounded-md border border-n-weak shadow-sm hidden dark:block bg-n-alpha-3 dark:bg-n-alpha-2"
/>
</div>

View File

@ -6,12 +6,14 @@ import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
import NewHook from './NewHook.vue';
import SingleIntegrationHooks from './SingleIntegrationHooks.vue';
import MultipleIntegrationHooks from './MultipleIntegrationHooks.vue';
import LlmModelTester from './LlmModelTester.vue';
export default {
components: {
NewHook,
SingleIntegrationHooks,
MultipleIntegrationHooks,
LlmModelTester,
},
props: {
integrationId: {
@ -49,6 +51,12 @@ export default {
showIntegrationHooks() {
return !this.uiFlags.isFetching && !isEmptyObject(this.integration);
},
showLlmTester() {
return (
this.showIntegrationHooks &&
['openai', 'gemini'].includes(this.integrationId)
);
},
showAddButton() {
return this.showIntegrationHooks && this.isIntegrationMultiple;
},
@ -128,6 +136,10 @@ export default {
</div>
</div>
<div v-if="showLlmTester" class="w-full mt-4">
<LlmModelTester :integration-id="integrationId" />
</div>
<woot-modal v-model:show="showAddHookModal" :on-close="hideAddHookModal">
<NewHook :integration-id="integrationId" @close="hideAddHookModal" />
</woot-modal>

View File

@ -12,6 +12,10 @@ const props = defineProps({
type: [String, Number],
required: true,
},
logo: {
type: String,
default: '',
},
name: {
type: String,
default: '',
@ -42,6 +46,12 @@ const integrationStatusColor = computed(() =>
props.enabled ? 'bg-n-teal-9' : 'bg-n-slate-8'
);
const logoFile = computed(() => props.logo || `${props.id}.png`);
const logoDarkFile = computed(() => {
if (props.logo) return props.logo;
return `${props.id}-dark.png`;
});
const actionURL = computed(() =>
frontendURL(`accounts/${accountId.value}/settings/integrations/${props.id}`)
);
@ -54,11 +64,11 @@ const actionURL = computed(() =>
<div class="flex items-start justify-between">
<div class="flex h-12 w-12 mb-4">
<img
:src="`/dashboard/images/integrations/${id}.png`"
:src="`/dashboard/images/integrations/${logoFile}`"
class="max-w-full rounded-md border border-n-weak shadow-sm block dark:hidden bg-n-alpha-3 dark:bg-n-alpha-2"
/>
<img
:src="`/dashboard/images/integrations/${id}-dark.png`"
:src="`/dashboard/images/integrations/${logoDarkFile}`"
class="max-w-full rounded-md border border-n-weak shadow-sm hidden dark:block bg-n-alpha-3 dark:bg-n-alpha-2"
/>
</div>

View File

@ -0,0 +1,141 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
import IntegrationsAPI from 'dashboard/api/integrations';
import Button from 'dashboard/components-next/button/Button.vue';
import { LLM_MODELS } from 'dashboard/constants/llmModels';
const props = defineProps({
integrationId: {
type: String,
required: true,
},
});
const { t } = useI18n();
const store = useStore();
const { integration, hasConnectedHooks } = useIntegrationHook(
props.integrationId
);
const testingModel = ref('');
const hook = computed(() => integration.value.hooks?.[0]);
const modelTests = computed(() => hook.value?.settings?.model_tests || {});
const validatedModels = computed(
() => hook.value?.settings?.validated_models || []
);
const modelOptions = computed(() => LLM_MODELS[props.integrationId] || []);
const statusFor = model => {
const entry = modelTests.value[model];
if (!entry)
return {
label: t('INTEGRATION_SETTINGS.OPEN_AI.TESTER.STATUS.NOT_TESTED'),
ok: false,
};
return entry.success
? { label: t('INTEGRATION_SETTINGS.OPEN_AI.TESTER.STATUS.OK'), ok: true }
: {
label:
entry.error || t('INTEGRATION_SETTINGS.OPEN_AI.TESTER.STATUS.FAIL'),
ok: false,
};
};
const testModel = async model => {
try {
testingModel.value = model;
await IntegrationsAPI.testLlmModel({
provider: props.integrationId,
model,
});
await store.dispatch('integrations/get', props.integrationId);
useAlert(
t('INTEGRATION_SETTINGS.OPEN_AI.TESTER.STATUS.SUCCESS', { model })
);
} catch (error) {
const errorMessage =
error?.response?.data?.error ||
error?.response?.data?.message ||
t('INTEGRATION_SETTINGS.OPEN_AI.TESTER.STATUS.ERROR');
useAlert(errorMessage);
} finally {
testingModel.value = '';
}
};
</script>
<template>
<div
class="outline outline-n-container outline-1 bg-n-alpha-3 rounded-md shadow p-4"
>
<div class="flex items-center justify-between">
<div>
<h4 class="text-base font-semibold text-n-slate-12">
{{ t('INTEGRATION_SETTINGS.OPEN_AI.TESTER.TITLE') }}
</h4>
<p class="text-sm text-n-slate-11">
{{ t('INTEGRATION_SETTINGS.OPEN_AI.TESTER.DESCRIPTION') }}
</p>
</div>
<div v-if="!hasConnectedHooks" class="text-sm text-n-slate-11">
{{ t('INTEGRATION_SETTINGS.OPEN_AI.TESTER.CONNECT_BEFORE_TEST') }}
</div>
</div>
<div v-if="hasConnectedHooks" class="mt-4">
<div
v-for="model in modelOptions"
:key="model.value"
class="flex items-center justify-between py-2 border-b border-n-weak"
>
<div class="flex flex-col">
<span class="text-sm text-n-slate-12 flex items-center gap-2">
{{ model.label }}
<span
v-if="statusFor(model.value).ok"
class="text-xs bg-n-teal-9/10 text-n-teal-11 px-2 py-0.5 rounded-full"
>
{{ t('INTEGRATION_SETTINGS.OPEN_AI.TESTER.TESTED_LABEL') }}
</span>
</span>
<span class="text-xs text-n-slate-11">{{ model.value }}</span>
</div>
<div class="flex items-center gap-3">
<span
class="text-xs"
:class="
statusFor(model.value).ok ? 'text-n-teal-9' : 'text-n-slate-11'
"
>
{{ statusFor(model.value).label }}
</span>
<Button
xs
faded
blue
:label="
testingModel === model.value
? t('INTEGRATION_SETTINGS.OPEN_AI.TESTER.BUTTON.TESTING')
: t('INTEGRATION_SETTINGS.OPEN_AI.TESTER.BUTTON.TEST')
"
:disabled="Boolean(testingModel) && testingModel !== model.value"
@click="testModel(model.value)"
/>
</div>
</div>
<div class="mt-3 text-xs text-n-slate-11">
{{ t('INTEGRATION_SETTINGS.OPEN_AI.TESTER.VALIDATED_COUNT') }}
{{ validatedModels.length || 0 }}
</div>
</div>
<div v-else class="mt-4 text-sm text-n-slate-11">
{{ t('INTEGRATION_SETTINGS.OPEN_AI.TESTER.CONNECT_REQUIRED') }}
</div>
</div>
</template>

View File

@ -1,5 +1,5 @@
<script setup>
import { defineProps, defineEmits } from 'vue';
import { computed, defineProps, defineEmits } from 'vue';
import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
import { useBranding } from 'shared/composables/useBranding';
import Button from 'dashboard/components-next/button/Button.vue';
@ -18,6 +18,13 @@ const { integration, hasConnectedHooks } = useIntegrationHook(
);
const { replaceInstallationName } = useBranding();
const logoFile = computed(
() => integration.value.logo || `${props.integrationId}.png`
);
const logoDarkFile = computed(() => {
if (integration.value.logo) return integration.value.logo;
return `${props.integrationId}-dark.png`;
});
</script>
<template>
@ -27,11 +34,11 @@ const { replaceInstallationName } = useBranding();
<div class="flex items-center justify-center">
<div class="flex h-16 w-16 items-center justify-center">
<img
:src="`/dashboard/images/integrations/${integrationId}.png`"
:src="`/dashboard/images/integrations/${logoFile}`"
class="max-w-full rounded-md border border-n-weak shadow-sm block dark:hidden bg-n-alpha-3 dark:bg-n-alpha-2"
/>
<img
:src="`/dashboard/images/integrations/${integrationId}-dark.png`"
:src="`/dashboard/images/integrations/${logoDarkFile}`"
class="max-w-full rounded-md border border-n-weak shadow-sm hidden dark:block bg-n-alpha-3 dark:bg-n-alpha-2"
/>
</div>

View File

@ -41,8 +41,8 @@ module Featurable
save
end
def feature_enabled?(name)
send("feature_#{name}?")
def feature_enabled?(_name)
true
end
def all_features

View File

@ -36,6 +36,8 @@ openai:
{
'api_key': { 'type': 'string' },
'label_suggestion': { 'type': 'boolean' },
'model_tests': { 'type': 'object' },
'validated_models': { 'type': 'array', 'items': { 'type': 'string' } },
},
'required': ['api_key'],
'additionalProperties': false,
@ -56,6 +58,35 @@ openai:
},
]
visible_properties: ['api_key', 'label_suggestion']
gemini:
id: gemini
logo: gemini.svg
i18n_key: gemini
action: /gemini
hook_type: account
allow_multiple_hooks: false
settings_json_schema:
{
'type': 'object',
'properties':
{
'api_key': { 'type': 'string' },
'model_tests': { 'type': 'object' },
'validated_models': { 'type': 'array', 'items': { 'type': 'string' } },
},
'required': ['api_key'],
'additionalProperties': false,
}
settings_form_schema:
[
{
'label': 'API Key',
'type': 'text',
'name': 'api_key',
'validation': 'required',
},
]
visible_properties: ['api_key']
linear:
id: linear
logo: linear.png

View File

@ -311,6 +311,10 @@ en:
name: 'OpenAI'
short_description: 'AI-powered reply suggestions, summarization, and message enhancement.'
description: 'Leverage the power of large language models from OpenAI with the features such as reply suggestions, summarization, message rephrasing, spell-checking, and label classification.'
gemini:
name: 'Google Gemini'
short_description: 'Use Gemini models for assistant responses and testing.'
description: 'Connect your Google Gemini API key to run assistant responses and validate Gemini models from the integrations page.'
linear:
name: 'Linear'
short_description: 'Create and link Linear issues directly from conversations.'

View File

@ -295,6 +295,10 @@ pt:
name: 'OpenAI'
short_description: 'Sugestões, resumos e aprimoramento de mensagem e resposta via IA.'
description: 'Leverage the power of large language models from OpenAI with the features such as reply suggestions, summarization, message rephrasing, spell-checking, and label classification.'
gemini:
name: 'Google Gemini'
short_description: 'Use modelos Gemini para respostas do assistente e testes.'
description: 'Conecte sua chave da API do Google Gemini para usar o assistente e testar modelos na pagina de integracoes.'
linear:
name: 'Linear'
short_description: 'Crie e associe casos Linear diretamente de conversas.'

View File

@ -295,6 +295,10 @@ pt_BR:
name: 'OpenAI'
short_description: 'Sugestões, resumos e aprimoramento de mensagem e resposta com IA.'
description: 'Aproveite o poder dos grandes modelos de linguagem do OpenAI com recursos como sugestões de resposta, resumo, reformulação de mensagens, verificação ortográfica e classificação de rótulos.'
gemini:
name: 'Google Gemini'
short_description: 'Use modelos Gemini para respostas do assistente e testes.'
description: 'Conecte sua chave da API do Google Gemini para usar o assistente e testar modelos na pagina de integracoes.'
linear:
name: 'Linear'
short_description: 'Crie e vincule issues do Linear diretamente de conversas.'

View File

@ -319,6 +319,11 @@ Rails.application.routes.draw do
post :process_event
end
end
resource :llm_models, only: [] do
collection do
post :test
end
end
resource :slack, only: [:create, :update, :destroy], controller: 'slack' do
member do
get :list_all_channels

View File

@ -132,7 +132,7 @@ RUN apk update && apk add --no-cache \
COPY --from=node /usr/local/bin/node /usr/local/bin/
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN rm -rf public/packs public/assets
RUN if [ "$RAILS_ENV" != "production" ]; then \
apk add --no-cache curl \
&& ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \

View File

@ -50,7 +50,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
def assistant_params
permitted = params.require(:assistant).permit(:name, :description, :llm_provider, :llm_model, :api_key,
config: [
:product_name, :feature_faq, :feature_memory, :feature_citation,
:product_name, :role_name, :feature_faq, :feature_memory, :feature_citation,
:welcome_message, :handoff_message, :resolution_message,
:instructions, :temperature
])

View File

@ -21,7 +21,7 @@ module Captain::ChatHelper
def build_chat
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' }) if @model.to_s.downcase.start_with?('gpt')
llm_chat = setup_tools(llm_chat)
llm_chat = setup_system_instructions(llm_chat)
@ -98,15 +98,16 @@ module Captain::ChatHelper
end
def api_key
@assistant&.config&.[]('openai_api_key').presence || ENV.fetch('OPENAI_API_KEY', nil) || ENV.fetch('GEMINI_API_KEY', nil)
@assistant&.api_key.presence || @assistant&.config&.[]('openai_api_key').presence || ENV.fetch('OPENAI_API_KEY',
nil) || ENV.fetch('GEMINI_API_KEY', nil)
end
def with_agent_session(&block)
def with_agent_session(&)
already_active = @agent_session_active
return yield if already_active
@agent_session_active = true
instrument_agent_session(instrumentation_params, &block)
instrument_agent_session(instrumentation_params, &)
ensure
@agent_session_active = false unless already_active
end

View File

@ -37,7 +37,7 @@ class Captain::Assistant < ApplicationRecord
has_many :copilot_threads, dependent: :destroy_async
has_many :scenarios, class_name: 'Captain::Scenario', dependent: :destroy_async
store_accessor :config, :temperature, :feature_faq, :feature_memory, :product_name, :playbook, :distance_threshold, :max_rag_results
store_accessor :config, :temperature, :feature_faq, :feature_memory, :product_name, :role_name, :playbook, :distance_threshold, :max_rag_results
validates :name, presence: true
validates :description, presence: true

View File

@ -101,7 +101,7 @@ class Captain::Llm::SystemPromptsService
<<~SYSTEM_PROMPT_MESSAGE
[Identity]
You are Captain, a helpful and friendly copilot assistant for support agents using the product #{product_name}. Your primary role is to assist support agents by retrieving information, compiling accurate responses, and guiding them through customer interactions.
You are Captain, a helpful and friendly copilot assistant for support agents using #{product_name}. Your primary role is to assist support agents by retrieving information, compiling accurate responses, and guiding them through customer interactions.
You should only provide information related to #{product_name} and must not address queries about other products or external events.
[Context]
@ -165,7 +165,7 @@ class Captain::Llm::SystemPromptsService
<<~SYSTEM_PROMPT_MESSAGE
[Identity]
Your name is #{assistant_name || 'Captain'}, a helpful, friendly, and knowledgeable assistant for the product #{product_name}. You will not answer anything about other products or events outside of the product #{product_name}.
Your name is #{assistant_name || 'Captain'}, a helpful, friendly, and knowledgeable #{config['role_name'].presence || 'Assistant'} for #{product_name}. You will not answer anything about other products or events outside of #{product_name}.
[Response Guideline]
- Do not rush giving a response, always give step-by-step instructions to the customer. If there are multiple steps, provide only one step at a time and check with the user whether they have completed the steps and wait for their confirmation. If the user has said okay or yes, continue with the steps.

View File

@ -0,0 +1,36 @@
class Llm::ModelTestService
TEST_PROMPT = 'Reply with the word ok.'
def initialize(provider:, model:, api_key:)
@provider = provider
@model = model
@api_key = api_key
end
def perform
response_text = nil
Llm::Config.with_api_key(@api_key, api_base: openai_api_base, provider: @provider) do |context|
chat = context.chat(model: @model)
chat.add_message(role: 'user', content: TEST_PROMPT)
response = chat.ask(TEST_PROMPT)
response_text = response&.content.to_s
end
{ success: true, message: response_text }
rescue StandardError => e
Rails.logger.error "[LLM][ModelTest] provider=#{@provider} model=#{@model} error=#{e.class}: #{e.message}"
{ success: false, error: e.message }
end
private
def openai_api_base
return nil unless @provider == 'openai'
endpoint = InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value.presence || LlmConstants::OPENAI_API_ENDPOINT
endpoint = endpoint.chomp('/')
endpoint = "#{endpoint}/v1" unless endpoint.end_with?('/v1')
endpoint
end
end

View File

@ -7,3 +7,6 @@ json.id resource.id
json.name resource.name
json.response_guidelines resource.response_guidelines
json.updated_at resource.updated_at.to_i
json.llm_provider resource.llm_provider
json.llm_model resource.llm_model
json.api_key resource.api_key

View File

@ -19,9 +19,7 @@ class ChatwootHub
end
def self.pricing_plan
return 'community' unless ChatwootApp.enterprise?
InstallationConfig.find_by(name: 'INSTALLATION_PRICING_PLAN')&.value || 'community'
'premium'
end
def self.pricing_plan_quantity

View File

@ -18,10 +18,14 @@ module Llm::Config
@initialized = false
end
def with_api_key(api_key, api_base: nil)
def with_api_key(api_key, api_base: nil, provider: 'openai')
context = RubyLLM.context do |config|
config.openai_api_key = api_key
config.openai_api_base = api_base
if provider == 'gemini'
config.gemini_api_key = api_key
else
config.openai_api_key = api_key
config.openai_api_base = api_base if api_base.present?
end
end
yield context
@ -32,7 +36,8 @@ module Llm::Config
def configure_ruby_llm
RubyLLM.configure do |config|
config.openai_api_key = system_api_key if system_api_key.present?
config.openai_api_base = openai_endpoint.chomp('/') if openai_endpoint.present?
config.openai_api_base = normalize_openai_api_base(openai_endpoint) if openai_endpoint.present?
config.gemini_api_key = gemini_api_key if gemini_api_key.present?
config.logger = Rails.logger
end
end
@ -44,5 +49,15 @@ module Llm::Config
def openai_endpoint
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value
end
def normalize_openai_api_base(endpoint)
endpoint = endpoint.chomp('/')
endpoint = "#{endpoint}/v1" unless endpoint.end_with?('/v1')
endpoint
end
def gemini_api_key
InstallationConfig.find_by(name: 'CAPTAIN_GEMINI_API_KEY')&.value
end
end
end

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect width="128" height="128" rx="20" fill="#e2e8f0"/>
<circle cx="64" cy="64" r="40" fill="#0ea5e9"/>
<text x="64" y="72" text-anchor="middle" font-size="36" font-family="Arial, sans-serif" fill="#0f172a">G</text>
</svg>

After

Width:  |  Height:  |  Size: 317 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
<rect width="128" height="128" rx="20" fill="#0f172a"/>
<circle cx="64" cy="64" r="40" fill="#38bdf8"/>
<text x="64" y="72" text-anchor="middle" font-size="36" font-family="Arial, sans-serif" fill="#0f172a">G</text>
</svg>

After

Width:  |  Height:  |  Size: 317 B