9. A UI do navegador
HTML, JS e CSS puros — sem build step, sem framework.
src/ui/ são três arquivos:
index.html— o esqueleto da páginaapp.js— estado do cliente e handlers de eventostyles.css— a camada visual
Sem framework, sem bundler, sem transpilação. O file picker nos
entrega o conteúdo da pasta, fetch conversa com o servidor, e o DOM é
atualizado na mão.
A página
index.html define:
- uma sidebar que lista os brains;
- uma topbar mostrando o brain ativo;
- um painel de chat com uma thread de mensagens e um form de input;
- um painel de criação de brain com input de nome, um botão Choose folder e um botão de submit Create and index.
Carrega styles.css e app.js de /assets/….
O modelo de estado
app.js mantém um único objeto de estado:
const state = {
brains: [],
activeBrainId: undefined,
messages: [],
selectedFolder: undefined,
};Funções de render leem de state e atualizam o DOM. Handlers de
evento mutam state e chamam a função de render relevante.
Sem DOM virtual — para uma UI desse tamanho, custaria mais do
que economizaria.
Lendo uma pasta
elements.browseFolder.addEventListener("click", () => {
elements.folderPicker.click();
});
elements.folderPicker.addEventListener("change", async () => {
const files = elements.folderPicker.files;
state.selectedFolder = await readSelectedFolder(files);
/* … render … */
});<input type="file" webkitdirectory> devolve uma lista plana de
objetos File com webkitRelativePath setado. readSelectedFolder
filtra pelas mesmas extensões suportadas do servidor, dropa
arquivos grandes e transforma cada File em um BrowserDocumentInput:
function shouldReadBrowserFile(relativePath, size) {
if (size > MAX_BROWSER_FILE_BYTES) return false;
const segments = relativePath.split("/");
if (segments.some((s) => s.startsWith(".") || IGNORED.has(s))) return false;
return SUPPORTED.has(extname(relativePath).toLowerCase());
}Essas constantes espelham src/files.ts. Precisam concordar, ou o
servidor rejeita o que a UI aceitou.
Criando um brain
elements.brainForm.addEventListener("submit", async (event) => {
event.preventDefault();
const body = {
name: elements.brainName.value,
folderName: state.selectedFolder.rootName,
documents: state.selectedFolder.documents,
};
const { brain } = await api("/api/brains/from-files", {
method: "POST",
body: JSON.stringify(body),
});
await refreshBrains();
state.activeBrainId = brain.id;
state.messages = [];
renderActiveBrain();
});api() é um wrapper fino de fetch que lança em respostas
não-2xx, expondo as mensagens de AppError do servidor ao
usuário como toasts.
Fazendo uma pergunta
elements.chatForm.addEventListener("submit", async (event) => {
event.preventDefault();
const question = elements.chatInput.value.trim();
if (!question || !state.activeBrainId) return;
state.messages.push({ role: "user", content: question });
renderChat();
const { answer, citations } = await api(
`/api/brains/${state.activeBrainId}/chat`,
{ method: "POST", body: JSON.stringify({ question }) },
);
state.messages.push({ role: "assistant", content: answer, citations });
renderChat();
});A thread de chat renderiza como Markdown via um renderizador minúsculo
no arquivo (renderMarkdown, formatInlineMarkdown, escapeHtml). Ele
trata bold, itálico, code, blocos de código e citações entre colchetes —
o suficiente para o que o modelo produz, e nada mais.
Por que sem framework
Para uma UI desse tamanho, o trade-off é claro:
- Sem build step. Edita
app.js, atualiza o navegador. Esse é o dev loop. - Fácil de ler de ponta a ponta. O cliente inteiro é um arquivo.
- Sem churn de versão. Continua funcionando sem a gente.
Se a UI crescesse para várias telas, um framework começaria a se pagar. Não cresceu.
Adicionando uma feature aqui
Para estender a UI, adiciona uma função de render pequena, dá um pedaço de
state para ela e chama dos handlers de evento existentes. Não
introduz um framework só pra adicionar um botão.
Isso fecha o walkthrough. Daqui, a seção Estrutura do código explica por que as fronteiras estão onde estão, e a seção Estender mostra onde plugar features novas.