From 462e00968ecc0ee20469029a7c59e32a5cf09c1b Mon Sep 17 00:00:00 2001 From: ErickHub192 Date: Wed, 12 Aug 2026 23:46:50 -0600 Subject: [PATCH] Un panel para las variables del proyecto Las apps de la sala guardan datos en SQLite local y con eso arrancan solas, pero en cuanto alguien quiere conectar su Supabase, su Postgres o la key de un proveedor para una app de agentes, no habia donde ponerlas. Ahora hay un panel en la barra, como el de Vercel: nombre y valor, agregar y quitar. Multi no sabe que significan; las escribe en el `.env` del workspace y de ahi las leen el proyecto y el agente. Asi sirve para cualquier cosa sin casarse con ningun proveedor. Por un panel y no por el chat: el chat se guarda en la BD, se le muestra a todos y se manda al modelo, asi que una credencial dictada ahi queda escrita para siempre. El `.env` ya esta en el .gitignore del motor, asi que tampoco entra a los commits ni viaja en el .zip. Son de la SALA, no de quien las puso, y el panel lo dice: a diferencia de tu API key (personal, en tu navegador), estas las usa el proyecto entero y el agente las puede leer, porque para conectarse tiene que poder. Se descarta lo que romperia el archivo (nombres con espacios, saltos de linea en el valor): una variable mal tecleada dejaria el .env ilegible y la app sin arrancar por algo que nadie veria. El demo lo cubre con 9 comprobaciones. Co-Authored-By: Claude Opus 5 --- package.json | 3 +- server/package.json | 3 +- server/src/demos/variables.ts | 84 ++++++++++++++++++ server/src/engine/env.ts | 105 ++++++++++++++++++++++ server/src/index.ts | 35 ++++++++ web/src/App.tsx | 4 + web/src/EnvPanel.tsx | 160 ++++++++++++++++++++++++++++++++++ web/src/i18n.tsx | 30 +++++++ web/src/styles.css | 119 +++++++++++++++++++++++++ 9 files changed, 541 insertions(+), 2 deletions(-) create mode 100644 server/src/demos/variables.ts create mode 100644 server/src/engine/env.ts create mode 100644 web/src/EnvPanel.tsx diff --git a/package.json b/package.json index 71ef76f..ae2b8c7 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "demo:presupuesto": "npm run demo:presupuesto -w server", "demo:turno-cortado": "npm run demo:turno-cortado -w server", "demo:exportar": "npm run demo:exportar -w server", - "uso": "npm run uso -w server --" + "uso": "npm run uso -w server --", + "demo:variables": "npm run demo:variables -w server" }, "engines": { "node": ">=22" diff --git a/server/package.json b/server/package.json index f224a4e..85be21e 100644 --- a/server/package.json +++ b/server/package.json @@ -26,7 +26,8 @@ "demo:imagenes": "tsx src/demos/imagenes.ts", "demo:proxy-rutas": "tsx src/demos/proxy-rutas.ts", "demo:exportar": "tsx src/demos/exportar.ts", - "uso": "tsx --no-warnings src/demos/uso.ts" + "uso": "tsx --no-warnings src/demos/uso.ts", + "demo:variables": "tsx src/demos/variables.ts" }, "dependencies": { "@fastify/cors": "^11.3.0", diff --git a/server/src/demos/variables.ts b/server/src/demos/variables.ts new file mode 100644 index 0000000..3f7a00e --- /dev/null +++ b/server/src/demos/variables.ts @@ -0,0 +1,84 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createWorkspace } from "../engine/workspace.js"; +import { guardarVariables, leerVariables } from "../engine/env.js"; + +/** + * Demo: las variables del proyecto de una sala (su `.env`). + * Uso: npm run demo:variables + * + * Lo que importa comprobar aquí es que el archivo quede LEGIBLE: lo van a leer + * el proyecto (con el cargador de su stack) y el agente (con read_file). Un + * nombre con espacios o un valor con salto de línea rompen el formato, y la app + * dejaría de arrancar por una variable que alguien tecleó mal en el panel. + * + * No necesita red ni servidor: prueba el módulo que escribe el archivo. + */ + +let pass = 0; +let fail = 0; +function check(name: string, ok: boolean, detail = ""): void { + if (ok) { + pass++; + console.log(` [ok] ${name}`); + } else { + fail++; + console.log(` [X] ${name} ${detail}`); + } +} + +async function main() { + console.log("\n=== variables del proyecto ===\n"); + + const ws = await createWorkspace("demo-variables", { clean: true }); + + console.log("1. Una sala nueva no tiene ninguna"); + check("empieza vacía", (await leerVariables(ws.dir)).length === 0); + + console.log("\n2. Se guardan y se vuelven a leer"); + await guardarVariables(ws.dir, [ + { nombre: "SUPABASE_URL", valor: "https://abc.supabase.co" }, + { nombre: "SUPABASE_ANON_KEY", valor: "ey.Jhb-Gci_OiJ" }, + ]); + const leidas = await leerVariables(ws.dir); + check("son dos", leidas.length === 2, String(leidas.length)); + check("el valor sobrevive entero", leidas[0].valor === "https://abc.supabase.co", leidas[0].valor); + + console.log("\n3. El archivo se ve como un .env de verdad"); + const texto = await readFile(join(ws.dir, ".env"), "utf8"); + check("formato NOMBRE=valor", texto.includes('SUPABASE_URL="https://abc.supabase.co"')); + check("lleva una explicación arriba", texto.startsWith("#")); + + console.log("\n4. Lo que rompería el archivo se descarta"); + await guardarVariables(ws.dir, [ + { nombre: "BUENA", valor: "si" }, + { nombre: "con espacios", valor: "no" }, + { nombre: "2EMPIEZA_CON_NUMERO", valor: "no" }, + { nombre: "SALTO", valor: "linea1\nlinea2" }, + ]); + const v2 = await leerVariables(ws.dir); + check("solo quedan las que un shell acepta", v2.length === 2, v2.map((v) => v.nombre).join(",")); + check( + "el salto de línea se aplana en vez de partir la asignación", + v2.find((v) => v.nombre === "SALTO")?.valor === "linea1 linea2", + ); + + console.log("\n5. La lista que manda el panel es la que queda"); + // Borrar es no mandarla: así el panel no necesita una operación aparte para + // quitar una variable. + await guardarVariables(ws.dir, [{ nombre: "UNICA", valor: "1" }]); + const v3 = await leerVariables(ws.dir); + check("las anteriores se fueron", v3.length === 1, v3.map((v) => v.nombre).join(",")); + + console.log("\n6. Y no entra al historial de la sala"); + const gitignore = await readFile(join(ws.dir, ".gitignore"), "utf8"); + check("el .env está ignorado", gitignore.includes(".env")); + + console.log(`\n${pass} pasaron, ${fail} fallaron\n`); + process.exit(fail > 0 ? 1 : 0); +} + +main().catch((err) => { + console.error("\ndemo falló:", err); + process.exit(1); +}); diff --git a/server/src/engine/env.ts b/server/src/engine/env.ts new file mode 100644 index 0000000..50772de --- /dev/null +++ b/server/src/engine/env.ts @@ -0,0 +1,105 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +/** + * Las variables de entorno del proyecto de una sala: su `.env`. + * + * Es lo que el proyecto necesita para conectarse a algo de fuera: la base de + * datos que trajo alguien, la key de un proveedor si la app usa IA, un token de + * pagos. Multi no las interpreta ni sabe qué significan; solo las escribe donde + * el proyecto las va a buscar. + * + * Van a un archivo y no a la base de Multi porque el proyecto las lee de ahí en + * tiempo de ejecución: cualquier stack sabe leer un `.env`, y el agente también, + * así que no hay que enseñarle nada nuevo. + * + * Son de la SALA, no de quien las puso: todos los que entren trabajan contra la + * misma base. El `.gitignore` del motor ya excluye `.env`, así que no entran a + * los commits ni viajan en el .zip. + */ + +/** Solo lo que un shell acepta como nombre de variable. */ +const NOMBRE_VALIDO = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** Tope por valor. Una credencial larga cabe de sobra; un archivo pegado, no. */ +const MAX_VALOR = 4000; + +/** Cuántas caben. Más que esto y ya no es configuración, es otra cosa. */ +const MAX_VARIABLES = 50; + +export interface Variable { + nombre: string; + valor: string; +} + +function rutaEnv(workspaceDir: string): string { + return join(workspaceDir, ".env"); +} + +/** + * Lee las variables del `.env` de la sala. + * + * Formato mínimo a propósito: `NOMBRE=valor`, una por línea. Se ignoran los + * comentarios y las líneas que no parezcan una asignación, para no romperse con + * un archivo que el agente haya escrito a su manera. + */ +export async function leerVariables(workspaceDir: string): Promise { + const ruta = rutaEnv(workspaceDir); + if (!existsSync(ruta)) return []; + + const texto = await readFile(ruta, "utf8").catch(() => ""); + const vars: Variable[] = []; + for (const linea of texto.split("\n")) { + const limpia = linea.trim(); + if (!limpia || limpia.startsWith("#")) continue; + const igual = limpia.indexOf("="); + if (igual <= 0) continue; + const nombre = limpia.slice(0, igual).trim(); + if (!NOMBRE_VALIDO.test(nombre)) continue; + // Las comillas son del formato, no del valor: se quitan al leer y se + // vuelven a poner al escribir. + const valor = limpia.slice(igual + 1).trim().replace(/^["']|["']$/g, ""); + vars.push({ nombre, valor }); + } + return vars; +} + +/** + * Escribe la lista completa de variables, pisando el `.env` anterior. + * + * Se escribe entero y no línea por línea porque el panel manda siempre la lista + * completa: lo que se ve ahí es lo que queda. Así borrar una variable es no + * mandarla, sin necesitar una operación aparte. + */ +export async function guardarVariables( + workspaceDir: string, + crudas: unknown, +): Promise { + const lista = Array.isArray(crudas) ? crudas : []; + const vistos = new Set(); + const vars: Variable[] = []; + + for (const v of lista) { + if (vars.length >= MAX_VARIABLES) break; + const nombre = typeof (v as Variable)?.nombre === "string" ? (v as Variable).nombre.trim() : ""; + const valor = typeof (v as Variable)?.valor === "string" ? (v as Variable).valor : ""; + // Un nombre inválido rompería el archivo para todas las demás, así que se + // descarta en vez de escribirlo y dejar el `.env` sin poder leerse. + if (!NOMBRE_VALIDO.test(nombre) || vistos.has(nombre)) continue; + vistos.add(nombre); + // Los saltos de línea partirían la asignación en dos y la segunda mitad + // quedaría suelta en el archivo. + vars.push({ nombre, valor: valor.replace(/[\r\n]/g, " ").slice(0, MAX_VALOR) }); + } + + const cabecera = [ + "# Las variables del proyecto de esta sala.", + "# Las escribe Multi desde el panel de la barra de arriba; se pueden editar", + "# aquí también, pero el panel las vuelve a escribir cuando alguien lo use.", + "", + ]; + const cuerpo = vars.map((v) => `${v.nombre}="${v.valor}"`); + await writeFile(rutaEnv(workspaceDir), [...cabecera, ...cuerpo, ""].join("\n"), "utf8"); + return vars; +} diff --git a/server/src/index.ts b/server/src/index.ts index 2145a3b..c28570e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -37,6 +37,7 @@ import { } from "./engine/adjuntos.js"; import { MAX_AGENTS_PER_ROOM, resumenDeOtros } from "./engine/agents.js"; import { fileMutation } from "./engine/file-mutation.js"; +import { leerVariables, guardarVariables } from "./engine/env.js"; import { startTurn, commitTurn, failTurnConCommit } from "./engine/turns.js"; import { commitAll, @@ -313,6 +314,40 @@ fastify.get<{ Params: { id: string } }>("/rooms/:id/export/estado", async (req, }; }); +/** + * Las variables de entorno del proyecto de la sala. + * + * GET devuelve las que hay; PUT reemplaza la lista completa (lo que manda el + * panel es lo que queda, así que borrar es no mandarla). + * + * Van por HTTP y no por el chat a propósito: el chat se guarda en la BD, se le + * muestra a todos y se manda al modelo. Una credencial ahí quedaría escrita para + * siempre. Aquí solo van al `.env` del workspace, que el .gitignore ya excluye. + * + * Lo que SÍ hay que saber: el agente puede leer ese archivo, porque el proyecto + * necesita conectarse. Quien las pone las está compartiendo con la sala, y eso + * lo dice el panel. + */ +fastify.get<{ Params: { id: string } }>("/rooms/:id/env", async (req, reply) => { + const room = getRoom(req.params.id) ?? (await wakeRoom(req.params.id)); + if (!room) return reply.code(404).send({ error: "sala no encontrada" }); + return { variables: await leerVariables(room.workspace.dir) }; +}); + +fastify.put<{ Params: { id: string }; Body: { variables?: unknown } }>( + "/rooms/:id/env", + async (req, reply) => { + const room = getRoom(req.params.id) ?? (await wakeRoom(req.params.id)); + if (!room) return reply.code(404).send({ error: "sala no encontrada" }); + + const variables = await guardarVariables(room.workspace.dir, req.body?.variables); + // A la sala se le dice CUÁNTAS quedaron, nunca sus valores: el aviso es para + // que nadie se pregunte por qué el proyecto cambió de comportamiento solo. + io.to(room.id).emit("env:changed", { cuantas: variables.length }); + return { variables }; + }, +); + /** * Borrar una sala: su proyecto, su contenedor y su historial. * diff --git a/web/src/App.tsx b/web/src/App.tsx index 9ed031f..cc423ef 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -18,6 +18,7 @@ import { MentionMenu } from "./MentionMenu.js"; import { Historial } from "./Historial.js"; import { BackCanvas, type Endpoint } from "./BackCanvas.js"; import { KeyPanel, loadStoredCredencial, type Credencial } from "./KeyPanel.js"; +import { EnvPanel } from "./EnvPanel.js"; import { useTextos } from "./i18n.js"; import { MenuSalas } from "./MenuSalas.js"; import { recordarSala, olvidarSala, recordarNombre } from "./historial-salas.js"; @@ -892,6 +893,9 @@ function Sala({ roomId, name }: { roomId: string | null; name: string }) { > {zipAviso ?? t.descargarZip} + {/* Las variables son del proyecto de la sala, así que sin sala no + hay dónde escribirlas. */} + {roomId && } {/* Sin sala no hay link que compartir: copiaría la URL pelada. */} + + {abierto && ( +
+
{t.envTitulo}
+

{t.envNota}

+ + {vars.length === 0 &&

{t.envVacio}

} + + {vars.map((v, i) => ( +
+ cambiar(i, "nombre", e.target.value)} + spellCheck={false} + /> + cambiar(i, "valor", e.target.value)} + spellCheck={false} + /> + +
+ ))} + +
+ + +
+ + {/* El proyecto lee el .env al arrancar, así que un cambio no se nota + hasta que el dev server se reinicia. Decirlo evita el rato de creer + que la variable no se guardó. */} + {guardado &&

{t.envReinicio}

} +
+ )} + + ); +} diff --git a/web/src/i18n.tsx b/web/src/i18n.tsx index 37ec5ba..f814449 100644 --- a/web/src/i18n.tsx +++ b/web/src/i18n.tsx @@ -59,6 +59,21 @@ const TEXTOS = { zipTrabajoSinGuardar: "hay trabajo sin guardar, el .zip lleva el último punto guardado", zipFallo: "no se pudo preparar el .zip", ponerMiKey: "poner mi key", + + // Variables del proyecto (.env) + envBoton: "Variables", + envTitulo: "Variables del proyecto", + envNota: + "Van al .env de esta sala: la app y el agente las leen. Son de la sala, así que las ve quien entre.", + envVacio: "Todavía no hay ninguna.", + envNombre: "NOMBRE", + envValor: "valor", + envAgregar: "Agregar", + envQuitar: "Quitar", + envGuardando: "Guardando…", + envGuardado: "Guardado", + envReinicio: "Reinicia el proyecto para que las tome (pídeselo al agente).", + envNoSePudo: "no se pudieron guardar: ", seleccionarElemento: "Seleccionar elemento", selecciono: "seleccionó", contraer: "contraer", @@ -207,6 +222,21 @@ const TEXTOS = { zipTrabajoSinGuardar: "there's unsaved work, the .zip has the last saved point", zipFallo: "couldn't prepare the .zip", ponerMiKey: "add my key", + + // Variables del proyecto (.env) + envBoton: "Variables", + envTitulo: "Project variables", + envNota: + "They go in this room's .env: the app and the agent read them. They belong to the room, so anyone who joins can see them.", + envVacio: "None yet.", + envNombre: "NAME", + envValor: "value", + envAgregar: "Add", + envQuitar: "Remove", + envGuardando: "Saving…", + envGuardado: "Saved", + envReinicio: "Restart the project so it picks them up (ask the agent).", + envNoSePudo: "couldn't save them: ", seleccionarElemento: "Select element", selecciono: "selected", contraer: "collapse", diff --git a/web/src/styles.css b/web/src/styles.css index 5de260b..06e6670 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -1709,3 +1709,122 @@ body::after { .tool-cerrar:hover { color: var(--texto); } + +/* ── Variables del proyecto (.env) ──────────────────────────────────────── + Mismo patrón que el panel de la key: un botón en la barra que abre una caja + flotante. La diferencia es de quién son las cosas: la key es tuya, estas son + de la sala. */ +.env-panel { + position: relative; +} +.env-caja { + position: absolute; + top: 34px; + right: 0; + z-index: 40; + width: 380px; + background: var(--panel); + border: 1px solid var(--linea); + border-radius: 8px; + padding: 14px 16px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.35); + /* Con muchas variables la caja crecería más que la pantalla. */ + max-height: 70vh; + overflow-y: auto; +} +.env-cab { + font-size: 10px; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--dim); + margin-bottom: 8px; +} +.env-nota, +.env-vacio, +.env-reinicio { + font-size: 11px; + color: var(--dim); + line-height: 1.5; + margin: 0 0 10px; +} +.env-vacio { + font-style: italic; +} +.env-reinicio { + margin: 10px 0 0; + color: var(--lavanda); +} +.env-fila { + display: flex; + gap: 6px; + margin-bottom: 6px; +} +.env-nombre, +.env-valor { + min-width: 0; + background: var(--fondo2); + border: 1px solid var(--linea); + border-radius: 4px; + padding: 6px 8px; + color: var(--texto); + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 11.5px; +} +/* El nombre es corto y en mayúsculas; el valor puede ser una cadena larga, así + que se lleva el espacio que sobra. */ +.env-nombre { + width: 38%; + text-transform: uppercase; +} +.env-valor { + flex: 1; +} +.env-nombre:focus, +.env-valor:focus { + outline: none; + border-color: var(--lavanda); +} +.env-quitar { + flex-shrink: 0; + width: 26px; + background: transparent; + border: none; + color: var(--dim); + font-size: 16px; + cursor: pointer; +} +.env-quitar:hover { + color: var(--texto); +} +.env-acciones { + display: flex; + gap: 8px; + margin-top: 10px; +} +.env-agregar, +.env-guardar { + flex: 1; + font-size: 10px; + letter-spacing: 2px; + text-transform: uppercase; + padding: 8px; + border-radius: 4px; + cursor: pointer; + border: 1px solid var(--linea); + background: transparent; + color: var(--dim); +} +.env-agregar:hover, +.env-guardar:hover:not(:disabled) { + color: var(--texto); + border-color: var(--dim); +} +.env-guardar { + background: var(--lavanda); + border-color: var(--lavanda); + color: var(--fondo2); +} +.env-guardar:disabled { + opacity: 0.6; + cursor: default; +}