LocalLens
Walkthrough

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ágina
  • app.js — estado do cliente e handlers de evento
  • styles.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.

On this page