Aller au contenu

Construire l'Interface Utilisateur

Démarrez la pile locale complète — le serveur de développement game-ui avec une API de jeu locale et un Agent Narratif AG-UI local (qui à son tour démarre le serveur MCP Inventory) — avec une seule commande :

Terminal window
pnpm nx dev game-ui

La cible dev sur game-ui a un dependsOn sur game-api:dev et dungeon_adventure.story:agent-dev, donc Nx démarre le serveur local de chaque projet en parallèle. Grâce aux connexions que nous avons configurées dans le Module 1, celles-ci démarrent à leur tour le serveur MCP Inventory et DynamoDB Local. Assurez-vous que votre moteur de conteneurs est en cours d’exécution, puis ouvrez le serveur de développement dans un navigateur.

Lorsque vous avez exécuté le générateur connection pour game-ui → story dans le Module 1, l’intégration AG-UI du site Shadcn a été générée pour vous. Cela vaut la peine d’y jeter un coup d’œil rapide :

  • Répertoirepackages/game-ui/src/
    • Répertoirecomponents/
      • AguiProvider.tsx Un seul CopilotKitProvider enregistré avec chaque agent AG-UI.
      • Répertoirecopilot/
        • index.tsx Ré-exporte les CopilotChat / CopilotSidebar / CopilotPopup thématisés.
        • ShadcnAssistantMessage.tsx, ShadcnUserMessage.tsx, ShadcnChatInput.tsx, ShadcnCursor.tsx, copilot.css
    • Répertoirehooks/
      • useAguiStoryAgent.tsx Instancie un HttpAgent @ag-ui/client pointant vers l’Agent Narratif déployé et complète threadId au minimum de 33 caractères d’id de session AgentCore.
    • main.tsx Enveloppe <App /> dans <AguiProvider>

Tout ce que nous devons faire est d’insérer un <CopilotChat agentId="agent" threadId={...} /> dans une route. Pour plus de détails sur la façon dont l’intégration est assemblée, consultez le guide de connexion React → AG-UI.

Remplacez packages/game-ui/src/styles.css — c’est le seul fichier que nous modifions pour le style. Il importe les globaux Shadcn partagés, remplace la palette par un thème de donjon éclairé aux torches, et fait en sorte que CopilotKit hérite de ces couleurs :

@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;
}

Nous avons besoin de deux routes — une pour choisir un héros, une pour jouer. Les deux utilisent les composants shadcn et le chat CopilotKit ; il n’y a pas d’interface de chat codée à la main.

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>
);
}

C’est le sélecteur de jeu : formulaire de nouveau jeu (shadcn Input + Button + Card) plus une liste “Continuer” alimentée par useGameApi().games.query avec useInfiniteQuery — une <div> sentinelle en bas surveillée par un IntersectionObserver appelle automatiquement fetchNextPage() lorsqu’elle est défilée dans la vue, et des spinners à côté du titre et en dessous de la liste affichent l’état de chargement. Démarrer un jeu saveGame la paire (playerName, genre) (pour qu’elle apparaisse la prochaine fois) et navigue vers la route de jeu.

Une fois enregistré, le serveur de développement à http://localhost:4200/ devrait maintenant vous permettre de démarrer une aventure et de discuter avec l’Agent Narratif.

game-select.png
game-conversation.png

Votre jeu est complet et vous avez testé chaque élément localement. Maintenant, déployons-le sur AWS pour que vous puissiez y jouer de n’importe où.

Terminal window
pnpm build
Terminal window
pnpm nx deploy infra "dungeon-adventure-infra-sandbox/*"

Votre premier déploiement prendra environ 6 minutes pour se terminer car il attend que toutes les ressources se stabilisent complètement. Les déploiements suivants sont plus rapides. Pour accélérer l’itération pendant le développement, vous pouvez opter pour le mode express CloudFormation en passant le flag --express, qui termine chaque opération de ressource dès que sa configuration est appliquée plutôt que d’attendre la stabilisation complète.

Une fois le déploiement terminé, vous verrez des sorties similaires aux suivantes :

Fenêtre de terminal
dungeon-adventure-infra-sandbox-Application
dungeon-adventure-infra-sandbox-Application: deploying... [2/2]
dungeon-adventure-infra-sandbox-Application
Deployment time: 354s
Outputs:
dungeon-adventure-infra-sandbox-Application.GameApiEndpointXXX = https://xxx.execute-api.region.amazonaws.com/prod/
dungeon-adventure-infra-sandbox-Application.GameUIDistributionDomainNameXXX = xxx.cloudfront.net
dungeon-adventure-infra-sandbox-Application.InventoryMcpArn = arn:aws:bedrock-agentcore:region:xxxxxxx:runtime/dungeonadventureventoryMcpServerXXXX-YYYY
dungeon-adventure-infra-sandbox-Application.RuntimeConfigApplicationId = xxxx
dungeon-adventure-infra-sandbox-Application.StoryAgentArn = arn:aws:bedrock-agentcore:region:xxxxxxx:runtime/dungeonadventurecationStoryAgentXXXX-YYYY
dungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxx
dungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityUserPoolClientIdXXX = xxxxxxxxxx
dungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityUserPoolIdXXX = region_xxx

Accédez à votre URL CloudFront (GameUIDistributionDomainName dans les sorties CDK), inscrivez-vous pour un nouveau compte, et jouez à votre jeu fonctionnant entièrement sur AWS !

Tâche 6 : Mélanger les composants locaux et déployés

Section intitulée « Tâche 6 : Mélanger les composants locaux et déployés »

Vous avez vu les deux extrêmes du spectre : tout local (dev) et tout déployé. Pendant le développement quotidien, il est souvent utile de mélanger les deux — par exemple, itérer sur le code du site web contre l’API et l’agent réels déployés, ou exécuter l’API localement contre la table DynamoDB réelle.

La clé est la variable d’environnement RUNTIME_CONFIG_APP_ID. Lorsqu’un projet s’exécute sans LOCAL_DEV=true, les recherches de configuration d’exécution récupèrent leur configuration depuis AWS AppConfig en utilisant cet identifiant d’application — la valeur RuntimeConfigApplicationId de vos sorties CDK.

Chaque site web a une cible load:runtime-config qui télécharge le runtime-config.json déployé (pool Cognito, points de terminaison API, ARN de l’agent) dans le serveur de développement local. Quelques combinaisons utiles :

  • Site web local → backend déployé. Récupérez la configuration déployée une fois, puis exécutez la cible serve simple pour que l’interface utilisateur communique avec l’API et l’agent déployés :

    Terminal window
    pnpm nx run game-ui:load:runtime-config
    Terminal window
    pnpm nx serve game-ui
  • API locale → table DynamoDB réelle. Exécutez la cible serve simple avec RUNTIME_CONFIG_APP_ID défini sur l’identifiant d’application déployé :

    Terminal window
    RUNTIME_CONFIG_APP_ID=<RuntimeConfigApplicationId from CDK outputs> pnpm nx serve game-api

Cette flexibilité vous permet de tester exactement la partie sur laquelle vous travaillez, contre les composants — locaux ou déployés — qui ont du sens.

Félicitations. Vous avez construit, testé et déployé votre Jeu d’Aventure en Donjon Agentique ! 🎉🎉🎉