@@ -378,13 +389,13 @@
-
Paquetes: forja_core / forja_dashboard
+
Paquetes: forja_core / forja_web (NiceGUI) + forja_common
Python: 3.11+
-
UI: Streamlit 1.38 + Tailwind (editores)
+
UI: NiceGUI (reemplazo moderno de Streamlit) + Tailwind
Runtime: LangGraph + FastAPI
Persistencia: YAML versionado + SQLite checkpoints
-
Este documento fue generado automáticamente tras la sesión de refactorización del 23 de mayo de 2026.
+
Actualizado el 23 de mayo de 2026 — Migración a NiceGUI iniciada, CLI removido por decisión de producto.
diff --git a/pyproject.toml b/pyproject.toml
index 28e950b..2f96e86 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -9,6 +9,16 @@ description = "Plataforma profesional de gobernanza de agentes IA"
requires-python = ">=3.11"
authors = [{name = "Juan", email = "jm0x@proton.me"}]
+[project.dependencies]
+httpx = ">=0.27,<0.28"
+
+[project.optional-dependencies]
+web = ["nicegui>=2.0"]
+
+[tool.setuptools.packages.find]
+where = ["core/src", "common/src", "web/src"]
+namespaces = false
+
[tool.ruff]
line-length = 100
target-version = "py311"
@@ -23,7 +33,7 @@ ignore = ["E501"]
"dashboard/src/forja_dashboard/pages/*" = ["N999"]
[tool.ruff.lint.isort]
-known-first-party = ["forja_core", "forja_dashboard"]
+known-first-party = ["forja_core", "forja_dashboard", "forja_common", "forja_web"]
[tool.mypy]
python_version = "3.11"
@@ -41,7 +51,7 @@ ignore_missing_imports = true
asyncio_mode = "auto"
testpaths = ["tests"]
addopts = "-ra -q --strict-markers"
-pythonpath = ["core/src", "dashboard/src"]
+pythonpath = ["core/src", "dashboard/src", "common/src", "web/src"]
markers = [
"integration: integration tests (slower, may touch FS)",
]
diff --git a/web/requirements.txt b/web/requirements.txt
new file mode 100644
index 0000000..c9cbe6a
--- /dev/null
+++ b/web/requirements.txt
@@ -0,0 +1,3 @@
+nicegui>=2.0
+httpx>=0.27,<0.28
+rich>=13.0 # optional, for nice tables if we want
diff --git a/web/src/forja_web/__init__.py b/web/src/forja_web/__init__.py
new file mode 100644
index 0000000..a5ce9c5
--- /dev/null
+++ b/web/src/forja_web/__init__.py
@@ -0,0 +1,5 @@
+"""forja_web - Modern NiceGUI dashboard for Forja."""
+
+from forja_web.main import main
+
+__all__ = ["main"]
diff --git a/web/src/forja_web/main.py b/web/src/forja_web/main.py
new file mode 100644
index 0000000..aefc538
--- /dev/null
+++ b/web/src/forja_web/main.py
@@ -0,0 +1,79 @@
+"""Forja Web - NiceGUI modern dashboard."""
+
+from __future__ import annotations
+
+from nicegui import ui
+
+from forja_web.pages import agents_page, approvals_page, executions_page, home_page, policies_page, run_page
+
+
+def main() -> None:
+ ui.page_title("Forja • Gobernanza de Agentes IA")
+
+ # Top bar
+ with ui.header().classes("bg-zinc-900 text-white items-center shadow-md"):
+ with ui.row().classes("w-full items-center px-6"):
+ ui.label("🔨 Forja").classes("text-2xl font-bold tracking-tight")
+ ui.space()
+ ui.label("Plataforma de gobernanza para agentes IA").classes("text-zinc-400 text-sm")
+
+ # Left drawer navigation
+ with ui.left_drawer(value=True, bordered=True).classes("bg-zinc-950 text-white"):
+ ui.label("Navegación").classes("text-xs uppercase tracking-widest text-zinc-500 px-2 pt-4 pb-1")
+ with ui.column().classes("gap-1"):
+ ui.link("Inicio", "/").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
+ ui.link("Agentes", "/agents").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
+ ui.link("Ejecutar", "/run").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
+ ui.link("Aprobaciones", "/approvals").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
+ ui.link("Historial", "/history").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
+ ui.link("Políticas", "/policies").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
+ ui.separator().classes("my-2")
+ ui.link("Forjar Agente", "/forge/agent").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
+ ui.link("Forjar Política", "/forge/policy").classes("text-base hover:bg-zinc-800 px-3 py-1.5 rounded")
+
+ # Main content area with pages
+ @ui.page("/")
+ def home() -> None:
+ home_page.render()
+
+ @ui.page("/agents")
+ def agents() -> None:
+ agents_page.render()
+
+ @ui.page("/run")
+ def run() -> None:
+ run_page.render()
+
+ @ui.page("/approvals")
+ def approvals() -> None:
+ approvals_page.render()
+
+ @ui.page("/history")
+ def history() -> None:
+ executions_page.render()
+
+ @ui.page("/policies")
+ def policies() -> None:
+ policies_page.render()
+
+ @ui.page("/forge/agent")
+ def forge_agent() -> None:
+ ui.label("🔨 Forjar Agente").classes("text-3xl font-semibold")
+ ui.label("Editor gráfico moderno (en desarrollo)").classes("text-zinc-400 mt-2")
+
+ @ui.page("/forge/policy")
+ def forge_policy() -> None:
+ ui.label("🛡️ Forjar Política").classes("text-3xl font-semibold")
+ ui.label("Editor de políticas (en desarrollo)").classes("text-zinc-400 mt-2")
+
+ ui.run(
+ title="Forja",
+ host="0.0.0.0",
+ port=8501,
+ dark=True,
+ reload=True,
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/web/src/forja_web/pages/agents_page.py b/web/src/forja_web/pages/agents_page.py
new file mode 100644
index 0000000..87fe57e
--- /dev/null
+++ b/web/src/forja_web/pages/agents_page.py
@@ -0,0 +1,33 @@
+"""Agents listing page."""
+
+from nicegui import ui
+
+from forja_common.client import CoreClient
+
+
+def render() -> None:
+ ui.label("📚 Registro de Agentes").classes("text-3xl font-semibold mb-6")
+
+ client = CoreClient()
+
+ try:
+ agents = client.list_agents()
+ except Exception as e:
+ ui.label(f"Error cargando agentes: {e}").classes("text-red-400")
+ return
+
+ if not agents:
+ ui.label("No hay agentes registrados todavía.").classes("text-zinc-400")
+ return
+
+ for agent in agents:
+ with ui.card().classes("w-full mb-4"):
+ with ui.row().classes("items-center justify-between"):
+ ui.label(agent.get("name", "sin-nombre")).classes("text-xl font-bold")
+ ui.badge(agent.get("latest_version", "v?"), color="blue")
+
+ if "description" in agent:
+ ui.label(agent["description"]).classes("text-sm text-zinc-400")
+
+ with ui.row().classes("gap-2 mt-2"):
+ ui.button("Ver versiones", on_click=lambda n=agent["name"]: ui.navigate.to(f"/agents/{n}"))
diff --git a/web/src/forja_web/pages/approvals_page.py b/web/src/forja_web/pages/approvals_page.py
new file mode 100644
index 0000000..e3bbbb3
--- /dev/null
+++ b/web/src/forja_web/pages/approvals_page.py
@@ -0,0 +1,44 @@
+"""Approvals page (HITL)."""
+
+from nicegui import ui
+
+from forja_common.client import CoreClient
+
+
+def render() -> None:
+ ui.label("🤝 Aprobaciones pendientes").classes("text-3xl font-semibold mb-6")
+
+ client = CoreClient()
+
+ try:
+ executions = client.list_executions()
+ pending = [e for e in executions if e.get("status") in ("awaiting_approval", "hitl")]
+ except Exception as e:
+ ui.label(f"Error: {e}").classes("text-red-400")
+ return
+
+ if not pending:
+ ui.label("No hay ejecuciones esperando aprobación.").classes("text-emerald-400 text-lg")
+ return
+
+ for ex in pending:
+ with ui.card().classes("w-full mb-3"):
+ ui.label(f"Trace: {ex.get('trace_id')}").classes("font-mono text-sm")
+ ui.label(f"Agente: {ex.get('agent_name')}")
+ with ui.row():
+ ui.button("Aprobar", color="green", on_click=lambda t=ex["trace_id"]: approve(t))
+ ui.button("Rechazar", color="red", on_click=lambda t=ex["trace_id"]: reject(t))
+
+
+def approve(trace_id: str) -> None:
+ client = CoreClient()
+ client.approve(trace_id, {"notes": "Approved from NiceGUI"})
+ ui.notify("Aprobado", type="positive")
+ ui.navigate.reload()
+
+
+def reject(trace_id: str) -> None:
+ client = CoreClient()
+ client.reject(trace_id, {"reason": "Rejected from NiceGUI"})
+ ui.notify("Rechazado", type="negative")
+ ui.navigate.reload()
diff --git a/web/src/forja_web/pages/executions_page.py b/web/src/forja_web/pages/executions_page.py
new file mode 100644
index 0000000..690550e
--- /dev/null
+++ b/web/src/forja_web/pages/executions_page.py
@@ -0,0 +1,30 @@
+"""History / executions page."""
+
+from nicegui import ui
+
+from forja_common.client import CoreClient
+
+
+def render() -> None:
+ ui.label("📜 Historial de Ejecuciones").classes("text-3xl font-semibold mb-6")
+
+ client = CoreClient()
+
+ try:
+ executions = client.list_executions()
+ except Exception as e:
+ ui.label(f"Error: {e}").classes("text-red-400")
+ return
+
+ if not executions:
+ ui.label("Aún no hay ejecuciones.").classes("text-zinc-400")
+ return
+
+ for ex in executions[:30]:
+ with ui.card().classes("w-full mb-2"):
+ with ui.row().classes("justify-between items-center"):
+ ui.label(ex.get("trace_id", "")[:20] + "...").classes("font-mono text-sm")
+ status = ex.get("status", "unknown")
+ color = "green" if status == "completed" else "orange"
+ ui.badge(status, color=color)
+ ui.label(f"{ex.get('agent_name')} • {ex.get('created_at', '')}").classes("text-xs text-zinc-400")
diff --git a/web/src/forja_web/pages/home_page.py b/web/src/forja_web/pages/home_page.py
new file mode 100644
index 0000000..2a8f7b2
--- /dev/null
+++ b/web/src/forja_web/pages/home_page.py
@@ -0,0 +1,26 @@
+"""Home page for the NiceGUI dashboard."""
+
+from nicegui import ui
+
+from forja_common.client import CoreClient
+
+
+def render() -> None:
+ ui.label("Bienvenido a Forja").classes("text-4xl font-bold tracking-tight mt-8")
+ ui.label(
+ "Plataforma profesional de gobernanza para agentes de IA. "
+ "Versión moderna con NiceGUI."
+ ).classes("text-lg text-zinc-400 mt-2")
+
+ ui.separator().classes("my-8")
+
+ with ui.card().classes("w-full max-w-2xl"):
+ ui.label("Estado del sistema").classes("text-xl font-semibold mb-4")
+
+ try:
+ client = CoreClient()
+ health = client.health()
+ ui.badge("Core API: Saludable", color="green").classes("text-lg")
+ ui.json_editor({"status": health})
+ except Exception as exc:
+ ui.badge(f"Core API unreachable: {exc}", color="red")
diff --git a/web/src/forja_web/pages/policies_page.py b/web/src/forja_web/pages/policies_page.py
new file mode 100644
index 0000000..96f80cc
--- /dev/null
+++ b/web/src/forja_web/pages/policies_page.py
@@ -0,0 +1,27 @@
+"""Policies page."""
+
+from nicegui import ui
+
+from forja_common.client import CoreClient
+
+
+def render() -> None:
+ ui.label("🛡️ Políticas de Guardrails").classes("text-3xl font-semibold mb-6")
+
+ client = CoreClient()
+
+ try:
+ policies = client.list_policies()
+ except Exception as e:
+ ui.label(f"Error: {e}").classes("text-red-400")
+ return
+
+ if not policies:
+ ui.label("No hay políticas definidas.").classes("text-zinc-400")
+ return
+
+ for p in policies:
+ with ui.card().classes("w-full mb-3"):
+ ui.label(p.get("name")).classes("font-bold text-lg")
+ versions = client.list_policy_versions(p["name"])
+ ui.label(f"{len(versions)} versiones").classes("text-sm text-zinc-400")
diff --git a/web/src/forja_web/pages/run_page.py b/web/src/forja_web/pages/run_page.py
new file mode 100644
index 0000000..ba17f0c
--- /dev/null
+++ b/web/src/forja_web/pages/run_page.py
@@ -0,0 +1,77 @@
+"""Ejecutar agente page - modern invoke form."""
+
+from __future__ import annotations
+
+import json
+
+from nicegui import ui
+
+from forja_common.client import CoreClient
+
+
+def render() -> None:
+ ui.label("▶️ Ejecutar Agente").classes("text-3xl font-semibold mb-2")
+ ui.label("Invoca un agente con todo el gobierno (guardrails + HITL + observabilidad)").classes("text-zinc-400 mb-6")
+
+ client = CoreClient()
+
+ # Load data
+ try:
+ agents = client.list_agents()
+ agent_names = [a["name"] for a in agents] if agents else []
+ policies = client.list_policies()
+ policy_names = [p["name"] for p in policies] if policies else []
+ except Exception as e:
+ ui.label(f"No se pudo conectar al Core: {e}").classes("text-red-400")
+ return
+
+ if not agent_names:
+ ui.label("No hay agentes registrados. Crea uno primero en 'Forjar Agente'.").classes("text-amber-400")
+ return
+
+ # Form state
+ selected_agent = ui.select(agent_names, label="Agente", value=agent_names[0]).classes("w-80")
+ scenario = ui.input("Escenario", value="01_sip_registration_drop").classes("w-80")
+
+ with ui.card().classes("w-full max-w-2xl mt-4"):
+ ui.label("Payload (JSON)").classes("font-medium mb-2")
+ payload_text = ui.textarea(
+ value=json.dumps({"scenario": "01_sip_registration_drop"}, indent=2),
+ placeholder='{"scenario": "..."}',
+ ).classes("w-full font-mono text-sm").props("rows=6")
+
+ result_box = ui.column().classes("w-full max-w-2xl mt-4")
+
+ async def do_invoke() -> None:
+ result_box.clear()
+ try:
+ body = json.loads(payload_text.value or "{}")
+ if "scenario" not in body:
+ body["scenario"] = scenario.value
+
+ with result_box:
+ ui.spinner("dots", size="lg")
+ ui.label("Invocando agente con gobernanza...").classes("ml-2")
+
+ res = client.invoke_agent(selected_agent.value, body)
+
+ result_box.clear()
+ with result_box:
+ if "api_error" in res:
+ ui.label("Error de la API").classes("text-red-400 font-bold")
+ ui.json_editor(res["api_error"])
+ else:
+ ui.label("✅ Ejecución iniciada").classes("text-emerald-400 text-lg font-semibold")
+ ui.json_editor(res, options={"readOnly": True})
+ if "trace_id" in res:
+ trace = res["trace_id"]
+ ui.button(
+ "Ver traza completa",
+ on_click=lambda: ui.navigate.to(f"/executions/{trace}"),
+ ).classes("mt-2")
+ except Exception as exc:
+ result_box.clear()
+ with result_box:
+ ui.label(f"Error: {exc}").classes("text-red-400")
+
+ ui.button("🚀 Invocar con Gobernanza", on_click=do_invoke, color="primary").classes("mt-4 px-8 py-2 text-lg")