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áginaapp.js— estado del cliente y handlers de eventosstyles.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.