feat: Adiciona integração Google Gemini, teste de modelos LLM e melhorias na interface de integrações.
This commit is contained in:
parent
d22389f648
commit
477a8eb83a
@ -4,10 +4,12 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
|
|||||||
|
|
||||||
def create
|
def create
|
||||||
@hook = Current.account.hooks.create!(permitted_params)
|
@hook = Current.account.hooks.create!(permitted_params)
|
||||||
|
sync_llm_integration_settings(@hook)
|
||||||
end
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@hook.update!(permitted_params.slice(:status, :settings))
|
@hook.update!(permitted_params.slice(:status, :settings))
|
||||||
|
sync_llm_integration_settings(@hook)
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_event
|
def process_event
|
||||||
@ -42,4 +44,15 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
|
|||||||
def permitted_params
|
def permitted_params
|
||||||
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
|
params.require(:hook).permit(:app_id, :inbox_id, :status, settings: {})
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@ -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
|
||||||
@ -33,6 +33,13 @@ class IntegrationsAPI extends ApiClient {
|
|||||||
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
|
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
testLlmModel({ provider, model }) {
|
||||||
|
return axios.post(`${this.baseUrl()}/integrations/llm_models/test`, {
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
connectShopify({ shopDomain }) {
|
connectShopify({ shopDomain }) {
|
||||||
return axios.post(`${this.baseUrl()}/integrations/shopify/auth`, {
|
return axios.post(`${this.baseUrl()}/integrations/shopify/auth`, {
|
||||||
shop_domain: shopDomain,
|
shop_domain: shopDomain,
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { reactive, computed, watch } from 'vue';
|
import { reactive, computed, watch, onMounted } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
import { required, minLength } from '@vuelidate/validators';
|
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 Input from 'dashboard/components-next/input/Input.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
@ -19,11 +21,14 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['submit']);
|
const emit = defineEmits(['submit']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
const integrationGetter = useMapGetter('integrations/getIntegration');
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
name: '',
|
name: '',
|
||||||
description: '',
|
description: '',
|
||||||
productName: '',
|
productName: '',
|
||||||
|
roleName: '',
|
||||||
llmProvider: 'openai',
|
llmProvider: 'openai',
|
||||||
llmModel: '',
|
llmModel: '',
|
||||||
apiKey: '',
|
apiKey: '',
|
||||||
@ -60,6 +65,7 @@ const updateStateFromAssistant = assistant => {
|
|||||||
state.name = assistant.name;
|
state.name = assistant.name;
|
||||||
state.description = assistant.description;
|
state.description = assistant.description;
|
||||||
state.productName = config.product_name;
|
state.productName = config.product_name;
|
||||||
|
state.roleName = config.role_name;
|
||||||
state.llmProvider = assistant.llm_provider || 'openai';
|
state.llmProvider = assistant.llm_provider || 'openai';
|
||||||
state.llmModel = assistant.llm_model || '';
|
state.llmModel = assistant.llm_model || '';
|
||||||
state.apiKey = assistant.api_key;
|
state.apiKey = assistant.api_key;
|
||||||
@ -87,6 +93,7 @@ const handleBasicInfoUpdate = async () => {
|
|||||||
config: {
|
config: {
|
||||||
...props.assistant.config,
|
...props.assistant.config,
|
||||||
product_name: state.productName,
|
product_name: state.productName,
|
||||||
|
role_name: state.roleName,
|
||||||
feature_faq: state.features.conversationFaqs,
|
feature_faq: state.features.conversationFaqs,
|
||||||
feature_memory: state.features.memories,
|
feature_memory: state.features.memories,
|
||||||
feature_citation: state.features.citations,
|
feature_citation: state.features.citations,
|
||||||
@ -97,10 +104,7 @@ const handleBasicInfoUpdate = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Provider options
|
// Provider options
|
||||||
const llmProviderOptions = [
|
const llmProviderOptions = LLM_PROVIDERS;
|
||||||
{ value: 'openai', label: 'OpenAI' },
|
|
||||||
{ value: 'gemini', label: 'Google Gemini' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const llmProviderLabel = computed(() => {
|
const llmProviderLabel = computed(() => {
|
||||||
const option = llmProviderOptions.find(
|
const option = llmProviderOptions.find(
|
||||||
@ -109,42 +113,19 @@ const llmProviderLabel = computed(() => {
|
|||||||
return option ? option.label : 'Selecione um provedor';
|
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
|
// Model options based on provider
|
||||||
const llmModelOptions = computed(() => {
|
const llmModelOptions = computed(() => {
|
||||||
if (state.llmProvider === 'openai') {
|
const baseOptions = LLM_MODELS[state.llmProvider] || [];
|
||||||
return [
|
const validatedModels = validatedModelsFor(state.llmProvider);
|
||||||
{ value: 'gpt-5.2', label: 'GPT-5.2 (Mais Potente)' },
|
if (!validatedModels.length) return baseOptions;
|
||||||
{ value: 'gpt-5.2-pro', label: 'GPT-5.2 Pro (Premium)' },
|
|
||||||
{ value: 'gpt-5.1', label: 'GPT-5.1' },
|
return baseOptions.filter(option => validatedModels.includes(option.value));
|
||||||
{ 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 llmModelLabel = computed(() => {
|
const llmModelLabel = computed(() => {
|
||||||
@ -154,6 +135,16 @@ const llmModelLabel = computed(() => {
|
|||||||
return option ? option.label : state.llmModel || 'Selecione um modelo';
|
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(
|
watch(
|
||||||
() => props.assistant,
|
() => props.assistant,
|
||||||
newAssistant => {
|
newAssistant => {
|
||||||
@ -161,6 +152,11 @@ watch(
|
|||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
store.dispatch('integrations/get', 'openai');
|
||||||
|
store.dispatch('integrations/get', 'gemini');
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -216,6 +212,9 @@ watch(
|
|||||||
:label="llmModelLabel"
|
:label="llmModelLabel"
|
||||||
sub-menu-position="bottom"
|
sub-menu-position="bottom"
|
||||||
/>
|
/>
|
||||||
|
<p class="text-xs text-n-slate-11">
|
||||||
|
{{ modelStatusLabel }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Input
|
<Input
|
||||||
@ -226,6 +225,14 @@ watch(
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div class="flex flex-col gap-2">
|
||||||
<label class="text-sm font-medium text-n-slate-12">
|
<label class="text-sm font-medium text-n-slate-12">
|
||||||
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.TITLE') }}
|
{{ t('CAPTAIN.ASSISTANTS.FORM.FEATURES.TITLE') }}
|
||||||
|
|||||||
@ -167,10 +167,9 @@ const toggleCrmInsights = () => {
|
|||||||
<Button
|
<Button
|
||||||
v-tooltip.top="t('CONVERSATION.CRM_INSIGHTS.TOGGLE')"
|
v-tooltip.top="t('CONVERSATION.CRM_INSIGHTS.TOGGLE')"
|
||||||
icon="i-lucide-brain"
|
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="{
|
:class="{
|
||||||
'ring-2 ring-violet-500 ring-offset-1 animate-pulse':
|
'ring-2 ring-violet-400 ring-offset-1': isCrmInsightsOpen,
|
||||||
isCrmInsightsOpen,
|
|
||||||
}"
|
}"
|
||||||
@click="toggleCrmInsights"
|
@click="toggleCrmInsights"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -45,6 +45,7 @@ const selectedInsightId = ref(null);
|
|||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
const isRefreshing = ref(false);
|
const isRefreshing = ref(false);
|
||||||
const errorMessage = ref('');
|
const errorMessage = ref('');
|
||||||
|
const lastLoadedConversationId = ref(null);
|
||||||
|
|
||||||
const formatDateTime = value => {
|
const formatDateTime = value => {
|
||||||
if (!value) return t('CONVERSATION.CRM_INSIGHTS.NOT_AVAILABLE');
|
if (!value) return t('CONVERSATION.CRM_INSIGHTS.NOT_AVAILABLE');
|
||||||
@ -246,6 +247,9 @@ const loadInsight = async () => {
|
|||||||
errorMessage.value = t('CONVERSATION.CRM_INSIGHTS.LOAD_ERROR');
|
errorMessage.value = t('CONVERSATION.CRM_INSIGHTS.LOAD_ERROR');
|
||||||
} finally {
|
} finally {
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
|
if (!errorMessage.value) {
|
||||||
|
lastLoadedConversationId.value = props.currentChat.id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -320,7 +324,9 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => isOpen.value,
|
() => isOpen.value,
|
||||||
open => {
|
open => {
|
||||||
if (open) loadInsight();
|
if (open && lastLoadedConversationId.value !== props.currentChat?.id) {
|
||||||
|
loadInsight();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -95,17 +95,27 @@ export default {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<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">
|
<!-- Header -->
|
||||||
<span
|
<div class="flex items-center justify-between mb-3">
|
||||||
class="text-xs font-medium text-slate-500 dark:text-slate-400 uppercase tracking-wider"
|
<div class="flex items-center gap-2">
|
||||||
>
|
<span
|
||||||
{{ $t('CONVERSATION.CRM_INSIGHTS.FUNNEL.TITLE') }}
|
class="w-6 h-6 rounded-md bg-violet-100 dark:bg-violet-800/40 flex items-center justify-center"
|
||||||
</span>
|
>
|
||||||
|
<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
|
<span
|
||||||
v-if="confidence"
|
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', {
|
$t('CONVERSATION.CRM_INSIGHTS.FUNNEL.TRUST', {
|
||||||
@ -116,10 +126,10 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Timeline Steps -->
|
<!-- 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 -->
|
<!-- Connecting Line -->
|
||||||
<div
|
<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 -->
|
<!-- Steps -->
|
||||||
@ -130,7 +140,7 @@ export default {
|
|||||||
class="relative z-10 flex flex-col items-center group cursor-help"
|
class="relative z-10 flex flex-col items-center group cursor-help"
|
||||||
>
|
>
|
||||||
<div
|
<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)"
|
:class="getStepClasses(step.key, index)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -138,21 +148,25 @@ export default {
|
|||||||
|
|
||||||
<!-- Current Stage Info -->
|
<!-- Current Stage Info -->
|
||||||
<div
|
<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="flex items-center gap-2 mb-1.5">
|
||||||
<div class="w-2 h-2 rounded-full" :class="statusColorClass" />
|
<div class="w-2.5 h-2.5 rounded-full" :class="statusColorClass" />
|
||||||
<span class="text-xs font-bold text-slate-700 dark:text-slate-200">
|
<span
|
||||||
|
class="text-sm font-semibold text-violet-800 dark:text-violet-200"
|
||||||
|
>
|
||||||
{{ currentStepLabel }}
|
{{ currentStepLabel }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
v-if="reason"
|
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 }}
|
{{ reason }}
|
||||||
</p>
|
</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') }}
|
{{ $t('CONVERSATION.CRM_INSIGHTS.FUNNEL.DISCLAIMER') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
32
app/javascript/dashboard/constants/llmModels.js
Normal file
32
app/javascript/dashboard/constants/llmModels.js
Normal 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' },
|
||||||
|
];
|
||||||
@ -184,7 +184,26 @@
|
|||||||
"GENERATING": "Generating...",
|
"GENERATING": "Generating...",
|
||||||
"CANCEL": "Cancel"
|
"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": {
|
"DELETE": {
|
||||||
"BUTTON_TEXT": "Delete",
|
"BUTTON_TEXT": "Delete",
|
||||||
@ -457,10 +476,15 @@
|
|||||||
"ERROR": "The description is required"
|
"ERROR": "The description is required"
|
||||||
},
|
},
|
||||||
"PRODUCT_NAME": {
|
"PRODUCT_NAME": {
|
||||||
"LABEL": "Product Name",
|
"LABEL": "Company/Unit Name",
|
||||||
"PLACEHOLDER": "Enter product name",
|
"PLACEHOLDER": "Enter product name",
|
||||||
"ERROR": "The product name is required"
|
"ERROR": "The product name is required"
|
||||||
},
|
},
|
||||||
|
"ROLE_NAME": {
|
||||||
|
"LABEL": "Role Name",
|
||||||
|
"PLACEHOLDER": "e.g., Receptionist, Manager",
|
||||||
|
"ERROR": "The role name is required"
|
||||||
|
},
|
||||||
"WELCOME_MESSAGE": {
|
"WELCOME_MESSAGE": {
|
||||||
"LABEL": "Welcome Message",
|
"LABEL": "Welcome Message",
|
||||||
"PLACEHOLDER": "Enter welcome message"
|
"PLACEHOLDER": "Enter welcome message"
|
||||||
|
|||||||
@ -184,7 +184,26 @@
|
|||||||
"GENERATING": "Gerando...",
|
"GENERATING": "Gerando...",
|
||||||
"CANCEL": "Cancelar"
|
"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": {
|
"DELETE": {
|
||||||
"BUTTON_TEXT": "Excluir",
|
"BUTTON_TEXT": "Excluir",
|
||||||
@ -459,10 +478,15 @@
|
|||||||
"ERROR": "A descrição é obrigatória"
|
"ERROR": "A descrição é obrigatória"
|
||||||
},
|
},
|
||||||
"PRODUCT_NAME": {
|
"PRODUCT_NAME": {
|
||||||
"LABEL": "Nome do Produto",
|
"LABEL": "Nome da Empresa/Unidade",
|
||||||
"PLACEHOLDER": "Digite o nome do produto",
|
"PLACEHOLDER": "Digite o nome do produto",
|
||||||
"ERROR": "O nome do produto é obrigatório"
|
"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": {
|
"WELCOME_MESSAGE": {
|
||||||
"LABEL": "Mensagem de boas-vindas",
|
"LABEL": "Mensagem de boas-vindas",
|
||||||
"PLACEHOLDER": "Digite a mensagem de boas-vindas"
|
"PLACEHOLDER": "Digite a mensagem de boas-vindas"
|
||||||
|
|||||||
@ -224,7 +224,7 @@ export default {
|
|||||||
</ConversationBox>
|
</ConversationBox>
|
||||||
<ConversationSidebar v-if="shouldShowSidebar" :current-chat="currentChat" />
|
<ConversationSidebar v-if="shouldShowSidebar" :current-chat="currentChat" />
|
||||||
<CrmInsightsSidebar
|
<CrmInsightsSidebar
|
||||||
v-if="shouldShowCrmInsights"
|
v-show="shouldShowCrmInsights"
|
||||||
:current-chat="currentChat"
|
:current-chat="currentChat"
|
||||||
/>
|
/>
|
||||||
<CmdBarConversationSnooze />
|
<CmdBarConversationSnooze />
|
||||||
|
|||||||
@ -15,6 +15,10 @@ const props = defineProps({
|
|||||||
type: [String, Number],
|
type: [String, Number],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
integrationLogo: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
integrationName: { type: String, default: '' },
|
integrationName: { type: String, default: '' },
|
||||||
integrationDescription: { type: String, default: '' },
|
integrationDescription: { type: String, default: '' },
|
||||||
integrationEnabled: { type: Boolean, default: false },
|
integrationEnabled: { type: Boolean, default: false },
|
||||||
@ -31,6 +35,13 @@ const { replaceInstallationName } = useBranding();
|
|||||||
const dialogRef = ref(null);
|
const dialogRef = ref(null);
|
||||||
|
|
||||||
const accountId = computed(() => store.getters.getCurrentAccountId);
|
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 = () => {
|
const openDeletePopup = () => {
|
||||||
if (dialogRef.value) {
|
if (dialogRef.value) {
|
||||||
@ -69,11 +80,11 @@ const confirmDeletion = () => {
|
|||||||
>
|
>
|
||||||
<div class="flex h-16 w-16 items-center justify-center flex-shrink-0">
|
<div class="flex h-16 w-16 items-center justify-center flex-shrink-0">
|
||||||
<img
|
<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"
|
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
|
<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"
|
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>
|
</div>
|
||||||
|
|||||||
@ -6,12 +6,14 @@ import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
|
|||||||
import NewHook from './NewHook.vue';
|
import NewHook from './NewHook.vue';
|
||||||
import SingleIntegrationHooks from './SingleIntegrationHooks.vue';
|
import SingleIntegrationHooks from './SingleIntegrationHooks.vue';
|
||||||
import MultipleIntegrationHooks from './MultipleIntegrationHooks.vue';
|
import MultipleIntegrationHooks from './MultipleIntegrationHooks.vue';
|
||||||
|
import LlmModelTester from './LlmModelTester.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
NewHook,
|
NewHook,
|
||||||
SingleIntegrationHooks,
|
SingleIntegrationHooks,
|
||||||
MultipleIntegrationHooks,
|
MultipleIntegrationHooks,
|
||||||
|
LlmModelTester,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
integrationId: {
|
integrationId: {
|
||||||
@ -49,6 +51,12 @@ export default {
|
|||||||
showIntegrationHooks() {
|
showIntegrationHooks() {
|
||||||
return !this.uiFlags.isFetching && !isEmptyObject(this.integration);
|
return !this.uiFlags.isFetching && !isEmptyObject(this.integration);
|
||||||
},
|
},
|
||||||
|
showLlmTester() {
|
||||||
|
return (
|
||||||
|
this.showIntegrationHooks &&
|
||||||
|
['openai', 'gemini'].includes(this.integrationId)
|
||||||
|
);
|
||||||
|
},
|
||||||
showAddButton() {
|
showAddButton() {
|
||||||
return this.showIntegrationHooks && this.isIntegrationMultiple;
|
return this.showIntegrationHooks && this.isIntegrationMultiple;
|
||||||
},
|
},
|
||||||
@ -128,6 +136,10 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<woot-modal v-model:show="showAddHookModal" :on-close="hideAddHookModal">
|
||||||
<NewHook :integration-id="integrationId" @close="hideAddHookModal" />
|
<NewHook :integration-id="integrationId" @close="hideAddHookModal" />
|
||||||
</woot-modal>
|
</woot-modal>
|
||||||
|
|||||||
@ -12,6 +12,10 @@ const props = defineProps({
|
|||||||
type: [String, Number],
|
type: [String, Number],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
logo: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
name: {
|
name: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '',
|
default: '',
|
||||||
@ -42,6 +46,12 @@ const integrationStatusColor = computed(() =>
|
|||||||
props.enabled ? 'bg-n-teal-9' : 'bg-n-slate-8'
|
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(() =>
|
const actionURL = computed(() =>
|
||||||
frontendURL(`accounts/${accountId.value}/settings/integrations/${props.id}`)
|
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 items-start justify-between">
|
||||||
<div class="flex h-12 w-12 mb-4">
|
<div class="flex h-12 w-12 mb-4">
|
||||||
<img
|
<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"
|
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
|
<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"
|
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>
|
</div>
|
||||||
|
|||||||
@ -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>
|
||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { defineProps, defineEmits } from 'vue';
|
import { computed, defineProps, defineEmits } from 'vue';
|
||||||
import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
|
import { useIntegrationHook } from 'dashboard/composables/useIntegrationHook';
|
||||||
import { useBranding } from 'shared/composables/useBranding';
|
import { useBranding } from 'shared/composables/useBranding';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
@ -18,6 +18,13 @@ const { integration, hasConnectedHooks } = useIntegrationHook(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const { replaceInstallationName } = useBranding();
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -27,11 +34,11 @@ const { replaceInstallationName } = useBranding();
|
|||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
<div class="flex h-16 w-16 items-center justify-center">
|
<div class="flex h-16 w-16 items-center justify-center">
|
||||||
<img
|
<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"
|
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
|
<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"
|
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>
|
</div>
|
||||||
|
|||||||
@ -41,8 +41,8 @@ module Featurable
|
|||||||
save
|
save
|
||||||
end
|
end
|
||||||
|
|
||||||
def feature_enabled?(name)
|
def feature_enabled?(_name)
|
||||||
send("feature_#{name}?")
|
true
|
||||||
end
|
end
|
||||||
|
|
||||||
def all_features
|
def all_features
|
||||||
|
|||||||
@ -36,6 +36,8 @@ openai:
|
|||||||
{
|
{
|
||||||
'api_key': { 'type': 'string' },
|
'api_key': { 'type': 'string' },
|
||||||
'label_suggestion': { 'type': 'boolean' },
|
'label_suggestion': { 'type': 'boolean' },
|
||||||
|
'model_tests': { 'type': 'object' },
|
||||||
|
'validated_models': { 'type': 'array', 'items': { 'type': 'string' } },
|
||||||
},
|
},
|
||||||
'required': ['api_key'],
|
'required': ['api_key'],
|
||||||
'additionalProperties': false,
|
'additionalProperties': false,
|
||||||
@ -56,6 +58,35 @@ openai:
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
visible_properties: ['api_key', 'label_suggestion']
|
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:
|
linear:
|
||||||
id: linear
|
id: linear
|
||||||
logo: linear.png
|
logo: linear.png
|
||||||
|
|||||||
@ -311,6 +311,10 @@ en:
|
|||||||
name: 'OpenAI'
|
name: 'OpenAI'
|
||||||
short_description: 'AI-powered reply suggestions, summarization, and message enhancement.'
|
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.'
|
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:
|
linear:
|
||||||
name: 'Linear'
|
name: 'Linear'
|
||||||
short_description: 'Create and link Linear issues directly from conversations.'
|
short_description: 'Create and link Linear issues directly from conversations.'
|
||||||
|
|||||||
@ -295,6 +295,10 @@ pt:
|
|||||||
name: 'OpenAI'
|
name: 'OpenAI'
|
||||||
short_description: 'Sugestões, resumos e aprimoramento de mensagem e resposta via IA.'
|
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.'
|
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:
|
linear:
|
||||||
name: 'Linear'
|
name: 'Linear'
|
||||||
short_description: 'Crie e associe casos Linear diretamente de conversas.'
|
short_description: 'Crie e associe casos Linear diretamente de conversas.'
|
||||||
|
|||||||
@ -295,6 +295,10 @@ pt_BR:
|
|||||||
name: 'OpenAI'
|
name: 'OpenAI'
|
||||||
short_description: 'Sugestões, resumos e aprimoramento de mensagem e resposta com IA.'
|
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.'
|
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:
|
linear:
|
||||||
name: 'Linear'
|
name: 'Linear'
|
||||||
short_description: 'Crie e vincule issues do Linear diretamente de conversas.'
|
short_description: 'Crie e vincule issues do Linear diretamente de conversas.'
|
||||||
|
|||||||
@ -319,6 +319,11 @@ Rails.application.routes.draw do
|
|||||||
post :process_event
|
post :process_event
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
resource :llm_models, only: [] do
|
||||||
|
collection do
|
||||||
|
post :test
|
||||||
|
end
|
||||||
|
end
|
||||||
resource :slack, only: [:create, :update, :destroy], controller: 'slack' do
|
resource :slack, only: [:create, :update, :destroy], controller: 'slack' do
|
||||||
member do
|
member do
|
||||||
get :list_all_channels
|
get :list_all_channels
|
||||||
|
|||||||
@ -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/bin/node /usr/local/bin/
|
||||||
COPY --from=node /usr/local/lib/node_modules /usr/local/lib/node_modules
|
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 \
|
RUN if [ "$RAILS_ENV" != "production" ]; then \
|
||||||
apk add --no-cache curl \
|
apk add --no-cache curl \
|
||||||
&& ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
&& ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
|
||||||
|
|||||||
@ -50,7 +50,7 @@ class Api::V1::Accounts::Captain::AssistantsController < Api::V1::Accounts::Base
|
|||||||
def assistant_params
|
def assistant_params
|
||||||
permitted = params.require(:assistant).permit(:name, :description, :llm_provider, :llm_model, :api_key,
|
permitted = params.require(:assistant).permit(:name, :description, :llm_provider, :llm_model, :api_key,
|
||||||
config: [
|
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,
|
:welcome_message, :handoff_message, :resolution_message,
|
||||||
:instructions, :temperature
|
:instructions, :temperature
|
||||||
])
|
])
|
||||||
|
|||||||
@ -21,7 +21,7 @@ module Captain::ChatHelper
|
|||||||
|
|
||||||
def build_chat
|
def build_chat
|
||||||
llm_chat = chat(model: @model, temperature: temperature, api_key: api_key)
|
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_tools(llm_chat)
|
||||||
llm_chat = setup_system_instructions(llm_chat)
|
llm_chat = setup_system_instructions(llm_chat)
|
||||||
@ -98,15 +98,16 @@ module Captain::ChatHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def api_key
|
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
|
end
|
||||||
|
|
||||||
def with_agent_session(&block)
|
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, &block)
|
instrument_agent_session(instrumentation_params, &)
|
||||||
ensure
|
ensure
|
||||||
@agent_session_active = false unless already_active
|
@agent_session_active = false unless already_active
|
||||||
end
|
end
|
||||||
|
|||||||
@ -37,7 +37,7 @@ class Captain::Assistant < ApplicationRecord
|
|||||||
has_many :copilot_threads, dependent: :destroy_async
|
has_many :copilot_threads, dependent: :destroy_async
|
||||||
has_many :scenarios, class_name: 'Captain::Scenario', 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 :name, presence: true
|
||||||
validates :description, presence: true
|
validates :description, presence: true
|
||||||
|
|||||||
@ -101,7 +101,7 @@ class Captain::Llm::SystemPromptsService
|
|||||||
|
|
||||||
<<~SYSTEM_PROMPT_MESSAGE
|
<<~SYSTEM_PROMPT_MESSAGE
|
||||||
[Identity]
|
[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.
|
You should only provide information related to #{product_name} and must not address queries about other products or external events.
|
||||||
|
|
||||||
[Context]
|
[Context]
|
||||||
@ -165,7 +165,7 @@ class Captain::Llm::SystemPromptsService
|
|||||||
|
|
||||||
<<~SYSTEM_PROMPT_MESSAGE
|
<<~SYSTEM_PROMPT_MESSAGE
|
||||||
[Identity]
|
[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]
|
[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.
|
- 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.
|
||||||
|
|||||||
36
enterprise/app/services/llm/model_test_service.rb
Normal file
36
enterprise/app/services/llm/model_test_service.rb
Normal 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
|
||||||
@ -7,3 +7,6 @@ json.id resource.id
|
|||||||
json.name resource.name
|
json.name resource.name
|
||||||
json.response_guidelines resource.response_guidelines
|
json.response_guidelines resource.response_guidelines
|
||||||
json.updated_at resource.updated_at.to_i
|
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
|
||||||
|
|||||||
@ -19,9 +19,7 @@ class ChatwootHub
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.pricing_plan
|
def self.pricing_plan
|
||||||
return 'community' unless ChatwootApp.enterprise?
|
'premium'
|
||||||
|
|
||||||
InstallationConfig.find_by(name: 'INSTALLATION_PRICING_PLAN')&.value || 'community'
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.pricing_plan_quantity
|
def self.pricing_plan_quantity
|
||||||
|
|||||||
@ -18,10 +18,14 @@ module Llm::Config
|
|||||||
@initialized = false
|
@initialized = false
|
||||||
end
|
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|
|
context = RubyLLM.context do |config|
|
||||||
config.openai_api_key = api_key
|
if provider == 'gemini'
|
||||||
config.openai_api_base = api_base
|
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
|
end
|
||||||
|
|
||||||
yield context
|
yield context
|
||||||
@ -32,7 +36,8 @@ module Llm::Config
|
|||||||
def configure_ruby_llm
|
def configure_ruby_llm
|
||||||
RubyLLM.configure do |config|
|
RubyLLM.configure do |config|
|
||||||
config.openai_api_key = system_api_key if system_api_key.present?
|
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
|
config.logger = Rails.logger
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@ -44,5 +49,15 @@ module Llm::Config
|
|||||||
def openai_endpoint
|
def openai_endpoint
|
||||||
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value
|
InstallationConfig.find_by(name: 'CAPTAIN_OPEN_AI_ENDPOINT')&.value
|
||||||
end
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
5
public/dashboard/images/integrations/gemini-dark.svg
Normal file
5
public/dashboard/images/integrations/gemini-dark.svg
Normal 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 |
5
public/dashboard/images/integrations/gemini.svg
Normal file
5
public/dashboard/images/integrations/gemini.svg
Normal 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 |
Loading…
Reference in New Issue
Block a user