@meistrari/minuta-nuxt
v1.3.3
Published
Embed do editor de minuta (docx paginado A4) do defesa.ai para apps Nuxt — componente + proxy Nitro, sem segredos no browser
Maintainers
Keywords
Readme
@meistrari/minuta-nuxt
Embed do editor de minuta do defesa.ai para qualquer app Nuxt: o documento paginado em A4 com edição rica e export DOCX fiel ao preview, renderizado como componente — sem iframe e sem segredos no browser.
Segue a mesma arquitetura do @meistrari/chat-nuxt: o componente fala com
rotas Nitro registradas no próprio app hospedeiro (/api/minuta/*), e o proxy
encaminha para a API do Case Defense Builder com o JWT Tela do usuário
logado — a API valida o token contra a auth central e resolve o workspace.
O pacote mora em packages/minuta-nuxt do repositório
case-defense-builder e é
publicado no npm pelo workflow Publish @meistrari/minuta-nuxt. O próprio
cliente do defesa.ai consome esta mesma versão publicada — a tela /minutas/:id
é só um invólucro do <MinutaEmbed>, então bug corrigido aqui chega nos dois.
Instalação
npm install @meistrari/minuta-nuxt// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@meistrari/minuta-nuxt'],
minutaNuxt: {
// URL da API do Case Defense Builder
cdbApiUrl: process.env.CDB_API_URL, // backend do defesa.ai
// opcional: cookie do host que carrega o JWT Tela do usuário
// (default: event.context.auth.token → cookie `tela-jwt`)
// authTokenCookie: 'meu-cookie-jwt',
},
})Uso
<template>
<MinutaEmbed
minuta-id="94a53f33-…"
style="height: 100vh"
@saved="onSaved"
/>
</template>Criando uma minuta a partir do host (composable auto-importado):
const { uploadMinutaFile, createMinuta } = useMinutaApi()
// arquivos de QUALQUER tipo — sobem pro Vault via proxy
const files = await Promise.all([...pickedFiles].map(f => uploadMinutaFile(f)))
const minuta = await createMinuta({
name: 'Contestação — João da Silva',
files,
// contexto livre sobre a peça (tipo, juízo, orientações do escritório…)
context: 'Contestação para o réu; linha de defesa: culpa exclusiva do consumidor.',
// agente Tela que escreve a minuta ("organizationName/repository")
agentId: 'tela/defesa-minuta-avulsa',
})
// navegue para a tela que renderiza <MinutaEmbed :minuta-id="minuta.id" />Para peça de texto fixo com campos variáveis, sem agente, veja Templates e variáveis.
Templates e variáveis
Nem toda peça precisa de agente. Quando o texto já está decidido e o que muda são alguns campos — petições intermediárias, ofícios, requerimentos de rotina — existe o caminho template: um documento com placeholders, escrito no mesmo editor A4, que vira minuta pronta no mesmo request, sem passar por agente nenhum.
O CDB não guarda template. Quem guarda é o app hospedeiro, que já tem cadastro para isso. O que o embed entrega é o editor e o formato; o que o CDB faz é trocar placeholder por valor.
Escrever o template
<MinutaTemplateEditor
v-model="cadastro.templateHtml"
:variables="CATALOGO"
style="height: 100vh"
@update:used-variables="keys => cadastro.variaveis = keys"
/>O catálogo é um array plano, definido pelo host — o CDB não sabe o que significa nenhuma dessas chaves:
const CATALOGO = [
{ key: 'PROCESSO', label: 'Nº do Processo', description: 'De info_processos.processo', group: 'Processo' },
{ key: 'NOME_REU', label: 'Nome do Réu', group: 'Réu' },
]No documento, o advogado digita / (ou usa o botão { } da barra), busca pelo
rótulo e insere. O placeholder é um nó atômico: seleciona inteiro, apaga
inteiro, e negrito/fonte aplicam nele por completo. Não dá para digitar uma
chave que não existe, e não dá para partir uma pela metade ao formatar —
que é o motivo de ser nó, e não #NOME_VARIAVEL no texto.
update:used-variables devolve as chaves usadas, na ordem do documento e sem
repetição. É com ela que o host sabe o que perguntar; ele nunca precisa ler o
HTML.
Gerar a peça
const minuta = await createMinuta({
name: 'Requerimento — contrato 123',
templateHtml: cadastro.templateHtml,
variables: { PROCESSO: '1234567-89.2026.8.26.0100', NOME_REU: null },
})
minuta.status // 'READY' — sem agente, sem polling
minuta.missingVariables // ['NOME_REU']Só texto nos valores: número e data chegam já formatados pelo host, que é quem
sabe se 1234.5 é R$ 1.234,50. null significa "não resolveu".
Variável sem valor não some. Ela continua sendo um placeholder vivo dentro
do documento, no parágrafo em que está — quem abrir a minuta vê o buraco onde
ele fica, em vez de descobrir que falta algo por um botão desabilitado. O host
decide o que fazer com missingVariables (abrir pendência, bloquear o
protocolo, ou deixar preencher no editor mesmo).
templateHtml e agentId se excluem: mandar os dois é 400.
Props do <MinutaTemplateEditor>
| Prop | Default | Descrição |
|---|---|---|
| modelValue | '' | HTML do template (v-model) |
| variables | [] | catálogo { key, label, description?, group? } |
| editable | true | permite editar |
| showToolbar | true | barra com o resumo das variáveis usadas |
| margins | 2,5cm/3cm | margens da página em px @96dpi |
| chrome | null | cabeçalho/rodapé; v-model:chrome para persistir |
Eventos: update:modelValue, update:usedVariables, update:chrome,
ready(editor). Slots: toolbar-start, toolbar-end.
defineExpose: getHtml(), usedVariables, editor.
Playground: bun run dev e abra /?template.
Props do <MinutaEmbed>
| Prop | Default | Descrição |
|---|---|---|
| minutaId | — | id da minuta avulsa no CDB |
| editable | true | permite editar o documento |
| autoSave | true | salva 2s após a última edição |
| showToolbar | true | barra com nome, estado de save e export |
| margins | 2,5cm/3cm | margens da página em px @96dpi |
| renamable | segue editable | nome editável na própria barra |
| chatAgentId | null | id de plataforma do agente assistente; sem ele, o chat não aparece |
Eventos: ready(editor), saved(minuta), error(message).
Slot: toolbar-start (o host injeta navegação própria, ex.: botão voltar).
defineExpose: saveNow(), exportDocx(), editor.
Dois agentes, dois lugares. O que escreve a minuta vai no agentId da
chamada de criação acima — é decisão de quem cria, e cada app manda o seu (o
mesmo campo existe na Data API, para quem cria do servidor; a env
MINUTA_AVULSA_AGENT do CDB é só o default de quem não mandar nada). O que
edita pelo chat é prop do componente (chatAgentId), porque vive dentro da
tela de edição.
A barra do embed inclui Histórico: versões são criadas automaticamente conforme o documento é editado (snapshot no auto-save, com espaçamento mínimo de 10 min) e podem ser restauradas — o estado atual vira um snapshot antes.
Comentários e colaboração em tempo real
<MinutaCommentsPanel> é o painel de comentários ao lado da folha: um card
por thread, alinhado ao trecho, com respostas, @menção, resolver/reabrir
e excluir. Ele conversa com o editor do core (MinutaEditor) e recebe as
threads do host por prop; cada mudança sai por evento. O módulo não guarda
nada, não notifica ninguém e não sabe quem são as pessoas — users é só a
lista para o @.
<script setup lang="ts">
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { createMinutaEditor } from '@meistrari/minuta-editor'
import { collabCommentThreads, minutaCollaboration, seedCollaborationDoc } from '@meistrari/minuta-editor/collab'
const doc = new Y.Doc()
const provider = new WebsocketProvider(wsUrl, contratoId, doc) // o provider é seu (auth na sala inclusa)
const { threads, handlers } = useCollabThreads(collabCommentThreads(doc)) // threads no próprio Y.Doc
const panel = ref()
const editor = shallowRef<MinutaEditor | null>(null)
onMounted(() => {
editor.value = createMinutaEditor(host.value!, {
html: '',
extensions: minutaCollaboration({ doc, user: { name: me.name, color: me.color }, awareness: provider.awareness }),
onCommentAdd: anchor => panel.value.startThread(anchor), // botão "comentar" do menu de seleção
})
provider.on('synced', () => seedCollaborationDoc(editor.value!, doc, htmlInicial))
})
</script>
<template>
<div class="layout"> <!-- a folha e o painel na MESMA área de rolagem -->
<div ref="host" />
<aside>
<MinutaCommentsPanel
ref="panel"
:editor="editor"
:threads="threads"
:current-user="{ id: me.id, name: me.name }"
:users="pessoasDoWorkspace"
v-bind="handlers"
@mention="({ userIds, message }) => notificar(userIds, message)"
/>
</aside>
</div>
</template>Sem colaboração (um usuário por vez, threads no seu banco): mesmo painel,
sem extensions; troque useCollabThreads pelos seus handlers
(@create, @message, @resolve, @remove) contra a sua API e passe
threads da sua consulta. A âncora continua no HTML (data-comment-id),
então ela viaja com as versões do documento que você guarda.
Harness local: /?colab&user=Ana no playground, com y-websocket de dev em
ws://localhost:1234 (HOST=localhost PORT=1234 node node_modules/y-websocket/bin/server.cjs);
abra outra aba com user=Bruno.
Como funciona
MinutaEmbedbusca a minuta em/api/minuta/:id(rota Nitro do módulo).- O proxy resolve o JWT do usuário (contexto de auth do host ou cookie) e
encaminha para
{cdbApiUrl}/standalone-minuta/:idcomAuthorization: Bearer. - Enquanto
status === 'PROCESSING', o embed faz polling (3s) — o próprio GET no CDB consulta o agente e materializa o resultado. - Com
status === 'READY', o HTML entra no editor paginado (Tiptap + motor de paginação A4 próprio). O export DOCX é 100% client-side; o Word reflui o texto como o preview (só a quebra manual do template é forçada).
Desenvolvimento
npm install
npm run build # nuxt-module-buildO núcleo do editor (MinutaDocxEditor.vue, paginationPlugin, docxExport,
editorExtensions) é portado do Case Defense Builder — a intenção é que o CDB
passe a consumir este package para existir um único editor mantido.
