Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -464,3 +464,35 @@ al agente. Si con eso se coordinan mejor, la regla vale la pena.
Ataca el hueco documentado en `docs-write-pisa.md`: el CAS protege archivos de
escrituras simultaneas, pero no evita que dos agentes trabajen sobre premisas
incompatibles.

---

## Blender en la sala, en vivo en el preview

Idea del 23 de septiembre de 2026, sin construir todavía. La pidió un usuario
que llegó a hacer una tarea de Blender, no pudo, y se fue a probar otra cosa.

**Lo que no encaja: un MCP de Blender.** Los que existen (`blender-mcp`) le
mandan Python a un Blender abierto en la compu de quien lo usa, por un addon
que escucha en `localhost`. El agente de Multi corre en el server y no llega a
ese `localhost` sin un programa puente del lado del usuario; solo él podría
"hospedar" el Blender, y el agente tampoco habla MCP.

**Lo que sí encaja: Blender con ventana dentro de la sala, transmitido.**
Blender corre en el contenedor sobre una pantalla virtual (`Xvfb`) y se ve en el
navegador con noVNC. El preview ES esa ventana: toda la sala la ve y la puede
girar. El agente lo maneja mandándole Python, que es lo mismo que hace el MCP
por dentro, con un script por bash. noVNC puede ser el "dev server" de la sala
(el único puerto que sale), y el proxy del preview ya pasa websockets.

**Lo que falta averiguar antes de prometerlo:**
1. Rendimiento: sin GPU, Blender dibuja por CPU; con 2 CPU y 2 GB puede ir lento.
2. `Xvfb` se instala con `apt` y el agente no es root en la sala. Probar primero
que lo instale él sin root (desempacar los `.deb` en su HOME, o micromamba);
si sale frágil, agregar solo `Xvfb` a `docker/room.Dockerfile`. Blender mismo
(~300 MB, portátil) lo baja el agente solo cuando alguien lo pide.
3. Que noVNC funcione a través del proxy del preview.

**Plan B que seguro funciona:** Blender sin ventana (`blender --background
--python`), renders a PNG y un visor 3D (three.js) del `.glb` exportado en el
preview, con botón para descargar el `.blend`. No se modela a mano, se pide.
44 changes: 42 additions & 2 deletions server/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ import {
nuevaAutorizacion,
nuevaPassword,
organizaciones,
proyectos,
ejecutarSql,
refrescar as refrescarSupabase,
urlDeAutorizacion as urlDeAutorizacionSupabase,
Expand Down Expand Up @@ -797,6 +798,7 @@ fastify.get<{ Params: { id: string } }>("/rooms/:id/supabase", async (req) => {
configurado: credencialDeSupabase() !== null && hayLlave(),
proyecto: conexion?.proyecto ?? null,
url: conexion?.proyecto ? urlDelProyecto(conexion.proyecto) : null,
etapa: etapaSupabase.get(req.params.id) ?? null,
// Tener proyecto no es lo mismo que tener las variables en el `.env`, que
// es lo único que el agente ve. El panel decía "conectada" en cuanto el
// proyecto existía, y si la preparación se cortaba después (el tope de
Expand Down Expand Up @@ -913,13 +915,28 @@ fastify.get<{ Querystring: { code?: string; state?: string; error?: string } }>(
*/
const preparacionesDeSupabase = new KeyedMutex();

/**
* Por dónde va la preparación de cada sala, para el `GET` del panel.
*
* Las etapas viajan por socket, y la vuelta de autorizar RECARGA la página: los
* primeros avisos llegaban antes de que la Sala se conectara, y el panel decía
* "Conectar Supabase" unos segundos como si no hubiera pasado nada. Con esto el
* panel sabe desde que carga que la base se está preparando.
*/
const etapaSupabase = new Map<string, string>();

async function prepararProyecto(roomId: string): Promise<void> {
// Serializado por sala: dos caminos llaman aquí (el callback al autorizar, y
// el botón de conectar cuando retoma), y si coinciden los dos leen que no hay
// proyecto y los dos lo crean. Supabase rechaza el segundo por nombre
// repetido, así que el síntoma es un error confuso en vez de una carrera
// evidente. Pasó la primera vez que se reconectó.
return preparacionesDeSupabase.run(roomId, () => prepararProyectoSerializado(roomId));
etapaSupabase.set(roomId, "creando");
try {
return await preparacionesDeSupabase.run(roomId, () => prepararProyectoSerializado(roomId));
} finally {
etapaSupabase.delete(roomId);
}
}

async function prepararProyectoSerializado(roomId: string): Promise<void> {
Expand All @@ -935,8 +952,10 @@ async function prepararProyectoSerializado(roomId: string): Promise<void> {
const room = getRoom(roomId) ?? (await wakeRoom(roomId));
if (!room) return;

const avisar = (etapa: string, extra: Record<string, unknown> = {}) =>
const avisar = (etapa: string, extra: Record<string, unknown> = {}) => {
etapaSupabase.set(roomId, etapa);
io.to(roomId).emit("supabase:etapa", { etapa, ...extra });
};

// Si el proyecto YA existe se retoma desde donde se quedó, en vez de crear
// otro. Levantar una base tarda minutos y cualquier cosa puede cortar el
Expand All @@ -945,6 +964,27 @@ async function prepararProyectoSerializado(roomId: string): Promise<void> {
let ref = conexion.proyecto ?? null;
let password = conexion.password ?? null;

// Sin proyecto guardado, puede que igual exista: la sala se desconectó (eso
// borra la conexión, no el proyecto, que es de la persona) y ahora vuelve a
// conectar. Crear otro rompía la sala: Supabase rechaza el nombre repetido,
// y si no lo rechazara la app quedaría apuntando a una base vacía, sin sus
// tablas ni sus datos. Se adopta el que ya lleva el nombre de esta sala.
if (!ref) {
const existente = (await proyectos(acceso)).find((p) => p.name === `multi-${roomId}`);
if (existente) {
if (existente.status === "INACTIVE") {
throw new FalloDeSupabase(
"el proyecto de esta sala está pausado en Supabase: reactívalo desde su panel y vuelve a conectar",
);
}
ref = existente.ref ?? existente.id;
console.log(`[supabase] ${roomId} ya tenía el proyecto ${ref}: se adopta en vez de crear otro`);
// La contraseña no se recupera: Supabase no la devuelve nunca. La app no
// la necesita; solo quien quiera entrar a la base por fuera.
await (await getStorage()).guardarConexionSupabase({ ...conexion, proyecto: ref, password: null });
}
}

if (!ref) {
avisar("creando");
const orgs = await organizaciones(acceso);
Expand Down
5 changes: 5 additions & 0 deletions server/src/supabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,11 @@ export interface Proyecto {
region?: string;
}

/** Los proyectos que la cuenta autorizada puede ver, de todas sus organizaciones. */
export async function proyectos(acceso: string): Promise<Proyecto[]> {
return pedir<Proyecto[]>(acceso, "/v1/projects");
}

/**
* Crea un proyecto y devuelve su referencia.
*
Expand Down
Loading