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