跳转到内容

构建UI

使用一条命令启动完整的本地堆栈——game-ui 开发服务器以及本地 Game API 和本地 AG-UI Story Agent(后者又会启动 Inventory MCP 服务器):

Terminal window
pnpm nx dev game-ui

game-ui 上的 dev 目标依赖于 game-api:devdungeon_adventure.story:agent-dev,因此 Nx 会并行启动每个项目的本地服务器。通过我们在模块一中设置的连接,这些服务器又会启动 Inventory MCP 服务器和 DynamoDB Local。确保您的容器引擎正在运行,然后在浏览器中打开开发服务器。

任务二:CopilotKit 已经配置好的地方

Section titled “任务二:CopilotKit 已经配置好的地方”

当您在 模块一 中为 game-ui → story 运行 connection 生成器时,Shadcn 网站的 AG-UI 集成已经为您生成。值得快速了解一下:

  • 文件夹packages/game-ui/src/
    • 文件夹components/
      • AguiProvider.tsx 单个 CopilotKitProvider 注册了所有 AG-UI 代理。
      • 文件夹copilot/
        • index.tsx 重新导出主题化的 CopilotChat / CopilotSidebar / CopilotPopup
        • ShadcnAssistantMessage.tsx, ShadcnUserMessage.tsx, ShadcnChatInput.tsx, ShadcnCursor.tsx, copilot.css
    • 文件夹hooks/
      • useAguiStoryAgent.tsx 实例化一个指向已部署故事代理的 @ag-ui/client HttpAgent,并将 threadId 填充到 AgentCore 的 33 字符最小会话 ID。
    • main.tsx <AguiProvider> 包装 <App />

我们需要做的就是将 <CopilotChat agentId="agent" threadId={...} /> 放入路由中。有关集成如何组合的更多详细信息,请参阅 React → AG-UI 连接指南

替换 packages/game-ui/src/styles.css ——这是我们为样式更改的唯一文件。它导入共享的 Shadcn 全局样式,将调色板覆盖为火把照明的地牢主题,并使 CopilotKit 继承这些颜色:

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

我们需要两个路由——一个用于选择英雄,一个用于游玩。两者都使用 shadcn 组件和 CopilotKit 聊天;没有手工编写的聊天 UI。

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

这是游戏选择器:新游戏表单(shadcn Input + Button + Card)加上由 useGameApi().games.query 通过 useInfiniteQuery 提供的”继续”列表——底部的哨兵 <div>IntersectionObserver 监视,当滚动到视图中时会自动调用 fetchNextPage(),标题旁边和列表下方的加载指示器显示加载状态。开始游戏会 saveGame (playerName, genre) 对(以便下次显示)并导航到游玩路由。

保存后,位于 http://localhost:4200/ 的开发服务器现在应该允许您开始冒险并与故事代理聊天。

game-select.png
game-conversation.png

您的游戏已完成,并且您已在本地测试了每个部分。现在让我们将其部署到 AWS,以便您可以从任何地方玩它。

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

您的第一次部署将需要大约 8 分钟才能完成。后续部署会更快。

部署完成后,您将看到类似以下的输出:

Terminal window
dungeon-adventure-infra-sandbox-Application
dungeon-adventure-infra-sandbox-Application: deploying... [2/2]
dungeon-adventure-infra-sandbox-Application
Deployment time: 220s
⚠️ Stack deployed using Express Mode. Resources still stabilizing: GameUICloudfrontDistribution, UserIdentityWebAcl, ...and more...
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

访问您的 CloudFront URL(CDK 输出中的 GameUIDistributionDomainName),注册一个新账户,并在完全运行在 AWS 上的游戏中畅玩!

任务六:混合本地和已部署组件

Section titled “任务六:混合本地和已部署组件”

您已经看到了两个极端:完全本地(dev)和完全部署。在日常开发中,混合使用两者通常很有用——例如,针对真实部署的 API 和代理迭代网站代码,或者针对真实 DynamoDB 表在本地运行 API。

关键是 RUNTIME_CONFIG_APP_ID 环境变量。当项目运行时没有 LOCAL_DEV=true,运行时配置查找会使用此应用程序 ID 从 AWS AppConfig 获取其配置——即 CDK 输出中的 RuntimeConfigApplicationId 值。

每个网站都有一个 load:runtime-config 目标,可将已部署的 runtime-config.json(Cognito 池、API 端点、代理 ARN)下载到本地开发服务器中。一些有用的组合:

  • 本地网站 → 已部署后端。 拉取一次已部署的配置,然后运行普通的 serve 目标,以便 UI 与已部署的 API 和代理通信:

    Terminal window
    pnpm nx run game-ui:load:runtime-config
    Terminal window
    pnpm nx serve game-ui
  • 本地 API → 真实 DynamoDB 表。 运行普通的 serve 目标,并将 RUNTIME_CONFIG_APP_ID 设置为已部署的应用程序 ID:

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

这种灵活性让您可以准确测试您正在处理的部分,针对任何有意义的组件——本地或已部署。

恭喜您。您已成功构建、测试并部署智能地牢冒险游戏!🎉🎉🎉�灵活性让您可以准确测试您正在处理的部分,针对任何有意义的组件——本地或已部署。

恭喜您。您已成功构建、测试并部署智能地牢冒险游戏!🎉🎉🎉