跳转到内容

构建UI

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

Terminal window
pnpm nx serve-local game-ui

game-ui:serve-local 依赖于 game-api:serve-localdungeon_adventure.story:agent-serve-local,因此 Nx 会并行启动每个项目的本地服务器。在浏览器中打开开发服务器并按照提示创建新用户。

任务二: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/ 的开发服务器现在应该允许您开始冒险并与故事代理聊天。

如果您愿意,也可以构建并部署代码到 CloudFront。
game-select.png
game-conversation.png

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