Runbook — Fluxos de Conversa do Sagazchat
Este runbook é um manual operacional para uma IA (ou pessoa) navegar, auditar e operar o módulo Fluxos de Conversa do Sagazchat via Playwright. Acompanha a doc user-facing em src/c
Este runbook é um manual operacional para uma IA (ou pessoa) navegar, auditar e operar o módulo Fluxos de Conversa do Sagazchat via Playwright. Acompanha a doc user-facing em src/content/automacao/fluxos-gestao.md, fluxos-blocos.md e fluxo-padrao.md. Foi escrito após auditoria completa em 2026-05-09 (sessão PETSHOP-EXEMPLO), com construção real de um fluxo de atendimento de petshop usando os blocos.
Status: trechos
[validado]foram executados nesta sessão. Trechos[a validar]são inferidos da UI ou do mapeamento DOM e dependem de cenário específico. Trechos[não validado por decisão]são operações destrutivas que não foram executadas deliberadamente.
1. Pré-requisitos
Idênticos ao runbook do Bate Papo (bate-papo § 1). Em resumo: Playwright local 1.59.1, Chromium do Playwright já instalado em %USERPROFILE%\AppData\Local\ms-playwright\chromium-1217\, sessão persistente CDP em scripts/_out/cdp.txt, viewport 1440×900 (com setViewportSize — note que window.innerHeight pode ficar em 1000 mesmo após setar 900, ver § 5).
2. Navegação
- URL da lista:
https://app.sagazchat.com/flowbuilders - URL de um fluxo:
https://app.sagazchat.com/flowbuilder/<id-numérico>(ex:/flowbuilder/533) - Sidebar:
Criação › Fluxos de Conversa. Usepage.locator('text=/^Fluxos de Conversa$/').first()ou navegue direto pela URL. - Cadastro de Agenda: fica no sidebar em Automação › Agendamentos. Precisa existir antes para ser selecionada no bloco Agenda.
- Cadastro de Variável global: fica em Configurações › Variável global. Precisa existir antes para aparecer no combobox do bloco Variável global.
- Cadastro de Remarketing: fica no módulo Remarketing. Precisa existir antes para usar os sub-tipos de inscrição/descadastro em remarketing. Detalhe operacional em
remarketing. - Cadastro de Assistente IA: fica em Assistentes IA. Precisa existir antes para usar o sub-tipo Assistente IA dentro do bloco Ação.
- Biblioteca de mídias: fica em Configurações → Bibliotecas (
/library). Mídias cadastradas ali podem ser selecionadas como fonte Biblioteca no bloco Conteúdo, sem novo upload.
Princípio: não recarregar a página entre operações. Cada
page.goto“atualiza” a tela visualmente — irritante e custoso. Trabalhe na página atual sempre que possível; só vá pra/flowbuildersquando precisar de fato listar/criar/excluir fluxos.
3. Mapa da UI por área
Coordenadas válidas para viewport 1440×900. Em viewport diferente, use getBoundingClientRect ao vivo.
3.1 Tela /flowbuilders (lista)
| Elemento | Posição típica | Seletor |
|---|---|---|
| Título “Fluxos” | (335, 30) | — |
| Busca “Pesquisar…” | (1080, 30) | input[placeholder="Pesquisar..."] |
| Botão + Adicionar | (~1290, 20) | getByRole('button', { name: /^Adicionar$/ }) |
| Botão Nova pasta | direita do Adicionar | getByRole('button', { name: /^Nova pasta$/ }) |
| Botão Importar | direita do Nova pasta | getByRole('button', { name: /^Importar$/ }) |
| Botão Raw Import | direita do Importar | getByRole('button', { name: /Raw Import/i }) |
| Linha de pasta | y varia | text=/^<NomeDaPasta>$/ |
| Linha de fluxo | y varia | text=/^<NomeDoFluxo>$/ |
Kebab ⋮ da linha | x ≈ 1380, mesma y da linha | botão MUI sem aria; identificar pela posição |
Pasta tem 2 ações no kebab: Editar nome / Apagar (validado pelo usuário).
Fluxo tem ações no kebab: Editar / Excluir / outras (Duplicar, Exportar — [a validar]).
3.2 Modais da lista
3.2.1 Modal Criar fluxo (botão Adicionar) — 2 etapas [validado]
Etapa 1 — Selecionar canal: modal mostra “Selecione o canal do fluxo” com cartões clicáveis “WhatsApp” e “Instagram” (não há combobox, não há input). Click no cartão avança.
Etapa 2 — Nome + Atalhos: modal mostra “Adicionar fluxo” com:
- Input
name(texto) - Input
shortcutItems[0].valuecom placeholder “Ex: enviar” - Botões: “Adicionar atalho”, “Cancelar”, “Adicionar” (verde — confirma)
await page.getByRole('button', { name: /^Adicionar$/ }).click();
await page.waitForTimeout(1500);
await page.locator('[role="dialog"]').getByText(/^WhatsApp$/, { exact: true }).first().click();
await page.waitForTimeout(1800);
await page.locator('[role="dialog"] input').first().fill('NOME_FLUXO');
await page.locator('[role="dialog"]').getByRole('button', { name: /Adicionar/i }).last().click();
await page.waitForTimeout(4000);
// Pode redirecionar pra /flowbuilder/<id>; se não, abrir manualmente pelo nome.
3.2.2 Modal Excluir fluxo [validado pelo cleanup]
Header “Deletar <nome>?” + texto “Tem certeza que deseja deletar este fluxo? Todas as integrações relacionados serão perdidos.” + botões Cancelar / Ok.
⚠️ Mesmo modal reaproveitado pelo Kanban (ver kanban § 5.5). Botão de confirmação é “Ok”, não “Excluir”.
3.2.3 Modal Nova pasta [validado abertura]
Modal simples com input de nome + botões Cancelar/Adicionar.
3.2.4 Modal Importar / Raw Import [validado abertura]
Importar: cola código exportado de outro fluxo. Raw Import: variante de importação raw (JSON cru). Confirmação efetiva [a validar].
3.3 Construtor /flowbuilder/<id>
| Elemento | Posição | Seletor |
|---|---|---|
Voltar < | (~290, 30) | botão sem aria |
| Nome do fluxo | (~420, 30) | h1/h2 |
| Botão Compartilhar fluxo | (~1265, 35) | getByRole('button', { name: /Compartilhar/i }) |
| Botão Salvar (header) | (~1385, 35) | botão fora de qualquer dialog, r.y < 80 |
| Tesoura ✂ | (~1115, 35) | função [a validar] |
| Ícone ⊕ | (~1155, 35) | função [a validar] |
| Paleta lateral | x ∈ [298, 432] | container scrollável com [draggable="true"] |
| Canvas | x > 432 | .react-flow |
3.4 Paleta de blocos [validado em 2026-05-09]
12 blocos visíveis (via scroll top + bottom):
| Ordem | Bloco | Categoria (user-facing) | Tipo |
|---|---|---|---|
| 1 | Conteúdo | Mensagem | Multi-item (8 cartões) |
| 2 | Menu | Mensagem | Pergunta com opções |
| 3 | Ação | Meta-bloco | Combobox de 12 sub-tipos |
| 4 | Randomizador | Fluxo | Saídas A/B com % |
| 5 | Salvar | Dados | Captura resposta em variável |
| 6 | Integração | Integração | HTTP outbound |
| 7 | Condição | Fluxo | Branching lógico |
| 8 | Atraso inteligente | Operação | Pausa programada |
| 9 | Distribuidor | Fluxo | Saídas rotativas |
| 10 | Variável global | Dados | Set/somar/subtrair |
| 11 | OpenAI | Integração | ChatGPT API |
| 12 | Agenda | Operação | Agendamento de evento |
Plus Início (node fixo .react-flow__node-start, não está na paleta).
Divergência com
fluxos-blocos.mduser-facing (que lista 18 blocos): Etiqueta, Departamentos, Remarketing, Notificação, Conexão de fluxo, Manipulador, Controlador de chat NÃO são blocos drag separados — são sub-tipos do bloco Ação. Pixel e Cartão pix (também sub-tipos de Ação) não estão no user-facing. Assistente IA (sub-tipo de Ação) não está no user-facing. OpenAI (bloco drag) não está no user-facing.
3.5 Painéis dos blocos
3.5.1 Bloco Conteúdo [validado — todos os 8 tipos]
Heading: “Adicionar conteúdo ao fluxo”.
Painel mostra 8 cartões de tipo de mídia em 2 linhas (4×2). Todos os tipos (validados em 2026-05-09 via sagaz-fluxos-conteudo-tipos.mjs):
| Cartão | Campos do item | Aceita arquivo (accept) |
|---|---|---|
| Texto | Editor whatsapp-editor (contenteditable) com toolbar B / I / Tachado / Emoji / Variável {} | — |
| Intervalo | Slider + input number “Tempo em segundos” (unidade fixa em segundos) | — |
| Imagem | Textarea “Legenda” (opcional) + <input type="file"> | .png, .jpg, .jpeg |
| Áudio | (sem legenda) + <input type="file"> | audio/ogg, audio/mp3, audio/opus |
| Vídeo | Textarea “Legenda” + <input type="file"> | video/mp4 |
| Arquivo | Textarea “Legenda” + <input type="file"> | .doc, .docx, .pdf, .txt, .xlsx, .xls, .csv, .zip, .rar, .json, .pptx |
| Contato | Nome (placeholder “Exemplo da silva”) + Organização (opcional) (placeholder “Exemplo LTDA”) + Número com seletor de país (🇧🇷 +55) | — |
| Sticker | (sem legenda) + <input type="file"> | .png, .jpg, .jpeg, .gif |
Fonte Biblioteca (v4.24.1): para itens de mídia, o painel também permite selecionar uma mídia já cadastrada em Configurações → Bibliotecas. A seleção reutiliza o arquivo por referência; não faça novo upload. A descrição/categoria da mídia permanece a fonte de contexto para a IA.
Características importantes:
- Empilhável: clicar em cartão adiciona novo item ao painel atual (sem fechar modal). Vários itens encadeados num único node Conteúdo. Não usar 1 node por mensagem.
- Lixeira por item: cada item adicionado tem ícone trash em x ≈ 1056 (path SVG
M6 19c0 1.1.9 2 2 2h8c1.1...). Click remove só esse item. - Painel é rolável: com muitos itens, lixeiras/cartões saem do viewport — usar
scrollIntoViewantes de clicar (ver § 5). - Modo edição vs criação: botão muda de “Adicionar” (criar novo node) pra “Salvar” (editar node existente).
- Áudio e Sticker NÃO aceitam legenda; os outros 3 (Imagem/Vídeo/Arquivo) aceitam.
- Upload via Playwright: usar
page.locator('[role="dialog"]:visible input[type="file"]').setInputFiles('caminho/arquivo.ext'). Conferir oacceptcorreto antes de upload.
Snippet — adicionar Imagem com legenda:
// Painel Conteúdo aberto
await clicarCartao('Imagem');
// Preencher legenda (último textarea[name="text"] do dialog)
const legendaPos = await page.evaluate(() => {
const v = Array.from(document.querySelectorAll('[role="dialog"]')).filter(d => {
const r = d.getBoundingClientRect();
return r.width > 50 && getComputedStyle(d).visibility !== 'hidden';
});
const dlg = v[v.length - 1];
const tas = Array.from(dlg.querySelectorAll('textarea[placeholder="Legenda"]'));
if (!tas.length) return null;
const t = tas[tas.length - 1];
t.scrollIntoView({ block: 'center' });
const r = t.getBoundingClientRect();
return { x: r.x + r.width/2, y: r.y + r.height/2 };
});
await page.mouse.click(legendaPos.x, legendaPos.y);
await page.keyboard.type('Olha que fofo!', { delay: 12 });
// Upload do arquivo
await page.locator('[role="dialog"]:visible input[type="file"]').last().setInputFiles('C:/caminho/foto.jpg');
await page.waitForTimeout(1500);
Snippet — adicionar Contato:
await clicarCartao('Contato');
// Inputs: Nome + Organização (opcional). Número usa componente customizado (não input simples)
const nomePos = await page.evaluate(() => {
const v = Array.from(document.querySelectorAll('[role="dialog"]')).filter(d => {
const r = d.getBoundingClientRect();
return r.width > 50 && getComputedStyle(d).visibility !== 'hidden';
});
const dlg = v[v.length - 1];
const inps = Array.from(dlg.querySelectorAll('input[placeholder="Exemplo da silva"]'));
if (!inps.length) return null;
const i = inps[inps.length - 1];
i.scrollIntoView({ block: 'center' });
const r = i.getBoundingClientRect();
return { x: r.x + r.width/2, y: r.y + r.height/2 };
});
await page.mouse.click(nomePos.x, nomePos.y);
await page.keyboard.type('Fulano Exemplo', { delay: 12 });
// Idem pra "Exemplo LTDA" (organização) — placeholder
// Pra Número: localizar input próximo ao seletor 🇧🇷 e digitar dígitos (formato +55 já default)
3.5.2 Bloco Menu [validado]
Heading: “Adicionar menu ao fluxo”.
Campos:
- Radio “Tipo”: Número / Emoticon / Lista (default Número)
- “Mensagem de explicação do menu” —
textarea[name="whatsapp-editor"] - Botão “Adicionar resposta” — cada click adiciona slot de resposta (input text)
- Combobox “Qual tipo de tempo”: Minutos / Horas / Dias (timeout) — vem default em “Minutos” (≠ Salvar que vem vazio)
- Botões: Cancelar / Salvar
Cada resposta vira uma saída do node com handle source data-handleid="aN" (a1, a2, a3…).
⚠️ Editar Menu existente preserva handles e adiciona novos [validado 2026-05-10]: ao adicionar uma 5ª resposta num Menu que já tinha 4, o sistema cria automaticamente data-handleid="a5" mantendo a1..a4 intactos. Edges existentes nas saídas anteriores não são quebradas.
3.5.3 Bloco Ação (meta-bloco) [validado]
Heading: “Editar ação” ou “Adicionar ação”.
Campos iniciais:
- Botão “Remover todas as etiquetas”
- Combobox “Adicionar uma ação” — placeholder
Digite para buscar… (ex: adicionar etiqueta) - Texto “Selecione no dropdown para adicionar o bloco da ação.”
- Botões: Cancelar / Salvar
Combobox lista 12 sub-tipos:
| # | Sub-tipo | Campos resultantes |
|---|---|---|
| 1 | Conexão de fluxo | Combobox “Selecione o fluxo” |
| 2 | Assistente IA | Combobox “Suporte IA” (Transferir para) |
| 3 | Adicionar etiqueta | Combobox “Selecione ou crie uma etiqueta” (Enter cria nova) |
| 4 | Remover etiqueta | Combobox “Selecione uma etiqueta” |
| 5 | Inscrição em Remarketing | Combobox “Selecione um remarketing”; salva sequenceId, sequenceName, subscriber: true |
| 6 | Descadastrar do Remarketing | Mesmo combobox; salva subscriber: false |
| 7 | Redirecionar para departamento | Combobox “Selecione o departamento” |
| 8 | Controlador de chat | Combobox “Escolha um estado” (Aguardando/Atendendo/Resolvido) |
| 9 | Notificar membro da equipe | Nome (texto) + Número (+55 (13) 91234 4321) + Mensagem |
| 10 | Manipulador | Var + op + tipo de valor + valor — campos aparecem progressivamente (ver detalhe abaixo) |
| 11 | Pixel | Combobox “Tipo de tracking” (Facebook por default) |
| 12 | Cartão pix | Nome + Tipo da chave (CPF default) + Chave pix |
⚠️ Aviso descoberto: “Conexão de fluxo ou Assistente IA ativo: outras ações ficam desativadas.” — esses 2 são exclusivos.
Manipulador — campos progressivos [validado 2026-05-10]
Os campos do sub-tipo Manipulador aparecem em sequência conforme preenche:
input[placeholder="Conta1"]— Nome da variável (placeholder estranho mas aceita qualquer nome, ex:servico_escolhido)input[placeholder="Qual tipo de operação"]— combobox com 6 opções:Definir valor·Somar ao valor·Subtrair ao valor·Dividir ao valor·Multiplicar ao valor·Conta matemáticainput[placeholder="Qual tipo de valor"]— só aparece após selecionar operação. Combobox com 2 opções:Texto/Númeroinput[placeholder="Texto"]ouinput[placeholder="Número"]— campo de valor, só aparece após selecionar tipo de valor. Atenção: distinguir do combobox anterior — pegar pelogetAttribute('role') !== 'combobox'.
⚠️ Não tente preencher em paralelo — se você pular pra preencher o valor antes de selecionar o tipo, o campo nem existe ainda.
3.5.4 Bloco Salvar [validado]
Heading: “Adicione Salvar”.
Campos:
- “Tempo mínimo caso o cliente não responda:” — input
name="timer"(number) + combobox “Qual tipo de tempo” (Minutos/Horas/Dias) - “Mensagem antes de aguardar a resposta:” (Opcional) —
whatsapp-editor(contenteditable). É AQUI que vai a pergunta (ex: “qual é o nome do seu pet?”). - “Campo para salvar a informação no usuário:” — textarea (não input!) com placeholder “Nome do campo”
- Switch “Aceitar mídias como resposta”
- “Tentativas para respostas erradas:” (Opcional) — input number, placeholder “Número de tentativas”
- “Mensagem para avisar que a resposta está errada:” (Opcional) —
whatsapp-editor - Botões: Cancelar / Adicionar
⚠️ Pergunta sempre vai dentro do bloco que captura. Errado: Conteúdo “Qual seu nome?” → Salvar (campo). Certo: Salvar (Mensagem antes = pergunta + campo). Senão a variável fica órfã.
⚠️ “Nome do campo” é <textarea>, não <input> — usar seletor textarea[placeholder="Nome do campo"].
⚠️ Combobox “Qual tipo de tempo” é OBRIGATÓRIO (validado 2026-05-10 em ESTETICA-EXEMPLO). Sem selecionar Minutos/Horas/Dias, click em “Adicionar” é silenciosamente ignorado — dialog não fecha, sem mensagem de erro visível. Sempre selecionar antes de confirmar:
const cbPos = await page.evaluate(() => {
const v = Array.from(document.querySelectorAll('[role="dialog"]'))
.filter(d => { const r = d.getBoundingClientRect(); return r.width > 50 && getComputedStyle(d).visibility !== 'hidden'; });
const dlg = v[v.length - 1];
const inp = dlg.querySelector('input[placeholder="Qual tipo de tempo"]');
inp.scrollIntoView({ block: 'center' });
const r = inp.getBoundingClientRect();
return { x: r.x + r.width/2, y: r.y + r.height/2 };
});
await page.mouse.click(cbPos.x, cbPos.y);
await page.waitForTimeout(800);
const opPos = await page.evaluate(() => {
const m = Array.from(document.querySelectorAll('[role="option"], li.MuiAutocomplete-option')).find(o => /^Minutos$/i.test((o.innerText || '').trim()));
const r = m.getBoundingClientRect();
return { x: r.x + r.width/2, y: r.y + r.height/2 };
});
await page.mouse.click(opPos.x, opPos.y);
⚠️ whatsapp-editor NÃO existe no painel Conteúdo (item Texto) mas EXISTE no painel Salvar (Mensagem antes / Mensagem de erro). No Conteúdo/Texto o seletor é textarea[name="text"]. Os dois coexistem na plataforma.
3.5.5 Bloco Randomizador [validado]
Heading: “Adicionar um randomizador ao fluxo”.
Campos:
- Slider 0% – 100% (divide saídas A/B)
- Botões: Cancelar / Adicionar
3.5.6 Bloco Condição [validado]
Heading: “Adicionar condição ao fluxo”.
Campos:
- Texto explicativo “Defina as condições e regra lógica para que o fluxo continue pela saída superior deste bloco:”
- Combobox “Selecionar condição” — 8 opções:
- Etiqueta · Dia da semana · Atendimento pendente · Atendimento aberto · Atendimento fechado · Nome · Número · Email
- Radio “Regra corresponde a todas as condições (e)” / “Regra corresponde a qualquer condição (ou)“
3.5.7 Bloco Distribuidor [validado]
Heading: “Adicionar distribuidor ao fluxo”.
- Botão “Adicionar saída” — cada click adiciona saída numerada
- Botões: Cancelar / Salvar
Distribui leads em rotação entre as saídas.
3.5.8 Bloco Variável global [validado]
Heading: “Adicione variável global”.
Campos:
- Combobox “Variável que será utilizada” (lista variáveis globais existentes)
- Combobox “Escolha qual operação” — 3 opções: Definir valor (=) / Somar ao valor (+) / Subtrair ao valor (-)
- Input “Valor” (text)
⚠️ Pré-requisito real: a variável global precisa estar criada em Configurações antes de usar este bloco. Se ela não existir, criar primeiro no módulo de Configurações; depois voltar ao Fluxos de Conversa e selecionar a variável no combobox. Um payload manual com nome/id inventado pode até renderizar visualmente, mas não deve ser tratado como configuração funcional se não veio de uma variável global real da conta.
Nota: Manipulador (sub-tipo da Ação) tem 6 operações, não 3 como Variável global — diferença real entre os dois blocos. Ver § 3.5.3 nota sobre Manipulador.
3.5.9 Bloco Atraso inteligente [validado]
Heading: “Adicionar um intervalo inteligente”.
Campos:
- Input number “Tempo” + combobox “Qual tipo de tempo” (Minutos/Horas/Dias)
- Switch (alternativa: Data específica) → input “DD/MM/YYYY hh:mm”
Diferente do Intervalo do Conteúdo (que é só segundos).
3.5.10 Bloco Integração [validado]
Heading: “Adicionar requisição HTTP”.
Campos:
- Combobox “Tipo de requisição”: POST / PUT / GET
- Input “Url da requisição” (
name="baseUrl", placeholderhttps://teste.exemplo/1234556/{id}) - Tabs: Header da requisição / Corpo da requisição / Mapear resposta
- Header padrão:
{ "Content-Type": "application/json", "Cache-Control": "no-cache" } - Botão “Testar requisição”
3.5.11 Bloco Agenda [validado]
Heading: “Criar evento na agenda”.
Campos extensos (~30 labels): combobox Agenda, dias a frente, textos do menu para escolher data/hora, dados do evento (título, mensagem, cor de fundo, cor do texto), aviso de compromisso. Painel é rolável.
⚠️ Pré-requisito real: precisa existir uma Agenda criada na conta em Automação para aparecer no combobox e ser selecionada no fluxo. Se não existir, criar a agenda primeiro pelo sidebar em Automação; depois voltar ao Fluxos de Conversa e selecionar essa agenda no bloco. Um payload manual com calendar: { id, name } pode renderizar visualmente o node, mas não deve ser tratado como configuração funcional se esse id não veio de uma agenda real da conta.
Cadastro observado em 2026-05-10:
- UI: sidebar Automação › Agendamentos (
/calendars) › botão Criar agenda. - Modal de criação: campo único
input[name="name"]+ botões Cancelar / Adicionar. - API interna usada pelo frontend:
GET /calendars?searchParam=&pageNumber=1lista agendas;POST /calendarscom payload{ "name": "Nome da agenda" }cria agenda. - Exemplo real criado na conta:
LAB - Agenda Flowbuilder,id: 35,uuid: 9eab2ae4-7ae1-4fba-a052-ca7f303464ed.
Configuração da agenda observada em 2026-05-10:
- A lista
/calendarstem menu de três pontos com Editar / Excluir. Editar nesta lista só renomeia a agenda; não é a tela completa de disponibilidade. - Para configurar disponibilidade, abrir a agenda pela linha/lista ou acessar a rota interna
/calendar/<uuid>/<nome-url-encoded>. Exemplo real:/calendar/9eab2ae4-7ae1-4fba-a052-ca7f303464ed/LAB%20-%20Agenda%20Flowbuilder. - Na tela da agenda, clicar no botão Configurar no topo direito. Isso abre o modal Configurar agenda. Cuidado: existe item Configurações no sidebar; não confundir com o botão Configurar da agenda.
- O modal carrega
GET /calendars/<uuid>/settings. Se ainda não existe configuração, a resposta vemnull. - Campos do modal:
- Quais dias da semana não é possível agendar? combobox multi-select. Precisa clicar/expandir. Opções observadas:
Dom,Seg,Ter,Qua,Qui,Sex,Sab. - Dias em que não farei agendamentos neste calendário lista os dias indisponíveis selecionados. Botão textual Adicionar confirma/adiciona a seleção atual.
- Horários:
Inicia os agendamentos(07:00placeholder),Entra em pausa(12:00),Volta da pausa(14:00),Fim dos agendamentos(17:00). - Intervalos: switch entre Intervalo fixo e Intervalo específico.
- No modo Intervalo fixo aparecem número + combobox de tipo de tempo. O combobox precisa ser expandido; opções observadas:
Minutos,Horas,Dias. - No modo Intervalo específico, clicar no switch para a direita. Abre a seção Horários disponíveis com botão textual Adicionar; cada clique adiciona um input de horário com placeholder
07:00. Usar quando os horários não seguem uma cadência fixa. - Duração dos eventos: número + combobox de tipo de tempo. Opções observadas:
Minutos,Horas,Dias.
- Quais dias da semana não é possível agendar? combobox multi-select. Precisa clicar/expandir. Opções observadas:
- Botões finais: Sair sem salvar e Salvar. Ao automatizar, clicar no botão com texto exatamente
Salvar, porqueSair sem salvartambém contém a palavra “salvar”. - Payload interno observado ao salvar configuração básica:
{
"specificDays": [],
"specificRange": [],
"times": {
"startTime": "09:00",
"startTimePause": "12:00",
"endTimePause": "13:00",
"endTime": "18:00"
},
"rangeMarked": {
"rangeMarkedTime": 30,
"rangeMarkedType": "Minutos"
},
"valueDefaultdaysWeek": [],
"duration": {
"number": "60",
"type": "Minutos"
}
}
- API interna usada pelo frontend para salvar:
PUT /calendars/<uuid>/settings. - Resposta real da agenda lab após salvar:
calendarId: 35,rangeMarkedTime: 30,rangeMarkedType: "Minutos",startTime: "09:00",startTimePause: "12:00",endTimePause: "13:00",endTime: "18:00",intervalNumber: 60,intervalPeriod: "Minutos". - Para outra IA: sempre abrir/expandir comboboxes e switchs antes de declarar que a agenda foi mapeada. Parte da configuração só aparece após clicar no switch Intervalo específico ou nos botões textuais Adicionar.
⚠️ Nomenclatura cruzada confusa request→response:
rangeMarked.rangeMarkedTime(request) = “Intervalo fixo” do modal = intervalo entre demos (60min = 1 demo/hora).duration.number(request) → viraintervalNumber(response) = “Duração dos eventos” do modal = duração de cada demo (30min).- Ou seja: o backend chama de
intervalNumbero que o frontend chama de “duration” e vice-versa. Não confiar no nome do campo na response — alinhar pelo modal visual ou pelo payload do request. valueDefaultdaysWeek: ['Dom', 'Sab'](request) virahiddenDays: [0, 6](response). Mapeamento: Dom=0, Seg=1, Ter=2, Qua=3, Qui=4, Sex=5, Sab=6.
⚠️ Chamadas diretas via fetch() em page.evaluate precisam de Bearer token:
- Cookies sozinhos retornam
401 ERR_SESSION_EXPIRED. Frontend usaAuthorization: Bearer <jwt>explícito. - Token está em
localStorage.token(string com aspas duplas — precisaJSON.parse). - Refresh token em
localStorage.tokenRefresh. - Padrão pra usar:
const v = localStorage.getItem('token');
const token = v?.startsWith('"') ? JSON.parse(v) : v;
const res = await fetch(`https://backend.sagazchat.com/calendars/${uuid}/settings`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
body: JSON.stringify(payload),
});
- Vale pra qualquer endpoint
backend.sagazchat.comchamado de dentro da página via fetch. Via UI nativa o frontend já injeta isso automaticamente.
3.5.12 Bloco OpenAI [validado]
Heading: “Adicionar ChatGPT API”.
Campos:
- “Pergunta ou mensagem para o cliente” — input text, placeholder “Qual sua dúvida sobre nossos produtos?”
- “Modelo do chatGPT” — combobox, opções:
gpt-4.1,gpt-4o,gpt-4o-mini,gpt-4-turbo,gpt-5.1,gpt-5-mini,gpt-5-nano,gpt-5.2 - “Chave de API (Token)” — input text (
name="token") - “Prompt” — textarea (
name="headerHttp"), placeholder “Coloque aqui tudo que IA precisa saber para informar ao cliente.” - Botões: Cancelar / Adicionar
3.5.13 Bloco Início [validado por regra operacional]
O Início é apenas onde começa o fluxo. Ele é o node fixo .react-flow__node-start, não fica na paleta e deve estar conectado ao primeiro bloco real da jornada. Não tratar o Início como lugar para configurar etiqueta, departamento, remarketing ou qualquer outro efeito; use blocos Ação dedicados depois do Início.
3.6 Guia geral: quando usar cada bloco
Esta seção é o mapa principal para outra IA saber usar o criador de fluxo como produto, independentemente do fluxo PETSHOP ou ESTETICA.
| Necessidade no fluxo | Bloco correto | Observação operacional |
|---|---|---|
| Começar o fluxo | Início | Node fixo; não vem da paleta. Serve para iniciar e conectar ao primeiro bloco real. |
| Enviar texto, imagem, áudio, vídeo, arquivo, contato, sticker ou pausa curta | Conteúdo | Empilhar múltiplos itens no mesmo node. Intervalo aqui é em segundos. |
| Fazer escolha com opções numeradas/lista/emoticon | Menu | Cada opção vira saída a1, a2, a3… Conectar todas as opções relevantes. |
| Capturar nome, telefone, dúvida, preferência ou qualquer resposta livre | Salvar | A pergunta vai dentro do próprio bloco Salvar, não em Conteúdo anterior. |
| Aplicar etiqueta, remover etiqueta, enviar para departamento, notificar equipe, manipular variável, conectar fluxo, remarketing, pixel, pix, IA assistente | Ação | É o meta-bloco de efeitos. Etiqueta/Departamento/Notificação não aparecem como blocos separados na paleta. |
| Criar bifurcação por regra | Condição | Usar para etiqueta, dia da semana, atendimento pendente/aberto/fechado, nome, número ou email. Conectar saída verdadeira e fallback. |
| Dividir tráfego por percentual | Randomizador | Útil para teste A/B ou distribuição probabilística. Conectar as duas saídas. |
| Revezar atendimento entre pessoas/filas | Distribuidor | Adicionar uma saída por destino. Depois conectar cada saída para o departamento/pessoa correta. |
| Alterar variável global existente | Variável global | Exige variável global criada antes em Configurações. Não confundir com Manipulador, que mexe em variável do contato/fluxo. |
| Esperar minutos/horas/dias ou data específica | Atraso inteligente | Usar para follow-up programado. Para pausa humana curta entre mensagens, usar Intervalo dentro de Conteúdo. |
| Chamar API externa | Integração | Configurar método, URL, headers, body e mapeamento. Usar “Testar requisição” antes de confiar. |
| Criar agendamento | Agenda | Exige agenda criada antes em Automação. Painel é longo e deve ser preenchido com scroll interno. |
| Responder com IA via ChatGPT API | OpenAI | Exige token, modelo e prompt. Usar com prompt fechado e saída humana de fallback. |
3.7 Guia geral: sub-tipos do bloco Ação
O bloco Ação é onde ficam vários recursos que parecem blocos independentes na documentação user-facing. No canvas, eles são sub-tipos dentro do mesmo painel.
| Sub-tipo de Ação | Usar para | Cuidados |
|---|---|---|
| Conexão de fluxo | Encaminhar para outro fluxo | É exclusivo: quando ativo, outras ações ficam desativadas. |
| Assistente IA | Transferir para suporte/assistente IA | Exige assistente criado antes em Assistentes IA. Também é exclusivo com outras ações. |
| Adicionar etiqueta | Marcar interesse, etapa, serviço, status ou intenção | Antes de criar, verificar etiqueta existente. Autocomplete pode criar duplicata. |
| Remover etiqueta | Limpar status antigo ou tirar usuário de uma etapa | Só usar quando a remoção faz parte da regra de negócio. |
| Inscrição em Remarketing | Entrar em campanha/lista de remarketing | Exige remarketing criado antes no módulo Remarketing. Campo: combobox “Selecione um remarketing”. Payload: { sequenceId, sequenceName, subscriber: true }. |
| Descadastrar do Remarketing | Remover de campanha/lista | Exige remarketing criado antes no módulo Remarketing. Campo: combobox “Selecione um remarketing”. Payload: { sequenceId, sequenceName, subscriber: false }. |
| Redirecionar para departamento | Mandar para Comercial, Financeiro, Suporte etc. | Não é “Distribuidor”; envia para uma fila/departamento específico. |
| Controlador de chat | Mudar estado do atendimento | Estados vistos: Aguardando, Atendendo, Resolvido. Usar com cuidado porque afeta operação. |
| Notificar membro da equipe | Avisar alguém por nome/número/mensagem | Bom para lead quente, agendamento solicitado, pagamento, exceção ou erro. |
| Manipulador | Definir/somar/subtrair/dividir/multiplicar variável do contato/fluxo | Campos aparecem progressivamente; não preencher fora de ordem. |
| Pixel | Disparar tracking | Não priorizar agora. Tipo padrão observado: Facebook. Validar conta/evento antes de produção. |
| Cartão pix | Enviar dados de pagamento PIX | Exige nome, tipo de chave e chave pix. Usar só em fluxo financeiro validado. |
3.8 Checklist para declarar um bloco “mapeado”
Uma IA só deve considerar um bloco mapeado quando souber:
- Como abrir o painel do bloco.
- Quais campos obrigatórios aparecem.
- Quais campos aparecem só depois de uma escolha anterior.
- Qual botão confirma (
AdicionarouSalvar). - Como o bloco cria saídas/handles no React Flow.
- Como validar no DOM ou no JSON salvo.
- Qual erro comum faz o modal ficar aberto sem feedback.
- Se a ação é destrutiva, operacional ou apenas visual.
Se algum item estiver ausente, marcar como [a validar] no runbook em vez de inventar.
4. Ações seguras vs disparadoras
| Ação | Disparadora? | Observação |
|---|---|---|
| Listar fluxos / pastas | Não | — |
| Abrir modal Criar fluxo | Não | Cancelar fecha sem efeito |
| Criar fluxo | Sim | Cria registro real; cleanup necessário |
| Editar nome do fluxo | Sim | — |
| Excluir fluxo | Sim — destrutiva | Apaga fluxo + integrações relacionadas |
| Excluir pasta | Sim — destrutiva | [a validar] se apaga fluxos dentro |
| Abrir construtor | Não | — |
| Adicionar bloco (click paleta) | Não | Cria node não-persistido até salvar |
| Editar painel + Cancelar | Não | — |
| Editar painel + Salvar/Adicionar | Não | Persiste local (não salva no servidor até Salvar do header) |
| Salvar fluxo (header) | Sim | Persiste tudo no backend |
| Deletar item dentro do painel Conteúdo | Não | Local até salvar |
| Deletar node (Backspace) | Não | Local até salvar |
| Conectar handles | Não | Local até salvar |
5. Quirks descobertas em 2026-05-09
5.1 Viewport e elementos rolados
setViewportSize({ width: 1440, height: 900 })maswindow.innerHeightpode reportar 1000. Não confiar em altura fixa — sempre comparar comwindow.innerHeightao testar visibilidade.- Painéis longos rolam dentro do dialog. Itens em
y > viewportHnão respondem amouse.click(x, y)mesmo quegetBoundingClientRectretorne aquela coordenada. Sempreelement.scrollIntoView({ block: 'center', behavior: 'instant' })antes, depois re-pegargetBoundingClientRect, depois clicar. Padrão validado:
await page.evaluate(() => {
const last = document.querySelector('SELETOR_DO_ALVO');
last.scrollIntoView({ block: 'center', behavior: 'instant' });
});
await page.waitForTimeout(700);
const pos = await page.evaluate(() => {
const el = document.querySelector('SELETOR_DO_ALVO');
const r = el.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
});
await page.mouse.click(pos.x, pos.y);
5.2 Filtro hasText: /^OpenAI$/ falha no Playwright
page.locator('[draggable="true"]').filter({ hasText: /^OpenAI$/ }) retorna count 0 mesmo com “OpenAI” presente. Workaround: re-mapear via page.evaluate retornando (innerText || '').trim() direto e clicar por coordenada. Padrão:
const blocos = await page.evaluate(() =>
Array.from(document.querySelectorAll('[draggable="true"]')).map(el => {
const r = el.getBoundingClientRect();
return { text: (el.innerText || '').trim(), x: r.x + r.width/2, y: r.y + r.height/2 };
})
);
const oai = blocos.find(b => /openai/i.test(b.text));
await page.mouse.click(oai.x, oai.y);
5.3 evaluate(fn, arg1, arg2) com múltiplos args = erro
Playwright só aceita 1 arg. Sempre passar como objeto:
// Errado: await page.evaluate((a, b) => ..., 1, 2)
// Certo:
await page.evaluate(({ a, b }) => ..., { a: 1, b: 2 });
5.4 Botão “Adicionar” do dialog via getByRole falha às vezes
Em painéis pesados, [role="dialog"]:visible não bate corretamente. Usar coordenada via evaluate:
async function clicarBotaoNoDialog(nome) {
const pos = await page.evaluate((n) => {
const v = Array.from(document.querySelectorAll('[role="dialog"]'))
.filter(d => { const r = d.getBoundingClientRect(); return r.width > 50 && getComputedStyle(d).visibility !== 'hidden'; });
const dlg = v[v.length - 1];
const btn = Array.from(dlg.querySelectorAll('button')).find(b => (b.innerText || '').trim() === n);
if (!btn) return null;
btn.scrollIntoView({ block: 'center', behavior: 'instant' });
const r = btn.getBoundingClientRect();
return { x: r.x + r.width/2, y: r.y + r.height/2 };
}, nome);
if (pos) { await page.mouse.click(pos.x, pos.y); await page.waitForTimeout(2500); }
}
5.5 Modal residual cancela sem querer
fecharModais() que aperta Cancelar em qualquer dialog visível pode cancelar painéis abertos legítimos. Não chamar fecharModais() entre clicarBotaoNoDialog('Adicionar') e Salvar fluxo — o painel já fechou naturalmente após Adicionar, qualquer Cancelar adicional vai fechar o dialog ERRADO.
5.6 Lixeira de item Conteúdo
Cada item adicionado dentro do bloco Conteúdo tem ícone SVG de delete em x ≈ 1056, path começando com M6 19c0 1.1.9 2 2 2h8c1.1.... Filtrar:
const lixeiras = Array.from(dlg.querySelectorAll('svg')).filter(svg => {
const r = svg.getBoundingClientRect();
if (r.x < 1030 || r.x > 1080) return false;
const path = svg.querySelector('path');
return path && /^M6 19c0/.test(path.getAttribute('d') || '');
});
5.7 Pergunta dentro do bloco que captura
Pra capturar resposta livre do cliente: pergunta vai em whatsapp-editor do bloco Salvar (campo “Mensagem antes de aguardar a resposta”) + nome do campo em textarea[placeholder="Nome do campo"]. NUNCA colocar a pergunta num Conteúdo separado anterior — a variável captura n mensagens depois mas não associa à pergunta original.
5.8 Conteúdo aceita múltiplos itens — não use 1 node por mensagem
Errado: 5 nodes Conteúdo (3 Texto + 2 Intervalo) encadeados
Certo: 1 node Conteúdo com 5 itens dentro
Para sequência humana de mensagens: empilhar Texto + Intervalo + Texto + Intervalo + Texto no mesmo bloco.
5.9 1s = robótico
Intervalos no Conteúdo são em segundos. Usar 3–5s entre mensagens para imitar tempo de digitação humano. 1s parece bot.
5.10 React Flow handles e conexão drag
- Orientação real (validada 2026-05-10 em fluxo
ESTETICA-EXEMPLO): o construtor do Sagazchat usa handles laterais, não verticais.- Source:
.react-flow__handle-right.sourceno node origem (inclusive.react-flow__node-start) - Target:
.react-flow__handle-left.targetno node destino (nodessingleBlock) - O
.react-flow__node-starttem só o handle right (source). Não tem left.
- Source:
- ⚠️ Runbook anterior falava em
bottom/top— falso para esta versão. Apêndice PETSHOP foi montado com a layout antiga; para novos fluxos, usar right/left. - Drag funciona com
mouse.move→mouse.down→mouse.moveem steps →mouse.up. Não precisa pointer events —mouse.*funciona. Adicionar 1 step intermediário em arco (srcX+30, srcY+10) ajuda o React Flow a registrar como drag intencional.
5.10a Criar bloco pela paleta é em DUAS FASES — node temporário + persistência
[validado 2026-05-10 em VENDA-SAGAZCHAT] Click (ou drag) em bloco da paleta:
- Fase 1 — criação temporária: o node aparece no canvas IMEDIATAMENTE com data-id próprio + painel de configuração abre (modal “Adicione X” ou “Adicionar X ao fluxo”).
- Fase 2 — persistência: só ao clicar o botão de confirmação do painel (“Adicionar” pra Conteúdo/Salvar/Ação, “Salvar” pra Menu) o node fica gravado.
- Cancelar OU ESC OU click em “Sair sem salvar” desfaz o node — ele some do canvas. Isso explica casos em que o painel “Adicione X” aparece num run, fecha por timeout/erro, e o node fica órfão visível por uns segundos antes do React Flow re-renderizar e o eliminar.
⚠️ Implicação operacional: ao automatizar, NUNCA assumir que clicar na paleta criou node final. Sempre:
- Conferir
[role="dialog"]aberto após click — se sim, painel temporário ativo. - Preencher TODOS os campos obrigatórios antes de confirmar.
- Após confirmar, conferir que
[role="dialog"]fechou E quedocument.querySelectorAll('.react-flow__node:not(.react-flow__node-start)')cresceu em 1. - Se Cancelar foi acionado em qualquer ponto, o node não existe e abrir painel via dblclick em “último node” vai cair no node anterior — bug latente que confunde scripts.
Validação obrigatória do node criado: comparar lista data-id antes e depois. Se o set não cresceu, ABORTAR — o click na paleta foi absorvido por outro elemento ou o painel foi cancelado.
5.10a-bis Bloco Salvar exige timer > 0 + tipo de tempo selecionado
[validado 2026-05-10 em VENDA-SAGAZCHAT] No painel “Adicione Salvar”:
- Campo
input[name="timer"](primeiroinput[type="number"]) começa em 0. Se ficar 0, o botão Adicionar não persiste o node (sem mensagem de erro visível — falha silenciosa). - Combobox
input[placeholder="Qual tipo de tempo"]é obrigatório (Minutos/Horas/Dias). - O segundo
input[type="number"](placeholder “Número de tentativas”) é opcional. - Whatsapp-editor (Mensagem antes) e textarea (Nome do campo) são opcionais funcionalmente mas obrigatórios pra ser útil.
⚠️ Cuidado com numberInputs.last().fill(...) — pega o “Número de tentativas”, não o timer principal. Usar input[name="timer"] explicitamente ou nth(0).
5.10a-quater Bloco Ação às vezes não abre painel automaticamente
[validado 2026-05-10 em VENDA-SAGAZCHAT] Diferente de Conteúdo/Salvar/Menu que abrem painel imediatamente após click na paleta, o bloco Ação pode aparecer no canvas (node temporário criado, data-id novo) sem painel aberto. Causa não identificada — pode ser timing ou comportamento esperado.
Workaround: após click na paleta + waitTimeout(~2200ms), verificar se [role="dialog"] está aberto. Se não estiver, fazer page.locator([data-id=”${novoId}“]).dblclick() pra abrir o painel manualmente. Pelos testes, o dblclick funciona consistentemente.
Note que esse node “criado mas sem painel” persiste no canvas até ser configurado ou removido — não é um node temporário que some sozinho (diferente do caso da § 5.10a quando o painel abre e usuário cancela). É como se a Ação tivesse pulado direto pra Fase 2 (persistido) sem passar pela configuração.
5.10a-ter Bloco Menu: confirmação é “Salvar”, input de opção é “Digite opção”
[validado 2026-05-10] No painel “Adicionar menu ao fluxo”:
- Botão de confirmação é “Salvar”, não “Adicionar”.
- Tipo de menu (radio): default “number” (1️⃣ 2️⃣ 3️⃣) — outras opções:
emoticon,list. - Adicionar opções: botão “Adicionar resposta” dentro do painel. Cada click cria um
input[placeholder="Digite opção"]. - Timer inicial (campo “Tempo mínimo caso o cliente não responda”) é
input[type="number"]nth(0) — começa em 0. Setar > 0. - O botão “Salvar” do Menu pode ficar fora da viewport (y > 900 em viewport 1440×900). O
scrollIntoViewIfNeededdo Playwright às vezes falha (element “not visible”). Workaround validado: capturar a posição viagetBoundingClientRect()empage.evaluate(que internamente chamascrollIntoView({ block: 'center' })) e clicar a coordenada viapage.mouse.click(x, y)diretamente.
5.10b Deletar edges é frágil — não deletar sem autorização
[validado 2026-05-10] Operações de deletar edge tentadas e seus problemas:
- Click no midpoint da edge + Backspace — seleciona o node adjacente em vez da edge. Backspace deleta o NODE inteiro com todas suas configurações + as edges associadas. Causou perda de Manipulador 4 (config completa) numa sessão.
- Click no
<path>SVG da edge (getPointAtLength+getScreenCTM) — funciona mas pode pegar edges sobrepostas no mesmo pixel. Causou perda de edge inesperada Menu(a4)→Manip4 numa sessão. - React fiber walk pra achar store — Zustand não está exposto via
memoizedState.nextchain do React Flow do Sagazchat. Walk completo da fiber tree (depth 20) não encontrougetState()com edges/nodes. Não-acessível por fora. data-source/data-target/iddas edges no DOM — não existem. Cada.react-flow__edgetem sóclass(sem ID). Identificação só por endpoints do path SVG, que é frágil.
Regra operacional: em fluxo crítico, não deletar node nem edge sem autorização explícita do usuário. Se a intenção for só organizar ou continuar o fluxo, adicionar/reconectar/reposicionar é preferível. Se algum dia for realmente necessário remover uma conexão, fazer backup do JSON do fluxo antes e editar a lista connections pela API interna do frontend (ver § 6.15), nunca por click cego no canvas.
5.10c Drag de edge falha em zoom muito out
[validado 2026-05-10] Em zoom out muito agressivo (Ctrl+wheel 14x ou mais), nodes ficam visíveis mas mouse.move/down/up no handle não dispara conexão. Edge counter não muda. Em zoom normal, nodes distantes >2000px ficam fora da viewport e drag também falha.
Sweet spot: zoom out ~6-8 níveis (Ctrl+wheel positivo) + pan canvas via drag em .react-flow__pane se necessário. Validar inView (handles em x:[50,1400] y:[50,850]) antes de cada drag.
5.10d Pan canvas funciona via drag em pane vazio
[validado 2026-05-10] Click+drag em coordenada DENTRO de .react-flow__pane mas FORA de qualquer node move a viewport (pan). Distância pequena suficiente: 300-400px por chamada.
const empty = await page.evaluate(() => {
const pane = document.querySelector('.react-flow__pane');
const r = pane.getBoundingClientRect();
return { x: r.x + r.width * 0.5, y: r.y + r.height * 0.6 };
});
await page.mouse.move(empty.x, empty.y);
await page.mouse.down();
await page.mouse.move(empty.x + 400, empty.y, { steps: 25 });
await page.mouse.up();
5.10e Etiquetas — autocomplete engana
[validado 2026-05-10] Quando você digita uma string no combobox de etiqueta (ex: int-limpeza), o autocomplete mostra a string como opção válida MESMO QUE a etiqueta não exista na conta. Click nela ou Enter cria etiqueta nova.
Padrão de detecção etiqueta existe vs criar é não confiável. Pra distinguir: listar opções do combobox SEM digitar (mostra todas existentes na conta), depois match exato. Se a etiqueta visada não está na lista pré-filtragem, é nova.
Convenção de etiquetas:
- Nome direto do serviço (
Limpeza de pele,Massagem,Depilação) — não usar prefixosint-* - Sem números prefixo (
2-qualificadoé errado,Lead interessadoé certo) - Financeiro NÃO precisa de etiqueta no fluxo
- Pra renomear/deletar etiqueta global: Configurações > submenu de Etiquetas (não dá pra fazer pelo bloco de fluxo)
5.11 Header do node tem botões de DUPLICAR e LIXEIRA — economia gigante
⚠️ Validado 2026-05-10 em ESTETICA-EXEMPLO. No header colorido de cada node (faixa do topo, ~40px de altura) existem 2 botões <button> com SVG, sempre visíveis (não precisam de hover/select):
| Posição relativa | Função | SVG path inicial |
|---|---|---|
| relX ≈ +111 do canto esquerdo do node, relY ≈ 10 | Duplicar | M16 1H4c-1.1 0-2 .9-2 2v14h2V3h12zm3 4H8c-1.1 0-2 ... |
| relX ≈ +135, relY ≈ 9 | Lixeira (deletar node) | M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6zM19 4... |
Quando usar duplicar em vez de criar do zero: quando precisa de N nodes com configuração quase idêntica (ex: 4 Manipuladores variando só o valor). Click em duplicar → novo node aparece próximo ao original com toda a configuração interna copiada → dblclick pra editar só o que muda.
// Click no botão duplicar de um node selecionado
async function duplicarNode(dataId) {
const pos = await page.evaluate((id) => {
const n = document.querySelector(`[data-id="${id}"]`);
const r = n.getBoundingClientRect();
const buttons = Array.from(n.querySelectorAll('button')).filter(b => {
const br = b.getBoundingClientRect();
return br.y - r.y < 40; // só header
});
// Primeiro button é duplicar, segundo é lixeira (esquerda → direita)
const dupBtn = buttons[0];
const br = dupBtn.getBoundingClientRect();
return { x: br.x + br.width/2, y: br.y + br.height/2 };
}, dataId);
await page.mouse.click(pos.x, pos.y);
await page.waitForTimeout(1500);
}
⚠️ Erro caro: na primeira passada de ESTETICA-EXEMPLO, criei 4 Manipuladores do zero (~25s cada via paleta + combobox + 4 campos), quando 1 + 3 duplicações teria sido ~1/3 do tempo. Sempre considerar duplicação quando há repetição estrutural.
5.12 Validação ao salvar
Painel Salvar (e provavelmente outros) só fecha ao clicar Adicionar/Salvar se campos obrigatórios estão preenchidos. Se não, o click é silenciosamente ignorado. Pra debug: tirar screenshot ANTES e DEPOIS do click pra ver se modal fechou.
6. Operações comuns (snippets)
Todos assumem connect() do scripts/_lib/connect.mjs.
6.1 Listar fluxos da raiz [validado]
await page.goto('https://app.sagazchat.com/flowbuilders', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2500);
const fluxos = await page.evaluate(() => {
const cells = Array.from(document.querySelectorAll('p, span'));
return cells
.filter(el => {
const r = el.getBoundingClientRect();
return r.x > 320 && r.x < 700 && r.y > 100 && r.y < 800;
})
.map(el => (el.innerText || '').trim())
.filter(t => t && t.length < 60);
});
6.2 Criar fluxo [validado]
await page.getByRole('button', { name: /^Adicionar$/ }).click();
await page.waitForTimeout(1500);
// Etapa 1: canal
await page.locator('[role="dialog"]').getByText(/^WhatsApp$/, { exact: true }).first().click();
await page.waitForTimeout(1800);
// Etapa 2: nome
await page.locator('[role="dialog"] input').first().fill('PETSHOP-EXEMPLO');
await page.locator('[role="dialog"]').getByRole('button', { name: /Adicionar/i }).last().click();
await page.waitForTimeout(4000);
// Pode redirecionar pra /flowbuilder/<id>; se não, abrir manualmente:
if (!page.url().includes('/flowbuilder/')) {
await page.locator(`text=/^PETSHOP-EXEMPLO$/`).first().click();
await page.waitForTimeout(3500);
}
6.3 Excluir fluxo (DESTRUTIVO) [validado pelo cleanup]
const row = page.locator(`text=/^${NOME}$/`).first();
const rb = await row.boundingBox();
const kebab = await page.evaluate((y) => {
return Array.from(document.querySelectorAll('button, [role="button"]'))
.map(el => { const r = el.getBoundingClientRect(); return { x: r.x, y: r.y, w: r.width, h: r.height }; })
.filter(it => it.y > y - 10 && it.y < y + 50 && it.x > 1100)
.sort((a, b) => b.x - a.x)[0];
}, rb.y);
await page.mouse.click(kebab.x + kebab.w/2, kebab.y + kebab.h/2);
await page.waitForTimeout(800);
await page.locator('[role="menuitem"], .MuiMenuItem-root').filter({ hasText: /^Excluir$/i }).click();
await page.waitForTimeout(1500);
// Modal "Deletar X?" — botão Ok
await page.locator('[role="dialog"]').getByRole('button', { name: /^Ok$/ }).click({ force: true });
await page.waitForTimeout(2500);
6.4 Listar paleta com scroll [validado]
async function scrollPaleta(dir) {
await page.evaluate((d) => {
const x = document.querySelectorAll('[draggable="true"]');
if (!x.length) return;
let p = x[0].parentElement;
for (let i = 0; i < 6; i++) {
if (!p) return;
if (p.scrollHeight > p.clientHeight) { p.scrollTop = d === 'top' ? 0 : p.scrollHeight; return; }
p = p.parentElement;
}
}, dir);
await page.waitForTimeout(500);
}
async function paletaCompleta() {
const set = new Set();
for (const dir of ['top', 'bottom']) {
await scrollPaleta(dir);
const blocos = await page.evaluate(() =>
Array.from(document.querySelectorAll('[draggable="true"]'))
.map(el => (el.innerText || '').trim()).filter(Boolean)
);
blocos.forEach(b => set.add(b));
}
return Array.from(set);
}
6.5 Adicionar bloco da paleta + abrir painel [validado]
async function clicarBlocoPaleta(nome) {
for (const dir of ['top', 'bottom']) {
await scrollPaleta(dir);
const blocos = await page.evaluate(() =>
Array.from(document.querySelectorAll('[draggable="true"]')).map(el => {
const r = el.getBoundingClientRect();
return { text: (el.innerText || '').trim(), x: r.x + r.width/2, y: r.y + r.height/2 };
})
);
const hit = blocos.find(b => new RegExp(`^${nome}$`).test(b.text));
if (hit) { await page.mouse.click(hit.x, hit.y); await page.waitForTimeout(1500); return true; }
}
return false;
}
async function abrirPainelDoUltimoNode() {
const dlgAberto = await page.evaluate(() =>
Array.from(document.querySelectorAll('[role="dialog"]')).some(d => {
const r = d.getBoundingClientRect();
return r.width > 50 && getComputedStyle(d).visibility !== 'hidden';
})
);
if (dlgAberto) return true;
const lastId = await page.evaluate(() => {
const arr = Array.from(document.querySelectorAll('.react-flow__node:not(.react-flow__node-start)'));
return arr.length ? arr[arr.length - 1].getAttribute('data-id') : null;
});
if (!lastId) return false;
await page.locator(`[data-id="${lastId}"]`).dblclick();
await page.waitForTimeout(1500);
return true;
}
6.6 Adicionar bloco Conteúdo com múltiplos itens [validado]
await clicarBlocoPaleta('Conteúdo');
await abrirPainelDoUltimoNode();
// Click cartão Texto e digitar
await page.locator('[role="dialog"]:visible').getByText(/^Texto$/, { exact: true }).first().click();
await page.waitForTimeout(1500);
const editor = page.locator('[role="dialog"]:visible textarea[name="whatsapp-editor"], [role="dialog"]:visible [contenteditable="true"]').first();
await editor.click();
await page.keyboard.type('Olá! 🐶 Bem-vindo!', { delay: 12 });
// Adicionar Intervalo de 3 segundos
await clicarCartaoConteudo('Intervalo'); // helper que re-mapeia ao vivo
const inp = page.locator('[role="dialog"]:visible input[type="number"]').last();
await inp.click();
await page.keyboard.press('Control+A');
await page.keyboard.type('3');
// Adicionar segundo Texto
await clicarCartaoConteudo('Texto');
const ed2 = page.locator('[role="dialog"]:visible textarea[name="whatsapp-editor"], [role="dialog"]:visible [contenteditable="true"]').last();
await ed2.click();
await page.keyboard.type('Como podemos ajudar?', { delay: 12 });
// Confirmar
await clicarBotaoNoDialog('Adicionar');
6.7 Adicionar bloco Salvar com pergunta + variável [validado]
await clicarBlocoPaleta('Salvar');
await abrirPainelDoUltimoNode();
// Tempo (5 minutos)
const inp = page.locator('[role="dialog"]:visible input[name="timer"], [role="dialog"]:visible input[type="number"]').first();
await inp.click();
await page.keyboard.press('Control+A');
await page.keyboard.type('5');
// Mensagem antes (a pergunta)
const editor = page.locator('[role="dialog"]:visible [contenteditable="true"], [role="dialog"]:visible textarea[name="whatsapp-editor"]').first();
await editor.click();
await page.keyboard.type('Qual é o nome do seu pet?', { delay: 10 });
// Campo (textarea, NÃO input)
const campo = page.locator('[role="dialog"]:visible textarea[placeholder="Nome do campo"]').first();
await campo.click();
await page.keyboard.type('nome_pet', { delay: 20 });
await clicarBotaoNoDialog('Adicionar');
6.8 Adicionar bloco Menu com N opções [validado]
await clicarBlocoPaleta('Menu');
await abrirPainelDoUltimoNode();
// Mensagem
const editor = page.locator('[role="dialog"]:visible [contenteditable="true"], [role="dialog"]:visible textarea[name="whatsapp-editor"]').first();
await editor.click();
await page.keyboard.type('Escolha uma opção:', { delay: 12 });
// Adicionar N respostas
const opcoes = ['Banho e Tosa', 'Consulta veterinária', 'Falar com atendente'];
for (const op of opcoes) {
// Click "Adicionar resposta" via coord (fica fora do viewport conforme adiciona)
const btnPos = await page.evaluate(() => {
const dlg = Array.from(document.querySelectorAll('[role="dialog"]')).find(d => {
const r = d.getBoundingClientRect();
return r.width > 50 && getComputedStyle(d).visibility !== 'hidden';
});
const btn = Array.from(dlg.querySelectorAll('button')).find(b => /Adicionar resposta/i.test(b.innerText || ''));
if (!btn) return null;
btn.scrollIntoView({ block: 'center', behavior: 'instant' });
const r = btn.getBoundingClientRect();
return { x: r.x + r.width/2, y: r.y + r.height/2 };
});
await page.mouse.click(btnPos.x, btnPos.y);
await page.waitForTimeout(800);
// Pegar último input vazio adicionado
const inpPos = await page.evaluate(() => {
const dlg = Array.from(document.querySelectorAll('[role="dialog"]')).find(d => {
const r = d.getBoundingClientRect();
return r.width > 50 && getComputedStyle(d).visibility !== 'hidden';
});
const inps = Array.from(dlg.querySelectorAll('input[type="text"], input:not([type])')).filter(i => i.name !== 'whatsapp-editor');
const vazios = inps.filter(i => !i.value);
if (!vazios.length) return null;
const e = vazios[vazios.length - 1];
e.scrollIntoView({ block: 'center' });
const r = e.getBoundingClientRect();
return { x: r.x + r.width/2, y: r.y + r.height/2 };
});
await page.mouse.click(inpPos.x, inpPos.y);
await page.keyboard.type(op, { delay: 12 });
}
await clicarBotaoNoDialog('Salvar');
6.9 Adicionar bloco Ação com sub-tipo [validado]
await clicarBlocoPaleta('Ação');
await abrirPainelDoUltimoNode();
// Combobox de tipo
const cbPos = await page.evaluate(() => {
const dlg = Array.from(document.querySelectorAll('[role="dialog"]')).find(d => {
const r = d.getBoundingClientRect();
return r.width > 50 && getComputedStyle(d).visibility !== 'hidden';
});
const c = dlg.querySelector('[role="combobox"]');
const r = c.getBoundingClientRect();
return { x: r.x + r.width/2, y: r.y + r.height/2 };
});
await page.mouse.click(cbPos.x, cbPos.y);
await page.waitForTimeout(800);
// Selecionar (ex: Adicionar etiqueta)
await page.locator('[role="option"]').filter({ hasText: /^Adicionar etiqueta$/ }).first().click();
await page.waitForTimeout(1200);
// Preencher subcampo (etiqueta)
const etiq = page.locator('[role="dialog"]:visible input[placeholder*="etiqueta" i]').first();
await etiq.click();
await page.keyboard.type('Cliente Petshop', { delay: 30 });
await page.keyboard.press('Enter'); // cria nova etiqueta
await clicarBotaoNoDialog('Salvar');
6.10 Conectar handles de 2 nodes [validado]
async function conectar(srcId, tgtId) {
const handles = await page.evaluate(({ sid, tid }) => {
const src = document.querySelector(`[data-id="${sid}"]`);
const tgt = document.querySelector(`[data-id="${tid}"]`);
// Handles laterais — right (source) → left (target)
const sH = src.querySelector('.react-flow__handle-right.source') || src.querySelector('.react-flow__handle-right');
const tH = tgt.querySelector('.react-flow__handle-left.target') || tgt.querySelector('.react-flow__handle-left');
if (!sH || !tH) return null;
src.scrollIntoView({ block: 'center', behavior: 'instant' });
const sR = sH.getBoundingClientRect();
const tR = tH.getBoundingClientRect();
return {
srcX: sR.x + sR.width/2, srcY: sR.y + sR.height/2,
tgtX: tR.x + tR.width/2, tgtY: tR.y + tR.height/2,
};
}, { sid: srcId, tid: tgtId });
await page.mouse.move(handles.srcX, handles.srcY);
await page.waitForTimeout(250);
await page.mouse.down();
await page.waitForTimeout(150);
await page.mouse.move(handles.srcX + 30, handles.srcY + 10, { steps: 6 }); // arco intermediário
await page.mouse.move(handles.tgtX, handles.tgtY, { steps: 25 });
await page.waitForTimeout(400);
await page.mouse.up();
await page.waitForTimeout(1200);
}
6.11 Remover item de dentro do bloco Conteúdo [validado]
// Painel Conteúdo aberto. Remove o ÚLTIMO item.
const lixeiraInfo = await page.evaluate(() => {
const dlg = Array.from(document.querySelectorAll('[role="dialog"]')).find(d => {
const r = d.getBoundingClientRect();
return r.width > 50 && getComputedStyle(d).visibility !== 'hidden';
});
const lixeiras = Array.from(dlg.querySelectorAll('svg')).filter(svg => {
const r = svg.getBoundingClientRect();
if (r.x < 1030 || r.x > 1080) return false;
const path = svg.querySelector('path');
return path && /^M6 19c0/.test(path.getAttribute('d') || '');
});
if (!lixeiras.length) return null;
const last = lixeiras[lixeiras.length - 1];
last.scrollIntoView({ block: 'center', behavior: 'instant' });
return { count: lixeiras.length };
});
await page.waitForTimeout(700);
const novaPos = await page.evaluate(() => {
const dlg = Array.from(document.querySelectorAll('[role="dialog"]')).find(d => {
const r = d.getBoundingClientRect();
return r.width > 50 && getComputedStyle(d).visibility !== 'hidden';
});
const lixeiras = Array.from(dlg.querySelectorAll('svg')).filter(svg => {
const r = svg.getBoundingClientRect();
if (r.x < 1030 || r.x > 1080) return false;
const path = svg.querySelector('path');
return path && /^M6 19c0/.test(path.getAttribute('d') || '');
});
const last = lixeiras[lixeiras.length - 1];
const r = last.getBoundingClientRect();
return { x: r.x + r.width/2, y: r.y + r.height/2 };
});
await page.mouse.click(novaPos.x, novaPos.y);
await page.waitForTimeout(1500);
6.12 Deletar node do canvas [validado]
await page.locator(`[data-id="${nodeId}"]`).click({ force: true });
await page.waitForTimeout(400);
await page.keyboard.press('Backspace'); // ou Delete
await page.waitForTimeout(800);
6.13 Duplicar node + editar valor [validado 2026-05-10]
Para criar N variantes de um mesmo bloco (ex: 4 Manipuladores variando só o valor):
// 1. Criar e configurar o "modelo" (N=1)
await criarManipulador('servico_escolhido', 'Limpeza de pele');
// 2. Para cada variante adicional: duplicar + editar
const modelos = [
{ handle: 'a2', valor: 'Massagem relaxante' },
{ handle: 'a3', valor: 'Depilação' },
{ handle: 'a4', valor: 'Falar com atendente' },
];
for (const m of modelos) {
const origemId = await page.evaluate(() => {
const arr = Array.from(document.querySelectorAll('.react-flow__node-action'));
return arr[arr.length - 1].getAttribute('data-id');
});
await duplicarNode(origemId); // ver § 5.11
// Novo node criado próximo. Localizar o novo (último), abrir, trocar só o input "Texto" do valor
// ... abrir painel + editar valor + Salvar
// ... conectar Menu(handle) → novo node
}
6.14 Salvar fluxo (header) [validado]
const headerBtn = await page.evaluate(() => {
const btns = Array.from(document.querySelectorAll('button'))
.filter(b => (b.innerText || '').trim() === 'Salvar')
.filter(b => { const r = b.getBoundingClientRect(); return r.width > 0 && r.y < 80; });
if (!btns.length) return null;
const r = btns[0].getBoundingClientRect();
return { x: r.x + r.width/2, y: r.y + r.height/2 };
});
await page.mouse.click(headerBtn.x, headerBtn.y);
await page.waitForTimeout(3000);
6.15 Operar o JSON do fluxo pela API interna usada pelo frontend [validado 2026-05-10]
Isto não é uma API pública/oficial documentada da Sagazchat. É a API interna que o próprio frontend usa, descoberta por inspeção da aplicação, window.RUNTIME_CONFIG e chamadas de rede do flowbuilder. Tratar como contrato interno: funciona para operação assistida, mas pode mudar sem aviso.
Quando usar:
- Auditar estado real do fluxo sem depender do DOM.
- Fazer backup antes de qualquer alteração.
- Corrigir conexões quando
source,targetesourceHandlejá são conhecidos. - Reposicionar blocos no canvas em lote.
- Validar se o fluxo salvo tem as conexões esperadas.
Quando não usar:
- Para fingir que existe API pública estável.
- Para deletar nodes/conexões sem autorização explícita.
- Para editar campos desconhecidos sem comparar com um payload salvo pelo frontend.
Base e autenticação vêm da sessão logada no browser:
const baseUrl = await page.evaluate(() => window.RUNTIME_CONFIG.VITE_APP_BACKEND_URL);
const token = await page.evaluate(() => JSON.parse(localStorage.getItem('token')));
Endpoints observados:
GET ${baseUrl}/flowbuilder/flow/<id>
POST ${baseUrl}/flowbuilder/flow
GET ${baseUrl}/tags/all
GET ${baseUrl}/flowbuilder/<id>/menuctr/<menuNodeId>
Formato essencial do fluxo:
{
idFlow: String(flowId),
nodes: flow.flow.nodes,
connections: flow.flow.connections
}
Cada node segue o padrão:
{
id: 'uuid-ou-id-do-react-flow',
type: 'singleBlock',
position: { x: 1040, y: 90 },
data: { type: 'menu', ... }
}
Cada conexão segue o padrão:
{
id: `reactflow__edge-${source}${sourceHandle ?? ''}-${target}`,
source,
target,
sourceHandle: 'a1', // null/undefined quando for saída única
targetHandle: null,
animated: true,
style: { stroke: '#555', strokeWidth: 2 }
}
Protocolo seguro:
- Fazer dump antes de alterar:
scripts/_out/flow-<id>-before-<slug>.json. - Alterar
nodes/connectionsem memória, preservando todos os campos não tocados. - Enviar
POST /flowbuilder/flowcom{ idFlow, nodes, connections }. - Fazer novo dump com
GET /flowbuilder/flow/<id>. - Rodar validador determinístico do fluxo.
- Tirar screenshot do canvas para validar o visual.
Exemplo mínimo de dump:
const data = await page.evaluate(async () => {
const response = await fetch(`${window.RUNTIME_CONFIG.VITE_APP_BACKEND_URL}/flowbuilder/flow/538`, {
headers: {
Authorization: `Bearer ${JSON.parse(localStorage.getItem('token'))}`,
},
});
return await response.json();
});
6.16 Organizar visual do canvas por coordenadas [validado 2026-05-10]
Para fluxos grandes, organizar por coordenadas no JSON é mais confiável que arrastar bloco por bloco. O objetivo é que a leitura no canvas siga a mesma ordem mental do atendimento.
Padrão recomendado:
coluna 1: Início
coluna 2: Conteúdo de boas-vindas
coluna 3: Salvar/capturar dado
coluna 4: Menu principal
coluna 5: Ações/qualificadores por opção
coluna 6: Conteúdo de qualificação
coluna 7: Menu de qualificação
coluna 8: Tag/ação de lead quente
coluna 9: Distribuidor
coluna 10: Atendentes/departamentos
Reservar margem à esquerda quando a paleta lateral estiver aberta. Em screenshots com viewport 1440×900, x: 250 deixa o node inicial parcialmente escondido pela paleta; preferir iniciar o primeiro bloco útil em x >= 500 ou fechar/recolher a paleta antes da captura.
Para fluxo de clínica estética, a hierarquia visual validada foi:
Início
-> Boas-vindas
-> Salvar nome
-> Menu principal
-> Limpeza de pele / Massagem / Depilação
-> Qualificação
-> Menu: agendar agora ou tirar dúvidas
-> Cliente quente
-> Distribuidor
-> Comercial - Atendente 1
-> Comercial - Atendente 2
-> Falar com atendente
-> Distribuidor
-> Falar com financeiro
-> Financeiro/RH
6.17 Criar fluxo-laboratório com todos os blocos não-IA [validado 2026-05-10]
Foi criado um fluxo real para testar o criador de fluxo como ferramenta, sem depender do fluxo de estética:
Nome: LAB-BLOCOS-SEM-IA-202605101624
URL: https://app.sagazchat.com/flowbuilder/539
Dump: scripts/_out/flow-539-lab-blocos-sem-ia.json
Screenshot: scripts/_out/fluxos/539-lab-blocos-sem-ia.png
Script: scripts/sagaz-lab-blocos-sem-ia-criar.mjs
O script cria o fluxo pela UI e depois salva o canvas pela API interna do frontend. Também aceita regravar um fluxo existente:
node scripts/sagaz-lab-blocos-sem-ia-criar.mjs
node scripts/sagaz-lab-blocos-sem-ia-criar.mjs 539
Para o bloco Agenda, o script agora exige uma agenda real já cadastrada. Ele tenta usar LAB_CALENDAR_ID, depois uma agenda com nome LAB_CALENDAR_NAME (default LAB - Agenda Flowbuilder), depois a primeira agenda existente. Se não houver agenda, o script falha em vez de montar node visual falso.
Para os sub-tipos de Remarketing, o script prefere um remarketing real chamado LAB - Remarketing Flowbuilder (LAB_REMARKETING_NAME). Se não encontrar, usa o primeiro remarketing existente; se a conta não tiver remarketing, usa fallback visual. Em 2026-05-11 o fluxo 539 foi atualizado para usar o remarketing real LAB - Remarketing Flowbuilder (id: 150, uuid: b96abd79-cdba-4051-86ef-d49f7c7174ef) nos dois sub-tipos: inscrição (subscriber: true) e descadastro (subscriber: false). O modo de criar/configurar remarketing está no runbook remarketing.
Blocos principais presentes no fluxo-laboratório:
start
singleBlock -> Conteúdo
stopFlow -> Salvar
menu
action
condition
randomizer
smartRange -> Atraso inteligente
globalVariables -> Variável global
distributor
httpRequest -> Integração
calendar -> Agenda
Blocos/recursos de IA excluídos:
openai
transfertoAI
Sub-tipos não-IA do bloco Ação exercitados:
taggy com isAdd=true -> Adicionar etiqueta
taggy com isAdd=false -> Remover etiqueta
remarketing subscriber=true
remarketing subscriber=false
queueDirect -> Redirecionar para departamento
ticketManager -> Controlador de chat
notification -> Notificar membro da equipe
mathematicalOperation -> Manipulador
pixel
cardPix
flowDirect -> Conexão de fluxo
⚠️ Payload do Agenda: o frontend renderiza data.calendar.name. Usar calendarName quebra a tela com erro Cannot read properties of undefined (reading 'name') e mostra fallback tipo “Voltamos já”.
⚠️ Agenda funcional vs visual: para o bloco Agenda funcionar de ponta a ponta, a agenda precisa ser criada antes no módulo de agenda/calendário da conta, configurada no botão Configurar da tela da agenda e selecionada pelo combobox do bloco. O fluxo-laboratório usa o node calendar para cobrir o bloco no canvas; se calendar.id não veio de uma agenda real, considerar esse node demonstração visual, não teste funcional de agendamento. Em 2026-05-10 o fluxo 539 foi atualizado para usar a agenda real LAB - Agenda Flowbuilder (id: 35) e essa agenda foi configurada com atendimento 09:00-18:00, pausa 12:00-13:00, intervalo fixo de 30 Minutos e duração de evento de 60 Minutos.
⚠️ Variável global funcional vs visual: para o bloco Variável global funcionar de ponta a ponta, a variável precisa ser criada antes em Configurações e selecionada no combobox do bloco. Se o fluxo-laboratório usar um identificador manual para cobrir o node globalVariables, considerar esse node demonstração visual, não teste funcional de variável global.
Formato mínimo que renderiza:
{
type: 'calendar',
data: {
calendar: { id: 123, name: 'Agenda real criada na conta' },
days: 7,
titleDate: 'Escolha uma data',
messageDate: '...',
buttonDate: 'Ver datas',
titleHour: 'Escolha um horário',
messageHour: '...',
buttonHour: 'Ver horários',
title: 'LAB: Evento de teste',
message: '...',
backgroundColor: '#A4CCCC',
textColor: '#13111C',
notice: '...'
}
}
Checklist pós-criação usado no script:
nodes: 15
connections: 16
hasAi: false
nodeTypes inclui todos os blocos principais não-IA
actionTypes não inclui transfertoAI
screenshot abre canvas, não tela de fallback
7. Detecção de sucesso
- Após criar fluxo: redirect pra
/flowbuilder/<id>. Se não, fluxo aparece na lista/flowbuilders. - Após criar coluna/node: counter de
.react-flow__nodecresce. Conferirdata-iddo último node não-start. - Após Adicionar/Salvar do dialog: dialog fecha (não há mais
[role="dialog"]:visible). Se ficar aberto, validação interna falhou (ver § 5.11). - Após conectar handles:
.react-flow__edgeaumenta em 1. - Após Salvar fluxo (header): toast MUI ou re-render dos nodes confirma persistência.
- Após operação pela API interna:
GET /flowbuilder/flow/<id>retorna os novosnodes/connections, o validador zera pendências obrigatórias e o screenshot do canvas mostra a ordem visual esperada.
8. Recuperação de erros
browserType.connectOverCDP: ECONNREFUSED: sessão CDP morta. Re-rodarscripts/sagaz-session.mjsem background.- Modal não fecha após Adicionar/Salvar: campo obrigatório vazio (provável). Tirar screenshot + capturar
dlg.innerTextpra diagnosticar. - Click em paleta sem efeito:
clicarBlocoPaletaprecisa scroll antes — usar helperpaletaMap()+scrollPaleta('top'/'bottom'). getByText('OpenAI')retorna 0: usar workaround do § 5.2.- Painel longo “perdido”: viewport rolável — usar
scrollIntoView(§ 5.1) antes de qualquer click em elemento abaixo do fold. - Conexão de handles não cria edge: confirmar que ambos handles existem (
src.querySelector('.react-flow__handle-bottom')etgt.querySelector('.react-flow__handle-top')). Mover mouse em steps ({ steps: 25 }) e darwaitForTimeout(300)antes doup.
9. Cleanup
Toda sessão de auditoria que cria fluxos deve fechar com cleanup:
// 1. Voltar pra /flowbuilders
await page.goto('https://app.sagazchat.com/flowbuilders', { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(2500);
// 2. Excluir cada fluxo TESTE-IA-* via § 6.3
// 3. Conferir que sumiram da lista
console.log('ainda existe?', await page.locator(`text=/^TESTE-IA-/`).count());
⚠️ Nunca deletar o fluxo PETSHOP-EXEMPLO sem autorização — ele foi mantido como referência operacional para futuras sessões.
10. Como iniciar nova auditoria
- Garantir sessão CDP viva (
scripts/sagaz-session.mjsem background; checarscripts/_out/cdp.txt). - Conectar com
connect()do_lib/connect.mjs. - Se for explorar: navegar pra
/flowbuilders, listar fluxos com § 6.1. - Se for criar fluxo de teste: use prefixo
TESTE-IA-<random>(ver § 6.2). Sempre cleanup ao final. - Se for continuar PETSHOP-EXEMPLO: ele já existe em
/flowbuilder/533(id pode mudar em re-runs). Localizar viatext=/^PETSHOP-EXEMPLO$/na lista. - Não recarregar a página entre operações — irrita o usuário e perde estado. Trabalhar na URL atual.
11. Estado do ESTETICA-EXEMPLO em 2026-05-10
Fluxo real em https://app.sagazchat.com/flowbuilder/538, montado para clínica de estética.
Estado lógico após correção pela API interna:
15 nodes
18 connections
directServiceToDistributor: 0
missingRequired: 0
Conexões obrigatórias validadas:
Início -> Boas-vindas
Boas-vindas -> Salvar nome
Salvar nome -> Menu principal
Menu a1 Limpeza de pele -> Ação servico_escolhido=Limpeza de pele
Menu a2 Massagem relaxante -> Ação servico_escolhido=Massagem relaxante
Menu a3 Depilação -> Ação servico_escolhido=Depilação
Ações de serviço -> Conteúdo de qualificação
Conteúdo de qualificação -> Menu de qualificação
Menu qualificação a1 Quero agendar agora -> Ação Cliente quente
Ação Cliente quente -> Distribuidor
Menu qualificação a2 Quero tirar dúvidas primeiro -> Distribuidor
Menu a4 Falar com atendente -> Ação atendente
Ação atendente -> Distribuidor
Menu a5 Falar com financeiro -> Financeiro/RH
Distribuidor -> Comercial - Atendente 1
Distribuidor -> Comercial - Atendente 2
Pontos ainda não fechados como produto final:
- A opção Quero agendar agora ainda marca/tagueia como cliente quente e envia ao Distribuidor. Não há bloco Agenda configurado nessa trilha.
- Saídas de timeout/não resposta do bloco Salvar não foram conectadas.
- O ajuste visual foi feito por coordenadas e validado por screenshot; se a paleta lateral estiver aberta, revisar margem esquerda antes de apresentar.
11.1 Exemplo aplicado: recursos usados no fluxo de estética
Esta subseção não é uma instrução para “continuar o ESTETICA”; é um exemplo concreto de como auditar se um fluxo usa etiquetas, variáveis, departamentos, notificações, agenda e distribuição. Use como referência de leitura, não como roteiro obrigatório.
Já configurado no dump atual:
- Etiquetas de serviço:
Limpeza de pele,Massagem,Depilação. - Etiqueta geral:
Lead interessado. - Etiqueta de intenção forte:
Cliente quente. - Variável:
servico_escolhido. - Filas/departamentos:
Comercial - Atendente 1,Comercial - Atendente 2,Financeiro/RH. - Distribuidor para alternar atendimento comercial entre Atendente 1 e Atendente 2.
Recursos que este exemplo ainda não cobre:
- Bloco Agenda na trilha
Quero agendar agora. - Bloco/sub-ação de Notificação para avisar comercial quando o lead estiver quente.
- Etiqueta
Dúvida antes de agendarpara quem escolhe tirar dúvidas. - Etiqueta
Financeiropara a opçãoFalar com financeiro. - Etiqueta
Agendamento solicitadodepois da agenda ou antes do repasse ao comercial. - Rota de timeout/não resposta do bloco Salvar para lembrete curto + retorno ao menu.
Desenho recomendado:
Escolheu serviço
-> etiqueta do serviço
-> Lead interessado
-> Qualificação
Quero agendar agora
-> Cliente quente
-> Agendamento solicitado
-> Notificar comercial
-> Agenda
-> Distribuidor
Quero tirar dúvidas primeiro
-> Dúvida antes de agendar
-> Distribuidor
Falar com financeiro
-> Financeiro
-> Financeiro/RH
Cuidado: antes de criar novas etiquetas via combobox, listar etiquetas existentes (/tags/all ou combobox sem filtro) e reutilizar por nome exato quando já existir. O autocomplete pode criar etiqueta nova sem deixar isso claro.
Apêndice — Estado do PETSHOP-EXEMPLO em 2026-05-09
5 nodes conectados em sequência:
Início (default)
↓
Conteúdo (1 bloco com 5 itens):
• Texto: "Olá! 🐶 Bem-vindo ao Pet Shop Pata Feliz!"
• Intervalo: 3 segundos
• Texto: "Aqui cuidamos do seu melhor amigo com muito carinho 🐾✨"
• Intervalo: 4 segundos
↓
Salvar:
• Tempo mínimo: 5 minutos
• Mensagem antes: "Antes de começarmos, qual é o nome do seu pet?"
• Campo: nome_pet
↓
Conteúdo: "Que nome lindo! 🐾 Em que posso ajudar você e seu pet hoje?"
↓
Menu (3 opções):
• Banho e Tosa
• Consulta veterinária
• Falar com atendente
Saídas do Menu ainda não conectadas a destinos (próxima iteração: cada opção → bloco Ação com Adicionar etiqueta + Redirecionar para departamento).
Posições no canvas após criação automática (visualmente desalinhadas — ordem de criação ≠ ordem lógica): Início(479) → Saudação(747) → Resposta(1013) → Salvar(1279) → Menu(1567). As linhas cruzam Resposta↔Salvar; reordenar manualmente é cosmético.
Apêndice — Scripts de referência criados nesta sessão
| Script | Propósito |
|---|---|
sagaz-fluxos-list.mjs | Mapa básico da tela /flowbuilders |
sagaz-fluxos-explore.mjs | Sidebar e navegação inicial |
sagaz-fluxos-criar.mjs | Criar fluxo (modal 1 etapa — desatualizado, ver criar2) |
sagaz-fluxos-criar2.mjs | Criar fluxo modal 2 etapas (canal + nome) |
sagaz-fluxos-investigar-acao.mjs | Lista paleta + 12 sub-tipos do Ação |
sagaz-fluxos-aprender.mjs | Abre painel de cada bloco e mapeia campos |
sagaz-fluxos-petshop-conteudo.mjs | Cria PETSHOP com 5 nodes Conteúdo (lição: usar 1 bloco com itens) |
sagaz-fluxos-petshop-iter1b.mjs | Consolida em 1 Conteúdo com itens empilhados |
sagaz-fluxos-petshop-fix.mjs / fix2.mjs | Remove item Texto + adiciona Salvar correto |
sagaz-fluxos-petshop-menu.mjs | Adiciona Menu com 3 respostas |
sagaz-fluxos-petshop-conectar.mjs | Conecta handles dos 5 nodes |
sagaz-fluxos-estado.mjs | Inspeção rápida do estado do canvas |
sagaz-fluxos-inspecionar-item-delete.mjs | Mapa DOM dos botões/SVGs do painel Conteúdo |
sagaz-estetica-map-completo.mjs | Mapeia nodes, handles, labels e edges do fluxo de estética pelo DOM |
sagaz-estetica-inspect-app.mjs | Inspeciona runtime config, localStorage e recursos carregados do app |
sagaz-estetica-fetch-chunks.mjs | Baixa chunks relevantes do frontend para investigar endpoints internos |
sagaz-estetica-api-dump.mjs | Faz backup/dump do fluxo 538 via API interna do frontend |
sagaz-estetica-api-corrigir-qualificacao.mjs | Corrige conexões da qualificação do ESTETICA-EXEMPLO com backup prévio |
sagaz-estetica-api-organizar-visual.mjs | Reposiciona nodes do ESTETICA-EXEMPLO no canvas por coordenadas |
sagaz-estetica-validar-fluxo.mjs | Valida conexões obrigatórias do fluxo de estética e aponta pendências |
sagaz-estetica-screenshot.mjs | Gera screenshot do canvas em scripts/_out/fluxos/ |