Seguridad · Formularios · Fragmentos · Chat
2026-04-26
Seguridad · Formularios · Fragmentos · Chat
st.form · st.form_submit_button · agrupar inputs sin reruns explosivosst.secrets · login manual · FastAPI + JWT · streamlit-authenticatorst.chat_message · st.chat_input · st.write_stream · historial en session_statest.form) 📋# Sin st.form — cada widget
# dispara una re-ejecución completa
ciudad = st.selectbox(
"Ciudad",
["Quito", "Guayaquil", "Cuenca"]
) # ← rerun #1
presupuesto = st.slider(
"Presupuesto", 50000, 300000
) # ← rerun #2
habitaciones = st.number_input(
"Habitaciones", 1, 10
) # ← rerun #3
# Con 10 filtros = 10 reruns
# Si cada uno consulta la DB... 💥
💡 st.form agrupa todos sus widgets y solo dispara una re-ejecución cuando el usuario presiona st.form_submit_button.
st.form — código completoimport streamlit as st
st.subheader("Buscador de Propiedades")
with st.form("buscador_casas"):
st.write("Configura tu búsqueda:")
col1, col2 = st.columns(2)
ciudad = col1.selectbox(
"Ciudad",
["Quito", "Guayaquil", "Cuenca"]
)
presupuesto = col2.slider(
"Presupuesto Máximo ($)",
50_000, 300_000, 100_000,
step=5_000
)
habitaciones = st.number_input(
"Mínimo de habitaciones", 1, 10, 2
)
solo_nuevas = st.checkbox(
"Solo propiedades nuevas"
)
# El único botón que dispara el rerun
enviado = st.form_submit_button(
"🔍 Buscar Propiedades",
type="primary"
)
if enviado:
st.success(
f"Buscando en **{ciudad}** · "
f"≤ ${presupuesto:,} · "
f"{habitaciones}+ hab."
)
# df_filtrado = df[
# (df.ciudad == ciudad) &
# (df.precio <= presupuesto)
# ]
import streamlit as st
import re
st.subheader("Registro de Cliente")
with st.form("registro_cliente"):
nombre = st.text_input(
"Nombre completo",
placeholder="Ana García"
)
email = st.text_input(
"Email",
placeholder="ana@empresa.com"
)
telefono = st.text_input(
"Teléfono (opcional)",
placeholder="+593 99 123 4567"
)
presupuesto = st.select_slider(
"Rango de presupuesto",
options=["< $50k", "$50k-100k",
"$100k-200k", "> $200k"],
value="$50k-100k"
)
acepta_terminos = st.checkbox(
"Acepto los términos y condiciones"
)
enviado = st.form_submit_button(
"📝 Registrar Cliente",
type="primary"
)
if enviado:
# Validar DESPUÉS de enviar el form
errores = []
if not nombre.strip():
errores.append("El nombre es requerido")
if not re.match(
r'^[\w.-]+@[\w.-]+\.\w{2,}$', email
):
errores.append("Email inválido")
if not acepta_terminos:
errores.append("Debes aceptar términos")
if errores:
for e in errores:
st.error(f"❌ {e}")
else:
st.success(
f"✅ {nombre} registrado correctamente!"
)
guardar_cliente(nombre, email, presupuesto)
if enviado: es el patrón correcto.
# ── Patrón avanzado: re-mostrar el form
# si hay errores (UX mejorada)
if enviado and not errores:
st.session_state.registro_ok = True
if st.session_state.get("registro_ok"):
st.balloons()
st.success("¡Registro completado!")
if st.button("Registrar otro cliente"):
st.session_state.registro_ok = False
st.rerun()
else:
# El form se muestra (o re-muestra)
# Si hubo errores, están visibles
# justo debajo del formulario
pass
session_state.
st.form con múltiples secciones y columnasimport streamlit as st
st.subheader("Ficha Completa de Inmueble")
with st.form("ficha_inmueble"):
# ── Sección 1: Identificación ──────────
st.markdown("#### 📍 Ubicación")
c1, c2, c3 = st.columns([2, 1, 1])
ciudad = c1.selectbox(
"Ciudad", ["Quito","Guayaquil","Cuenca"]
)
sector = c2.text_input("Sector")
codigo = c3.text_input("Código postal")
st.divider()
# ── Sección 2: Características ─────────
st.markdown("#### 🏠 Características")
c4, c5, c6 = st.columns(3)
tipo = c4.selectbox(
"Tipo", ["Casa","Departamento","Oficina"]
)
habitaciones = c5.number_input("Hab.", 1,20,3)
banos = c6.number_input("Baños",1,10,2)
area = st.slider(
"Área (m²)", 30, 500, 120
)
st.divider()
# ── Sección 3: Precio y extras ─────────
st.markdown("#### 💰 Precio y Extras")
precio = st.number_input(
"Precio ($)", 10000, 1000000, 150000,
step=5000
)
extras = st.multiselect(
"Amenidades",
["Parqueadero","Piscina","Gimnasio",
"Seguridad 24h","Terraza","Bodega"]
)
disponible = st.date_input(
"Disponible desde"
)
# ── Botones de acción ──────────────────
col_b1, col_b2 = st.columns([1, 3])
guardar = col_b1.form_submit_button(
"💾 Guardar", type="primary"
)
borrador = col_b2.form_submit_button(
"📝 Guardar borrador"
)
if guardar:
st.success("✅ Inmueble guardado en la DB")
if borrador:
st.info("📝 Borrador guardado localmente")
form_submit_button. Cada uno retorna True/False independientemente.
st.form + @st.cache_data — el combo perfectoimport streamlit as st
import pandas as pd
@st.cache_data
def cargar_propiedades() -> pd.DataFrame:
"""Carga UNA sola vez — costosa."""
return pd.read_csv("propiedades_ecuador.csv")
@st.cache_data
def filtrar_propiedades(
ciudad: str,
precio_max: int,
habitaciones: int
) -> pd.DataFrame:
"""Cache por combinación de parámetros."""
df = cargar_propiedades()
return df[
(df["ciudad"] == ciudad) &
(df["precio"] <= precio_max) &
(df["habitaciones"] >= habitaciones)
]
# ── El form evita reruns al ajustar sliders
with st.form("filtros"):
ciudad = st.selectbox(
"Ciudad",
["Quito", "Guayaquil", "Cuenca"]
)
precio_max = st.slider(
"Precio máximo", 50_000, 500_000
)
min_hab = st.number_input("Hab.", 1, 10)
buscar = st.form_submit_button(
"Buscar", type="primary"
)
# Solo filtra cuando se presiona el botón
# Y si ya buscaste con los mismos parámetros,
# @st.cache_data devuelve el resultado
# sin recalcular → DOBLE OPTIMIZACIÓN
if buscar:
with st.spinner("Filtrando..."):
resultados = filtrar_propiedades(
ciudad, precio_max, min_hab
)
st.metric("Propiedades encontradas",
len(resultados))
st.dataframe(resultados, use_container_width=True)
@st.cache_data(ttl=300) para actualizar la caché cada 5 minutos con datos frescos de la DB.
# ❌ NUNCA hagas esto
import psycopg2
conn = psycopg2.connect(
host="prod.mydb.com",
user="admin",
password="sup3r_s3cr3t0", # 💀
database="clientes"
)
# Git push → credencial pública
# Bots escanean repos en segundos.
st.secrets — el paso cero📁 mkdir .streamlit && touch .streamlit/secrets.toml → agregar al .gitignore antes del primer commit
session_stateimport streamlit as st
# Estado inicial
if "autenticado" not in st.session_state:
st.session_state.autenticado = False
if not st.session_state.autenticado:
st.title("🔐 Acceso Restringido")
usuario = st.text_input("Usuario")
clave = st.text_input("Contraseña",
type="password")
if st.button("Ingresar", type="primary"):
if (usuario == st.secrets["USUARIO_ADMIN"]
and clave == st.secrets["CLAVE_ADMIN"]):
st.session_state.autenticado = True
st.rerun()
else:
st.error("Credenciales incorrectas")
st.stop() # nada más se ejecuta
# ✅ Solo llega aquí si está autenticado
st.title("🏠 Dashboard Principal")
if st.button("Cerrar sesión"):
st.session_state.autenticado = False
st.rerun()
st.stop() — el guardián del scriptfrom fastapi import FastAPI, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
import jwt, bcrypt
from datetime import datetime, timedelta
app = FastAPI()
SECRET = "mi_firma_jwt"
USERS_DB = {
"ana": bcrypt.hashpw(b"pass123",
bcrypt.gensalt())
}
@app.post("/token")
async def login(
form: OAuth2PasswordRequestForm = Depends()
):
stored = USERS_DB.get(form.username)
if not stored or not bcrypt.checkpw(
form.password.encode(), stored
):
raise HTTPException(401,
"Credenciales inválidas")
token = jwt.encode({
"sub": form.username,
"exp": datetime.utcnow()
+ timedelta(hours=8)
}, SECRET, algorithm="HS256")
return {"access_token": token,
"token_type": "bearer"}
import streamlit as st
import requests, jwt
API_URL = st.secrets["API_URL"]
SECRET = st.secrets["JWT_SECRET"]
if "token" not in st.session_state:
st.session_state.token = None
if not st.session_state.token:
st.title("🔐 Login")
user = st.text_input("Usuario")
pw = st.text_input("Contraseña",
type="password")
if st.button("Ingresar", type="primary"):
r = requests.post(
f"{API_URL}/token",
data={"username": user,
"password": pw}
)
if r.status_code == 200:
st.session_state.token = \
r.json()["access_token"]
st.rerun()
else:
st.error("Credenciales inválidas")
st.stop()
# Decodificar token y verificar expiración
try:
payload = jwt.decode(
st.session_state.token,
SECRET, algorithms=["HS256"]
)
except jwt.ExpiredSignatureError:
st.session_state.token = None
st.warning("Sesión expirada")
st.rerun()
st.title(f"Hola, {payload['sub']} 👋")
streamlit-authenticator 🏆import streamlit as st
import streamlit_authenticator as stauth
# 1. Definir credenciales (hashes bcrypt)
config = {
"credentials": {
"usernames": {
"ana": {
"name": "Ana García",
"email": "ana@empresa.com",
"password": "$2b$12$..." # hash
},
"carlos": {
"name": "Carlos López",
"email": "carlos@empresa.com",
"password": "$2b$12$..." # hash
}
}
},
"cookie": {
"name": "app_cookie",
"key": st.secrets["JWT_SECRET"],
"expiry_days": 30
}
}
# 2. Crear el autenticador
auth = stauth.Authenticate(
config["credentials"],
config["cookie"]["name"],
config["cookie"]["key"],
config["cookie"]["expiry_days"],
auto_hash=True # hashea automáticamente
)
# 3. Renderizar widget de login
auth.login()
# 4. Reaccionar al estado
if st.session_state["authentication_status"]:
auth.logout("Cerrar sesión", "sidebar")
nombre = st.session_state["name"]
st.title(f"¡Hola, {nombre}! 👋")
st.write("Aquí va el dashboard...")
elif st.session_state["authentication_status"] \
is False:
st.error("Usuario o contraseña incorrectos")
elif st.session_state["authentication_status"] \
is None:
st.warning("Ingresa tus credenciales")
streamlit-authenticator.readthedocs.io
stauth.Hasherimport streamlit_authenticator as stauth
# ── Generar hashes (solo una vez al configurar)
hashed = stauth.Hasher(
["pass123", "secreto456"]
).generate()
# → ['$2b$12$abc...', '$2b$12$xyz...']
# ── Verificar si un string ya es hash
es_hash = stauth.Hasher.is_hash(
"$2b$12$abc..."
) # → True
# ── con auto_hash=True en Authenticate,
# puedes pasar texto plano directamente
# y se hashea automáticamente al inicio.
secrets.toml. Nunca almacenes el texto plano de las contraseñas.
| Método | Complejidad | Multi-usuario | Cookies | Ideal para |
|---|---|---|---|---|
| session_state manual | ⭐ Baja | ❌ No | ❌ No | Prototipos, un solo admin, demos |
| FastAPI + JWT | ⭐⭐⭐ Alta | ✅ Sí | ⚠️ Manual | Arquitecturas multi-app, API propia, control total |
| streamlit-authenticator | ⭐⭐ Media | ✅ Sí | ✅ Nativo | Apps Streamlit multi-usuario, sin backend propio |
💡 Regla práctica: empieza con session_state para prototipos → escala a streamlit-authenticator para multi-usuario → usa FastAPI + JWT si necesitas una API que sirva a múltiples aplicaciones.
session_stateimport streamlit as st
import streamlit_authenticator as stauth
# Definir usuarios con roles
config = {
"credentials": {
"usernames": {
"ana_admin": {
"name": "Ana García",
"email": "ana@empresa.com",
"password": "$2b$12$...",
"role": "admin"
},
"carlos_viewer": {
"name": "Carlos López",
"email": "carlos@empresa.com",
"password": "$2b$12$...",
"role": "viewer"
}
}
},
"cookie": {
"name": "app_cookie",
"key": st.secrets["JWT_SECRET"],
"expiry_days": 30
}
}
auth = stauth.Authenticate(
config["credentials"],
config["cookie"]["name"],
config["cookie"]["key"],
config["cookie"]["expiry_days"]
)
auth.login()
if st.session_state["authentication_status"]:
user = st.session_state["username"]
role = config["credentials"]["usernames"] \
[user]["role"]
st.session_state["role"] = role
auth.logout("Salir", "sidebar")
st.title(
f"Hola, {st.session_state['name']} 👋"
)
# ── Control de acceso por rol ──────────
if role == "admin":
st.success("🔑 Modo Administrador")
tab1, tab2, tab3 = st.tabs(
["📊 Dashboard",
"⚙️ Gestión",
"🗄️ Datos brutos"]
)
elif role == "viewer":
st.info("👁️ Modo Solo Lectura")
tab1, = st.tabs(["📊 Dashboard"])
# tab2 y tab3 no existen para viewer
def require_role(roles: list):
"""Decorador para proteger secciones
según el rol del usuario actual."""
def decorator(func):
def wrapper(*args, **kwargs):
current_role = st.session_state.get(
"role", None
)
if current_role not in roles:
st.error(
f"🚫 Acceso denegado. "
f"Necesitas rol: {roles}"
)
st.stop()
return func(*args, **kwargs)
return wrapper
return decorator
# Uso en cualquier función de la app:
@require_role(["admin"])
def panel_administracion():
st.subheader("⚙️ Gestión de usuarios")
st.dataframe(obtener_todos_los_usuarios())
if st.button("Eliminar usuario"):
# solo admins llegan aquí
pass
@require_role(["admin", "editor"])
def panel_edicion():
st.subheader("✏️ Editor de contenido")
# admins y editors llegan aquí
secrets.tomlimport streamlit as st
import os
# ── Opción 1: st.secrets (recomendado Streamlit)
clave = st.secrets["CLAVE_ADMIN"]
# ── Opción 2: variables de entorno del SO
# Útil en Docker, CI/CD, servidores Linux
clave = os.environ.get(
"CLAVE_ADMIN",
"valor_por_defecto" # si no existe
)
# ── Opción 3: python-dotenv (local dev)
# pip install python-dotenv
from dotenv import load_dotenv
load_dotenv() # carga el .env
clave = os.getenv("CLAVE_ADMIN")
# ── Estrategia híbrida (producción real)
def get_secret(key: str) -> str:
"""Busca en st.secrets primero,
cae a variables de entorno."""
try:
return st.secrets[key]
except (KeyError, FileNotFoundError):
val = os.environ.get(key)
if val is None:
raise ValueError(
f"Secreto '{key}' no encontrado "
f"en secrets.toml ni en env vars"
)
return val
# Uso transparente
DB_URL = get_secret("DB_URL")
API_KEY = get_secret("OPENAI_KEY")
.env + python-dotenv-e VAR=valuefrom fastapi import FastAPI, Depends, HTTPException
from fastapi.security import HTTPBearer, \
HTTPAuthorizationCredentials
from fastapi.middleware.cors import CORSMiddleware
import jwt
app = FastAPI(title="Auth API para Streamlit")
# ── CORS: permitir peticiones desde Streamlit ─
app.add_middleware(
CORSMiddleware,
allow_origins=["http://localhost:8501",
"https://mi-app.streamlit.app"],
allow_methods=["POST", "GET"],
allow_headers=["*"],
)
security = HTTPBearer()
# ── Dependencia reutilizable ──────────────────
def verificar_token(
creds: HTTPAuthorizationCredentials
= Depends(security)
) -> dict:
try:
payload = jwt.decode(
creds.credentials,
SECRET, algorithms=["HS256"]
)
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(401, "Token expirado")
except jwt.InvalidTokenError:
raise HTTPException(401, "Token inválido")
# ── Endpoints protegidos ──────────────────────
@app.get("/api/propiedades")
async def listar_propiedades(
payload: dict = Depends(verificar_token)
):
"""Solo accesible con JWT válido"""
usuario = payload["sub"]
return {"usuario": usuario,
"datos": obtener_propiedades()}
@app.get("/api/admin/usuarios")
async def listar_usuarios(
payload: dict = Depends(verificar_token)
):
"""Solo para admins"""
if payload.get("role") != "admin":
raise HTTPException(403, "Sin permisos")
return obtener_todos_los_usuarios()
# ── En Streamlit: llamar endpoints protegidos ─
import streamlit as st
import requests
def api_get(endpoint: str) -> dict:
"""Helper para llamadas autenticadas a API"""
token = st.session_state.get("token")
if not token:
st.error("No autenticado")
st.stop()
headers = {"Authorization": f"Bearer {token}"}
r = requests.get(
f"{st.secrets['API_URL']}{endpoint}",
headers=headers
)
if r.status_code == 401:
# Token expirado — forzar re-login
st.session_state.token = None
st.warning("Sesión expirada. Re-inicia.")
st.rerun()
if r.status_code == 403:
st.error("🚫 Sin permisos para esta acción")
st.stop()
r.raise_for_status()
return r.json()
# Uso en cualquier parte de la app:
propiedades = api_get("/api/propiedades")
st.dataframe(propiedades["datos"])
@st.fragment ⚡import streamlit as st
import time
# Sección pesada — solo carga al inicio
st.subheader("Análisis Inmobiliario")
with st.spinner("Cargando gráfico..."):
time.sleep(2) # simula carga pesada
st.line_chart({"precio": [100,120,95,
110,130]})
# ─────────────────────────────────────────
# @st.fragment → solo esta función se
# re-ejecuta al interactuar DESDE adentro
# ─────────────────────────────────────────
@st.fragment
def panel_rapido():
st.divider()
favorito = st.toggle("⭐ Guardar favorito")
if st.button("📤 Compartir análisis"):
st.success("¡Enlace copiado!")
if favorito:
st.info("Guardado en tus favoritos")
panel_rapido()
# El gráfico de arriba NO parpadea
# al interactuar con este panel
@st.fragment — el impacto visualpanel_rapido() se re-ejecuta (instantáneo)@st.fragment(run_every=5) → auto-actualiza el fragmento cada 5 segundos. Perfecto para live data.
@st.fragment Avanzado ⚡run_every — dashboards en tiempo realimport streamlit as st
import pandas as pd
import random
import time
st.title("📡 Monitor de Ventas en Vivo")
# ── Esta parte carga una vez (pesada) ─────
@st.cache_data
def cargar_historico():
return pd.read_csv("ventas_historicas.csv")
df_hist = cargar_historico()
st.line_chart(df_hist.set_index("fecha"))
st.caption("📊 Histórico — actualiza al inicio")
# ── Este fragmento se auto-actualiza ──────
@st.fragment(run_every=5) # cada 5 segundos
def kpis_en_vivo():
"""Re-ejecuta solo este bloque cada 5s.
El gráfico histórico de arriba NO recarga."""
# Simula datos en tiempo real
ventas_hoy = random.randint(80, 150)
ticket_prom = random.uniform(45.0, 95.0)
activos = random.randint(12, 35)
col1, col2, col3 = st.columns(3)
col1.metric(
"💰 Ventas hoy",
f"${ventas_hoy:,}",
delta=f"{random.randint(-5,15)}%"
)
col2.metric(
"🎫 Ticket promedio",
f"${ticket_prom:.2f}",
delta=f"{random.randint(-3,8):.1f}%"
)
col3.metric(
"👥 Usuarios activos",
activos,
delta=random.randint(-3, 5)
)
st.caption(
f"⏱️ Actualizado: {time.strftime('%H:%M:%S')}"
)
kpis_en_vivo()
run_every=5 acepta segundos (int), timedelta, o strings como "5s", "1m". El fragmento se re-ejecuta automáticamente sin que el usuario haga nada.
import streamlit as st
st.title("🏢 Dashboard Ejecutivo")
# Barra de navegación — carga una vez
seccion = st.selectbox(
"Ver sección:",
["Ventas", "Inventario", "Clientes"]
)
# ── Fragmento 1: KPIs (actualiza cada 10s)
@st.fragment(run_every=10)
def bloque_kpis():
st.subheader("📊 KPIs en vivo")
col1, col2 = st.columns(2)
col1.metric("Ventas", "$12,400", "+8%")
col2.metric("Leads", "47", "+3")
# ── Fragmento 2: Chat lateral (independiente)
@st.fragment
def bloque_chat():
st.subheader("💬 Asistente")
if "chat_msgs" not in st.session_state:
st.session_state.chat_msgs = []
for m in st.session_state.chat_msgs:
with st.chat_message(m["role"]):
st.write(m["content"])
if q := st.chat_input("Pregunta algo..."):
st.session_state.chat_msgs.append(
{"role": "user", "content": q}
)
st.session_state.chat_msgs.append(
{"role": "assistant",
"content": f"Analizando: {q}"}
)
st.rerun(scope="fragment")
# ── Layout: 2 columnas con fragmentos ─────
col_izq, col_der = st.columns([2, 1])
with col_izq:
bloque_kpis()
# aquí va el contenido de 'seccion'
mostrar_seccion(seccion)
with col_der:
bloque_chat()
mostrar_seccion() → solo re-carga cuando cambia el selectboxst.rerun(scope="fragment") — re-ejecuta solo el fragmento actual, no toda la app. Disponible desde Streamlit 1.33+.
| Componente | Qué hace |
|---|---|
| st.chat_message | Renderiza una burbuja con avatar (user o assistant). Dentro puedes usar cualquier elemento de Streamlit. |
| st.chat_input | Barra fija en la parte inferior. Devuelve el texto al presionar Enter o el ícono de envío. |
| st.write_stream | Consume un generador y muestra el texto palabra a palabra (efecto typewriter / ChatGPT). |
| session_state | Guarda el historial de mensajes para que no se borre en cada rerun. |
session_state, el chat olvida toda la conversación cada vez que el usuario escribe una letra.
import streamlit as st
st.subheader("🏡 Asistente Inmobiliario")
# ── Inicializar historial ─────────────────
if "mensajes" not in st.session_state:
st.session_state.mensajes = [{
"role": "assistant",
"content": "¡Hola! Soy tu asesor "
"inmobiliario virtual. "
"¿Qué tipo de propiedad "
"buscas hoy?"
}]
# ── Renderizar historial completo ─────────
for msg in st.session_state.mensajes:
with st.chat_message(msg["role"]):
st.write(msg["content"])
# ── Capturar nueva pregunta ───────────────
pregunta = st.chat_input(
"Ej: Busco un depto de 2 hab en Quito..."
)
if pregunta:
# 1. Guardar y mostrar mensaje del usuario
st.session_state.mensajes.append(
{"role": "user", "content": pregunta}
)
with st.chat_message("user"):
st.write(pregunta)
# 2. Generar y mostrar respuesta
with st.chat_message("assistant"):
respuesta = st.write_stream(
generar_respuesta(pregunta)
)
# 3. Guardar respuesta en historial
st.session_state.mensajes.append(
{"role": "assistant",
"content": respuesta}
)
import time
# ── Generador para efecto streaming ──────
def generar_respuesta(pregunta: str):
"""Simula respuesta palabra a palabra"""
texto = (
f"Basándome en tu búsqueda de "
f"'{pregunta}', encontré 3 opciones "
f"disponibles. La primera está en el "
f"norte de la ciudad con 2 dormitorios "
f"y parqueadero incluido..."
)
for palabra in texto.split():
yield palabra + " "
time.sleep(0.04) # efecto typewriter
# ── Con OpenAI API real ───────────────────
# from openai import OpenAI
# client = OpenAI(
# api_key=st.secrets["OPENAI_KEY"]
# )
#
# def generar_respuesta(pregunta):
# stream = client.chat.completions.create(
# model="gpt-4o",
# stream=True,
# messages=st.session_state.mensajes
# )
# for chunk in stream:
# delta = chunk.choices[0].delta
# if delta.content:
# yield delta.content
st.write_stream() acepta cualquier generador Python. Compatible directamente con stream=True de OpenAI, Anthropic y Gemini.
import streamlit as st
SYSTEM_PROMPT = """Eres un asesor inmobiliario
experto en el mercado ecuatoriano. Tienes acceso
a una base de datos de propiedades en Quito,
Guayaquil y Cuenca. Responde siempre en español,
de manera concisa y profesional. Si el usuario
pregunta por propiedades, pide sus filtros
(ciudad, presupuesto, habitaciones) antes de
mostrar resultados."""
# ── Inicializar con mensaje del sistema ───
if "historial" not in st.session_state:
st.session_state.historial = [
{"role": "system",
"content": SYSTEM_PROMPT}
]
# El sistema NO se muestra en la UI
# ── Renderizar solo mensajes user/assistant ─
for msg in st.session_state.historial:
if msg["role"] == "system":
continue # ocultar prompt del sistema
with st.chat_message(msg["role"]):
st.markdown(msg["content"])
# ── Input ─────────────────────────────────
if prompt := st.chat_input("Escribe aquí..."):
st.session_state.historial.append(
{"role": "user", "content": prompt}
)
with st.chat_message("user"):
st.markdown(prompt)
with st.chat_message("assistant"):
with st.spinner("Pensando..."):
respuesta = llamar_llm(
st.session_state.historial
)
st.markdown(respuesta)
st.session_state.historial.append(
{"role": "assistant",
"content": respuesta}
)
# ── Sidebar: controles del chat ───────────
with st.sidebar:
st.caption(
f"💬 {len(st.session_state.historial)-1}"
f" mensajes en memoria"
)
if st.button("🗑️ Limpiar conversación"):
st.session_state.historial = [
{"role": "system",
"content": SYSTEM_PROMPT}
]
st.rerun()
from openai import OpenAI
client = OpenAI(
api_key=st.secrets["OPENAI_KEY"]
)
def llamar_llm(historial: list) -> str:
"""Envía el historial completo a OpenAI.
Incluye el system prompt automáticamente."""
respuesta = client.chat.completions.create(
model="gpt-4o-mini",
messages=historial, # todo el contexto
max_tokens=500,
temperature=0.7
)
return respuesta.choices[0].message.content
# ── Con streaming (efecto typewriter) ─────
def llamar_llm_stream(historial: list):
"""Generador para st.write_stream()"""
stream = client.chat.completions.create(
model="gpt-4o-mini",
messages=historial,
max_tokens=500,
stream=True # ← activa streaming
)
for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
yield delta.content
session_state.
import streamlit as st
import base64
from openai import OpenAI
client = OpenAI(api_key=st.secrets["OPENAI_KEY"])
st.title("🏠 Analiza tu Propiedad con IA")
st.caption(
"Sube una foto del inmueble y pregunta "
"lo que quieras."
)
# ── Upload de imagen ──────────────────────
imagen = st.file_uploader(
"📸 Sube una foto del inmueble",
type=["jpg", "jpeg", "png", "webp"]
)
if imagen:
st.image(imagen, caption="Tu propiedad",
use_container_width=True)
# ── Chat con imagen ───────────────────────
if "msgs_img" not in st.session_state:
st.session_state.msgs_img = []
for msg in st.session_state.msgs_img:
with st.chat_message(msg["role"]):
st.markdown(msg["content"]
if isinstance(msg["content"],str)
else msg["content"][0]["text"])
pregunta = st.chat_input(
"Pregunta sobre la foto..."
if imagen else
"Sube una imagen primero..."
)
if pregunta and imagen:
# Convertir imagen a base64
img_b64 = base64.b64encode(
imagen.getvalue()
).decode()
img_type = imagen.type
# Mensaje con imagen + texto
msg_usuario = {
"role": "user",
"content": [
{"type": "image_url",
"image_url": {
"url": f"data:{img_type};"
f"base64,{img_b64}"
}},
{"type": "text", "text": pregunta}
]
}
st.session_state.msgs_img.append(msg_usuario)
with st.chat_message("user"):
st.markdown(pregunta)
with st.chat_message("assistant"):
respuesta = st.write_stream(
analizar_imagen(
st.session_state.msgs_img
)
)
st.session_state.msgs_img.append(
{"role": "assistant",
"content": respuesta}
)
def analizar_imagen(historial: list):
"""Analiza imágenes con GPT-4 Vision."""
stream = client.chat.completions.create(
model="gpt-4o", # ← soporta visión
messages=[
{"role": "system",
"content": "Eres un perito en "
"bienes raíces. Analiza las fotos "
"de inmuebles y da estimaciones "
"de valor, estado y mejoras "
"posibles. Sé específico y "
"profesional."},
*historial
],
max_tokens=800,
stream=True
)
for chunk in stream:
delta = chunk.choices[0].delta
if delta.content:
yield delta.content
st.file_uploader puede estar dentro o fuera de un st.form. Para uploads + texto juntos, fuera del form es más flexible.
import streamlit as st
import tiktoken # pip install tiktoken
# ── Contar tokens (OpenAI) ────────────────
def contar_tokens(historial: list,
modelo: str = "gpt-4o-mini"
) -> int:
enc = tiktoken.encoding_for_model(modelo)
total = 0
for msg in historial:
contenido = msg.get("content", "")
if isinstance(contenido, str):
total += len(enc.encode(contenido))
total += 4 # overhead por mensaje
return total
# ── Truncar si supera el límite ───────────
MAX_TOKENS = 3000 # dejar margen para respuesta
def gestionar_contexto(historial: list) -> list:
"""Mantiene el system prompt + mensajes
más recientes dentro del límite de tokens."""
if not historial:
return historial
system = [m for m in historial
if m["role"] == "system"]
mensajes = [m for m in historial
if m["role"] != "system"]
# Ir eliminando mensajes viejos hasta
# que quepa en el límite
while (contar_tokens(system + mensajes)
> MAX_TOKENS and len(mensajes) > 2):
mensajes.pop(0) # eliminar el más viejo
return system + mensajes
# ── Uso en el chat ────────────────────────
if prompt := st.chat_input("Pregunta..."):
st.session_state.historial.append(
{"role": "user", "content": prompt}
)
# Antes de llamar al LLM, gestionar contexto
contexto = gestionar_contexto(
st.session_state.historial
)
respuesta = llamar_llm(contexto)
# La respuesta va al historial completo
st.session_state.historial.append(
{"role": "assistant",
"content": respuesta}
)
mi_app_inmobiliaria/
├── .streamlit/
│ └── secrets.toml # 🔒 gitignore
├── pages/
│ ├── 1_🏠_Buscar.py # formulario
│ ├── 2_📊_Dashboard.py # fragmentos
│ └── 3_💬_Asistente.py # chat
├── utils/
│ ├── auth.py # autenticación
│ ├── database.py # consultas DB
│ └── llm.py # integración IA
├── data/
│ └── propiedades.csv
└── app.py # entrada principal
# app.py — punto de entrada
import streamlit as st
import streamlit_authenticator as stauth
from utils.auth import cargar_config_auth
# 1. Configurar autenticación global
config = cargar_config_auth()
auth = stauth.Authenticate(
config["credentials"],
config["cookie"]["name"],
config["cookie"]["key"],
config["cookie"]["expiry_days"]
)
auth.login()
if not st.session_state["authentication_status"]:
st.stop()
# 2. Usuario autenticado — configurar navegación
auth.logout("Cerrar sesión", "sidebar")
st.sidebar.success(
f"👋 {st.session_state['name']}"
)
# 3. Las páginas se cargan automáticamente
# desde la carpeta /pages
# pages/1_🏠_Buscar.py
import streamlit as st
import pandas as pd
@st.cache_data
def cargar_datos() -> pd.DataFrame:
return pd.read_csv("data/propiedades.csv")
st.title("🏠 Buscador de Propiedades")
# ── Formulario de búsqueda ────────────────
with st.form("busqueda"):
col1, col2 = st.columns(2)
ciudad = col1.selectbox(
"Ciudad",
["Quito", "Guayaquil", "Cuenca"]
)
precio_max = col2.slider(
"Precio máximo", 50_000, 500_000
)
min_hab = st.number_input("Hab. mín.", 1, 10)
buscar = st.form_submit_button("🔍 Buscar")
if buscar:
df = cargar_datos()
resultado = df[
(df.ciudad == ciudad) &
(df.precio <= precio_max) &
(df.habitaciones >= min_hab)
]
st.metric("Resultados", len(resultado))
st.dataframe(
resultado,
column_config={
"precio": st.column_config.NumberColumn(
"Precio", format="$%d"
),
"foto": st.column_config.ImageColumn(
"Foto"
)
},
use_container_width=True
)
# ── Panel de acciones (fragment) ──────
@st.fragment
def acciones_resultado():
sel = st.selectbox(
"Seleccionar propiedad",
resultado["id"].tolist()
)
col_a, col_b, col_c = st.columns(3)
if col_a.button("⭐ Favorito"):
st.toast("¡Agregado a favoritos!")
if col_b.button("📞 Contactar"):
st.info("Enviando solicitud...")
if col_c.button("📊 Comparar"):
st.info("Agregado a comparador")
acciones_resultado()
# pages/2_📊_Dashboard.py
import streamlit as st
import plotly.express as px
st.title("📊 Dashboard Inmobiliario")
# ── Filtro de período — NO en form ────────
periodo = st.radio(
"Período",
["7 días", "30 días", "90 días"],
horizontal=True
)
# ── Sección pesada — carga una vez ────────
@st.cache_data
def cargar_metricas_historicas(periodo):
# simula consulta a DB
import pandas as pd, numpy as np
n = int(periodo.split()[0])
fechas = pd.date_range(
end="today", periods=n
)
return pd.DataFrame({
"fecha": fechas,
"ventas": np.random.randint(5,25,n),
"leads": np.random.randint(20,80,n)
})
df_hist = cargar_metricas_historicas(periodo)
fig = px.line(
df_hist, x="fecha",
y=["ventas", "leads"],
template="plotly_dark",
color_discrete_map={
"ventas": "#FF4B4B",
"leads": "#3b82f6"
}
)
fig.update_layout(
plot_bgcolor="#0d0d0d",
paper_bgcolor="#0d0d0d",
margin=dict(l=0,r=0,t=20,b=0)
)
st.plotly_chart(fig, use_container_width=True)
# ── KPIs en vivo (se actualizan solos) ────
@st.fragment(run_every="15s")
def kpis_tiempo_real():
import random
st.divider()
st.caption("⚡ KPIs en tiempo real")
m1,m2,m3,m4 = st.columns(4)
m1.metric("Activos ahora",
random.randint(10,40),
random.randint(-3,5))
m2.metric("Consultas/hora",
random.randint(8,30),
random.randint(-2,4))
m3.metric("Tasa conversión",
f"{random.uniform(2,8):.1f}%",
f"{random.uniform(-1,2):.1f}%")
m4.metric("Precio prom. $k",
random.randint(85,140),
random.randint(-5,10))
kpis_tiempo_real()
import streamlit as st
import requests
from contextlib import contextmanager
# ── Notificaciones no intrusivas ──────────
def notificar(mensaje: str,
tipo: str = "info",
icono: str = ""):
"""Muestra un toast en vez de interrumpir."""
if tipo == "exito":
st.toast(f"✅ {mensaje}", icon="✅")
elif tipo == "error":
st.toast(f"❌ {mensaje}", icon="❌")
elif tipo == "advertencia":
st.toast(f"⚠️ {mensaje}", icon="⚠️")
else:
st.toast(mensaje)
# ── Context manager para errores de API ───
@contextmanager
def manejar_errores_api(operacion: str):
"""Envuelve llamadas a APIs externas."""
try:
yield
except requests.exceptions.Timeout:
st.error(
f"⏱️ Timeout al {operacion}. "
f"Intenta de nuevo."
)
st.stop()
except requests.exceptions.ConnectionError:
st.error(
"🌐 Sin conexión al servidor. "
"Verifica tu red."
)
st.stop()
except requests.exceptions.HTTPError as e:
if e.response.status_code == 429:
st.warning(
"⚡ Demasiadas peticiones. "
"Espera unos segundos."
)
elif e.response.status_code >= 500:
st.error(
"🔧 Error del servidor. "
"El equipo fue notificado."
)
st.stop()
# ── Uso en el chat ────────────────────────
if prompt := st.chat_input("Pregunta..."):
with manejar_errores_api("consultar IA"):
with st.spinner("Consultando IA..."):
respuesta = llamar_llm(
st.session_state.historial
)
st.session_state.historial.append(
{"role": "assistant",
"content": respuesta}
)
notificar("Respuesta recibida", "exito")
# ── st.status para operaciones largas ─────
with st.status("Procesando consulta...",
expanded=True) as status:
st.write("🔍 Buscando en base de datos...")
resultados_db = consultar_db(filtros)
st.write("🤖 Analizando con IA...")
analisis = analizar_con_llm(resultados_db)
st.write("📊 Generando visualizaciones...")
graficos = crear_graficos(resultados_db)
status.update(
label="✅ Consulta completada",
state="complete",
expanded=False
)
st.plotly_chart(graficos)
st.markdown(analisis)
# ── Feedback del usuario ───────────────────
col_ok, col_mal = st.columns(2)
if col_ok.button("👍 Útil"):
notificar("¡Gracias por tu feedback!", "exito")
registrar_feedback("positivo", analisis)
if col_mal.button("👎 Mejorar"):
with st.expander("¿Qué mejorarías?"):
mejora = st.text_area("Cuéntanos...")
if st.button("Enviar", key="fb_send"):
registrar_feedback("negativo",
analisis, mejora)
notificar("Feedback registrado","exito")
st.rerun()
st.toast() muestra notificaciones en la esquina sin interrumpir el flujo. st.status() muestra el progreso de operaciones multi-paso.
# .streamlit/config.toml
# (este SÍ va al repo — no tiene secretos)
[theme]
primaryColor = "#FF4B4B"
backgroundColor = "#0d0d0d"
secondaryBackgroundColor = "#1a1a1a"
textColor = "#e8e8e8"
font = "monospace"
[server]
headless = true
port = 8501
enableCORS = false
enableXsrfProtection = true # seguridad
[browser]
gatherUsageStats = false # privacidad
[runner]
fastReruns = true # mejor rendimiento
config.toml configura el tema y el servidor. secrets.toml guarda credenciales. Los dos van en .streamlit/ pero solo secrets.toml va en .gitignore.
streamlit run app.py en un Dockerfile + variables de entorno# requirements.txt para deployment
streamlit>=1.33.0
streamlit-authenticator>=0.3.3
openai>=1.0.0
pandas>=2.0.0
plotly>=5.0.0
requests>=2.31.0
python-dotenv>=1.0.0
bcrypt>=4.0.0
PyJWT>=2.8.0
tiktoken>=0.7.0
# FastAPI (si tienes backend separado):
fastapi>=0.111.0
uvicorn>=0.29.0
secrets.toml en .gitignore. Mismo código en local y en la nube.session_state recuerda el estado entre reruns · st.stop() bloquea el resto del script.bcrypt para contraseñas · tokens con expiración · arquitectura escalable.pip install streamlit-authenticator.st.chat_message · st.chat_input · st.write_stream · historial persistente en session_state.streamlit-authenticatorbcryptsecrets.tomlst.form con mínimo 4 filtros@st.fragment para panel de accionesrun_every=10 para live data
st.chat_input con historial persistentest.write_stream🎯 El desafío real: que el formulario de filtros NO se borre cuando el usuario escribe en el chat. Tip: usa @st.fragment en el bloque del chat, o guarda los valores del form en session_state antes de que el chat dispare reruns.
¡A construir! 🚀
Módulo 4 — Completado
Preguntas · Dudas · Reto 🙋
streamlit-authenticator.readthedocs.io
Módulo 4 · Streamlit · Seguridad · Formularios · Fragmentos · Chat