AI Copilot (embed)
Widget de chat SSE embutido no shell: tools MCP in-process, trava de confirmação de escrita, cotas de custo.
AI Copilot (embed)
O Copilot é o widget de chat com IA embutido no shell admin do MAD — o
ícone 🤖 na barra superior (notch) abre um painel que carrega um iframe
apontando para [ai] embed_frontend_url. É distinto da Central de
Comando: o Copilot é para usuários finais do
seu app conversarem com os próprios dados de negócio (via tools do
servidor MCP); a Central de Comando é uma
ferramenta admin-only que opera o app em si (código, menu, permissões).
As duas superfícies compartilham o mesmo motor por baixo (AgentRunner,
ConfirmCoordinator, ConversationStore, SseSink, MadAi) — a diferença
está só em quais tools cada uma recebe e em como a confirmação de escrita
é re-validada.
Habilitando
// config/mad.php
'ai' => [
'enabled' => (bool) env('MAD_AI_ENABLED', false), // off => /embed/v1/* nem registra
'apply_enabled' => (bool) env('MAD_AGENT_APPLY_ENABLED', false), // agente APLICA mudancas
'proxy' => (bool) env('MAD_AI_PROXY', false), // veja "Modo proxy" abaixo
'coding_plan' => (bool) env('MAD_AI_CODING_PLAN', false), // stream direto no Node do builder
'embed_stream_url' => env('MAD_AI_EMBED_STREAM_URL', 'http://localhost:3000/embed-llm/chat'),
'provider' => 'openrouter', // ou 'anthropic'
'max_steps' => '8',
'max_tokens' => '8192',
'embed_frontend_url' => env('MAD_AI_EMBED_FRONTEND_URL', 'http://localhost:7400/embed.html'),
// Micro-BI (widgets salvos + tela "Meus Dashboards") — ON por padrao,
// herdando ai.enabled. Veja Dashboards de IA (embed).
'widgets_enabled' => (bool) env('MAD_AI_WIDGETS_ENABLED', true),
'widgets_full_schema' => (bool) env('MAD_AI_WIDGETS_FULL_SCHEMA', false),
'dashboards_frontend_url' => env('MAD_AI_DASHBOARDS_FRONTEND_URL', 'http://localhost:7400/dashboard.embed.html'),
'anthropic_api_key' => getenv('ANTHROPIC_API_KEY') ?: '',
'anthropic_model' => 'claude-haiku-4-5',
'openrouter_api_key' => getenv('OPENROUTER_API_KEY') ?: '',
'openrouter_model' => 'google/gemma-4-31b-it:free', // modelos :free NAO fazem tool use
],
Precedência dos três modos de streaming: coding_plan > proxy >
in-process (o app chama o provider ele mesmo).
Com a flag ligada, o token do widget é cunhado no login
(LoginForm::doLogin): McpTokenService::issueEmbed($login) gera um token
mcp_embed_… e guarda em session('embed_copilot_token'), revogando
qualquer token anterior do mesmo usuário. O shell reusa esse token enquanto
estiver ativo e reemite sozinho se ele expirar ou for revogado — você não
precisa gerenciar isso manualmente.
A configuração efetiva do modelo, porém, tem uma camada acima do
config/mad.php: a tela de Preferências do sistema (ai_provider,
ai_anthropic_model, ai_openrouter_model, ai_max_steps, ...) tem
prioridade sobre o config, que por sua vez tem prioridade sobre variáveis de
ambiente. MadAi::boot() resolve essa precedência a cada boot e monta
config('ai') (o namespace do pacote laravel/ai — não confundir com
config('mad.ai'), que é só a camada de fallback do MAD).
Endpoints
| Endpoint | Auth | O quê |
|---|---|---|
POST /embed/v1/chat |
Bearer (token MCP) | turno do agente → stream SSE |
GET /embed/v1/conversations |
Bearer | lista as conversas do usuário do token |
GET /embed/v1/conversations/{id} |
Bearer | mensagens/transcript de 1 conversa (404 se for de outro usuário) |
GET /embed/v1/token |
Bearer | mint do JWT do embed (server-to-server) quando coding_plan está ligado — EmbedTokenController |
GET /embed/v1/tools |
Bearer | defs de tool no formato Anthropic (name/description/input_schema) — loop de tools client-side do coding plan |
POST /embed/v1/tool-call |
Bearer | executa uma tool {name, args} → {ok, result, ms} (EmbedToolsController) |
Com [ai] widgets_enabled ligado (default), o mesmo grupo registra os
endpoints do micro-BI — favoritos, widgets salvos e dashboards. Eles têm
página própria: Dashboards de IA (embed).
| Endpoint | O quê |
|---|---|
GET/POST /embed/v1/favorites, DELETE /embed/v1/favorites/{id}, POST /embed/v1/favorites/{id}/run |
blocos do chat salvos como favorito + replay sem LLM |
GET /embed/v1/widgets, GET /embed/v1/widgets/{id}/render, PATCH/DELETE /embed/v1/widgets/{id} |
widgets de BI salvos (spec determinística) |
GET/POST /embed/v1/dashboards, GET/PATCH/DELETE /embed/v1/dashboards/{id}, POST /embed/v1/dashboards/{id}/home, PUT /embed/v1/dashboards/{id}/share, GET /embed/v1/share-targets |
dashboards do usuário, tela inicial e compartilhamento |
Middleware: McpManifestAuthMiddleware — o mesmo do servidor MCP (resolve
o Bearer para um SystemUser, popula sessão/permissões). CORS é
deliberadamente liberal (Access-Control-Allow-Origin: *) porque o iframe é
genuinamente cross-origin — a fronteira de segurança real é o token, não a
origem. embed/v1/* é a única exceção de validateCsrfTokens
(bootstrap/app.php): o fluxo é stateless/cross-origin, sem cookie de
sessão.
Fluxo de streaming SSE
Cada frame é data: {"type": "...", "data": {...}}\n\n, terminado por
data: [DONE]\n\n. Tipos de evento:
type |
Quando |
|---|---|
text_delta |
pedaço de texto do modelo |
tool_use_start |
uma tool MCP começou a rodar (card "executando…") |
tool_use_end |
resultado da tool ({tool, params, ms, result, ok}) — substitui o card |
block |
bloco visual completo ({type: 'kpis'|'bar'|'table'|...}) — veja Blocos visuais |
usage |
tokens do turno (prompt, completion, cached, cache_write) |
message_end |
fim da resposta do turno |
error |
erro amigável (limite de cota, falha do provider, etc.) |
Exemplo — turno de leitura
curl -N -X POST http://localhost:8912/embed/v1/chat \
-H "Authorization: Bearer mcp_embed_xxx" -H "Content-Type: application/json" \
-d '{"message":"quantos clientes temos em SP?"}'
data: {"type":"tool_use_start","data":{"tool":"list_clientes","params":{"filtros":{"uf":"SP"},"limite":500},"running":true}}
data: {"type":"tool_use_end","data":{"tool":"list_clientes","params":{...},"ms":6,"result":{"total":1,"rows":[{"id":5,"nome":"Construtora Horizonte","email":"contato@***",...}]}}}
data: {"type":"text_delta","data":{"text":"**1 cliente em SP.**"}}
data: {"type":"usage","data":{"prompt":1564,"completion":10,"total":1574}}
data: {"type":"message_end","data":[]}
data: [DONE]
O modelo montou o filtro uf=SP sozinho; a tool rodou in-process (6ms),
PII já veio mascarada (contato@***).
Tools MCP in-process
McpToolCaller executa as tools do manifesto MCP
sem round-trip de rede — chama $tool->handle() diretamente no mesmo
processo PHP, reusando o mesmo McpToolFactory do servidor externo. Como a
instância já é filtrada por shouldRegister() sob o McpCurrentUser da
sessão, permissão (piso+matriz), mascaramento de PII e auditoria continuam
acontecendo dentro da própria tool — o caller só orquestra e cronometra.
Nomes de tool no manifesto usam ponto (ns.slug, ex.: clientes.del_cliente)
mas a Anthropic (direta, via OpenRouter, Bedrock ou Vertex) exige nomes de
tool batendo com ^[a-zA-Z0-9_-]{1,128}$ — McpToolCaller::publicName()
reescreve para ns__slug só para o loop do agente; o servidor MCP externo
continua usando os nomes canônicos.
Disponibilidade de tool = manifesto × permissão do usuário corrente — o
mesmo conjunto que tools/list retornaria para esse token no servidor MCP.
Trava de confirmação de escrita
O loop do agente nunca escreve. Para create/update/del (ou qualquer
tool marcada confirm), McpDataTool::handle() faz self-gate: em vez de
chamar a tool, registra a chamada pendente (ConfirmCoordinator::stash()) e
emite um bloco confirm por SSE — a execução real só acontece no próximo
turno, de forma determinística, sem chamar o modelo de novo.
# Turno 2 — "crie um cliente chamado Teste E2E"
data: {"type":"text_delta","data":{"text":"Vou criar o cliente com os dados fornecidos."}}
data: {"type":"block","data":{"type":"confirm","id":"confirm-01754ed436c7","tool":"create_cliente",
"title":"Confirmar ação: create_cliente","danger":false,
"fields":[{"k":"nome","v":"Teste E2E"},{"k":"email","v":"teste@e2e.com"}]}}
data: {"type":"text_delta","data":{"text":"Um cartão de confirmação foi exibido."}}
data: [DONE]
Nesse ponto nada foi escrito no banco. O front então envia um segundo
POST, desta vez com confirm em vez de message:
curl -N -X POST .../embed/v1/chat -H "Authorization: Bearer ..." \
-d '{"confirm":{"id":"confirm-01754ed436c7","approved":true}}'
# Turno 3 — aprovação
data: {"type":"tool_use_start","data":{"tool":"create_cliente","params":{"nome":"Teste E2E",...},"running":true}}
data: {"type":"tool_use_end","data":{...,"ms":6,"result":{"created":true,"id":"13"}}}
data: {"type":"text_delta","data":{"text":"Acao \"create_cliente\" executada com sucesso."}}
data: [DONE]
O id confirm-<hex> correlaciona a aprovação com a chamada original — ele é
persistido em mad_ai_conversation.pending_json entre as duas requisições
HTTP (cada turno SSE é uma requisição própria). Antes de executar, o
controller revalida a permissão sob o usuário do request atual
(McpPermissionResolver::canUseTool()) — approved: true vindo do front,
sozinho, nunca é suficiente. Reprovar (approved: false) cancela sem tocar
no banco. É possível também enviar values no payload de confirmação para
o usuário editar campos antes de aprovar (mesclados sobre os args originais).
Blocos visuais
Além das tools de dados, o agente recebe um conjunto fixo de render tools
(show_kpis, show_bar_chart, show_line_chart, show_donut_chart,
show_table, show_list, show_gauge, show_funnel, show_timeline,
show_progress, show_callout, show_detail, show_dashboard,
confirm_action, entre outras) que emitem blocos type: "block" no SSE em
vez de o modelo despejar dados como markdown cru. show_dashboard agrupa
blocos já emitidos no mesmo turno (por referência de id) num grid com
filtros — útil para o modelo montar uma visão composta sem reemitir JSON
inteiro. Cada bloco "data-aware" carrega uma source: {tool, args} que
permite ao front oferecer "salvar como favorito" (reexecuta a mesma consulta
depois, sem LLM).
Tools de interação e de BI
Além das tools de dados (manifesto MCP) e das render tools, o
EmbedChatController monta mais um conjunto fixo:
| Tool | Classe | O quê |
|---|---|---|
ask_user |
Mad\Ai\Tools\AskUserTool |
pergunta bloqueante com até 4 opções clicáveis — emite bloco callout + follow chips (SseSink::setFollow) e o turno encerra; a escolha volta como mensagem nova |
suggest_next |
Mad\Ai\Tools\SuggestNextTool |
2–4 próximos passos como chips no message_end — irmã leve, sem semântica de bloqueio |
db_schema |
Mad\Ai\Tools\DbSchemaTool |
tabelas/colunas consultáveis + dialeto SQL; por default só o que o manifesto MCP expõe ([ai] widgets_full_schema abre o banco inteiro) |
preview_widget / save_widget |
Mad\Ai\Tools\WidgetUpsertTool (uma instância por modo) |
recebe o WidgetSpec {type, sql, map, style}, valida a SQL (WidgetSqlGuard), executa, emite o bloco; em save, persiste no WidgetStore |
list_widgets |
Mad\Ai\Tools\ListWidgetsTool |
lista os widgets salvos do usuário (id/título/tipo) para atualizar via save_widget.widgetId |
save_dashboard |
Mad\Ai\Tools\SaveDashboardTool |
monta um dashboard persistente (até 12 widgets) a partir de widgets já salvos |
As quatro últimas só entram quando [ai] widgets_enabled está ligado (default
true) e independem do manifesto — db_schema degrada para introspecção
nativa quando não há manifesto. Detalhe do ciclo em
Dashboards de IA (embed).
Custo e cotas
Todo turno grava uma linha em mad_ai_token_usage (UsageLog::record) com
tokens (prompt/completion/cache_read/cache_write) e, quando o
provider informa (OpenRouter sim, Anthropic direto não), cost_usd — a
mesma tabela que alimenta a tela "Consumo de IA".
Antes de chamar o modelo, Mad\Usage\QuotaService::assertWithinQuota() soma
o consumo do chamador contra as regras cadastradas na tela "Cotas de IA":
- Escopo da regra, do mais específico ao mais genérico:
token > user > group > unit > global. Regra de contexto específico (ex.:embed_chat) vence regra de contexto nulo no mesmo escopo. - Período avaliado independentemente:
day/month/total. - Ação:
block(barra a chamada) ouwarn(só sinaliza). - Sem regra que combine = ilimitado (degrada seguro).
A checagem é pré-chamada: como o custo da chamada atual ainda é
desconhecido, ela bloqueia só quem já está no limite — a chamada que
cruza a linha roda (e é contabilizada); a próxima é que é barrada. Estouro de
regra block interrompe o turno antes de montar tools/chamar o modelo,
emitindo error com a mensagem amigável ("Limite diario de uso de IA atingido (1.500/1.000 tokens).") — sem custo de chamada extra. Falha de
infraestrutura no próprio serviço de cota não bloqueia o chat
(fail-open) — só QuotaExceededException barra de fato.
Modo proxy
Quando o app foi gerado pelo MadBuilder e não tem chave de IA própria,
[ai] proxy (env MAD_AI_PROXY) encaminha o chat para a nuvem: o app local
não chama o provedor diretamente — faz POST {MAD_BUILDER_URL}/api/app/ai/chat
autenticado com MAD_PROJECT_TOKEN e repassa o stream (passthrough). A chave
de IA fica só no MadBuilder, que debita o consumo na conta do
desenvolvedor.
Testando manualmente
MAD_AI_ENABLED=true OPENROUTER_API_KEY=sk-or-... php artisan serve --port=8912
php artisan tinker --execute='echo App\Service\Mcp\McpTokenService::issue("admin","manual","mcp_embed_").PHP_EOL;'
curl -N -X POST http://localhost:8912/embed/v1/chat \
-H "Authorization: Bearer <TOKEN>" -H "Content-Type: application/json" \
-d '{"message":"quem é você? responda em uma frase."}'
curl -s http://localhost:8912/embed/v1/conversations -H "Authorization: Bearer <TOKEN>"
artisan serveé single-thread — para testar SSE junto com outras requisições simultâneas, usePHP_CLI_SERVER_WORKERS=4.
Veja também
- Servidor MCP — as tools que o Copilot chama in-process vêm de lá; o manifesto e o modelo de permissão são os mesmos.
- Dashboards de IA (embed) — widgets salvos, favoritos e a tela "Meus Dashboards" que reexecutam sem LLM.
- Central de Comando (Agent Console) — a variante admin-only que opera o app em vez de dados de negócio.