feat(captain/hermes-builder): UI Vue + endpoints pra chat com Construtor

Tela "Construtor" no painel Captain (acessivel em sidebar pra admins) que
permite criar novo agente Hermes via chat guiado com agente Construtor
(profile Hermes separado).

Backend (admin scope):
- POST /api/v1/accounts/:id/captain/hermes_builder — manda mensagem do
  admin pro gateway do Construtor (Hermes na porta 8646)
- GET — retorna historico da sessao (Rails.cache, TTL 4h)
- DELETE /reset — limpa sessao
- POST /webhooks/captain/builder_callback — recebe respostas async do
  Construtor via plugin captain-http-callback do Hermes
- HermesBuilder::Storage (Rails.cache) — persiste msgs por session_key
  (account_id + user_id) com role/content/created_at
- HermesBuilder::Dispatcher — encaminha pro webhook do Construtor com
  HMAC opcional via ENV HERMES_BUILDER_WEBHOOK_SECRET

Frontend:
- Pagina Vue HermesBuilder/Index.vue — chat simples com:
  * Lista de mensagens com bubbles user/construtor
  * Indicador "digitando..." enquanto aguarda resposta
  * Input com Enter pra enviar / Shift+Enter pra nova linha
  * Polling 2s pra novas msgs
  * Botao Limpar conversa
- API client em api/captain/hermesBuilder.js
- Rota captain_hermes_builder_index (admin only)
- Item no sidebar Captain "Construtor (Hermes)"
- i18n keys CAPTAIN.HERMES_BUILDER em pt_BR + en

UX flow:
  Admin abre tela → digita "olá" → Construtor pergunta nome → admin
  responde → marca, persona, tabela (com opcao copiar de existente),
  regras, FAQs, identidade → resumo → confirmar → Construtor chama
  save_agent_spec → JSON salvo em /tmp/agent-specs/<slug>.json pra
  revisao posterior. NAO cria filesystem do profile nem registros DB
  (etapa SEPARADA, prox sessao).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-05-01 21:00:41 -03:00
parent d35084334c
commit 40fd0c8f50
13 changed files with 589 additions and 0 deletions

View File

@ -0,0 +1,64 @@
# Recebe callback do Hermes Construtor (plugin captain-http-callback).
#
# Construtor responde async via POST pra esta URL com:
# { content: "<resposta>", reply_to: ..., metadata: {...}, timestamp: ... }
#
# Este controller identifica a sessão do admin (por session_id no metadata
# OU pelo cache key derivado de account_id que veio na query string) e
# armazena a resposta no Rails.cache pra UI poder ler via polling.
class Webhooks::Captain::HermesBuilderCallbackController < ApplicationController
skip_before_action :verify_authenticity_token, raise: false
def process_payload
content = params[:content].to_s.strip
return head :bad_request if content.blank?
session_key = resolve_session_key
if session_key.blank?
Rails.logger.warn('[HermesBuilder::Callback] no session_key resolvable — ignorando')
return head :ok
end
HermesBuilder::Storage.append(session_key, role: 'construtor', content: content)
Rails.logger.info("[HermesBuilder::Callback] reply received for #{session_key} (#{content.length} chars)")
head :ok
rescue StandardError => e
Rails.logger.error("[HermesBuilder::Callback] error: #{e.class}: #{e.message}")
head :internal_server_error
end
private
# Estratégia: usar o session_id do metadata (Hermes propaga o chat_id).
# Fallback: account_id da query string + último user que mandou msg
# (raro, mas evita perder resposta).
def resolve_session_key
chat_id = params[:metadata]&.[](:chat_id) || params.dig(:metadata, 'chat_id')
if chat_id.is_a?(String) && chat_id.include?('builder-')
# Formato: webhook:construtor-admin:session:builder-<account>-<user>
session_id = chat_id.split(':').last
return "hermes_builder:#{session_id}" if session_id.start_with?('builder-')
end
account_id = params[:account_id]
return nil if account_id.blank?
# Fallback: pega últimas 5 sessões do account, retorna a mais recente
# com mensagens. Aceitável pra MVP com 1 admin testando por vez.
recent_session_key_for(account_id)
end
def recent_session_key_for(account_id)
return nil unless Rails.cache.respond_to?(:redis)
pattern = "hermes_builder:builder-#{account_id}-*"
keys = Rails.cache.redis.with { |c| c.keys(pattern) }
return nil if keys.blank?
keys.first.sub(/^.*?(hermes_builder:.*)$/, '\1')
rescue StandardError => e
Rails.logger.warn("[HermesBuilder::Callback] recent_session_key fallback failed: #{e.class} - #{e.message}")
nil
end
end

View File

@ -0,0 +1,22 @@
/* global axios */
import ApiClient from '../ApiClient';
class HermesBuilder extends ApiClient {
constructor() {
super('captain/hermes_builder', { accountScoped: true });
}
fetchMessages() {
return axios.get(this.url);
}
sendMessage(text) {
return axios.post(this.url, { text });
}
reset() {
return axios.delete(`${this.url}/reset`);
}
}
export default new HermesBuilder();

View File

@ -424,6 +424,12 @@ const menuItems = computed(() => {
activeOn: ['captain_roleta_index'],
to: accountScopedRoute('captain_roleta_index'),
},
{
name: 'HermesBuilder',
label: t('SIDEBAR.CAPTAIN_HERMES_BUILDER'),
activeOn: ['captain_hermes_builder_index'],
to: accountScopedRoute('captain_hermes_builder_index'),
},
{
name: 'Funnel',
label: t('SIDEBAR.CAPTAIN_FUNNEL'),

View File

@ -436,6 +436,20 @@
}
},
"CAPTAIN": {
"HERMES_BUILDER": {
"TITLE": "Agent Builder",
"DESCRIPTION": "Create new Hermes agents through a guided chat with the Builder.",
"HEADER_TITLE": "Agent Builder",
"HEADER_DESCRIPTION": "Chat with the Builder to create a new Hermes agent. It asks questions and saves the spec as JSON for review at the end.",
"RESET": "Clear conversation",
"RESET_CONFIRM": "Clear current conversation with the Builder?",
"EMPTY_STATE": "Type \"hello\" to start. The Builder will guide you.",
"PLACEHOLDER": "Type and press Enter to send (Shift+Enter for new line)",
"SEND": "Send",
"SESSION_LABEL": "Session:",
"SEND_FAILED": "Send failed: {message}",
"RESET_FAILED": "Failed to clear session."
},
"BANNER": {
"RESPONSES": "You have used more than 80% of your responses limit. To continue using Captain AI, please upgrade.",
"DOCUMENTS": "Documents limit reached. Please upgrade to continue using Captain AI."

View File

@ -350,6 +350,7 @@
"CAPTAIN_GALLERY": "Gallery",
"CAPTAIN_RESERVATIONS": "Reservations",
"CAPTAIN_ROLETA": "Roulette — Redeem",
"CAPTAIN_HERMES_BUILDER": "Builder (Hermes)",
"CAPTAIN_FUNNEL": "Conversion Funnel",
"CAPTAIN_LIFECYCLE": "Customer Journey",
"CAPTAIN_REPORTS": "AI Reports",

View File

@ -437,6 +437,20 @@
}
},
"CAPTAIN": {
"HERMES_BUILDER": {
"TITLE": "Construtor de Agentes",
"DESCRIPTION": "Crie novos agentes Hermes via chat guiado com o Construtor.",
"HEADER_TITLE": "Construtor de Agentes",
"HEADER_DESCRIPTION": "Converse com o Construtor pra criar um novo agente Hermes. Ele faz perguntas e ao final salva a especificação em JSON pra revisão.",
"RESET": "Limpar conversa",
"RESET_CONFIRM": "Limpar conversa atual com o Construtor?",
"EMPTY_STATE": "Mande \"olá\" pra começar. O Construtor vai te guiar.",
"PLACEHOLDER": "Escreva e Enter pra enviar (Shift+Enter pula linha)",
"SEND": "Enviar",
"SESSION_LABEL": "Sessão:",
"SEND_FAILED": "Erro ao enviar: {message}",
"RESET_FAILED": "Falha ao limpar sessão."
},
"BANNER": {
"RESPONSES": "Você usou mais de 80% do seu limite de respostas. Para continuar usando o Capitão IA, faça um upgrade.",
"DOCUMENTS": "Limite de documentos atingido. Faça um upgrade para continuar usando o Capitão IA."

View File

@ -349,6 +349,7 @@
"CAPTAIN_GALLERY": "Galeria",
"CAPTAIN_RESERVATIONS": "Reservas",
"CAPTAIN_ROLETA": "Roleta — Resgate",
"CAPTAIN_HERMES_BUILDER": "Construtor (Hermes)",
"CAPTAIN_FUNNEL": "Funil de Conversão",
"CAPTAIN_LIFECYCLE": "Jornada do Cliente",
"CAPTAIN_REPORTS": "Relatórios IA",

View File

@ -0,0 +1,316 @@
<script setup>
import {
ref,
computed,
onMounted,
onBeforeUnmount,
nextTick,
watch,
} 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';
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;
// 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 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>
<PageLayout
: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">
{{ t('CAPTAIN.HERMES_BUILDER.EMPTY_STATE') }}
</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>
</PageLayout>
</template>
<style scoped lang="scss">
.builder-wrapper {
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;
}
.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

@ -18,6 +18,7 @@ import ResponsesPendingIndex from './responses/Pending.vue';
import CustomToolsIndex from './tools/Index.vue';
import ReservationsIndex from './reservations/Index.vue';
import RoletaIndex from './roleta/Index.vue';
import HermesBuilderIndex from './builder/Index.vue';
import FunnelIndex from './funnel/Index.vue';
import LifecycleIndex from './lifecycle/Index.vue';
import LifecycleRules from './lifecycle/Rules.vue';
@ -149,6 +150,19 @@ export const routes = [
name: 'captain_roleta_index',
meta,
},
{
path: frontendURL('accounts/:accountId/captain/hermes-builder'),
component: HermesBuilderIndex,
name: 'captain_hermes_builder_index',
meta: {
permissions: ['administrator'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
{
path: frontendURL('accounts/:accountId/captain/funnel'),
component: FunnelIndex,

View File

@ -58,6 +58,11 @@ Rails.application.routes.draw do
post :bulk_create, on: :collection
end
namespace :captain do
resources :hermes_builder, only: [:index, :create] do
collection do
delete :reset
end
end
resource :preferences, only: [:show, :update]
resources :assistants do
member do
@ -638,6 +643,7 @@ Rails.application.routes.draw do
post 'webhooks/shopify', to: 'webhooks/shopify#events'
post 'webhooks/wuzapi/:inbox_id', to: 'webhooks/wuzapi#process_payload'
post 'webhooks/captain/hermes_callback', to: 'webhooks/captain/hermes_callback#process_payload'
post 'webhooks/captain/builder_callback', to: 'webhooks/captain/hermes_builder_callback#process_payload'
post 'webhooks/captain/mcp', to: 'webhooks/captain/mcp#process_payload'
namespace :twitter do

View File

@ -0,0 +1,54 @@
# Endpoints da UI Hermes Builder no painel Captain.
#
# Fluxo:
# 1. UI faz POST /messages com texto do admin
# 2. Backend encaminha pro gateway Hermes Construtor (porta 8646 na VPS)
# 3. Construtor processa async e dispara http_callback (HermesBuilderCallbackController)
# 4. Callback armazena resposta no Rails.cache
# 5. UI faz GET /messages a cada 2s (polling) e renderiza
#
# Sessão é por account+user (1 admin → 1 sessão de builder por vez).
class Enterprise::Api::V1::Accounts::Captain::HermesBuilderController < Api::V1::Accounts::BaseController
before_action :authorize_admin
def index
msgs = HermesBuilder::Storage.messages_for(session_key)
render json: { messages: msgs, session_id: session_id }
end
def create
text = params[:text].to_s.strip
return render json: { error: 'Texto vazio' }, status: :bad_request if text.blank?
HermesBuilder::Storage.append(session_key, role: 'user', content: text)
HermesBuilder::Dispatcher.send_to_construtor(session_id: session_id, message: text)
render json: { ok: true, session_id: session_id }, status: :accepted
rescue HermesBuilder::Dispatcher::DispatchError => e
Rails.logger.error("[HermesBuilder#create] dispatch failed: #{e.message}")
render json: { error: "Falha ao contatar Construtor: #{e.message}" }, status: :bad_gateway
end
def reset
HermesBuilder::Storage.clear(session_key)
render json: { ok: true }
end
private
def authorize_admin
return if current_user&.administrator?
render json: { error: 'Apenas administradores podem usar o Builder' }, status: :forbidden
end
# Sessão é por (account, user). Hermes Construtor mantém memória estável
# graças ao plugin captain-webhook que rewrita chat_id usando hermes_session_id.
def session_id
"builder-#{Current.account.id}-#{current_user.id}"
end
def session_key
"hermes_builder:#{session_id}"
end
end

View File

@ -0,0 +1,45 @@
# Encaminha mensagens da UI Hermes Builder pro gateway do Construtor (Hermes).
#
# Configuração:
# ENV['HERMES_BUILDER_WEBHOOK_URL'] — default: http://172.17.0.1:8646/webhooks/construtor-admin
# ENV['HERMES_BUILDER_WEBHOOK_SECRET'] — secret HMAC pra assinar payload
#
# Construtor responde async via plugin captain-http-callback que faz POST
# pra /webhooks/captain/builder_callback (HermesBuilderCallbackController).
module HermesBuilder::Dispatcher
DEFAULT_URL = 'http://172.17.0.1:8646/webhooks/construtor-admin'.freeze
TIMEOUT = 10
class DispatchError < StandardError; end
module_function
def send_to_construtor(session_id:, message:)
payload = { message: message, hermes_session_id: session_id }
body = payload.to_json
headers = signed_headers(body)
Rails.logger.info("[HermesBuilder::Dispatcher] sending session=#{session_id} (#{message.length} chars)")
response = HTTParty.post(webhook_url, body: body, headers: headers, timeout: TIMEOUT)
return response if response.success? || response.code == 202
raise DispatchError, "Construtor returned HTTP #{response.code}: #{response.body.to_s.truncate(200)}"
rescue HTTParty::Error, Net::ReadTimeout, Net::OpenTimeout, Errno::ECONNREFUSED => e
raise DispatchError, "Network error contacting Construtor (#{e.class}): #{e.message}"
end
def webhook_url
ENV.fetch('HERMES_BUILDER_WEBHOOK_URL', DEFAULT_URL)
end
def signed_headers(body)
headers = { 'Content-Type' => 'application/json; charset=utf-8' }
secret = ENV.fetch('HERMES_BUILDER_WEBHOOK_SECRET', nil)
if secret.present?
sig = OpenSSL::HMAC.hexdigest('SHA256', secret, body)
headers['X-Hub-Signature-256'] = "sha256=#{sig}"
end
headers
end
end

View File

@ -0,0 +1,32 @@
# Storage de mensagens da UI Hermes Builder.
#
# Persistência em Rails.cache (Redis em prod) — TTL 4h. Suficiente pra uma
# sessão de criação completa de agente (geralmente ~10-20min).
#
# Cada mensagem tem: role ('user' | 'construtor'), content (string),
# created_at (ISO8601). Lista cresce em ordem cronológica.
module HermesBuilder
end
module HermesBuilder::Storage
TTL = 4.hours
MAX_MESSAGES = 200
module_function
def messages_for(session_key)
Rails.cache.fetch(session_key) { [] }
end
def append(session_key, role:, content:)
msgs = messages_for(session_key)
msgs << { role: role, content: content.to_s, created_at: Time.current.iso8601 }
msgs = msgs.last(MAX_MESSAGES)
Rails.cache.write(session_key, msgs, expires_in: TTL)
msgs
end
def clear(session_key)
Rails.cache.delete(session_key)
end
end