Bỏ qua để đến nội dung

Xây dựng giao diện người dùng

Khởi động toàn bộ stack cục bộ — máy chủ phát triển game-ui cùng với Game API cục bộ và AG-UI Story Agent cục bộ (lần lượt khởi động Inventory MCP server) — chỉ với một lệnh:

Terminal window
pnpm nx dev game-ui

Target dev trên game-uidependsOn trên game-api:devdungeon_adventure.story:agent-dev, vì vậy Nx khởi động máy chủ cục bộ của mọi dự án song song. Thông qua các kết nối chúng ta đã thiết lập trong Module 1, chúng lần lượt khởi động Inventory MCP server và DynamoDB Local. Đảm bảo container engine của bạn đang chạy, sau đó mở máy chủ phát triển 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 trang web Shadcn đã được tạo cho bạn. Đáng để xem qua nhanh:

  • Thư mụcpackages/game-ui/src/
    • Thư mụccomponents/
      • AguiProvider.tsx Single CopilotKitProvider registered 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
    • Thư mụchooks/
      • 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>

Tất cả những gì chúng ta cần làm là thêm <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, hãy xem hướng dẫn kết nối React → AG-UI.

Nhiệm vụ 3: Thay đổi giao diện cho hầm ngục

Phần tiêu đề “Nhiệm vụ 3: Thay đổi giao diện 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 việc tạo kiểu. Nó import các global Shadcn được chia sẻ, ghi đè bảng màu thành theme hầm ngục được thắp sáng bởi ngọn đuốc, và làm cho CopilotKit kế thừa các 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;
}

Chúng ta cần hai route — một để chọn nhân vật, 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ự viết.

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

Đây là bộ chọn trò chơi: form trò chơi mới (shadcn Input + Button + Card) cộng với danh sách “Tiếp tục” đượ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 tiêu đề và bên dưới danh sách hiển thị trạng thái đang tải. Bắt đầu một trò chơi sẽ saveGame cặp (playerName, genre) (để nó hiển thị lần sau) và điều hướng đến route chơi.

Sau khi lưu, máy chủ phát triển tại http://localhost:4200/ giờ đây 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.

game-select.png
game-conversation.png

Trò chơi của bạn đã hoàn thành và bạn đã kiểm tra từng phần cục bộ. Bây giờ hãy triển khai nó lên AWS để bạn có thể chơi từ bất kỳ đâu.

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

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 sử dụng CloudFormation express mode bằng cách truyền cờ --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 đầu ra tương tự như sau:

Terminal window
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

Điều hướng đến URL CloudFront của bạn (GameUIDistributionDomainName từ các đầu ra CDK), đă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 thành phần cục bộ và đã triển khai

Phần tiêu đề “Nhiệm vụ 6: Kết hợp các thành phần cục bộ và đã triển khai”

Bạn đã thấy hai đầu của phổ: mọi thứ cục bộ (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 trên code website với API và agent thực đã triển khai, hoặc chạy API cục bộ 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ừ các đầu ra CDK 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ủ phát triển cục bộ. Một số kết hợp hữu ích:

  • Website cục bộ → backend đã triển khai. Tải config đã triển khai một lần, sau đó chạy target serve thông thường để UI giao tiếp với API và agent đã triển khai:

    Terminal window
    pnpm nx run game-ui:load:runtime-config
    Terminal window
    pnpm nx serve game-ui
  • API cục bộ → bảng DynamoDB thực. Chạy target serve thông thường với RUNTIME_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-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ỳ thành phần nào — cục bộ 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 Trò chơi Phiêu lưu Hầm ngục với Agent của mình! 🎉🎉🎉