wikiracing-llms / src /components /game-component.tsx
stillerman's picture
stillerman HF Staff
Better win screens
8b6f89e
"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
Flag,
Clock,
Hash,
ArrowRight,
Bot,
User,
ChevronDown,
ChevronUp,
Info,
} from "lucide-react";
import { useInference } from "@/lib/inference";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { API_BASE } from "@/lib/constants";
import ForceDirectedGraph from "./force-directed-graph";
import qwen3Data from "../../results/qwen3.json"
// Simple Switch component since it's not available in the UI components
const Switch = ({
checked,
onCheckedChange,
disabled,
id,
}: {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
id?: string;
}) => {
return (
<button
id={id}
type="button"
role="switch"
aria-checked={checked}
data-state={checked ? "checked" : "unchecked"}
disabled={disabled}
onClick={() => onCheckedChange(!checked)}
className={cn(
"focus-visible:ring-ring/50 peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-50",
checked ? "bg-primary" : "bg-input"
)}
>
<span
data-state={checked ? "checked" : "unchecked"}
className={cn(
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform",
checked ? "translate-x-4" : "translate-x-0"
)}
/>
</button>
);
};
type Message = {
role: "user" | "assistant" | "game" | "result" | "error";
content: string;
metadata?: {
page?: string;
links?: string[];
status?: "playing" | "won" | "lost";
path?: string[];
};
};
const buildPrompt = (
current: string,
target: string,
path_so_far: string[],
links: string[]
) => {
const formatted_links = links
.map((link, index) => `${index + 1}. ${link}`)
.join("\n");
const path_so_far_str = path_so_far.join(" -> ");
return `You are playing WikiRun, trying to navigate from one Wikipedia article to another using only links.
IMPORTANT: You MUST put your final answer in <answer>NUMBER</answer> tags, where NUMBER is the link number.
For example, if you want to choose link 3, output <answer>3</answer>.
Current article: ${current}
Target article: ${target}
You have ${links.length} link(s) to choose from:
${formatted_links}
Your path so far: ${path_so_far_str}
Think about which link is most likely to lead you toward the target article.
First, analyze each link briefly and how it connects to your goal, then select the most promising one.
Remember to format your final answer by explicitly writing out the xml number tags like this: <answer>NUMBER</answer>`;
};
interface GameComponentProps {
player: "me" | "model";
model?: string;
maxHops: number;
startPage: string;
targetPage: string;
onReset: () => void;
maxTokens: number;
maxLinks: number;
}
export default function GameComponent({
player,
model,
maxHops,
startPage,
targetPage,
onReset,
maxTokens,
maxLinks,
}: GameComponentProps) {
const [currentPage, setCurrentPage] = useState<string>(startPage);
const [currentPageLinks, setCurrentPageLinks] = useState<string[]>([]);
const [linksLoading, setLinksLoading] = useState<boolean>(false);
const [hops, setHops] = useState<number>(0);
const [timeElapsed, setTimeElapsed] = useState<number>(0);
const [visitedNodes, setVisitedNodes] = useState<string[]>([startPage]);
const [gameStatus, setGameStatus] = useState<"playing" | "won" | "lost">(
"playing"
);
const [continuousPlay, setContinuousPlay] = useState<boolean>(true);
const [autoRunning, setAutoRunning] = useState<boolean>(true);
const [convo, setConvo] = useState<Message[]>([]);
const [expandedMessages, setExpandedMessages] = useState<
Record<number | string, boolean>
>({ game: false });
const messagesEndRef = useRef<HTMLDivElement>(null);
const {
status: modelStatus,
partialText,
inference
} = useInference({
apiKey:
window.localStorage.getItem("huggingface_access_token") || undefined,
});
const fetchCurrentPageLinks = useCallback(async () => {
setLinksLoading(true);
const response = await fetch(
`${API_BASE}/get_article_with_links/${currentPage}`
);
const data = await response.json();
setCurrentPageLinks(data.links.slice(0, maxLinks));
setLinksLoading(false);
}, [currentPage, maxLinks]);
useEffect(() => {
fetchCurrentPageLinks();
}, [fetchCurrentPageLinks]);
useEffect(() => {
if (gameStatus === "playing") {
const timer = setInterval(() => {
setTimeElapsed((prev) => prev + 1);
}, 1000);
return () => clearInterval(timer);
}
}, [gameStatus]);
// Check win condition
useEffect(() => {
if (currentPage === targetPage) {
setGameStatus("won");
} else if (hops >= maxHops) {
setGameStatus("lost");
}
}, [currentPage, targetPage, hops, maxHops]);
const handleLinkClick = (link: string) => {
if (gameStatus !== "playing") return;
setCurrentPage(link);
setHops((prev) => prev + 1);
setVisitedNodes((prev) => [...prev, link]);
};
const currentRuns = useMemo(() => {
const q3runs = qwen3Data.runs.filter((run) => run.result === "win");
if (visitedNodes.length === 0) {
return q3runs;
}
return [
{
steps: [
{
type: "start",
article: startPage,
},
...visitedNodes.map((node) => ({ type: "move", article: node })),
],
start_article: startPage,
destination_article: targetPage,
},
...q3runs,
];
}, [visitedNodes, startPage, targetPage]);
const makeModelMove = async () => {
const prompt = buildPrompt(
currentPage,
targetPage,
visitedNodes,
currentPageLinks
);
pushConvo({
role: "user",
content: prompt,
});
const {status, result: modelResponse} = await inference({
model: model,
prompt,
maxTokens: maxTokens,
});
if (status === "error") {
pushConvo({
role: "error",
content: "Error during inference: " + modelResponse,
});
setAutoRunning(false);
return;
}
pushConvo({
role: "assistant",
content: modelResponse,
});
console.log("Model response", modelResponse);
const answer = modelResponse.match(/<answer>(.*?)<\/answer>/)?.[1];
if (!answer) {
console.error("No answer found in model response");
return;
}
// try parsing the answer as an integer
const answerInt = parseInt(answer);
if (isNaN(answerInt)) {
console.error("Invalid answer found in model response");
return;
}
if (answerInt < 1 || answerInt > currentPageLinks.length) {
console.error(
"Selected link out of bounds",
answerInt,
"from ",
currentPageLinks.length,
"links"
);
return;
}
const selectedLink = currentPageLinks[answerInt - 1];
// Add a game status message after each move
pushConvo({
role: "game",
content: `Model selected link ${answerInt}: ${selectedLink}`,
metadata: {
page: currentPage,
links: [...currentPageLinks],
},
});
console.log(
"Model picked selectedLink",
selectedLink,
"from ",
currentPageLinks
);
handleLinkClick(selectedLink);
};
const handleGiveUp = () => {
setGameStatus("lost");
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs < 10 ? "0" : ""}${secs}`;
};
const pushConvo = (message: Message) => {
setConvo((prev) => [...prev, message]);
};
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
scrollToBottom();
}, [convo, partialText]);
const toggleMessageExpand = (index: number | string) => {
setExpandedMessages((prev) => ({
...prev,
[index]: !prev[index],
}));
};
// Effect for continuous play mode
useEffect(() => {
if (
continuousPlay &&
autoRunning &&
player === "model" &&
gameStatus === "playing" &&
modelStatus !== "thinking" &&
!linksLoading
) {
const timer = setTimeout(() => {
makeModelMove();
}, 1000);
return () => clearTimeout(timer);
}
}, [
continuousPlay,
autoRunning,
player,
gameStatus,
modelStatus,
linksLoading,
currentPage,
]);
// Add a result message when the game ends
useEffect(() => {
if (gameStatus !== "playing" && convo.length > 0 && convo[convo.length - 1].role !== "result") {
pushConvo({
role: "result",
content: gameStatus === "won"
? `${model} successfully navigated from ${visitedNodes[0]} to ${targetPage} in ${hops} moves!`
: `${model} failed to reach ${targetPage} within the ${maxHops} hop limit.`,
metadata: {
status: gameStatus,
path: [...visitedNodes],
},
});
}
}, [gameStatus]);
return (
<div className="grid grid-cols-1 md:grid-cols-12 gap-2 h-[calc(100vh-200px)] grid-rows-[auto_1fr_1fr]">
{/* Condensed Game Status Card */}
<Card className="p-2 col-span-12 h-12 row-start-1">
<div className="flex items-center justify-between h-full">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{currentPage}</span>
</div>
<div className="flex items-center gap-1">
<Flag className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">{targetPage}</span>
</div>
<div
className="flex items-center gap-1 cursor-help relative group"
title="Path history"
>
<Hash className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{hops} / {maxHops}
</span>
<div className="invisible absolute bottom-full left-0 mb-2 p-2 bg-popover border rounded-md shadow-md text-xs max-w-[300px] z-50 group-hover:visible whitespace-pre-wrap">
Path: {visitedNodes.join(" → ")}
</div>
</div>
<div className="flex items-center gap-1">
<Clock className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">
{formatTime(timeElapsed)}
</span>
</div>
</div>
<div className="flex items-center gap-2">
{gameStatus === "playing" && (
<>
{player === "model" && (
<>
{continuousPlay ? (
<Button
onClick={() => setAutoRunning(!autoRunning)}
size="sm"
className="h-8"
>
{autoRunning ? "Stop" : "Start"}
</Button>
) : (
<Button
onClick={makeModelMove}
disabled={modelStatus === "thinking" || linksLoading}
size="sm"
className="h-8"
>
Next Move
</Button>
)}
<div className="flex items-center gap-1 ml-1">
<Switch
id="continuous-play"
checked={continuousPlay}
onCheckedChange={(checked) => {
setContinuousPlay(checked);
if (!checked) setAutoRunning(false);
}}
disabled={
modelStatus === "thinking" ||
linksLoading ||
(continuousPlay && autoRunning)
}
/>
<Label htmlFor="continuous-play" className="text-xs">
Auto
</Label>
</div>
</>
)}
{player === "me" && (
<Button
onClick={handleGiveUp}
variant="destructive"
size="sm"
className="h-8"
>
Give Up
</Button>
)}
</>
)}
{gameStatus !== "playing" && (
<Button
onClick={onReset}
variant="outline"
size="sm"
className="h-8"
>
New Game
</Button>
)}
</div>
</div>
</Card>
{/* Links panel - larger now */}
{player === "me" && (
<Card className="p-3 md:col-span-6 h-full overflow-hidden row-span-2 row-start-2">
<h2 className="text-lg font-bold mb-2">
Available Links
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-2 text-xs text-muted-foreground cursor-help inline-flex items-center">
<Info className="h-3 w-3 mr-1" />
Why are some links missing?
</span>
</TooltipTrigger>
<TooltipContent className="max-w-[300px] p-3">
<p>
We're playing on a pruned version of Simple Wikipedia so
that every path between articles is possible. See dataset
details{" "}
<a
href="https://huggingface.co/datasets/HuggingFaceTB/simplewiki-pruned-350k"
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 underline hover:text-blue-800"
>
here
</a>
.
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</h2>
{gameStatus === "playing" ? (
<div className="flex flex-wrap content-start overflow-y-auto h-[calc(100%-2.5rem)]">
{currentPageLinks
.sort((a, b) => a.localeCompare(b))
.map((link) => (
<Button
key={link}
variant="outline"
size="sm"
className="justify-start overflow-hidden text-ellipsis whitespace-nowrap w-[calc(33.333%-0.5rem)] m-[0.25rem]"
onClick={() => handleLinkClick(link)}
>
{link}
</Button>
))}
</div>
) : (
<div className="flex items-center justify-center h-[calc(100%-2.5rem)]">
{gameStatus === "won" ? (
<div className="bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200 p-6 rounded-lg w-full shadow-sm">
<div className="flex flex-col items-center text-center">
<div className="mb-3 bg-green-100 p-3 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-green-600"><polyline points="20 6 9 17 4 12"></polyline></svg>
</div>
<h3 className="font-bold text-xl text-green-800 mb-2">
You won!
</h3>
<p className="text-green-700 mb-4">
You reached <span className="font-bold">{targetPage}</span> in <span className="font-bold">{hops}</span> {hops === 1 ? 'hop' : 'hops'} in {formatTime(timeElapsed)}
</p>
<div className="bg-white rounded-md p-4 my-3 w-full max-w-md border border-green-100">
<h4 className="font-medium text-sm text-green-800 mb-2">Your Path:</h4>
<div className="flex flex-wrap items-center gap-2 justify-center text-sm">
{visitedNodes.map((node, index) => (
<div key={`path-${index}`} className="flex items-center">
<span className="bg-green-50 px-2 py-1 rounded border border-green-100 font-medium">{node}</span>
{index < visitedNodes.length - 1 && (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mx-1 text-green-400"><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg>
)}
</div>
))}
</div>
</div>
<div className="flex gap-2 mt-2">
<Button
onClick={onReset}
variant="outline"
size="sm"
className="bg-white"
>
New Game
</Button>
</div>
</div>
</div>
) : (
<div className="bg-red-100 text-red-800 p-4 rounded-md w-full">
<h3 className="font-bold">Game Over</h3>
<p>
You didn't reach {targetPage} within {maxHops} hops.
</p>
<Button
onClick={onReset}
variant="outline"
size="sm"
className="mt-2"
>
New Game
</Button>
</div>
)}
</div>
)}
</Card>
)}
{/* Reasoning panel - spans full height on left side */}
{player === "model" && (
<Card className="p-3 md:col-span-6 h-full overflow-hidden row-span-2 row-start-2">
<h2 className="text-lg font-bold mb-2">LLM Reasoning</h2>
<div className="overflow-y-auto h-[calc(100%-2.5rem)] space-y-2 pr-2">
{convo.map((message, index) => {
const isExpanded = expandedMessages[index] || false;
if (message.role === "user" || message.role === "assistant") {
const isLongUserMessage =
message.role === "user" && message.content.length > 300;
const shouldTruncate = isLongUserMessage && !isExpanded;
return (
<div
key={`message-${index}`}
className={`p-2 rounded-lg text-xs ${
message.role === "assistant"
? "bg-blue-50 border border-blue-100"
: "bg-gray-50 border border-gray-100"
}`}
>
<div className="flex items-center gap-1 mb-1 text-xs font-medium text-muted-foreground">
{message.role === "assistant" ? (
<>
<Bot className="h-3 w-3" />
<span>Assistant</span>
</>
) : (
<>
<User className="h-3 w-3" />
<span>User</span>
</>
)}
</div>
<div>
<p className="whitespace-pre-wrap text-xs">
{shouldTruncate
? message.content.substring(0, 300) + "..."
: message.content}
</p>
{isLongUserMessage && (
<Button
variant="ghost"
size="sm"
className="mt-1 h-5 text-xs flex items-center gap-1 text-muted-foreground hover:text-foreground"
onClick={() => toggleMessageExpand(index)}
>
{isExpanded ? (
<>
<ChevronUp className="h-3 w-3" /> Show less
</>
) : (
<>
<ChevronDown className="h-3 w-3" /> Show more
</>
)}
</Button>
)}
</div>
</div>
);
} else if (message.role === "game") {
// Game status block
return (
<div
key={`game-${index}`}
className="p-2 rounded-lg bg-yellow-50 border border-yellow-100 text-xs"
>
<div className="flex items-center justify-between mb-1">
<div className="flex items-center gap-1 text-xs font-medium text-muted-foreground">
<Info className="h-3 w-3" />
<span>Game Status</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-5 text-xs flex items-center gap-1 text-muted-foreground hover:text-foreground p-0"
onClick={() => toggleMessageExpand(index)}
>
{expandedMessages[index] ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
)}
</Button>
</div>
<div>
<p className="font-medium">{message.content}</p>
{message.metadata?.page && (
<p className="mt-1">Current page: {message.metadata.page}</p>
)}
{message.metadata?.links && (
<p className="mt-1">
Available links: {message.metadata.links.length}
{!isExpanded && message.metadata.links.length > 0 && (
<span className="text-muted-foreground">
{" "}({message.metadata.links.slice(0, 3).join(", ")}
{message.metadata.links.length > 3 ? "..." : ""})
</span>
)}
</p>
)}
{isExpanded && message.metadata?.links && (
<div className="mt-2 space-y-1">
{message.metadata.links.map((link, i) => (
<div key={i} className="text-xs text-muted-foreground">
{i+1}. {link}
</div>
))}
</div>
)}
</div>
</div>
);
} else if (message.role === "result") {
// Result block
const isWon = message.metadata?.status === "won";
return (
<div
key={`result-${index}`}
className={`p-2 rounded-lg text-xs ${
isWon
? "bg-gradient-to-r from-green-50 to-emerald-50 border border-green-200"
: "bg-red-50 border border-red-100"
}`}
>
{isWon ? (
<div className="flex flex-col items-center text-center">
<div className="mb-2 bg-green-100 p-2 rounded-full">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-green-600"><polyline points="20 6 9 17 4 12"></polyline></svg>
</div>
<h3 className="font-bold text-sm text-green-800 mb-1">{message.content}</h3>
{message.metadata?.path && (
<div className="bg-white rounded p-2 my-2 w-full border border-green-100">
<h4 className="font-medium text-xs text-green-800 mb-1">Path:</h4>
<div className="flex flex-wrap items-center gap-1 justify-center text-xs">
{message.metadata.path.map((node, index) => (
<div key={`result-path-${index}`} className="flex items-center">
<span className="bg-green-50 px-1.5 py-0.5 rounded border border-green-100 font-medium">{node}</span>
{index < message.metadata.path.length - 1 && (
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mx-1 text-green-400"><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg>
)}
</div>
))}
</div>
</div>
)}
</div>
) : (
<div>
<div className="flex items-center gap-1 mb-1 text-xs font-medium text-muted-foreground">
<Flag className="h-3 w-3" />
<span>Game Over</span>
</div>
<p>{message.content}</p>
{message.metadata?.path && (
<p className="mt-1 text-xs text-muted-foreground">
Path: {message.metadata.path.join(" → ")}
</p>
)}
</div>
)}
</div>
);
} else if (message.role === "error") {
return (
<div className="p-2 rounded-lg bg-red-50 border border-red-100 text-xs">
<p>{message.content}</p>
</div>
);
}
return null;
})}
{modelStatus === "thinking" && (
<div className="p-2 rounded-lg bg-blue-50 border border-blue-100 text-xs">
<div className="flex items-center gap-1 mb-1 text-xs font-medium text-muted-foreground">
<Bot className="h-3 w-3" />
<span className="animate-pulse">Thinking...</span>
</div>
<p className="whitespace-pre-wrap text-xs">{partialText}</p>
</div>
)}
<div ref={messagesEndRef} />
</div>
</Card>
)}
{/* Wikipedia view - top right quadrant */}
<Card className="p-3 md:col-span-6 h-full overflow-hidden row-start-2">
<h2 className="text-lg font-bold mb-2">Wikipedia View</h2>
<div className="relative w-full h-[calc(100%-2.5rem)] overflow-hidden">
<iframe
style={{
transform: "scale(0.5, 0.5)",
width: "calc(100% * 2)",
height: "calc(100% * 2)",
transformOrigin: "top left",
position: "absolute",
top: 0,
left: 0,
}}
src={`https://simple.wikipedia.org/wiki/${currentPage.replace(
" ",
"_"
)}`}
className="border-0"
/>
</div>
</Card>
{/* Force directed graph - bottom right quadrant */}
<Card className="p-3 md:col-span-6 h-full overflow-hidden row-start-3">
<ForceDirectedGraph runs={currentRuns} runId={0} />
</Card>
</div>
);
}