O assistente
O app de um cliente pode ter um assistente: a pessoa pergunta sobre os dados em português ("quantos clientes estão
ativos?") e recebe uma tabela ou uma frase. O app não tem um chat pronto: o chat é uma Página que você monta, com a
cara que quiser. Ela pergunta com um fetch e desenha a resposta.
A IA nunca vê os dados do cliente: ela só entende a pergunta e escolhe a consulta. Quem lê os dados, conta e monta a tabela é o servidor, com o login de quem pergunta.
O assistente é ligado por cliente, pelo time de desenvolvimento, que também decide quais dados ele lê. Para ligar num cliente, ou para ele responder sobre um dado novo, é um pedido ao time. Por enquanto, ele só consulta: para mudar algo, a pessoa usa as Páginas.
Perguntar
fetch('/api/assistant', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: 'Quantos clientes estão ativos?' }),
});
As regras da ponte são as mesmas de Chamar a API: não escreva o cliente, o endereço do site nem cabeçalho de segurança.
question: a pergunta, de 1 a 500 caracteres. Ponhamaxlength="500"no campo.before: para continuar a conversa ("e no mês passado?"), mande a pergunta anterior e ochoiceda resposta dela:{ question: '...', choice: resposta.choice }. Sem obefore, a pergunta começa outro assunto.- Uma pergunta por vez: desabilite o botão até a resposta chegar. Costuma levar poucos segundos, e pode chegar a 45.
O que volta
Toda resposta traz:
| campo | o quê |
|---|---|
kind | "table" (uma tabela) ou "text" (só uma frase) |
sentence | numa tabela, o título dela; num texto, a resposta |
suggestions | de 0 a 3 perguntas prontas. Mostre como botões e mande o texto como está, com o before |
choice | guarde para o before da próxima pergunta. Não precisa ler o que tem dentro |
Uma tabela traz também:
| campo | o quê |
|---|---|
columns | as colunas, cada uma com o label (o título) e o type (texto, numero, data, mes, instante, booleano, enum ou documento). Use o type para alinhar: numero à direita |
rows | as linhas, até 50, cada uma uma lista de textos já formatados em português (2.848,50, 17/10/2026) |
total | quantas linhas havia antes do corte de 50: mostre "50 de 60" |
filters | o filtro em palavras ("situação ativo"). Mostre sempre: diz o que foi contado de verdade |
notice | um aviso, quando há ("Há 2 clientes com esse nome."). Mostre quando vier |
{"kind":"table","sentence":"Os clientes ativos",
"columns":[{"label":"Nome","type":"texto"},{"label":"Situação","type":"enum"}],
"rows":[["Acme","ativo"],["Beta","ativo"]],"total":2,"filters":"situação ativo",
"suggestions":["Quantos clientes estão inativos?"],"choice":{"…":"…"}}
Um texto é a frase, as sugestões e o choice. Quando o assistente não pode responder ("Eu só consulto. Para mudar
algo, use a Página."), vem também o refusal: desenhe como qualquer texto.
Os números e os nomes da tabela vêm dos dados do cliente, não da IA. Mesmo assim, mostre um aviso de que as respostas são de uma IA.
A conversa ao voltar
A conversa fica guardada no app enquanto a aba está aberta. Quando a pessoa sai do chat e volta, ela chega em
workflow.conversation: uma lista de { question, answer }, em que answer é a resposta inteira. Desenhe-a de novo
ao abrir, e continue a partir da última. Recarregar a página ou abrir outra aba começa vazio.
O link para o chat
workflow.assistant é true quando quem vê tem o assistente. Mostre o caminho para o chat só nesse caso:
<a href="/assistente" id="link-assistente" hidden>Perguntar ao assistente</a>
<script>
if (workflow.assistant) {
document.getElementById('link-assistente').hidden = false;
}
</script>
O caminho /assistente é só um exemplo: é o caminho que você der à Página do chat. No construtor, o
workflow.assistant é sempre true, para você ver o seu HTML.
Um chat inteiro
Funciona: perguntar, desenhar a tabela e o texto, as sugestões, os erros e a conversa ao voltar.
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<style>
body { font-family: sans-serif; margin: 0; padding: 24px; }
table { border-collapse: collapse; margin: 8px 0; }
th, td { border: 1px solid #ddd; padding: 4px 8px; }
td.numero { text-align: right; }
</style>
</head>
<body>
<h1>Assistente</h1>
<p><small>As respostas são geradas por uma IA. Confira antes de usar.</small></p>
<div id="conversa"></div>
<form id="pergunta">
<input name="texto" required maxlength="500" placeholder="Pergunte sobre os dados" autocomplete="off">
<button>Perguntar</button>
</form>
<p id="estado" role="status"></p>
<script>
const conversa = document.getElementById('conversa');
const form = document.getElementById('pergunta');
const estado = document.getElementById('estado');
const mensagens = {
400: 'Escreva uma pergunta de até 500 caracteres.',
403: 'Você não tem permissão para usar o assistente.',
404: 'O assistente não está ligado para este cliente.',
429: 'Muitas perguntas agora. Tente de novo daqui a pouco.',
502: 'Não entendi a pergunta. Tente com outras palavras.',
503: 'O assistente não respondeu. Tente de novo em instantes.',
};
let anterior = null; // a última pergunta e o choice dela: o before da próxima
function texto(tag, conteudo) {
const elemento = document.createElement(tag);
elemento.textContent = conteudo;
return elemento;
}
function desenhar(pergunta, resposta) {
const bloco = document.createElement('section');
bloco.append(texto('p', 'Você: ' + pergunta), texto('p', resposta.sentence));
if (resposta.kind === 'table') {
const tabela = document.createElement('table');
const cabecalho = tabela.createTHead().insertRow();
for (const coluna of resposta.columns) {
cabecalho.append(texto('th', coluna.label));
}
const corpo = tabela.createTBody();
for (const linha of resposta.rows) {
const tr = corpo.insertRow();
linha.forEach((valor, i) => {
const td = tr.insertCell();
td.textContent = valor;
td.className = resposta.columns[i].type;
});
}
bloco.append(tabela);
const contagem = resposta.rows.length + ' de ' + resposta.total;
bloco.append(texto('p', resposta.filters ? resposta.filters + ' · ' + contagem : contagem));
if (resposta.notice) {
bloco.append(texto('p', resposta.notice));
}
}
for (const sugestao of resposta.suggestions) {
const botao = texto('button', sugestao);
botao.type = 'button';
botao.addEventListener('click', () => perguntar(sugestao));
bloco.append(botao);
}
conversa.append(bloco);
}
async function perguntar(pergunta) {
const botoes = document.querySelectorAll('button');
botoes.forEach((botao) => (botao.disabled = true));
estado.textContent = 'Pensando…';
let certo = false;
try {
const res = await fetch('/api/assistant', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(anterior ? { question: pergunta, before: anterior } : { question: pergunta }),
});
if (res.ok) {
const resposta = await res.json();
desenhar(pergunta, resposta);
anterior = { question: pergunta, choice: resposta.choice };
estado.textContent = '';
certo = true;
} else {
estado.textContent = mensagens[res.status] ?? 'Não foi possível responder. Tente de novo.';
}
} catch {
estado.textContent = 'Sem conexão. Tente de novo.';
}
botoes.forEach((botao) => (botao.disabled = false));
return certo;
}
form.addEventListener('submit', async (evento) => {
evento.preventDefault();
if (await perguntar(form.elements.texto.value.trim())) {
form.elements.texto.value = '';
}
});
for (const { question, answer } of workflow.conversation) {
desenhar(question, answer);
anterior = { question, choice: answer.choice };
}
</script>
</body>
</html>
Ponha o texto da resposta com textContent, como na API: um nome com < aparece como texto. Para um botão "Novo
assunto", basta voltar o anterior a null.
Não funciona:
// O cliente no caminho: a chamada não sai, e o fetch rejeita.
fetch('/api/assistant/outro', { method: 'POST' /* ... */ });
// A conversa inteira no before: só a pergunta anterior e o choice dela. Dá 400.
body: JSON.stringify({ question: 'e agora?', before: workflow.conversation });
// Duas perguntas ao mesmo tempo: a segunda dá 429.
perguntar('Quantos clientes?');
perguntar('Quantas faturas?');
Os erros
| O que volta | Quer dizer | O que a Página faz |
|---|---|---|
400 invalid_request | A pergunta está vazia, tem mais de 500 caracteres, ou o before não é o que a resposta anterior mandou. | Peça uma pergunta mais curta; confira o before. |
401 no_session | O login de quem usa acabou. O construtor e o app levam ao login sozinhos. | Nada além de uma mensagem. |
403 forbidden | Quem usa não tem o papel que o assistente pede. No construtor, conta o seu papel. | Diga que a pessoa não tem permissão. |
404 not_found | O assistente não está ligado para este cliente. | Diga isso; peça ao time de desenvolvimento para ligar. |
429 rate_limited | Uma pergunta já está esperando resposta, a pessoa passou de 100 perguntas no dia, ou a IA está cheia. | Peça para tentar daqui a pouco. |
502 ai_failed | A IA não entendeu a pergunta, duas vezes. | Peça para perguntar com outras palavras. |
503 unavailable | A IA não respondeu a tempo. | Peça para tentar de novo em instantes. |
500 internal | A leitura dos dados falhou. | Diga que não foi possível e deixe tentar de novo. |
No canvas e no "ver", a pergunta vai com o seu login, nos dados de verdade do cliente, e conta nas suas 100 perguntas do dia. Não pergunte ao abrir a Página: o canvas roda cada Página, e cada vez que ele a mostra seria uma pergunta. Pergunte só depois de um clique.