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:
parent
d35084334c
commit
40fd0c8f50
@ -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
|
||||
22
app/javascript/dashboard/api/captain/hermesBuilder.js
Normal file
22
app/javascript/dashboard/api/captain/hermesBuilder.js
Normal 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();
|
||||
@ -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'),
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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."
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
45
enterprise/app/services/hermes_builder/dispatcher.rb
Normal file
45
enterprise/app/services/hermes_builder/dispatcher.rb
Normal 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
|
||||
32
enterprise/app/services/hermes_builder/storage.rb
Normal file
32
enterprise/app/services/hermes_builder/storage.rb
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user