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:
Rodribm10 2026-05-02 15:27:40 -03:00
parent 47f32b540b
commit 28e880d7b6
10 changed files with 1219 additions and 348 deletions

View File

@ -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();

View File

@ -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"
}
} }
} }

View File

@ -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"
}
} }
} }

View File

@ -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>

View File

@ -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>

View File

@ -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">
{{ 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 </div>
variant="primary" <div class="builder-panels">
:disabled="!input.trim() || sending" <BuilderChat v-show="activeIndex === 0" />
@click="sendMessage" <BuilderVerification v-show="activeIndex === 1" />
>
{{ 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>

View File

@ -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]

View File

@ -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

View 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

View 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