LocalLens
Walkthrough

9. La UI del navegador

HTML, JS y CSS planos — sin build step, sin framework.

src/ui/ son tres archivos:

  • index.html — el esqueleto de la página
  • app.js — estado del cliente y handlers de eventos
  • styles.css — la capa visual

Sin framework, sin bundler, sin transpilación. El file picker nos entrega el contenido de la carpeta, fetch habla con el servidor y el DOM se actualiza a mano.

La página

index.html define:

  • un sidebar que lista cerebros;
  • un topbar que muestra el cerebro activo;
  • un panel de chat con un hilo de mensajes y un form de input;
  • un panel de creación de cerebro con un input de nombre, un botón Choose folder y un botón submit Create and index.

Carga styles.css y app.js desde /assets/….

El modelo de estado

app.js mantiene un solo objeto de estado:

const state = {
  brains: [],
  activeBrainId: undefined,
  messages: [],
  selectedFolder: undefined,
};

Las funciones de render leen de state y actualizan el DOM. Los handlers de eventos mutan state y llaman a la función de render relevante. Sin DOM virtual — para una UI de este tamaño, costaría más de lo que ahorraría.

Leyendo una carpeta

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> devuelve una lista plana de objetos File con webkitRelativePath seteado. readSelectedFolder filtra por las mismas extensiones soportadas que el servidor, tira los archivos grandes y convierte cada File en un 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());
}

Estas constantes reflejan src/files.ts. Tienen que coincidir, o el servidor rechaza lo que la UI aceptó.

Creando un cerebro

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() es un wrapper delgado de fetch que arroja en responses no 2xx, aflorando los mensajes de AppError del servidor al usuario como toasts.

Haciendo una pregunta

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();
});

El hilo del chat se renderiza como Markdown vía un renderer chico in-file (renderMarkdown, formatInlineMarkdown, escapeHtml). Maneja negritas, itálicas, código, bloques de código y citas con corchetes — suficiente para lo que produce el modelo, ni más ni menos.

Por qué sin framework

Para una UI de este tamaño, el trade-off es claro:

  • Sin build step. Edita app.js, refresca el navegador. Ese es el loop de dev.
  • Fácil de leer de extremo a extremo. Todo el cliente es un solo archivo.
  • Sin churn de versiones. Sigue funcionando sin nosotros.

Si la UI creciera a varias pantallas, un framework empezaría a pagar por sí mismo. No lo ha hecho.

Agregar una feature acá

Para extender la UI, agrega una función de render chica, dale una pieza de state y llámala desde los handlers de eventos existentes. No introduzcas un framework solo para agregar un botón.

Eso cierra el walkthrough. Desde acá, la sección de Estructura del código explica por qué las fronteras aterrizan donde aterrizan, y la sección Extender muestra dónde enchufar features nuevas.

On this page