Spaces:
Runtime error
Runtime error
| "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> | |
| ); | |
| } | |