Ir al contenido

Construir la UI

Inicia el stack local completo — el servidor de desarrollo game-ui junto con una Game API local y un Story Agent AG-UI local (que a su vez arranca el servidor MCP de Inventario) — con un solo comando:

Terminal window
pnpm nx serve-local game-ui

game-ui:serve-local tiene dependsOn en game-api:serve-local y dungeon_adventure.story:agent-serve-local, por lo que Nx inicia el servidor local de cada proyecto en paralelo. Abre el servidor de desarrollo en un navegador y sigue las indicaciones para crear un nuevo usuario.

Tarea 2: Dónde CopilotKit ya está conectado

Sección titulada «Tarea 2: Dónde CopilotKit ya está conectado»

Cuando ejecutaste el generador connection para game-ui → story en el Módulo 1, la integración AG-UI del sitio web Shadcn se generó para ti. Vale la pena echarle un vistazo rápido:

  • Directoriopackages/game-ui/src/
    • Directoriocomponents/
      • AguiProvider.tsx Single CopilotKitProvider registered with every AG-UI agent.
      • Directoriocopilot/
        • index.tsx Re-exports themed CopilotChat / CopilotSidebar / CopilotPopup.
        • ShadcnAssistantMessage.tsx, ShadcnUserMessage.tsx, ShadcnChatInput.tsx, ShadcnCursor.tsx, copilot.css
    • Directoriohooks/
      • useAguiStoryAgent.tsx Instantiates an @ag-ui/client HttpAgent pointing at the deployed Story Agent and pads threadId to AgentCore’s 33-character minimum session id.
    • main.tsx Wraps <App /> in <AguiProvider>

Todo lo que necesitamos hacer es colocar un <CopilotChat agentId="agent" threadId={...} /> en una ruta. Para más detalles sobre cómo se ensambla la integración, consulta la guía de conexión React → AG-UI.

Reemplaza packages/game-ui/src/styles.css — este es el único archivo que cambiamos para el estilo. Importa los estilos globales compartidos de Shadcn, sobrescribe la paleta a un tema de mazmorra iluminada por antorchas, y hace que CopilotKit herede esos colores:

@import '../../common/shadcn/src/styles/globals.css';
@source './**/*.{ts,tsx}';
/* Dungeon theme — torch-lit parchment on stone. Applied to `:root` for the
* page and to `[data-copilotkit][data-copilotkit]` for CopilotKit's chat
* surface; CopilotKit ships a same-specificity `[data-copilotkit]` rule that
* resets `--background` back to white, so we bump specificity with the
* doubled selector. */
:root,
[data-copilotkit][data-copilotkit] {
--background: oklch(0.18 0.02 60);
--foreground: oklch(0.92 0.04 85);
--card: oklch(0.22 0.03 60);
--card-foreground: oklch(0.92 0.04 85);
--popover: oklch(0.2 0.02 60);
--popover-foreground: oklch(0.92 0.04 85);
--primary: oklch(0.75 0.15 75);
--primary-foreground: oklch(0.15 0.02 60);
--secondary: oklch(0.28 0.04 60);
--secondary-foreground: oklch(0.92 0.04 85);
--muted: oklch(0.25 0.02 60);
--muted-foreground: oklch(0.7 0.04 85);
--accent: oklch(0.4 0.12 30);
--accent-foreground: oklch(0.95 0.04 85);
--destructive: oklch(0.55 0.22 25);
--border: oklch(0.35 0.03 60);
--input: oklch(0.3 0.03 60);
--ring: oklch(0.75 0.15 75);
--sidebar: oklch(0.15 0.02 60);
--sidebar-foreground: oklch(0.88 0.04 85);
--sidebar-primary: oklch(0.75 0.15 75);
--sidebar-primary-foreground: oklch(0.15 0.02 60);
--sidebar-accent: oklch(0.28 0.04 60);
--sidebar-accent-foreground: oklch(0.92 0.04 85);
--sidebar-border: oklch(0.3 0.03 60);
--sidebar-ring: oklch(0.75 0.15 75);
}
body {
font-family: 'Georgia', 'Cambria', serif;
background:
radial-gradient(circle at 20% 10%, oklch(0.25 0.05 70 / 0.4), transparent 40%),
radial-gradient(circle at 80% 90%, oklch(0.25 0.1 30 / 0.3), transparent 40%),
var(--background);
}
h1, h2, h3 {
letter-spacing: 0.05em;
}
/* CopilotChat positions its sticky input wrapper with
* `pointer-events: none` (so scroll events can pass through to the message
* list behind it). That inherits into the input too, which means users can't
* click the chat input. Re-enable pointer events for every direct child of
* the sticky wrapper — the input itself, its buttons, and anything else the
* chat surface might render there. */
.copilotKitChat > .cpk\:pointer-events-none > * {
pointer-events: auto;
}

Necesitamos dos rutas — una para elegir un héroe, otra para jugar. Ambas usan componentes de shadcn y el chat de CopilotKit; no hay una interfaz de chat hecha a mano.

import { useInfiniteQuery, useMutation } from '@tanstack/react-query';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Button } from ':dungeon-adventure/common-shadcn/components/ui/button';
import { Input } from ':dungeon-adventure/common-shadcn/components/ui/input';
import {
Card,
CardContent,
} from ':dungeon-adventure/common-shadcn/components/ui/card';
import { Spinner } from ':dungeon-adventure/common-shadcn/components/ui/spinner';
import { useGameApi } from '../hooks/useGameApi';
import type { IGame } from ':dungeon-adventure/game-api';
const GENRES = ['medieval', 'zombie', 'superhero'] as const;
export const Route = createFileRoute('/')({ component: RouteComponent });
function RouteComponent() {
const [playerName, setPlayerName] = useState('');
const [pending, setPending] = useState<IGame['genre'] | null>(null);
const navigate = useNavigate();
const gameApi = useGameApi();
const saveGame = useMutation(gameApi.games.save.mutationOptions());
const games = useInfiniteQuery(
gameApi.games.query.infiniteQueryOptions(
{ limit: 10 },
{ getNextPageParam: ({ cursor }) => cursor ?? undefined },
),
);
const savedGames = useMemo(
() => games.data?.pages.flatMap((p) => p.items) ?? [],
[games.data],
);
// Auto-fetch subsequent pages when the sentinel at the bottom of the list
// scrolls into view — keeps the homepage a simple infinite scroll without
// a "Load more" button.
const sentinel = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const el = sentinel.current;
if (!el || !games.hasNextPage) return;
const io = new IntersectionObserver(
(entries) => {
if (
entries.some((e) => e.isIntersecting) &&
!games.isFetchingNextPage
) {
void games.fetchNextPage();
}
},
{ rootMargin: '120px' },
);
io.observe(el);
return () => io.disconnect();
}, [games.hasNextPage, games.isFetchingNextPage, games.fetchNextPage]);
const startGame = async (player: string, genre: IGame['genre']) => {
if (!player.trim()) return;
setPending(genre);
try {
if (!savedGames.find((g) => g.playerName === player)) {
await saveGame.mutateAsync({ playerName: player, genre });
}
await navigate({
to: '/game/$playerName',
params: { playerName: player },
search: { genre },
});
} finally {
setPending(null);
}
};
const busy = pending !== null;
const firstLoad = games.isLoading;
return (
<div className="mx-auto flex w-full max-w-2xl flex-col gap-8">
<div className="text-center">
<h1 className="bg-gradient-to-r from-amber-300 to-rose-400 bg-clip-text text-5xl font-bold text-transparent">
AI Dungeon Adventure
</h1>
<p className="text-muted-foreground mt-2">
Pick a hero name, choose a genre, begin.
</p>
</div>
<Card>
<CardContent className="flex flex-col gap-4 pt-6">
<Input
placeholder="Your hero's name"
value={playerName}
disabled={busy}
onChange={(e) => setPlayerName(e.target.value)}
/>
<div className="grid grid-cols-3 gap-3">
{GENRES.map((genre) => (
<Button
key={genre}
variant="secondary"
disabled={!playerName.trim() || busy}
onClick={() => startGame(playerName, genre)}
>
{pending === genre && <Spinner />}
{genre[0].toUpperCase() + genre.slice(1)}
</Button>
))}
</div>
</CardContent>
</Card>
<div className="flex flex-col gap-2">
<h2 className="flex items-center gap-2 text-xl font-semibold">
Continue
{(firstLoad || games.isFetching) && <Spinner className="size-4" />}
</h2>
{!firstLoad && savedGames.length === 0 && (
<p className="text-muted-foreground text-sm">
No saved games yet — start a new adventure above.
</p>
)}
{savedGames.map((g) => (
<Button
key={g.playerName}
variant="outline"
className="justify-between"
disabled={busy}
onClick={() => startGame(g.playerName, g.genre)}
>
<span>{g.playerName}</span>
<span className="text-muted-foreground text-sm">
{g.genre[0].toUpperCase() + g.genre.slice(1)}
</span>
</Button>
))}
<div ref={sentinel} aria-hidden className="h-1" />
{games.isFetchingNextPage && (
<div className="flex justify-center py-2">
<Spinner />
</div>
)}
</div>
</div>
);
}

Este es el selector de juego: formulario de nuevo juego (shadcn Input + Button + Card) más una lista de “Continuar” alimentada por useGameApi().games.query con useInfiniteQuery — un <div> centinela inferior observado por un IntersectionObserver llama automáticamente a fetchNextPage() cuando se desplaza a la vista, y los spinners junto al encabezado y debajo de la lista muestran el estado de carga. Iniciar un juego guarda con saveGame el par (playerName, genre) (para que aparezca la próxima vez) y navega a la ruta de juego.

Una vez guardado, el servidor de desarrollo en http://localhost:4200/ ahora debería permitirte iniciar una aventura y chatear con el Agente de Historia.

También puedes compilar e implementar tu código en CloudFront si lo prefieres.
game-select.png
game-conversation.png

¡Felicidades! Has construido y desplegado tu Juego de Aventuras de Mazmorras con Agentes. 🎉🎉🎉