feat(captain/hermes-builder): aba Verificação com 22+ checks + reparo automático
UI nova dentro do Construtor (Hermes) — TabBar com Chat e Verificação. Verificação roda HermesBuilder::Validator (DB+runtime) e exibe resultado agrupado por categoria, com botão Refazer inline em FAIL/WARN reparáveis. Backend (porta dos checks DB do CLI bin/hermes-validate): - HermesBuilder::Validator com 22+ checks: engine, profile, port, secret, parent, unit, Brand, CaptainInbox sync (o bug que travou Juliana), pricing dry-run, Inter creds, typing/response_delay, registry MCP completo. - HermesBuilder::Repairer com 4 handlers automáticos: set_engine_hermes, sync_captain_inbox_unit, set_default_typing_delay, set_default_response_delay. - Endpoints novos: GET assistants, GET validate?slug=, POST repair. Frontend: - builder/Index.vue: wrapper com TabBar. - builder/BuilderChat.vue: extraído do Index original. - builder/BuilderVerification.vue: dropdown + Conferir agora + lista agrupada por categoria com badges + botão Refazer inline. i18n: keys em pt_BR e en sob CAPTAIN_HERMES_BUILDER.VERIFY.*. Filesystem/systemd checks ficam pro CLI hermes-validate (Rails container não enxerga /root/.hermes/profiles do host). Validado HTTP: GET /validate?slug=juliana_qnn1 → 28 PASS / 0 FAIL / 1 WARN. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
47f32b540b
commit
28e880d7b6
@ -21,6 +21,18 @@ class HermesBuilder extends ApiClient {
|
|||||||
reset() {
|
reset() {
|
||||||
return axios.delete(`${this.url}/reset`);
|
return axios.delete(`${this.url}/reset`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fetchAssistants() {
|
||||||
|
return axios.get(`${this.url}/assistants`);
|
||||||
|
}
|
||||||
|
|
||||||
|
validate(slug) {
|
||||||
|
return axios.get(`${this.url}/validate`, { params: { slug } });
|
||||||
|
}
|
||||||
|
|
||||||
|
repair(slug, repairId) {
|
||||||
|
return axios.post(`${this.url}/repair`, { slug, repair_id: repairId });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new HermesBuilder();
|
export default new HermesBuilder();
|
||||||
|
|||||||
@ -905,6 +905,35 @@
|
|||||||
"SESSION_LABEL": "Session:",
|
"SESSION_LABEL": "Session:",
|
||||||
"SEND_FAILED": "Send failed: {message}",
|
"SEND_FAILED": "Send failed: {message}",
|
||||||
"RESET_FAILED": "Failed to clear session.",
|
"RESET_FAILED": "Failed to clear session.",
|
||||||
"START": "Start creation"
|
"START": "Start creation",
|
||||||
|
"TAB_CHAT": "Chat (Builder)",
|
||||||
|
"TAB_VERIFY": "Verification",
|
||||||
|
"VERIFY": {
|
||||||
|
"TITLE": "Agent verification",
|
||||||
|
"DESCRIPTION": "Runs health checks (database, routing, pricing, MCP) for a Hermes agent. For each failure with a Repair button, the UI attempts an automatic fix. Other failures need hermes-provision on the VPS.",
|
||||||
|
"NO_ASSISTANTS": "No Hermes agents registered",
|
||||||
|
"RUN": "Run check",
|
||||||
|
"RUNNING": "Checking...",
|
||||||
|
"REPAIR": "Repair",
|
||||||
|
"REPAIRING": "Repairing...",
|
||||||
|
"OK_LABEL": "OK",
|
||||||
|
"FAILS_LABEL": "failures",
|
||||||
|
"WARN_LABEL": "warnings",
|
||||||
|
"OF_TOTAL": "of {total} checks",
|
||||||
|
"VERDICT_PASS": "Ready to ship",
|
||||||
|
"VERDICT_FAIL": "Critical failures — fix first",
|
||||||
|
"EMPTY": "Select an agent and click Run check to start verification.",
|
||||||
|
"EMPTY_RESULTS": "No checks returned — agent removed?",
|
||||||
|
"REPAIR_FAILED": "Failed: {message}",
|
||||||
|
"REPAIR_OK": "Repaired: {message}",
|
||||||
|
"FETCH_FAILED": "Error loading assistants: {message}",
|
||||||
|
"VALIDATE_FAILED": "Validation failed: {message}",
|
||||||
|
"CATEGORY_DB": "Database",
|
||||||
|
"CATEGORY_PRICING": "Pricing",
|
||||||
|
"CATEGORY_ROUTING": "Captain → Hermes routing",
|
||||||
|
"CATEGORY_HUMANIZATION": "Humanization (typing/delay/gallery)",
|
||||||
|
"CATEGORY_MCP": "Registered MCP tools",
|
||||||
|
"CATEGORY_OTHER": "Other"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -906,6 +906,35 @@
|
|||||||
"SESSION_LABEL": "Sessão:",
|
"SESSION_LABEL": "Sessão:",
|
||||||
"SEND_FAILED": "Erro ao enviar: {message}",
|
"SEND_FAILED": "Erro ao enviar: {message}",
|
||||||
"RESET_FAILED": "Falha ao limpar sessão.",
|
"RESET_FAILED": "Falha ao limpar sessão.",
|
||||||
"START": "Iniciar criação"
|
"START": "Iniciar criação",
|
||||||
|
"TAB_CHAT": "Chat (Construtor)",
|
||||||
|
"TAB_VERIFY": "Verificação",
|
||||||
|
"VERIFY": {
|
||||||
|
"TITLE": "Verificação de agente",
|
||||||
|
"DESCRIPTION": "Roda os checks de saúde (banco, roteamento, preços, MCP) de um agente Hermes. Para cada falha com botão Refazer, a UI tenta corrigir automaticamente. Demais falhas precisam de hermes-provision na VPS.",
|
||||||
|
"NO_ASSISTANTS": "Nenhum agente Hermes cadastrado",
|
||||||
|
"RUN": "Conferir agora",
|
||||||
|
"RUNNING": "Conferindo...",
|
||||||
|
"REPAIR": "Refazer",
|
||||||
|
"REPAIRING": "Reparando...",
|
||||||
|
"OK_LABEL": "OK",
|
||||||
|
"FAILS_LABEL": "falhas",
|
||||||
|
"WARN_LABEL": "atenção",
|
||||||
|
"OF_TOTAL": "de {total} checks",
|
||||||
|
"VERDICT_PASS": "Pode soltar",
|
||||||
|
"VERDICT_FAIL": "Há falhas críticas — corrija antes",
|
||||||
|
"EMPTY": "Selecione um agente e clique em Conferir agora pra rodar a verificação.",
|
||||||
|
"EMPTY_RESULTS": "Sem checks retornados — o agente foi removido?",
|
||||||
|
"REPAIR_FAILED": "Falha: {message}",
|
||||||
|
"REPAIR_OK": "Reparado: {message}",
|
||||||
|
"FETCH_FAILED": "Erro carregando assistentes: {message}",
|
||||||
|
"VALIDATE_FAILED": "Falha ao validar: {message}",
|
||||||
|
"CATEGORY_DB": "Banco de dados",
|
||||||
|
"CATEGORY_PRICING": "Preços",
|
||||||
|
"CATEGORY_ROUTING": "Roteamento Captain → Hermes",
|
||||||
|
"CATEGORY_HUMANIZATION": "Humanização (typing/delay/galeria)",
|
||||||
|
"CATEGORY_MCP": "Tools MCP registradas",
|
||||||
|
"CATEGORY_OTHER": "Outros"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -0,0 +1,362 @@
|
|||||||
|
<script setup>
|
||||||
|
import {
|
||||||
|
ref,
|
||||||
|
computed,
|
||||||
|
onMounted,
|
||||||
|
onBeforeUnmount,
|
||||||
|
nextTick,
|
||||||
|
watch,
|
||||||
|
} from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import hermesBuilderApi from 'dashboard/api/captain/hermesBuilder';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const messages = ref([]);
|
||||||
|
const input = ref('');
|
||||||
|
const sending = ref(false);
|
||||||
|
const polling = ref(null);
|
||||||
|
const scrollContainer = ref(null);
|
||||||
|
const sessionId = ref(null);
|
||||||
|
|
||||||
|
const lastMessageRole = computed(() => messages.value.at(-1)?.role || null);
|
||||||
|
const isWaiting = computed(
|
||||||
|
() => sending.value || lastMessageRole.value === 'user'
|
||||||
|
);
|
||||||
|
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
const el = scrollContainer.value;
|
||||||
|
if (el) el.scrollTop = el.scrollHeight;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchMessages = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await hermesBuilderApi.fetchMessages();
|
||||||
|
messages.value = data.messages || [];
|
||||||
|
sessionId.value = data.session_id;
|
||||||
|
await nextTick();
|
||||||
|
scrollToBottom();
|
||||||
|
} catch (e) {
|
||||||
|
// silencioso — polling repete
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendMessage = async () => {
|
||||||
|
const text = input.value.trim();
|
||||||
|
if (!text || sending.value) return;
|
||||||
|
sending.value = true;
|
||||||
|
messages.value.push({
|
||||||
|
role: 'user',
|
||||||
|
content: text,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
input.value = '';
|
||||||
|
await nextTick();
|
||||||
|
scrollToBottom();
|
||||||
|
try {
|
||||||
|
await hermesBuilderApi.sendMessage(text);
|
||||||
|
} catch (e) {
|
||||||
|
useAlert(
|
||||||
|
t('CAPTAIN_HERMES_BUILDER.SEND_FAILED', {
|
||||||
|
message: e.response?.data?.error || e.message || 'unknown',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
sending.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeydown = e => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
sendMessage();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetSession = async () => {
|
||||||
|
// eslint-disable-next-line no-alert
|
||||||
|
if (!window.confirm(t('CAPTAIN_HERMES_BUILDER.RESET_CONFIRM'))) return;
|
||||||
|
try {
|
||||||
|
await hermesBuilderApi.reset();
|
||||||
|
messages.value = [];
|
||||||
|
} catch (e) {
|
||||||
|
useAlert(t('CAPTAIN_HERMES_BUILDER.RESET_FAILED'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startSession = async () => {
|
||||||
|
if (sending.value) return;
|
||||||
|
sending.value = true;
|
||||||
|
try {
|
||||||
|
await hermesBuilderApi.start();
|
||||||
|
} catch (e) {
|
||||||
|
useAlert(
|
||||||
|
t('CAPTAIN_HERMES_BUILDER.SEND_FAILED', {
|
||||||
|
message: e.response?.data?.error || e.message || 'unknown',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
sending.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = iso => {
|
||||||
|
if (!iso) return '';
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchMessages();
|
||||||
|
polling.value = setInterval(fetchMessages, 2000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (polling.value) clearInterval(polling.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(messages, () => nextTick().then(scrollToBottom), { deep: true });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="builder-wrapper">
|
||||||
|
<header class="builder-header">
|
||||||
|
<div>
|
||||||
|
<h2>{{ t('CAPTAIN_HERMES_BUILDER.HEADER_TITLE') }}</h2>
|
||||||
|
<p>{{ t('CAPTAIN_HERMES_BUILDER.HEADER_DESCRIPTION') }}</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="ghost" size="sm" @click="resetSession">
|
||||||
|
{{ t('CAPTAIN_HERMES_BUILDER.RESET') }}
|
||||||
|
</Button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section ref="scrollContainer" class="messages">
|
||||||
|
<div v-if="!messages.length" class="empty-state">
|
||||||
|
<p>{{ t('CAPTAIN_HERMES_BUILDER.EMPTY_STATE') }}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="start-button"
|
||||||
|
:disabled="sending"
|
||||||
|
@click="startSession"
|
||||||
|
>
|
||||||
|
{{ t('CAPTAIN_HERMES_BUILDER.START') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-for="(msg, idx) in messages"
|
||||||
|
:key="idx"
|
||||||
|
class="msg"
|
||||||
|
:class="[`msg--${msg.role}`]"
|
||||||
|
>
|
||||||
|
<div class="msg__bubble">
|
||||||
|
<div class="msg__content">{{ msg.content }}</div>
|
||||||
|
<div class="msg__meta">{{ formatTime(msg.created_at) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="isWaiting" class="msg msg--construtor">
|
||||||
|
<div class="msg__bubble msg__bubble--typing">
|
||||||
|
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<footer class="composer">
|
||||||
|
<textarea
|
||||||
|
v-model="input"
|
||||||
|
rows="2"
|
||||||
|
:placeholder="t('CAPTAIN_HERMES_BUILDER.PLACEHOLDER')"
|
||||||
|
:disabled="sending"
|
||||||
|
@keydown="handleKeydown"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
:disabled="!input.trim() || sending"
|
||||||
|
@click="sendMessage"
|
||||||
|
>
|
||||||
|
{{ t('CAPTAIN_HERMES_BUILDER.SEND') }}
|
||||||
|
</Button>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<p v-if="sessionId" class="session-debug">
|
||||||
|
{{ t('CAPTAIN_HERMES_BUILDER.SESSION_LABEL') }} {{ sessionId }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.builder-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
height: calc(100vh - 260px);
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: var(--color-background-light, #f7f8fa);
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-light, #6b7280);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--color-background, #fff);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
margin: auto;
|
||||||
|
color: var(--color-text-light, #9ca3af);
|
||||||
|
font-size: 14px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.start-button {
|
||||||
|
background: var(--color-woot-500, #1f93ff);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 24px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--color-woot-600, #1976d2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg {
|
||||||
|
display: flex;
|
||||||
|
|
||||||
|
&--user {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--construtor {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg__bubble {
|
||||||
|
max-width: 70%;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--color-background-light, #f3f4f6);
|
||||||
|
font-size: 14px;
|
||||||
|
|
||||||
|
.msg--user & {
|
||||||
|
background: var(--color-woot-500, #1f93ff);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg__content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg__meta {
|
||||||
|
font-size: 11px;
|
||||||
|
margin-top: 4px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.msg__bubble--typing {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-text-light, #6b7280);
|
||||||
|
animation: typing 1.4s infinite ease-in-out;
|
||||||
|
|
||||||
|
&:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
&:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes typing {
|
||||||
|
0%,
|
||||||
|
60%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.3;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
30% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.composer {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
background: var(--color-background, #fff);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
flex: 1;
|
||||||
|
border: none;
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
font: inherit;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-debug {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-light, #9ca3af);
|
||||||
|
text-align: right;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -0,0 +1,443 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import hermesBuilderApi from 'dashboard/api/captain/hermesBuilder';
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const assistants = ref([]);
|
||||||
|
const selectedSlug = ref('');
|
||||||
|
const checks = ref([]);
|
||||||
|
const summary = ref(null);
|
||||||
|
const loading = ref(false);
|
||||||
|
const repairing = ref({});
|
||||||
|
|
||||||
|
const groupedChecks = computed(() => {
|
||||||
|
const groups = {};
|
||||||
|
checks.value.forEach(c => {
|
||||||
|
const cat = c.category || 'outros';
|
||||||
|
if (!groups[cat]) groups[cat] = [];
|
||||||
|
groups[cat].push(c);
|
||||||
|
});
|
||||||
|
return groups;
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoryLabel = cat => {
|
||||||
|
const map = {
|
||||||
|
db: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_DB',
|
||||||
|
pricing: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_PRICING',
|
||||||
|
routing: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_ROUTING',
|
||||||
|
humanization: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_HUMANIZATION',
|
||||||
|
mcp: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_MCP',
|
||||||
|
};
|
||||||
|
return t(map[cat] || 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_OTHER');
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchAssistants = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await hermesBuilderApi.fetchAssistants();
|
||||||
|
assistants.value = data.assistants || [];
|
||||||
|
if (assistants.value.length && !selectedSlug.value) {
|
||||||
|
selectedSlug.value = assistants.value[0].slug;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
useAlert(
|
||||||
|
t('CAPTAIN_HERMES_BUILDER.VERIFY.FETCH_FAILED', {
|
||||||
|
message: e.message || 'unknown',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runValidation = async () => {
|
||||||
|
if (!selectedSlug.value || loading.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
checks.value = [];
|
||||||
|
summary.value = null;
|
||||||
|
try {
|
||||||
|
const { data } = await hermesBuilderApi.validate(selectedSlug.value);
|
||||||
|
checks.value = data.results || [];
|
||||||
|
summary.value = data;
|
||||||
|
} catch (e) {
|
||||||
|
useAlert(
|
||||||
|
t('CAPTAIN_HERMES_BUILDER.VERIFY.VALIDATE_FAILED', {
|
||||||
|
message: e.response?.data?.error || e.message || 'unknown',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const runRepair = async check => {
|
||||||
|
if (!check.repair_id) return;
|
||||||
|
repairing.value[check.repair_id] = true;
|
||||||
|
try {
|
||||||
|
const { data } = await hermesBuilderApi.repair(
|
||||||
|
selectedSlug.value,
|
||||||
|
check.repair_id
|
||||||
|
);
|
||||||
|
if (data.ok) {
|
||||||
|
useAlert(
|
||||||
|
t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR_OK', {
|
||||||
|
message: data.message || 'OK',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await runValidation();
|
||||||
|
} else {
|
||||||
|
useAlert(
|
||||||
|
t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR_FAILED', {
|
||||||
|
message: data.error || 'unknown',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
useAlert(
|
||||||
|
t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR_FAILED', {
|
||||||
|
message: e.response?.data?.error || e.message || 'unknown',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
repairing.value[check.repair_id] = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusIcon = status => {
|
||||||
|
if (status === 'PASS') return '✓';
|
||||||
|
if (status === 'FAIL') return '✗';
|
||||||
|
if (status === 'WARN') return '⚠';
|
||||||
|
return '?';
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusClass = status => {
|
||||||
|
if (status === 'PASS') return 'badge--pass';
|
||||||
|
if (status === 'FAIL') return 'badge--fail';
|
||||||
|
if (status === 'WARN') return 'badge--warn';
|
||||||
|
return 'badge--unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchAssistants();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="verification-wrapper">
|
||||||
|
<header class="verification-header">
|
||||||
|
<h2>{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.TITLE') }}</h2>
|
||||||
|
<p>{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.DESCRIPTION') }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="controls">
|
||||||
|
<select
|
||||||
|
v-model="selectedSlug"
|
||||||
|
class="select"
|
||||||
|
:disabled="!assistants.length || loading"
|
||||||
|
>
|
||||||
|
<option v-if="!assistants.length" value="">
|
||||||
|
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.NO_ASSISTANTS') }}
|
||||||
|
</option>
|
||||||
|
<option v-for="a in assistants" :key="a.id" :value="a.slug">
|
||||||
|
{{ a.name }} — {{ a.slug }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
:disabled="!selectedSlug || loading"
|
||||||
|
@click="runValidation"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
loading
|
||||||
|
? t('CAPTAIN_HERMES_BUILDER.VERIFY.RUNNING')
|
||||||
|
: t('CAPTAIN_HERMES_BUILDER.VERIFY.RUN')
|
||||||
|
}}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="summary" class="summary">
|
||||||
|
<span class="summary__item summary__item--pass">
|
||||||
|
{{ summary.pass }} {{ t('CAPTAIN_HERMES_BUILDER.VERIFY.OK_LABEL') }}
|
||||||
|
</span>
|
||||||
|
<span v-if="summary.fail" class="summary__item summary__item--fail">
|
||||||
|
{{ summary.fail }}
|
||||||
|
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.FAILS_LABEL') }}
|
||||||
|
</span>
|
||||||
|
<span v-if="summary.warn" class="summary__item summary__item--warn">
|
||||||
|
{{ summary.warn }} {{ t('CAPTAIN_HERMES_BUILDER.VERIFY.WARN_LABEL') }}
|
||||||
|
</span>
|
||||||
|
<span class="summary__total">
|
||||||
|
{{
|
||||||
|
t('CAPTAIN_HERMES_BUILDER.VERIFY.OF_TOTAL', { total: summary.total })
|
||||||
|
}}
|
||||||
|
</span>
|
||||||
|
<span v-if="summary.ok" class="summary__verdict summary__verdict--pass">
|
||||||
|
✅ {{ t('CAPTAIN_HERMES_BUILDER.VERIFY.VERDICT_PASS') }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="summary__verdict summary__verdict--fail">
|
||||||
|
❌ {{ t('CAPTAIN_HERMES_BUILDER.VERIFY.VERDICT_FAIL') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="checks.length" class="checks-section">
|
||||||
|
<div v-for="(items, cat) in groupedChecks" :key="cat" class="check-group">
|
||||||
|
<h3 class="check-group__title">
|
||||||
|
{{ categoryLabel(cat) }}
|
||||||
|
</h3>
|
||||||
|
<ul class="check-list">
|
||||||
|
<li
|
||||||
|
v-for="(check, idx) in items"
|
||||||
|
:key="idx"
|
||||||
|
class="check-item"
|
||||||
|
:class="`check-item--${check.status.toLowerCase()}`"
|
||||||
|
>
|
||||||
|
<span class="check-item__badge" :class="statusClass(check.status)">
|
||||||
|
{{ statusIcon(check.status) }}
|
||||||
|
</span>
|
||||||
|
<div class="check-item__body">
|
||||||
|
<div class="check-item__label">{{ check.label }}</div>
|
||||||
|
<div v-if="check.detail" class="check-item__detail">
|
||||||
|
{{ check.detail }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
v-if="
|
||||||
|
check.repair_id &&
|
||||||
|
(check.status === 'FAIL' || check.status === 'WARN')
|
||||||
|
"
|
||||||
|
type="button"
|
||||||
|
class="repair-btn"
|
||||||
|
:disabled="repairing[check.repair_id]"
|
||||||
|
@click="runRepair(check)"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
repairing[check.repair_id]
|
||||||
|
? t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIRING')
|
||||||
|
: t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR')
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<p v-else-if="!loading && summary" class="empty-state">
|
||||||
|
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.EMPTY_RESULTS') }}
|
||||||
|
</p>
|
||||||
|
<p v-else-if="!loading" class="empty-state">
|
||||||
|
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.EMPTY') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.verification-wrapper {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
height: calc(100vh - 260px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.verification-header {
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: var(--color-background-light, #f7f8fa);
|
||||||
|
border-radius: 12px;
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--color-text-light, #6b7280);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.select {
|
||||||
|
flex: 1;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
background: var(--color-background, #fff);
|
||||||
|
font-size: 14px;
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--color-woot-500, #1f93ff);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--color-background, #fff);
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
font-size: 13px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&__item {
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&--pass {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
&--fail {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
&--warn {
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__total {
|
||||||
|
color: var(--color-text-light, #6b7280);
|
||||||
|
}
|
||||||
|
|
||||||
|
&__verdict {
|
||||||
|
margin-left: auto;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&--pass {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
&--fail {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checks-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-group {
|
||||||
|
background: var(--color-background, #fff);
|
||||||
|
border: 1px solid var(--color-border, #e5e7eb);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
|
||||||
|
&__title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-light, #6b7280);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
&--fail {
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
&--warn {
|
||||||
|
background: #fffbeb;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-item__badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
&.badge--pass {
|
||||||
|
background: #16a34a;
|
||||||
|
}
|
||||||
|
&.badge--fail {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
&.badge--warn {
|
||||||
|
background: #d97706;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-item__body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-item__label {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.check-item__detail {
|
||||||
|
margin-top: 2px;
|
||||||
|
color: var(--color-text-light, #6b7280);
|
||||||
|
font-size: 12px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repair-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid var(--color-woot-500, #1f93ff);
|
||||||
|
background: var(--color-background, #fff);
|
||||||
|
color: var(--color-woot-500, #1f93ff);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
background: var(--color-woot-500, #1f93ff);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--color-text-light, #9ca3af);
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 32px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,127 +1,23 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import { ref, computed } from 'vue';
|
||||||
ref,
|
|
||||||
computed,
|
|
||||||
onMounted,
|
|
||||||
onBeforeUnmount,
|
|
||||||
nextTick,
|
|
||||||
watch,
|
|
||||||
} from 'vue';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { useAlert } from 'dashboard/composables';
|
|
||||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||||
import hermesBuilderApi from 'dashboard/api/captain/hermesBuilder';
|
import BuilderChat from './BuilderChat.vue';
|
||||||
|
import BuilderVerification from './BuilderVerification.vue';
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const messages = ref([]);
|
const tabs = computed(() => [
|
||||||
const input = ref('');
|
{ label: t('CAPTAIN_HERMES_BUILDER.TAB_CHAT'), key: 'chat' },
|
||||||
const sending = ref(false);
|
{ label: t('CAPTAIN_HERMES_BUILDER.TAB_VERIFY'), key: 'verification' },
|
||||||
const polling = ref(null);
|
]);
|
||||||
const scrollContainer = ref(null);
|
|
||||||
const sessionId = ref(null);
|
|
||||||
|
|
||||||
const lastMessageRole = computed(() => messages.value.at(-1)?.role || null);
|
const activeIndex = ref(0);
|
||||||
const isWaiting = computed(
|
|
||||||
() => sending.value || lastMessageRole.value === 'user'
|
|
||||||
);
|
|
||||||
|
|
||||||
const scrollToBottom = () => {
|
const handleTabChanged = tab => {
|
||||||
const el = scrollContainer.value;
|
activeIndex.value = tabs.value.findIndex(item => item.key === tab.key);
|
||||||
if (el) el.scrollTop = el.scrollHeight;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchMessages = async () => {
|
|
||||||
try {
|
|
||||||
const { data } = await hermesBuilderApi.fetchMessages();
|
|
||||||
messages.value = data.messages || [];
|
|
||||||
sessionId.value = data.session_id;
|
|
||||||
await nextTick();
|
|
||||||
scrollToBottom();
|
|
||||||
} catch (e) {
|
|
||||||
// silencioso — polling repete
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendMessage = async () => {
|
|
||||||
const text = input.value.trim();
|
|
||||||
if (!text || sending.value) return;
|
|
||||||
sending.value = true;
|
|
||||||
// optimistic UI
|
|
||||||
messages.value.push({
|
|
||||||
role: 'user',
|
|
||||||
content: text,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
input.value = '';
|
|
||||||
await nextTick();
|
|
||||||
scrollToBottom();
|
|
||||||
try {
|
|
||||||
await hermesBuilderApi.sendMessage(text);
|
|
||||||
} catch (e) {
|
|
||||||
useAlert(
|
|
||||||
t('CAPTAIN_HERMES_BUILDER.SEND_FAILED', {
|
|
||||||
message: e.response?.data?.error || e.message || 'unknown',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
sending.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleKeydown = e => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
|
||||||
e.preventDefault();
|
|
||||||
sendMessage();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetSession = async () => {
|
|
||||||
// eslint-disable-next-line no-alert
|
|
||||||
if (!window.confirm(t('CAPTAIN_HERMES_BUILDER.RESET_CONFIRM'))) return;
|
|
||||||
try {
|
|
||||||
await hermesBuilderApi.reset();
|
|
||||||
messages.value = [];
|
|
||||||
} catch (e) {
|
|
||||||
useAlert(t('CAPTAIN_HERMES_BUILDER.RESET_FAILED'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const startSession = async () => {
|
|
||||||
if (sending.value) return;
|
|
||||||
sending.value = true;
|
|
||||||
try {
|
|
||||||
await hermesBuilderApi.start();
|
|
||||||
// Construtor vai responder com primeira pergunta via callback async
|
|
||||||
// — polling pega em ~2s
|
|
||||||
} catch (e) {
|
|
||||||
useAlert(
|
|
||||||
t('CAPTAIN_HERMES_BUILDER.SEND_FAILED', {
|
|
||||||
message: e.response?.data?.error || e.message || 'unknown',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
sending.value = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatTime = iso => {
|
|
||||||
if (!iso) return '';
|
|
||||||
const d = new Date(iso);
|
|
||||||
return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
fetchMessages();
|
|
||||||
polling.value = setInterval(fetchMessages, 2000);
|
|
||||||
});
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (polling.value) clearInterval(polling.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
watch(messages, () => nextTick().then(scrollToBottom), { deep: true });
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -129,243 +25,27 @@ watch(messages, () => nextTick().then(scrollToBottom), { deep: true });
|
|||||||
:title="t('CAPTAIN_HERMES_BUILDER.TITLE')"
|
:title="t('CAPTAIN_HERMES_BUILDER.TITLE')"
|
||||||
:description="t('CAPTAIN_HERMES_BUILDER.DESCRIPTION')"
|
:description="t('CAPTAIN_HERMES_BUILDER.DESCRIPTION')"
|
||||||
>
|
>
|
||||||
<div class="builder-wrapper">
|
<div class="builder-tabs">
|
||||||
<header class="builder-header">
|
<TabBar
|
||||||
<div>
|
:tabs="tabs"
|
||||||
<h2>{{ t('CAPTAIN_HERMES_BUILDER.HEADER_TITLE') }}</h2>
|
:initial-active-tab="activeIndex"
|
||||||
<p>{{ t('CAPTAIN_HERMES_BUILDER.HEADER_DESCRIPTION') }}</p>
|
@tab-changed="handleTabChanged"
|
||||||
</div>
|
/>
|
||||||
<Button variant="ghost" size="sm" @click="resetSession">
|
</div>
|
||||||
{{ t('CAPTAIN_HERMES_BUILDER.RESET') }}
|
<div class="builder-panels">
|
||||||
</Button>
|
<BuilderChat v-show="activeIndex === 0" />
|
||||||
</header>
|
<BuilderVerification v-show="activeIndex === 1" />
|
||||||
|
|
||||||
<section ref="scrollContainer" class="messages">
|
|
||||||
<div v-if="!messages.length" class="empty-state">
|
|
||||||
<p>{{ t('CAPTAIN_HERMES_BUILDER.EMPTY_STATE') }}</p>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="start-button"
|
|
||||||
:disabled="sending"
|
|
||||||
@click="startSession"
|
|
||||||
>
|
|
||||||
{{ t('CAPTAIN_HERMES_BUILDER.START') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-for="(msg, idx) in messages"
|
|
||||||
:key="idx"
|
|
||||||
class="msg"
|
|
||||||
:class="[`msg--${msg.role}`]"
|
|
||||||
>
|
|
||||||
<div class="msg__bubble">
|
|
||||||
<div class="msg__content">{{ msg.content }}</div>
|
|
||||||
<div class="msg__meta">{{ formatTime(msg.created_at) }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="isWaiting" class="msg msg--construtor">
|
|
||||||
<div class="msg__bubble msg__bubble--typing">
|
|
||||||
<span class="dot" /><span class="dot" /><span class="dot" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<footer class="composer">
|
|
||||||
<textarea
|
|
||||||
v-model="input"
|
|
||||||
rows="2"
|
|
||||||
:placeholder="t('CAPTAIN_HERMES_BUILDER.PLACEHOLDER')"
|
|
||||||
:disabled="sending"
|
|
||||||
@keydown="handleKeydown"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
:disabled="!input.trim() || sending"
|
|
||||||
@click="sendMessage"
|
|
||||||
>
|
|
||||||
{{ t('CAPTAIN_HERMES_BUILDER.SEND') }}
|
|
||||||
</Button>
|
|
||||||
</footer>
|
|
||||||
|
|
||||||
<p v-if="sessionId" class="session-debug">
|
|
||||||
{{ t('CAPTAIN_HERMES_BUILDER.SESSION_LABEL') }} {{ sessionId }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.builder-wrapper {
|
.builder-tabs {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.builder-panels {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 16px;
|
|
||||||
height: calc(100vh - 220px);
|
|
||||||
max-width: 900px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.builder-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
padding: 16px 20px;
|
|
||||||
background: var(--color-background-light, #f7f8fa);
|
|
||||||
border-radius: 12px;
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
margin: 0 0 4px;
|
|
||||||
font-size: 18px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
color: var(--color-text-light, #6b7280);
|
|
||||||
font-size: 13px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages {
|
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
|
||||||
padding: 16px;
|
|
||||||
background: var(--color-background, #fff);
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--color-border, #e5e7eb);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
margin: auto;
|
|
||||||
color: var(--color-text-light, #9ca3af);
|
|
||||||
font-size: 14px;
|
|
||||||
text-align: center;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 16px;
|
|
||||||
align-items: center;
|
|
||||||
|
|
||||||
p {
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.start-button {
|
|
||||||
background: var(--color-woot-500, #1f93ff);
|
|
||||||
color: #fff;
|
|
||||||
border: none;
|
|
||||||
padding: 10px 24px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: background 0.15s;
|
|
||||||
|
|
||||||
&:hover:not(:disabled) {
|
|
||||||
background: var(--color-woot-600, #1976d2);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:disabled {
|
|
||||||
opacity: 0.5;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg {
|
|
||||||
display: flex;
|
|
||||||
|
|
||||||
&--user {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
|
||||||
|
|
||||||
&--construtor {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg__bubble {
|
|
||||||
max-width: 70%;
|
|
||||||
padding: 10px 14px;
|
|
||||||
border-radius: 14px;
|
|
||||||
background: var(--color-background-light, #f3f4f6);
|
|
||||||
font-size: 14px;
|
|
||||||
|
|
||||||
.msg--user & {
|
|
||||||
background: var(--color-woot-500, #1f93ff);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg__content {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg__meta {
|
|
||||||
font-size: 11px;
|
|
||||||
margin-top: 4px;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.msg__bubble--typing {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
padding: 12px 16px;
|
|
||||||
|
|
||||||
.dot {
|
|
||||||
width: 6px;
|
|
||||||
height: 6px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: var(--color-text-light, #6b7280);
|
|
||||||
animation: typing 1.4s infinite ease-in-out;
|
|
||||||
|
|
||||||
&:nth-child(2) {
|
|
||||||
animation-delay: 0.2s;
|
|
||||||
}
|
|
||||||
&:nth-child(3) {
|
|
||||||
animation-delay: 0.4s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes typing {
|
|
||||||
0%,
|
|
||||||
60%,
|
|
||||||
100% {
|
|
||||||
opacity: 0.3;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
30% {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(-3px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.composer {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--color-background, #fff);
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--color-border, #e5e7eb);
|
|
||||||
|
|
||||||
textarea {
|
|
||||||
flex: 1;
|
|
||||||
border: none;
|
|
||||||
resize: none;
|
|
||||||
outline: none;
|
|
||||||
font: inherit;
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-debug {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--color-text-light, #9ca3af);
|
|
||||||
text-align: right;
|
|
||||||
margin: 0;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -62,6 +62,9 @@ Rails.application.routes.draw do
|
|||||||
collection do
|
collection do
|
||||||
post :start
|
post :start
|
||||||
delete :reset
|
delete :reset
|
||||||
|
get :assistants
|
||||||
|
get :validate
|
||||||
|
post :repair
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
resource :preferences, only: [:show, :update]
|
resource :preferences, only: [:show, :update]
|
||||||
|
|||||||
@ -51,6 +51,33 @@ class Api::V1::Accounts::Captain::HermesBuilderController < Api::V1::Accounts::B
|
|||||||
render json: { ok: true }
|
render json: { ok: true }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Lista assistentes Hermes da conta atual pra dropdown da aba Verificação.
|
||||||
|
def assistants
|
||||||
|
rows = ::Captain::Assistant.where(account_id: Current.account.id, engine: 'hermes')
|
||||||
|
.order(:name)
|
||||||
|
.pluck(:id, :name, :hermes_profile_name)
|
||||||
|
.map { |id, name, slug| { id: id, name: name, slug: slug } }
|
||||||
|
render json: { assistants: rows }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Roda o validator (porta dos checks DB do CLI hermes-validate).
|
||||||
|
def validate
|
||||||
|
slug = params[:slug].to_s.strip
|
||||||
|
return render json: { error: 'slug required' }, status: :bad_request if slug.blank?
|
||||||
|
|
||||||
|
render json: HermesBuilder::Validator.run(slug)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Aplica reparo automatizado pra um check FAIL/WARN específico.
|
||||||
|
def repair
|
||||||
|
slug = params[:slug].to_s.strip
|
||||||
|
repair_id = params[:repair_id].to_s.strip
|
||||||
|
return render json: { ok: false, error: 'slug e repair_id required' }, status: :bad_request if slug.blank? || repair_id.blank?
|
||||||
|
|
||||||
|
result = HermesBuilder::Repairer.repair(slug: slug, repair_id: repair_id)
|
||||||
|
render json: result, status: result[:ok] ? :ok : :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def authorize_admin
|
def authorize_admin
|
||||||
|
|||||||
87
enterprise/app/services/hermes_builder/repairer.rb
Normal file
87
enterprise/app/services/hermes_builder/repairer.rb
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
# Reparos automatizados pra checks FAIL/WARN identificados pelo Validator.
|
||||||
|
#
|
||||||
|
# Cada `repair_id` presente em Validator::Result mapeia pra um handler aqui.
|
||||||
|
# Reparos cobrem só estado em DB (Captain::Assistant, CaptainInbox, Inbox,
|
||||||
|
# config). Reparos de filesystem/systemd ficam pro CLI hermes-provision na
|
||||||
|
# VPS — UI mostra mensagem orientadora pro admin.
|
||||||
|
class HermesBuilder::Repairer
|
||||||
|
REPAIR_HANDLERS = %w[
|
||||||
|
set_engine_hermes
|
||||||
|
sync_captain_inbox_unit
|
||||||
|
set_default_typing_delay
|
||||||
|
set_default_response_delay
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
def self.repair(slug:, repair_id:)
|
||||||
|
asst = ::Captain::Assistant.find_by(hermes_profile_name: slug, engine: 'hermes')
|
||||||
|
return failure("Assistant '#{slug}' não encontrado") if asst.nil?
|
||||||
|
return failure("Reparo '#{repair_id}' não suportado pela UI. Rode hermes-provision na VPS.") unless REPAIR_HANDLERS.include?(repair_id)
|
||||||
|
|
||||||
|
send("repair_#{repair_id}", asst)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("[HermesBuilder::Repairer] error: #{e.class}: #{e.message}")
|
||||||
|
failure("Erro: #{e.class}: #{e.message}")
|
||||||
|
end
|
||||||
|
|
||||||
|
class << self
|
||||||
|
private
|
||||||
|
|
||||||
|
# rubocop:disable Rails/SkipsModelValidations
|
||||||
|
def repair_set_engine_hermes(asst)
|
||||||
|
asst.update_columns(engine: 'hermes')
|
||||||
|
success("Engine setado pra 'hermes' no assistant #{asst.id}")
|
||||||
|
end
|
||||||
|
# rubocop:enable Rails/SkipsModelValidations
|
||||||
|
|
||||||
|
# Resincroniza CaptainInbox.captain_unit_id com Assistant.captain_unit_id.
|
||||||
|
# Esse foi o bug raiz que travou a Juliana — CaptainInbox apontava pra
|
||||||
|
# unit antiga (Dolce Amore) enquanto Assistant.captain_unit_id era a
|
||||||
|
# nova (Qnn01). Resolve_unit usa CaptainInbox como segundo nível e
|
||||||
|
# vazava unit errada quando o context['assistant_id'] não chegava.
|
||||||
|
# rubocop:disable Rails/SkipsModelValidations
|
||||||
|
def repair_sync_captain_inbox_unit(asst)
|
||||||
|
return failure('Assistant sem captain_unit_id setado — corrija primeiro pelo cadastro') if asst.captain_unit_id.blank?
|
||||||
|
|
||||||
|
ci = ::CaptainInbox.where(captain_assistant_id: asst.id).first
|
||||||
|
return failure('Sem CaptainInbox pra esse assistant — vincule no painel de inboxes primeiro') if ci.nil?
|
||||||
|
|
||||||
|
old = ci.captain_unit_id
|
||||||
|
ci.update_columns(captain_unit_id: asst.captain_unit_id)
|
||||||
|
|
||||||
|
::Captain::Unit.where(inbox_id: ci.inbox_id).where.not(id: asst.captain_unit_id).update_all(inbox_id: nil)
|
||||||
|
::Captain::Unit.where(id: asst.captain_unit_id).update_all(inbox_id: ci.inbox_id)
|
||||||
|
|
||||||
|
success("CaptainInbox.unit_id ressincronizado: #{old} → #{asst.captain_unit_id}")
|
||||||
|
end
|
||||||
|
# rubocop:enable Rails/SkipsModelValidations
|
||||||
|
|
||||||
|
def repair_set_default_typing_delay(asst)
|
||||||
|
ci = ::CaptainInbox.where(captain_assistant_id: asst.id).first
|
||||||
|
return failure('Sem inbox vinculada') if ci&.inbox.nil?
|
||||||
|
|
||||||
|
ci.inbox.update!(typing_delay: 5)
|
||||||
|
success("Inbox #{ci.inbox.id} typing_delay setado pra 5s")
|
||||||
|
end
|
||||||
|
|
||||||
|
def repair_set_default_response_delay(asst)
|
||||||
|
cfg = asst.config.to_h.merge(
|
||||||
|
'response_delay' => {
|
||||||
|
'mode' => 'typing_simulation',
|
||||||
|
'chars_per_second' => 25,
|
||||||
|
'min_seconds' => 1.5,
|
||||||
|
'max_seconds' => 6.0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
asst.update!(config: cfg)
|
||||||
|
success('Humanização typing_simulation ativada (default: 25 cps, 1.5s..6s)')
|
||||||
|
end
|
||||||
|
|
||||||
|
def success(msg)
|
||||||
|
{ ok: true, message: msg }
|
||||||
|
end
|
||||||
|
|
||||||
|
def failure(msg)
|
||||||
|
{ ok: false, error: msg }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
199
enterprise/app/services/hermes_builder/validator.rb
Normal file
199
enterprise/app/services/hermes_builder/validator.rb
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
# Validação de saúde de um agente Hermes — porta dos checks DB/runtime do
|
||||||
|
# script CLI `bin/hermes-validate` pro contexto Rails (consumido pela aba
|
||||||
|
# "Verificação" no Construtor UI).
|
||||||
|
#
|
||||||
|
# Cobre: configuração do Captain::Assistant, mapeamento CaptainInbox/Unit,
|
||||||
|
# pricing dry-run, credenciais Inter, registry MCP, humanização. NÃO cobre
|
||||||
|
# filesystem (SOUL.md/SKILL.md/config.yaml) nem systemd — esses ficam pro
|
||||||
|
# CLI (rodar no shell da VPS) porque o container Rails não tem visibilidade
|
||||||
|
# do `/root/.hermes/profiles/` do host.
|
||||||
|
#
|
||||||
|
# Cada check tem `repair_id` opcional — quando setado, indica que existe
|
||||||
|
# handler em HermesBuilder::Repairer pra ressincronizar/corrigir o estado
|
||||||
|
# (ex: sync_captain_inbox_unit, set_engine_hermes, set_default_typing).
|
||||||
|
class HermesBuilder::Validator
|
||||||
|
EXPECTED_MCP_TOOLS = %w[
|
||||||
|
generate_pix faq_lookup add_label send_suite_images react_to_message
|
||||||
|
update_contact get_contact_history check_pix_payment reschedule_reservation
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
Result = Struct.new(:label, :status, :detail, :repair_id, :category, keyword_init: true) do
|
||||||
|
def to_h
|
||||||
|
{ label: label, status: status, detail: detail.to_s, repair_id: repair_id, category: category }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.run(slug)
|
||||||
|
new(slug).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(slug)
|
||||||
|
@slug = slug.to_s.strip
|
||||||
|
@results = []
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
asst = ::Captain::Assistant.find_by(hermes_profile_name: @slug, engine: 'hermes')
|
||||||
|
if asst.nil?
|
||||||
|
add('Captain::Assistant existe', 'FAIL', "Nenhum assistant com hermes_profile_name='#{@slug}'", category: 'db')
|
||||||
|
return summary
|
||||||
|
end
|
||||||
|
|
||||||
|
@asst = asst
|
||||||
|
@ci = ::CaptainInbox.where(captain_assistant_id: asst.id).first
|
||||||
|
@inbox = @ci&.inbox
|
||||||
|
@unit = asst.captain_unit
|
||||||
|
|
||||||
|
check_db
|
||||||
|
check_pricing
|
||||||
|
check_routing
|
||||||
|
check_humanization
|
||||||
|
check_mcp_tools
|
||||||
|
summary
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def check_db
|
||||||
|
check_db_engine
|
||||||
|
check_db_endpoint
|
||||||
|
check_db_unit_consistency
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_db_engine
|
||||||
|
add('engine=hermes', pf(@asst.engine == 'hermes'), @asst.engine,
|
||||||
|
repair_id: 'set_engine_hermes', category: 'db')
|
||||||
|
add('hermes_profile_name setado', pf(@asst.hermes_profile_name.present?),
|
||||||
|
@asst.hermes_profile_name, category: 'db')
|
||||||
|
add('parent_assistant_id setado', pw(@asst.parent_assistant_id.present?),
|
||||||
|
"parent=#{@asst.parent_assistant_id}", category: 'db')
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_db_endpoint
|
||||||
|
add('hermes_port setado', pf(@asst.hermes_port.present?),
|
||||||
|
"port=#{@asst.hermes_port}", category: 'db')
|
||||||
|
add('hermes_subscription_secret setado', pf(@asst.hermes_subscription_secret.present?),
|
||||||
|
@asst.hermes_subscription_secret.present? ? 'presente' : 'vazio', category: 'db')
|
||||||
|
add('hermes_webhook_base_url', pf(@asst.hermes_webhook_base_url.to_s.start_with?('http')),
|
||||||
|
@asst.hermes_webhook_base_url, category: 'db')
|
||||||
|
end
|
||||||
|
|
||||||
|
def pf(bool) = bool ? 'PASS' : 'FAIL'
|
||||||
|
def pw(bool) = bool ? 'PASS' : 'WARN'
|
||||||
|
|
||||||
|
def check_db_unit_consistency
|
||||||
|
add('captain_unit_id setado', @asst.captain_unit_id.present? ? 'PASS' : 'FAIL',
|
||||||
|
@unit&.name, category: 'db')
|
||||||
|
|
||||||
|
ci_unit = @ci&.captain_unit_id
|
||||||
|
sync_ok = @asst.captain_unit_id.present? && ci_unit == @asst.captain_unit_id
|
||||||
|
add('CaptainInbox.unit == Assistant.unit', sync_ok ? 'PASS' : 'FAIL',
|
||||||
|
"asst=#{@asst.captain_unit_id} ci=#{ci_unit}",
|
||||||
|
repair_id: sync_ok ? nil : 'sync_captain_inbox_unit', category: 'db')
|
||||||
|
|
||||||
|
add('Brand resolvida', @unit&.brand.present? ? 'PASS' : 'FAIL', @unit&.brand&.name, category: 'db')
|
||||||
|
add('CaptainInbox mapeada', @inbox.present? ? 'PASS' : 'WARN', "inbox=#{@inbox&.id}", category: 'db')
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_pricing
|
||||||
|
cats = (@unit&.pricing_categories&.includes(:amounts)&.to_a) || []
|
||||||
|
add('Pricing categorias > 0', cats.any? ? 'PASS' : 'FAIL',
|
||||||
|
"#{cats.size} cats: #{cats.map(&:key).join(',')}", category: 'pricing')
|
||||||
|
add('Pricing amounts > 0', cats.flat_map { |c| c.amounts.to_a }.size.positive? ? 'PASS' : 'FAIL',
|
||||||
|
"#{cats.flat_map { |c| c.amounts.to_a }.size} amounts", category: 'pricing')
|
||||||
|
|
||||||
|
check_pricing_dry_run(cats)
|
||||||
|
check_inter_creds
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_pricing_dry_run(cats)
|
||||||
|
return if cats.empty?
|
||||||
|
|
||||||
|
first_cat = cats.first.key
|
||||||
|
res = ::Captain::Mcp::PricingTables.calculate(
|
||||||
|
unit_id: @unit.id, suite_category: first_cat,
|
||||||
|
period: 'pernoite_promo', total_guests: 2
|
||||||
|
)
|
||||||
|
if res[:error]
|
||||||
|
add('Pricing dry-run', 'FAIL', "ERR: #{res[:error]}", category: 'pricing')
|
||||||
|
else
|
||||||
|
add('Pricing dry-run', 'PASS', "OK R$ #{res[:amount]} (#{first_cat}/pernoite)", category: 'pricing')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_inter_creds
|
||||||
|
inter_ok = @unit.present? && @unit.respond_to?(:inter_credentials_present?) && @unit.inter_credentials_present?
|
||||||
|
add('Credenciais Inter completas', pw(inter_ok),
|
||||||
|
inter_ok ? 'cert+key+client_id presentes' : 'faltam — generate_pix cai no fallback',
|
||||||
|
category: 'pricing')
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_routing
|
||||||
|
if @inbox.blank?
|
||||||
|
add('Captain::Hermes.enabled_for?', 'WARN', 'sem inbox mapeada', category: 'routing')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
enabled = ::Captain::Hermes.enabled_for?(@inbox)
|
||||||
|
add('Captain::Hermes.enabled_for?', enabled ? 'PASS' : 'FAIL', enabled.to_s, category: 'routing')
|
||||||
|
|
||||||
|
url = ::Captain::Hermes.webhook_url_for(@inbox).to_s
|
||||||
|
expected_suffix = "/webhooks/captain-inbox-#{@slug}"
|
||||||
|
add('webhook_url aponta pra slug', url.end_with?(expected_suffix) ? 'PASS' : 'FAIL',
|
||||||
|
url, category: 'routing')
|
||||||
|
|
||||||
|
secret = ::Captain::Hermes.subscription_signing_secret(@inbox).to_s
|
||||||
|
add('subscription_signing_secret presente', secret.present? ? 'PASS' : 'FAIL',
|
||||||
|
secret.present? ? "#{secret.first(8)}..." : 'vazio', category: 'routing')
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_humanization
|
||||||
|
typing_delay = @inbox.respond_to?(:typing_delay) ? @inbox.typing_delay.to_i : 0
|
||||||
|
add('Inbox.typing_delay > 0 (debounce)', typing_delay.positive? ? 'PASS' : 'WARN',
|
||||||
|
"#{typing_delay}s",
|
||||||
|
repair_id: typing_delay.positive? ? nil : 'set_default_typing_delay',
|
||||||
|
category: 'humanization')
|
||||||
|
|
||||||
|
mode = @asst.config.to_h.dig('response_delay', 'mode')
|
||||||
|
add('config.response_delay (typing simulation)', mode == 'typing_simulation' ? 'PASS' : 'WARN',
|
||||||
|
mode || 'none',
|
||||||
|
repair_id: mode == 'typing_simulation' ? nil : 'set_default_response_delay',
|
||||||
|
category: 'humanization')
|
||||||
|
|
||||||
|
gallery_count = @unit&.gallery_items&.count || 0
|
||||||
|
add('GalleryItem para fotos', gallery_count.positive? ? 'PASS' : 'WARN',
|
||||||
|
"#{gallery_count} items (send_suite_images precisa)", category: 'humanization')
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_mcp_tools
|
||||||
|
registered = mcp_tool_names
|
||||||
|
EXPECTED_MCP_TOOLS.each do |t|
|
||||||
|
add("MCP tool '#{t}' registrado", registered.include?(t) ? 'PASS' : 'FAIL', nil, category: 'mcp')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def mcp_tool_names
|
||||||
|
::Captain::Mcp::ToolRegistry::TOOLS.map(&:name)
|
||||||
|
rescue StandardError
|
||||||
|
[]
|
||||||
|
end
|
||||||
|
|
||||||
|
def add(label, status, detail = nil, repair_id: nil, category: nil)
|
||||||
|
@results << Result.new(label: label, status: status, detail: detail, repair_id: repair_id, category: category)
|
||||||
|
end
|
||||||
|
|
||||||
|
def summary
|
||||||
|
pass = @results.count { |r| r.status == 'PASS' }
|
||||||
|
fail_n = @results.count { |r| r.status == 'FAIL' }
|
||||||
|
warn = @results.count { |r| r.status == 'WARN' }
|
||||||
|
{
|
||||||
|
slug: @slug,
|
||||||
|
ok: fail_n.zero?,
|
||||||
|
total: @results.size,
|
||||||
|
pass: pass,
|
||||||
|
fail: fail_n,
|
||||||
|
warn: warn,
|
||||||
|
results: @results.map(&:to_h)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Reference in New Issue
Block a user