Xây dựng giao diện người dùng
Nhiệm vụ 1: Chạy mọi thứ ở local
Phần tiêu đề “Nhiệm vụ 1: Chạy mọi thứ ở local”Khởi động toàn bộ stack local — máy chủ dev game-ui cùng với Game API local và AG-UI Story Agent local (lần lượt khởi động máy chủ Inventory MCP) — bằng một lệnh duy nhất:
pnpm nx dev game-uiyarn nx dev game-uinpx nx dev game-uibunx nx dev game-uiTarget dev trên game-ui có dependsOn trên game-api:dev và dungeon_adventure.story:agent-dev, vì vậy Nx khởi động máy chủ local của mọi dự án song song. Thông qua các kết nối mà chúng ta thiết lập trong Module 1, chúng lần lượt khởi động máy chủ Inventory MCP và DynamoDB Local. Đảm bảo container engine của bạn đang chạy, sau đó mở máy chủ dev trong trình duyệt.
Nhiệm vụ 2: Nơi CopilotKit đã được kết nối
Phần tiêu đề “Nhiệm vụ 2: Nơi CopilotKit đã được kết nối”Khi bạn chạy generator connection cho game-ui → story trong Module 1, tích hợp AG-UI của website Shadcn đã được tạo cho bạn. Đáng để xem qua nhanh:
Thư mụcpackages/game-ui/src/
Thư mụccomponents/
- AguiProvider.tsx Single
CopilotKitProviderregistered with every AG-UI agent. Thư mụccopilot/
- index.tsx Re-exports themed
CopilotChat/CopilotSidebar/CopilotPopup. - ShadcnAssistantMessage.tsx, ShadcnUserMessage.tsx, ShadcnChatInput.tsx, ShadcnCursor.tsx, copilot.css
- index.tsx Re-exports themed
- AguiProvider.tsx Single
Thư mụchooks/
- useAguiStoryAgent.tsx Instantiates an
@ag-ui/clientHttpAgentpointing at the deployed Story Agent and padsthreadIdto AgentCore’s 33-character minimum session id.
- useAguiStoryAgent.tsx Instantiates an
- main.tsx Wraps
<App />in<AguiProvider>
Tất cả những gì chúng ta cần làm là thả một <CopilotChat agentId="agent" threadId={...} /> vào một route. Để biết thêm chi tiết về cách tích hợp được kết hợp, xem hướng dẫn kết nối React → AG-UI.
Nhiệm vụ 3: Thay đổi style cho hầm ngục
Phần tiêu đề “Nhiệm vụ 3: Thay đổi style cho hầm ngục”Thay thế packages/game-ui/src/styles.css — đây là file duy nhất chúng ta thay đổi cho styling. Nó import các global Shadcn được chia sẻ, ghi đè bảng màu thành chủ đề hầm ngục được thắp sáng bằng đuốc, và làm cho CopilotKit kế thừa những màu đó:
@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;}Nhiệm vụ 4: Tạo các route của trò chơi
Phần tiêu đề “Nhiệm vụ 4: Tạo các route của trò chơi”Chúng ta cần hai route — một để chọn hero, một để chơi. Cả hai đều sử dụng các component shadcn và chat CopilotKit; không có giao diện chat tự tạo.
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> );}import { createFileRoute } from '@tanstack/react-router';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';
export const Route = createFileRoute('/')({ component: RouteComponent,});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="text-center"> <header> <h1>Welcome</h1> <p>Welcome to your new React website!</p> </header> <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> );}Đây là bộ chọn trò chơi: form new-game (shadcn Input + Button + Card) cộng với danh sách “Continue” được cung cấp bởi useGameApi().games.query với useInfiniteQuery — một <div> sentinel ở dưới cùng được theo dõi bởi IntersectionObserver tự động gọi fetchNextPage() khi được cuộn vào tầm nhìn, và các spinner bên cạnh heading và bên dưới danh sách hiển thị trạng thái loading. Bắt đầu một trò chơi saveGame cặp (playerName, genre) (để nó hiển thị lần sau) và điều hướng đến route chơi.
import { UseAgentUpdate, useAgent } from '@copilotkit/react-core/v2';import { useQuery } from '@tanstack/react-query';import { createFileRoute } from '@tanstack/react-router';import { useEffect, useMemo, useRef } from 'react';import { CopilotChat } from '../../components/copilot';import { useGameApi } from '../../hooks/useGameApi';import type { IGame } from '@dungeon-adventure/game-api';
// AgentCore session ids must be at least 33 characters. The AG-UI hook pads// the threadId to this length before sending, so the thread id is stable for// a given (player, genre) pair — revisiting the URL continues the same story.const buildThreadId = (playerName: string, genre: string) => `${playerName}-${genre}`.padEnd(33, '0');
export const Route = createFileRoute('/game/$playerName')({ component: RouteComponent, validateSearch: (search: Record<string, unknown>) => ({ genre: search.genre as IGame['genre'], }),});
function RouteComponent() { const { playerName } = Route.useParams(); const { genre } = Route.useSearch(); const threadId = useMemo( () => buildThreadId(playerName, genre), [playerName, genre], );
const gameApi = useGameApi(); const inventory = useQuery( gameApi.inventory.query.queryOptions({ playerName, limit: 100 }), ); // Conversation history persisted by the agent's ``S3SessionManager``. Each // turn is stored as ``session_<threadId>/agents/agent_default/messages/…``. // // `staleTime: 0` + `refetchOnMount: 'always'` together force a fresh read // on every visit — the cached snapshot from the *first* time we loaded // this route (before the agent had written any turns back to S3) would // otherwise look like an empty thread on revisit and trigger re-priming. const pastActions = useQuery({ ...gameApi.actions.query.queryOptions({ sessionId: threadId }), staleTime: 0, refetchOnMount: 'always', });
const { agent } = useAgent({ agentId: 'agent', updates: [UseAgentUpdate.OnMessagesChanged], });
// Hydrate the chat once the history query resolves. For a fresh thread // (no stored messages) the agent's system prompt expects the player's // name and genre in the first user message, so send that priming line. // // We wait for `isFetching` to go false (rather than just `isLoading`) so // that revisits with a cached empty result from the first visit aren't // mistaken for a fresh thread — the background refetch is what sees the // turns the agent wrote since. const primedRef = useRef(false); useEffect(() => { if (!agent || primedRef.current) return; if (pastActions.isFetching || !pastActions.isSuccess) return; primedRef.current = true; const items = pastActions.data.items; if (items.length > 0) { agent.setMessages( items.map((a) => ({ id: `m-${a.messageId}`, role: a.role, content: a.content, })), ); return; } agent.addMessage({ id: crypto.randomUUID(), role: 'user', content: `My name is ${playerName}. Start my ${genre} adventure.`, }); void agent.runAgent(); }, [ agent, pastActions.data, pastActions.isFetching, pastActions.isSuccess, playerName, genre, ]);
// The agent's ``add-to-inventory`` tool calls mutate DynamoDB directly, so // the inventory query needs a nudge to refetch as turns complete. The // ``useAgent({ updates: [OnMessagesChanged] })`` subscription re-renders // this route on each message event — refetch whenever the message count // changes, which covers both the initial populate and every subsequent // turn. const seenMessages = useRef(0); useEffect(() => { if (!agent) return; if (agent.messages.length !== seenMessages.current) { seenMessages.current = agent.messages.length; void inventory.refetch(); } });
return ( <div className="relative flex h-[calc(100vh-10rem)] min-h-0 flex-col"> {!!inventory.data?.items.length && ( <aside className="bg-accent text-accent-foreground pointer-events-none absolute right-4 top-4 z-10 w-56 rounded-lg border p-3 shadow-lg"> <div className="mb-1 font-semibold">📦 Inventory</div> <ul className="flex flex-col gap-0.5 text-sm"> {inventory.data.items.map((item) => ( <li key={item.itemName}> {item.emoji ?? '•'} {item.itemName} {item.quantity > 1 ? ` (x${item.quantity})` : ''} </li> ))} </ul> </aside> )} <CopilotChat agentId="agent" threadId={threadId} labels={{ chatInputPlaceholder: 'What do you do?', welcomeMessageText: `${playerName}'s ${genre} adventure`, }} /> </div> );}Đây là route chơi. Nó xây dựng threadId xác định ({player}-{genre} được đệm đến 33 ký tự — hook AG-UI sẽ gửi nó nguyên văn làm session id của AgentCore), render <CopilotChat agentId="agent" threadId={threadId} />, và phủ inventory từ useGameApi().inventory.query lên trên. Khi mount, useGameApi().actions.query({ sessionId: threadId }) đọc lịch sử hội thoại mà agent đã lưu trữ trong S3 và — nếu có — gọi agent.setMessages(...) để rehydrate chat; nếu không nó gửi một user message priming để khởi động câu chuyện. agent.messages được subscribe thông qua useAgent({ updates: [OnMessagesChanged] }) vì vậy mỗi lượt mới cũng refetch inventory query (các lời gọi MCP tool mutate DynamoDB trực tiếp).
Sau khi lưu, máy chủ dev tại http://localhost:4200/ bây giờ sẽ cho phép bạn bắt đầu một cuộc phiêu lưu và trò chuyện với Story Agent.

Nhiệm vụ 5: Triển khai lên AWS
Phần tiêu đề “Nhiệm vụ 5: Triển khai lên AWS”Trò chơi của bạn đã hoàn thành và bạn đã kiểm tra mọi phần ở local. Bây giờ hãy triển khai nó lên AWS để bạn có thể chơi từ bất kỳ đâu.
Build code của bạn
Phần tiêu đề “Build code của bạn”pnpm buildyarn buildnpm run buildbun buildTriển khai ứng dụng của bạn
Phần tiêu đề “Triển khai ứng dụng của bạn”pnpm nx deploy-sandbox infrayarn nx deploy-sandbox infranpx nx deploy-sandbox infrabunx nx deploy-sandbox infraTarget deploy-sandbox triển khai stage sandbox được định nghĩa trong packages/infra/src/main.ts.
Lần triển khai đầu tiên của bạn sẽ mất khoảng 6 phút để hoàn thành vì nó đợi tất cả các tài nguyên ổn định hoàn toàn. Các lần triển khai tiếp theo sẽ nhanh hơn. Để tăng tốc độ lặp lại trong quá trình phát triển, bạn có thể chọn CloudFormation express mode bằng cách truyền flag --express, điều này hoàn thành mỗi thao tác tài nguyên ngay khi cấu hình của nó được áp dụng thay vì đợi ổn định hoàn toàn.
Sau khi triển khai hoàn tất, bạn sẽ thấy các output tương tự như sau:
dungeon-adventure-infra-sandbox-Applicationdungeon-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.netdungeon-adventure-infra-sandbox-Application.InventoryMcpArn = arn:aws:bedrock-agentcore:region:xxxxxxx:runtime/dungeonadventureventoryMcpServerXXXX-YYYYdungeon-adventure-infra-sandbox-Application.RuntimeConfigApplicationId = xxxxdungeon-adventure-infra-sandbox-Application.StoryAgentArn = arn:aws:bedrock-agentcore:region:xxxxxxx:runtime/dungeonadventurecationStoryAgentXXXX-YYYYdungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityIdentityPoolIdXXX = region:xxxdungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityUserPoolClientIdXXX = xxxxxxxxxxdungeon-adventure-infra-sandbox-Application.UserIdentityUserIdentityUserPoolIdXXX = region_xxxĐiều hướng đến CloudFront URL của bạn (GameUIDistributionDomainName từ CDK outputs), đăng ký tài khoản mới và chơi trò chơi của bạn chạy hoàn toàn trên AWS!
Nhiệm vụ 6: Kết hợp các component local và đã triển khai
Phần tiêu đề “Nhiệm vụ 6: Kết hợp các component local và đã triển khai”Bạn đã thấy hai đầu của phổ: mọi thứ local (dev) và mọi thứ đã triển khai. Trong quá trình phát triển hàng ngày, thường hữu ích khi kết hợp cả hai — ví dụ, lặp lại code website với API và agent thực đã triển khai, hoặc chạy API ở local với bảng DynamoDB thực.
Chìa khóa là biến môi trường RUNTIME_CONFIG_APP_ID. Khi một dự án chạy không có LOCAL_DEV=true, các tra cứu runtime config lấy cấu hình của chúng từ AWS AppConfig sử dụng application id này — giá trị RuntimeConfigApplicationId từ CDK outputs của bạn.
Mỗi website có một target load-runtime-config tải xuống runtime-config.json đã triển khai (Cognito pool, API endpoints, agent ARN) vào máy chủ dev local. Một số kết hợp hữu ích:
-
Website local → backend đã triển khai. Pull config đã triển khai một lần, sau đó chạy target
servethuần túy để UI giao tiếp với API và agent đã triển khai:Terminal window pnpm nx load-runtime-config game-uiTerminal window yarn nx load-runtime-config game-uiTerminal window npx nx load-runtime-config game-uiTerminal window bunx nx load-runtime-config game-uiTerminal window pnpm nx serve game-uiTerminal window yarn nx serve game-uiTerminal window npx nx serve game-uiTerminal window bunx nx serve game-ui -
API local → bảng DynamoDB thực. Chạy target
servethuần túy vớiRUNTIME_CONFIG_APP_IDđược đặt thành application id đã triển khai:Terminal window RUNTIME_CONFIG_APP_ID=<RuntimeConfigApplicationId from CDK outputs> pnpm nx serve game-apiTerminal window RUNTIME_CONFIG_APP_ID=<RuntimeConfigApplicationId from CDK outputs> yarn nx serve game-apiTerminal window RUNTIME_CONFIG_APP_ID=<RuntimeConfigApplicationId from CDK outputs> npx nx serve game-apiTerminal window RUNTIME_CONFIG_APP_ID=<RuntimeConfigApplicationId from CDK outputs> bunx nx serve game-api
Tính linh hoạt này cho phép bạn kiểm tra chính xác phần bạn đang làm việc, với bất kỳ component nào — local hoặc đã triển khai — có ý nghĩa.
Chúc mừng. Bạn đã xây dựng, kiểm tra và triển khai Agentic Dungeon Adventure Game của mình! 🎉🎉🎉