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() {
|
||||
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();
|
||||
|
||||
@ -905,6 +905,35 @@
|
||||
"SESSION_LABEL": "Session:",
|
||||
"SEND_FAILED": "Send failed: {message}",
|
||||
"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:",
|
||||
"SEND_FAILED": "Erro ao enviar: {message}",
|
||||
"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>
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
onBeforeUnmount,
|
||||
nextTick,
|
||||
watch,
|
||||
} from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import hermesBuilderApi from 'dashboard/api/captain/hermesBuilder';
|
||||
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
|
||||
import BuilderChat from './BuilderChat.vue';
|
||||
import BuilderVerification from './BuilderVerification.vue';
|
||||
|
||||
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 tabs = computed(() => [
|
||||
{ label: t('CAPTAIN_HERMES_BUILDER.TAB_CHAT'), key: 'chat' },
|
||||
{ label: t('CAPTAIN_HERMES_BUILDER.TAB_VERIFY'), key: 'verification' },
|
||||
]);
|
||||
|
||||
const lastMessageRole = computed(() => messages.value.at(-1)?.role || null);
|
||||
const isWaiting = computed(
|
||||
() => sending.value || lastMessageRole.value === 'user'
|
||||
);
|
||||
const activeIndex = ref(0);
|
||||
|
||||
const scrollToBottom = () => {
|
||||
const el = scrollContainer.value;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
const handleTabChanged = tab => {
|
||||
activeIndex.value = tabs.value.findIndex(item => item.key === tab.key);
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<template>
|
||||
@ -129,243 +25,27 @@ watch(messages, () => nextTick().then(scrollToBottom), { deep: true });
|
||||
:title="t('CAPTAIN_HERMES_BUILDER.TITLE')"
|
||||
:description="t('CAPTAIN_HERMES_BUILDER.DESCRIPTION')"
|
||||
>
|
||||
<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"
|
||||
<div class="builder-tabs">
|
||||
<TabBar
|
||||
:tabs="tabs"
|
||||
:initial-active-tab="activeIndex"
|
||||
@tab-changed="handleTabChanged"
|
||||
/>
|
||||
<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 class="builder-panels">
|
||||
<BuilderChat v-show="activeIndex === 0" />
|
||||
<BuilderVerification v-show="activeIndex === 1" />
|
||||
</div>
|
||||
</PageLayout>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.builder-wrapper {
|
||||
.builder-tabs {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.builder-panels {
|
||||
display: flex;
|
||||
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>
|
||||
|
||||
@ -62,6 +62,9 @@ Rails.application.routes.draw do
|
||||
collection do
|
||||
post :start
|
||||
delete :reset
|
||||
get :assistants
|
||||
get :validate
|
||||
post :repair
|
||||
end
|
||||
end
|
||||
resource :preferences, only: [:show, :update]
|
||||
|
||||
@ -51,6 +51,33 @@ class Api::V1::Accounts::Captain::HermesBuilderController < Api::V1::Accounts::B
|
||||
render json: { ok: true }
|
||||
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
|
||||
|
||||
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