feat: Adiciona internacionalização para a base de conhecimento Jasmine, ajusta regras de lint e atualiza dependências.

This commit is contained in:
Rodrigo Borba 2026-01-25 11:50:50 -03:00
parent 2672d21136
commit c0cd8c24b0
21 changed files with 265 additions and 120 deletions

View File

@ -60,6 +60,7 @@ gem 'aws-actionmailbox-ses', '~> 0'
##-- gems for database --#
gem 'groupdate'
gem 'fiddle'
gem 'pg'
gem 'redis'
gem 'redis-namespace'

View File

@ -338,6 +338,7 @@ GEM
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
fiddle (1.1.8)
flag_shih_tzu (0.3.23)
foreman (0.87.2)
fugit (1.11.1)
@ -1071,6 +1072,7 @@ DEPENDENCIES
faker
faraday_middleware-aws-sigv4
fcm
fiddle
flag_shih_tzu
foreman
geocoder
@ -1180,7 +1182,7 @@ DEPENDENCIES
working_hours
RUBY VERSION
ruby 3.4.4p34
ruby 3.4.4p34
BUNDLED WITH
2.5.5
2.5.5

View File

@ -46,23 +46,32 @@ class JasmineAPI extends ApiClient {
}
unlinkCollection(inboxId, collectionId) {
return axios.delete(`${this.url}/${inboxId}/jasmine/collections/${collectionId}`);
return axios.delete(
`${this.url}/${inboxId}/jasmine/collections/${collectionId}`
);
}
// Documents
getDocuments(collectionId) {
return axios.get(`${this.jasmineUrl}/collections/${collectionId}/documents`);
return axios.get(
`${this.jasmineUrl}/collections/${collectionId}/documents`
);
}
uploadDocument(collectionId, content, title) {
return axios.post(`${this.jasmineUrl}/collections/${collectionId}/documents`, {
title,
content,
});
return axios.post(
`${this.jasmineUrl}/collections/${collectionId}/documents`,
{
title,
content,
}
);
}
deleteDocument(collectionId, documentId) {
return axios.delete(`${this.jasmineUrl}/collections/${collectionId}/documents/${documentId}`);
return axios.delete(
`${this.jasmineUrl}/collections/${collectionId}/documents/${documentId}`
);
}
// Playground
@ -85,4 +94,3 @@ class JasmineAPI extends ApiClient {
}
export default new JasmineAPI();

View File

@ -109,7 +109,8 @@ export function usePolicy() {
if (!flag) return false;
// Bypass paywall for Captain in development
if (['captain_integration', 'captain_integration_v2'].includes(flag)) return false;
if (['captain_integration', 'captain_integration_v2'].includes(flag))
return false;
if (isACustomBrandedInstance.value) {
// custom branded instances never show paywall

View File

@ -40,6 +40,7 @@ import whatsappTemplates from './whatsappTemplates.json';
import contentTemplates from './contentTemplates.json';
import mfa from './mfa.json';
import yearInReview from './yearInReview.json';
import jasmine from './jasmine.json';
export default {
...advancedFilters,
@ -84,4 +85,5 @@ export default {
...contentTemplates,
...mfa,
...yearInReview,
...jasmine,
};

View File

@ -0,0 +1,72 @@
{
"JASMINE": {
"HEADER": {
"TITLE": "Jasmine AI Agents",
"DESCRIPTION": "Manage your AI SDR agents. Select an inbox to configure your knowledge base.",
"EMPTY": "No inboxes found"
},
"CONFIG": {
"TITLE": "Jasmine AI Configuration",
"DESCRIPTION": "Configure the AI agent for this inbox.",
"ENABLE": "Enable Jasmine AI Agent",
"SYSTEM_PROMPT": "System Prompt",
"SYSTEM_PROMPT_HELP": "Define the persona and behavioral rules for the agent.",
"UPDATE_BUTTON": "Update Configuration"
},
"KNOWLEDGE_BASE": {
"TITLE": "Knowledge Base",
"DESCRIPTION": "Manage knowledge collections for this inbox",
"ADD_BUTTON": "+ New Collection",
"DOCUMENTS": "Documents",
"LOADING_DOCS": "Loading documents...",
"UNTITLED_DOC": "Untitled Document",
"NO_DOCS": "No documents yet. Add your first document below.",
"ADD_DOC_HEADER": "Add New Document",
"DOC_TITLE_PLACEHOLDER": "Document title (optional)",
"DOC_CONTENT_PLACEHOLDER": "Paste or type your knowledge content here...",
"ADD_DOC_BUTTON": "Add Document",
"NO_COLLECTIONS": "No collections yet. Create one to get started.",
"CREATE_MODAL": {
"TITLE": "Create Collection",
"NAME_PLACEHOLDER": "Collection name",
"VISIBILITY_PRIVATE": "Private (This inbox only)",
"VISIBILITY_SHARED": "Shared (All inboxes)",
"CANCEL": "Cancel",
"CREATE": "Create"
},
"DELETE_CONFIRM": "Are you sure you want to delete this document?",
"DOCUMENT_DELETE_SUCCESS": "Document deleted successfully",
"COLLECTION_DELETE_SUCCESS": "Collection deleted successfully",
"SAVE_SUCCESS": "Changes saved successfully",
"DOCUMENT_CREATE_SUCCESS": "Document created successfully",
"COLLECTION_CREATE_SUCCESS": "Collection created successfully"
},
"PLAYGROUND": {
"TITLE": "Jasmine AI Playground",
"DESCRIPTION": "Test Jasmine responses in real-time before enabling for customers.",
"SELECT_INBOX": "Select an Inbox to test",
"CHOOSE_INBOX": "Choose an inbox...",
"WARNING": "Make sure Jasmine is enabled and configured for this inbox",
"EMPTY_STATE_TITLE": "Send a message to test Jasmine",
"EMPTY_STATE_EXAMPLES": "Try: \"Hello\", \"How much does it cost?\", \"How does it work?\"",
"LOADING": "Jasmine is thinking...",
"INPUT_PLACEHOLDER": "Type a test message...",
"CLEAR_TOOLTIP": "Clear conversation",
"NO_INBOX_SELECTED": "Select an inbox above to start testing"
},
"INBOX_LIST": {
"ACTIVE": "Active",
"CONFIGURE": "Configure",
"DESCRIPTION": "Channel {channel} configured for Jasmine AI"
},
"WUZAPI": {
"STATUS": "Status: {status}",
"ACCOUNT_ERROR": "Error: Account ID not loaded. Please refresh the page.",
"CONNECT_FALLBACK": "Click to initiate connection",
"CONNECT_BUTTON_FALLBACK": "Connect WhatsApp",
"WEBHOOK_SECTION": "Webhook Configuration",
"GET_WEBHOOK_INFO": "Get Webhook Info",
"UPDATE_WEBHOOK": "Update Webhook Connection"
}
}
}

View File

@ -44,4 +44,3 @@ const routes = [
];
export default routes;

View File

@ -45,8 +45,8 @@ const getChannelName = channelType => {
>
<template #header>
<BaseSettingsHeader
title="Agentes Jasmine AI"
description="Gerencie seus agentes de IA SDR. Selecione uma caixa de entrada para configurar sua base de conhecimento."
:title="$t('JASMINE.HEADER.TITLE')"
:description="$t('JASMINE.HEADER.DESCRIPTION')"
/>
</template>
@ -64,14 +64,12 @@ const getChannelName = channelType => {
class="flex items-center justify-center size-12 rounded-lg bg-n-blue-2"
>
<span
:class="[
getChannelIcon(inbox.channel_type),
'size-6 text-n-blue-text',
]"
class="size-6 text-n-blue-text"
:class="[getChannelIcon(inbox.channel_type)]"
/>
</div>
<span
v-tooltip="'Ativo'"
v-tooltip="$t('JASMINE.INBOX_LIST.ACTIVE')"
class="text-white p-0.5 rounded-full size-5 flex items-center justify-center bg-n-teal-9"
>
<i class="i-ph-check-bold text-sm" />
@ -83,13 +81,20 @@ const getChannelName = channelType => {
<span class="text-base font-semibold text-n-slate-12">{{
inbox.name
}}</span>
<Button label="Configurar" link @click.stop="openInbox(inbox.id)" />
<Button
:label="$t('JASMINE.INBOX_LIST.CONFIGURE')"
link
@click.stop="openInbox(inbox.id)"
/>
</div>
<!-- Description -->
<p class="text-sm text-n-slate-11">
Canal {{ getChannelName(inbox.channel_type) }} configurado para
Jasmine AI
{{
$t('JASMINE.INBOX_LIST.DESCRIPTION', {
channel: getChannelName(inbox.channel_type),
})
}}
</p>
</div>
</div>

View File

@ -63,8 +63,8 @@ const clearChat = () => {
<SettingsLayout :is-loading="false">
<template #header>
<BaseSettingsHeader
title="Playground Jasmine AI"
description="Teste as respostas da Jasmine em tempo real antes de ativar para os clientes."
:title="$t('JASMINE.PLAYGROUND.TITLE')"
:description="$t('JASMINE.PLAYGROUND.DESCRIPTION')"
/>
</template>
@ -73,20 +73,25 @@ const clearChat = () => {
<!-- Inbox Selector -->
<div class="mb-4">
<label class="block text-sm font-medium text-n-slate-12 mb-2">
Selecione uma Inbox para testar
{{ $t('JASMINE.PLAYGROUND.SELECT_INBOX') }}
</label>
<select
v-model="selectedInboxId"
class="w-full max-w-md px-3 py-2 text-sm rounded-lg border border-n-weak bg-n-solid-1 text-n-slate-12"
>
<option :value="null">Escolha uma inbox...</option>
<option :value="null">
{{ $t('JASMINE.PLAYGROUND.CHOOSE_INBOX') }}
</option>
<option v-for="inbox in inboxes" :key="inbox.id" :value="inbox.id">
{{ inbox.name }}
</option>
</select>
<p v-if="selectedInbox" class="text-xs text-n-slate-11 mt-1">
Certifique-se de que a Jasmine está ativada e configurada para
esta inbox
{{
$t('JASMINE.PLAYGROUND.FETCH_ERROR', {
error: $t('JASMINE.PLAYGROUND.WARNING'),
})
}}
</p>
</div>
@ -102,17 +107,17 @@ const clearChat = () => {
class="text-center text-n-slate-11 py-12"
>
<span class="i-lucide-message-square size-12 mb-4 opacity-50" />
<p>Envie uma mensagem para testar a Jasmine</p>
<p>{{ $t('JASMINE.PLAYGROUND.EMPTY_STATE_TITLE') }}</p>
<p class="text-xs mt-2">
Experimente: "Olá", "Quanto custa?", "Como funciona?"
{{ $t('JASMINE.PLAYGROUND.EMPTY_STATE_EXAMPLES') }}
</p>
</div>
<div
v-for="(msg, index) in messages"
:key="index"
class="max-w-[80%] rounded-lg p-3"
:class="[
'max-w-[80%] rounded-lg p-3',
msg.role === 'user'
? 'ml-auto bg-n-blue-9 text-white'
: msg.role === 'error'
@ -125,10 +130,16 @@ const clearChat = () => {
v-if="msg.debug"
class="mt-2 pt-2 border-t border-n-weak text-xs text-n-slate-11"
>
<span class="font-mono"
>{{ msg.debug.model }} | temp:
{{ msg.debug.temperature }}</span
>
<span class="font-mono">
{{
$t('JASMINE.PLAYGROUND.MODEL', { model: msg.debug.model })
}}
{{
$t('JASMINE.PLAYGROUND.TEMPERATURE', {
temp: msg.debug.temperature,
})
}}
</span>
</div>
</div>
@ -137,7 +148,9 @@ const clearChat = () => {
class="flex items-center gap-2 text-n-slate-11"
>
<span class="i-lucide-loader-2 size-4 animate-spin" />
<span class="text-sm">Jasmine está pensando...</span>
<span class="text-sm">{{
$t('JASMINE.PLAYGROUND.LOADING')
}}</span>
</div>
</div>
@ -148,7 +161,7 @@ const clearChat = () => {
v-model="inputMessage"
type="text"
class="flex-1 px-3 py-2 text-sm rounded-lg border border-n-weak bg-n-solid-1 text-n-slate-12"
placeholder="Digite uma mensagem de teste..."
:placeholder="$t('JASMINE.PLAYGROUND.INPUT_PLACEHOLDER')"
:disabled="isLoading"
@keyup.enter="sendMessage"
/>
@ -158,7 +171,7 @@ const clearChat = () => {
@click="sendMessage"
/>
<Button
v-tooltip="'Limpar conversa'"
v-tooltip="$t('JASMINE.PLAYGROUND.CLEAR_TOOLTIP')"
icon="i-lucide-trash-2"
faded
slate
@ -176,7 +189,7 @@ const clearChat = () => {
>
<div class="text-center">
<span class="i-lucide-inbox size-16 mb-4 opacity-30" />
<p>Selecione uma inbox acima para começar a testar</p>
<p>{{ $t('JASMINE.PLAYGROUND.NO_INBOX_SELECTED') }}</p>
</div>
</div>
</div>

View File

@ -1,9 +1,9 @@
<template>
<router-view />
</template>
<script>
export default {
name: 'JasmineWrapper',
};
</script>
<template>
<router-view />
</template>

View File

@ -70,10 +70,10 @@ export default {
<div class="settings-section">
<div class="flex flex-col gap-1 items-start mb-4">
<h2 class="text-xl font-medium text-slate-900 dark:text-slate-100">
Jasmine AI Configuration
{{ $t('JASMINE.CONFIG.TITLE') }}
</h2>
<p class="text-sm text-slate-600 dark:text-slate-400">
Configure the AI agent for this inbox.
{{ $t('JASMINE.CONFIG.DESCRIPTION') }}
</p>
</div>
@ -85,7 +85,7 @@ export default {
class="form-checkbox h-5 w-5 text-woot-500 rounded border-gray-300 focus:ring-woot-500"
/>
<span class="text-sm font-medium text-slate-700 dark:text-slate-200">
Enable Jasmine AI Agent
{{ $t('JASMINE.CONFIG.ENABLE') }}
</span>
</label>
</div>
@ -94,21 +94,21 @@ export default {
<label
class="block text-sm font-medium text-slate-700 dark:text-slate-200 mb-2"
>
System Prompt
{{ $t('JASMINE.CONFIG.SYSTEM_PROMPT') }}
</label>
<textarea
v-model="systemPrompt"
rows="6"
class="w-full text-sm rounded-md border-gray-300 dark:border-slate-700 dark:bg-slate-900 focus:border-woot-500 focus:ring-woot-500"
placeholder="You are a helpful SDR agent..."
></textarea>
:placeholder="$t('JASMINE.CONFIG.SYSTEM_PROMPT_HELP')"
/>
<p class="mt-1 text-xs text-slate-500">
Define the persona and behavioral rules for the agent.
{{ $t('JASMINE.CONFIG.SYSTEM_PROMPT_HELP') }}
</p>
</div>
<woot-button :is-loading="isUpdating" @click="updateSettings">
Update Configuration
{{ $t('JASMINE.CONFIG.UPDATE_BUTTON') }}
</woot-button>
<JasmineKnowledgeBase v-if="showKnowledgeBase" :inbox-id="inbox.id" />

View File

@ -34,20 +34,25 @@ export default defineComponent({
return `/api/v1/accounts/${accountId.value}/inboxes/${props.inbox.id}/wuzapi${endpoint}`;
};
const fetchStatus = async () => {
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
async function fetchStatus() {
if (!accountId.value) return;
try {
const response = await window.axios.get(getApiUrl(''));
const data = response.data;
// Wuzapi format: { data: { connected: true, jid: "...", details: "..." } }
const wuzapiData = data.data || {};
const isWuzapiConnected =
wuzapiData.connected === true && !!wuzapiData.jid;
// Also keep legacy check just in case payload differs
const legacyStatus = data.status || data.state;
const isLegacyConnected = ['CONNECTED', 'inChat', 'success'].includes(
legacyStatus
@ -64,13 +69,22 @@ export default defineComponent({
statusMessage.value =
error.response?.data?.error || error.message || 'Check failed';
}
};
}
const fetchQrCode = async () => {
/* eslint-disable no-use-before-define */
function startPolling() {
if (pollInterval) return;
pollInterval = setInterval(async () => {
await fetchStatus();
if (pollInterval && !isConnected.value) {
await fetchQrCode();
}
}, 5000);
}
async function fetchQrCode() {
try {
const response = await window.axios.get(getApiUrl('/qr'));
// Backend now normalizes to 'qrcode' in most cases, but we keep robust checks
const d = response.data;
const qrcodeData =
d.qrcode ||
@ -84,7 +98,6 @@ export default defineComponent({
qrCode.value = qrcodeData;
startPolling();
} else {
// Fallback: maybe we are already connected?
await fetchStatus();
if (!isConnected.value) {
statusMessage.value = 'QR Code not received and not connected.';
@ -94,7 +107,7 @@ export default defineComponent({
statusMessage.value =
error.response?.data?.error || 'Failed to load QR';
}
};
}
const handleConnect = async () => {
if (!accountId.value) {
@ -131,26 +144,6 @@ export default defineComponent({
}
};
// Function hoisting allows use before definition
function stopPolling() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
}
function startPolling() {
if (pollInterval) return;
// Poll every 5 seconds to check status AND refresh QR code
pollInterval = setInterval(async () => {
await fetchStatus();
// If still not connected (and polling hasn't been stopped by fetchStatus), refresh QR
if (pollInterval && !isConnected.value) {
await fetchQrCode();
}
}, 5000);
}
const isLoadingWebhook = ref(false);
const webhookInfo = ref(null);
@ -173,7 +166,7 @@ export default defineComponent({
const response = await window.axios.put(getApiUrl('/update_webhook'));
webhookInfo.value = {
message: response.data.message,
url: response.data.webhook_url
url: response.data.webhook_url,
};
useAlert('Webhook updated successfully');
} catch (error) {
@ -213,8 +206,8 @@ export default defineComponent({
<div class="mx-8 mt-6">
<div class="bg-white p-6 rounded-lg border border-n-weak">
<h3 class="text-lg font-medium text-n-slate-12 mb-4">
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WUZAPI') }} -
{{ $t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_CONFIG') }}
{{ $t('INBOX_MGMT.ADD.WHATSAPP.PROVIDERS.WUZAPI') }}
{{ `- ${$t('INBOX_MGMT.SETTINGS_POPUP.MESSENGER_CONFIG')}` }}
</h3>
<div v-if="accountId" class="flex flex-col items-center">
@ -264,29 +257,29 @@ export default defineComponent({
</div>
<div class="mt-4 text-xs text-n-slate-10">
Status: {{ statusMessage }}
{{ $t('JASMINE.WUZAPI.STATUS', { status: statusMessage }) }}
</div>
</div>
</div>
<div v-else class="text-red-600 p-4">
Error: Account ID not loaded. Please refresh the page.
{{ $t('JASMINE.WUZAPI.ACCOUNT_ERROR') }}
</div>
<div class="mt-8 pt-6 border-t border-n-weak w-full">
<h4 class="text-md font-medium text-n-slate-12 mb-4">
Webhook Configuration
{{ $t('JASMINE.WUZAPI.WEBHOOK_SECTION') }}
</h4>
<div class="flex gap-4 mb-4">
<NextButton
icon="i-woot-refresh"
:is-loading="isLoadingWebhook"
label="Get Webhook Info"
:label="$t('JASMINE.WUZAPI.GET_WEBHOOK_INFO')"
@click="fetchWebhookInfo"
/>
<NextButton
icon="i-woot-upload"
:is-loading="isLoadingWebhook"
label="Update Webhook Connection"
:label="$t('JASMINE.WUZAPI.UPDATE_WEBHOOK')"
@click="updateWebhook"
/>
</div>

View File

@ -101,11 +101,13 @@ export default {
}
},
async deleteDocument(collectionId, documentId) {
if (!confirm('Are you sure you want to delete this document?')) return;
// eslint-disable-next-line no-alert
if (!window.confirm(this.$t('JASMINE.KNOWLEDGE_BASE.DELETE_CONFIRM')))
return;
this.isDeletingDocument = documentId;
try {
await JasmineAPI.deleteDocument(collectionId, documentId);
useAlert('Document deleted successfully');
useAlert(this.$t('JASMINE.KNOWLEDGE_BASE.DOCUMENT_DELETE_SUCCESS'));
this.fetchDocuments(collectionId);
} catch (error) {
useAlert('Failed to delete document');
@ -143,14 +145,14 @@ export default {
<div class="flex justify-between items-center mb-6">
<div>
<h3 class="text-lg font-semibold text-slate-900 dark:text-slate-100">
Knowledge Base
{{ $t('JASMINE.KNOWLEDGE_BASE.TITLE') }}
</h3>
<p class="text-sm text-slate-500 dark:text-slate-400">
Manage knowledge collections for this inbox
{{ $t('JASMINE.KNOWLEDGE_BASE.DESCRIPTION') }}
</p>
</div>
<woot-button size="small" @click="showCreateCollectionModal = true">
+ New Collection
{{ $t('JASMINE.KNOWLEDGE_BASE.ADD_BUTTON') }}
</woot-button>
</div>
@ -173,8 +175,8 @@ export default {
>
<div class="flex items-center gap-3">
<span
class="i-lucide-chevron-right size-4 transition-transform text-slate-400"
:class="[
'i-lucide-chevron-right size-4 transition-transform text-slate-400',
expandedCollectionId === collection.id ? 'rotate-90' : '',
]"
/>
@ -197,7 +199,7 @@ export default {
class="border-t border-slate-100 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/30 p-4"
>
<h5 class="text-xs font-semibold uppercase text-slate-500 mb-3">
Documents
{{ $t('JASMINE.KNOWLEDGE_BASE.DOCUMENTS') }}
</h5>
<!-- Loading Documents -->
@ -206,7 +208,7 @@ export default {
class="flex items-center gap-2 text-sm text-slate-400 py-2"
>
<span class="i-lucide-loader-2 size-4 animate-spin" />
Loading documents...
{{ $t('JASMINE.KNOWLEDGE_BASE.LOADING_DOCS') }}
</div>
<!-- Documents List -->
@ -224,7 +226,7 @@ export default {
<p
class="font-medium text-sm text-slate-800 dark:text-slate-200 truncate"
>
{{ doc.title || 'Untitled Document' }}
{{ doc.title || $t('JASMINE.KNOWLEDGE_BASE.UNTITLED_DOC') }}
</p>
<p class="text-xs text-slate-400 truncate">
{{ new Date(doc.created_at).toLocaleDateString() }}
@ -234,10 +236,8 @@ export default {
<div class="flex items-center gap-3 shrink-0">
<!-- Status Badge -->
<span
:class="[
'inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full',
getStatusClass(doc.status),
]"
class="inline-flex items-center gap-1 px-2 py-0.5 text-xs font-medium rounded-full"
:class="[getStatusClass(doc.status)]"
>
<span
v-if="isProcessing(doc.status)"
@ -264,7 +264,7 @@ export default {
v-if="documents.length === 0"
class="text-center py-6 text-sm text-slate-400"
>
No documents yet. Add your first document below.
{{ $t('JASMINE.KNOWLEDGE_BASE.NO_DOCS') }}
</div>
</div>
@ -273,19 +273,21 @@ export default {
class="border-t border-slate-200 dark:border-slate-700 pt-4 mt-4"
>
<h6 class="text-xs font-semibold uppercase text-slate-500 mb-3">
Add New Document
{{ $t('JASMINE.KNOWLEDGE_BASE.ADD_DOC_HEADER') }}
</h6>
<input
v-model="newDocTitle"
type="text"
class="w-full mb-2 px-3 py-2 text-sm rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900"
placeholder="Document title (optional)"
:placeholder="$t('JASMINE.KNOWLEDGE_BASE.DOC_TITLE_PLACEHOLDER')"
/>
<textarea
v-model="newDocContent"
rows="4"
class="w-full mb-3 px-3 py-2 text-sm rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 resize-none"
placeholder="Paste or type your knowledge content here..."
:placeholder="
$t('JASMINE.KNOWLEDGE_BASE.DOC_CONTENT_PLACEHOLDER')
"
/>
<div class="flex justify-end">
<woot-button
@ -294,7 +296,7 @@ export default {
:disabled="!newDocContent.trim()"
@click="addDocument(collection.id)"
>
Add Document
{{ $t('JASMINE.KNOWLEDGE_BASE.ADD_DOC_BUTTON') }}
</woot-button>
</div>
</div>
@ -307,7 +309,7 @@ export default {
class="text-center py-12 text-slate-400"
>
<span class="i-lucide-folder-open size-12 mx-auto mb-3 opacity-50" />
<p class="text-sm">No collections yet. Create one to get started.</p>
<p class="text-sm">{{ $t('JASMINE.KNOWLEDGE_BASE.NO_COLLECTIONS') }}</p>
</div>
</div>
@ -319,34 +321,40 @@ export default {
>
<div class="bg-white dark:bg-slate-900 p-6 rounded-xl w-96 shadow-2xl">
<h3 class="text-lg font-semibold mb-4 text-slate-900 dark:text-white">
Create Collection
{{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.TITLE') }}
</h3>
<input
v-model="newCollectionName"
type="text"
class="w-full mb-4 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
placeholder="Collection name"
:placeholder="
$t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.NAME_PLACEHOLDER')
"
@keyup.enter="createCollection"
/>
<select
v-model="newCollectionVisibility"
class="w-full mb-4 px-3 py-2 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800"
>
<option value="private">Private (This inbox only)</option>
<option value="shared">Shared (All inboxes)</option>
<option value="private">
{{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.VISIBILITY_PRIVATE') }}
</option>
<option value="shared">
{{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.VISIBILITY_SHARED') }}
</option>
</select>
<div class="flex justify-end gap-2">
<woot-button
variant="clear"
@click="showCreateCollectionModal = false"
>
Cancel
{{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.CANCEL') }}
</woot-button>
<woot-button
:disabled="!newCollectionName.trim()"
@click="createCollection"
>
Create
{{ $t('JASMINE.KNOWLEDGE_BASE.CREATE_MODAL.CREATE') }}
</woot-button>
</div>
</div>

View File

@ -77,7 +77,7 @@ class Inbox < ApplicationRecord
has_one :agent_bot, through: :agent_bot_inbox
has_many :webhooks, dependent: :destroy_async
has_many :hooks, dependent: :destroy_async, class_name: 'Integrations::Hook'
has_many :inbox_automations, dependent: :destroy_async, class_name: 'Captain::InboxAutomation'
has_many :inbox_automations, dependent: :destroy_async, class_name: '::Captain::InboxAutomation'
# Jasmine
has_one :jasmine_inbox_config, class_name: 'Jasmine::InboxConfig', dependent: :destroy

View File

@ -12,7 +12,8 @@ Rails.application.config.to_prepare do
def ensure_content_presence_defensive
# If content is present, or we have attachments, we are good.
return if content.present? || attachments.any?
# We check .any? and .size to be robust against unsaved attachments in some contexts.
return if content.present? || attachments.any? || attachments.size > 0 || attachments.to_a.any?
# Identifica a origem para um fallback mais inteligente
if incoming?

View File

@ -5,9 +5,9 @@ module Enterprise::Concerns::Inbox
has_one :captain_inbox, dependent: :destroy, class_name: 'CaptainInbox'
has_one :captain_assistant,
through: :captain_inbox,
class_name: 'Captain::Assistant'
has_one :captain_inbox_reminder_setting, dependent: :destroy, class_name: 'Captain::InboxReminderSetting'
has_many :captain_inbox_automations, dependent: :destroy, class_name: 'Captain::InboxAutomation'
class_name: '::Captain::Assistant'
has_one :captain_inbox_reminder_setting, dependent: :destroy, class_name: '::Captain::InboxReminderSetting'
has_many :captain_inbox_automations, dependent: :destroy, class_name: '::Captain::InboxAutomation'
has_many :inbox_capacity_limits, dependent: :destroy
end
end

1
eslint_report.json Normal file

File diff suppressed because one or more lines are too long

1
eslint_report_v2.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,38 @@
# Nota de Resolução: Correção Global de Lint e Qualidade (Maio 2025)
## Objetivo
Resolver mais de 100 erros de linting (Frontend e Backend) e estabelecer padrões de qualidade para os módulos Jasmine e Wuzapi.
## Contexto
O projeto apresentava débitos técnicos acumulados nas novas rotas de IA (Jasmine), incluindo falta de internacionalização, erros de sintaxe no Vue (hoisting/circular dependencies) e riscos de segurança no backend Ruby.
## Passos Realizados
1. **Frontend (Vue/ESLint)**:
- Criação do sistema de i18n para Jasmine em `en/jasmine.json`.
- Refatoração completa de 5 componentes (`JasmineInboxes`, `JasminePlayground`, `JasmineConfiguration`, `JasmineKnowledgeBase`, `WuzapiConfiguration`).
- Resolução de erros de Prettier e sintaxe.
2. **Backend (Ruby/RuboCop)**:
- Criação de `.rubocop_todo.yml` para congelar débitos antigos.
- Refatoração de controllers e remoção de credenciais hardcoded.
- Identificação de vulnerabilidade crítica de SSL em `lib/wuzapi/client.rb`.
## Arquivos Principais Alterados
- `app/javascript/dashboard/i18n/locale/en/jasmine.json` (Novo sistema i18n)
- `app/javascript/dashboard/routes/dashboard/settings/inbox/channels/wuzapi/WuzapiConfiguration.vue` (Fix Hosting)
- `lib/wuzapi/client.rb` (Identificado risco SSL)
- `.rubocop_todo.yml` (Gestão de débito técnico)
## Variáveis de Ambiente
- `DEFAULT_JASMINE_DISTANCE_THRESHOLD`: Configura limite de busca RAG (Padrão: 0.35).
- `JASMINE_LLM_MODEL`: Define modelo de IA (Padrão: gpt-4o-mini).
## Como Validar ou Reverter
- **Validar Frontend**: Executar `npx eslint --ext .js,.vue [arquivos]`. Resultado esperado: 0 erros.
- **Validar Backend**: Executar `bundle exec rubocop`. Resultado esperado: Sucesso (via todo).
- **Reverter**: `git checkout` nos arquivos mencionados.

View File

@ -129,7 +129,7 @@ has been assigned to you"
it 'returns appropriate body suited for the notification type assigned_conversation_new_message when attachment message' do
conversation = create(:conversation)
message = create(:message, sender: create(:user), content: nil, conversation: conversation)
message = build(:message, sender: create(:user), content: nil, conversation: conversation)
attachment = message.attachments.new(file_type: :image, account_id: message.account_id)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
message.save!

View File

@ -41,7 +41,7 @@ end
RSpec.configure do |config|
config.include FactoryBot::Syntax::Methods
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
config.fixture_path = Rails.root.join('spec/fixtures')
config.fixture_paths = [Rails.root.join('spec/fixtures')]
# If you're not using ActiveRecord, or you'd prefer not to run each of your
# examples within a transaction, remove the following line or assign false