Files
argument.es/frontend.tsx
Theo Browne 6b4ed5fcb3 minimax
2026-02-20 02:52:39 -08:00

447 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useRef } from "react";
import { createRoot } from "react-dom/client";
import "./frontend.css";
// ── Types (mirrors game.ts) ─────────────────────────────────────────────────
type Model = { id: string; name: string };
type TaskInfo = { model: Model; startedAt: number; finishedAt?: number; result?: string; error?: string };
type VoteInfo = { voter: Model; startedAt: number; finishedAt?: number; votedFor?: Model; error?: boolean };
type RoundState = {
num: number;
phase: "prompting" | "answering" | "voting" | "done";
prompter: Model;
promptTask: TaskInfo;
prompt?: string;
contestants: [Model, Model];
answerTasks: [TaskInfo, TaskInfo];
votes: VoteInfo[];
scoreA?: number;
scoreB?: number;
};
type GameState = { completed: RoundState[]; active: RoundState | null; scores: Record<string, number>; done: boolean };
type ServerMessage = { type: "state"; data: GameState; totalRounds: number };
// ── Model Assets & Colors ───────────────────────────────────────────────────
const MODEL_COLORS: Record<string, string> = {
"Gemini 3.1 Pro": "#4285F4",
"Kimi K2": "#00E599",
"DeepSeek 3.2": "#4D6BFE",
"GLM-5": "#1F63EC",
"GPT-5.2": "#10A37F",
"Opus 4.6": "#D97757",
"Sonnet 4.6": "#D97757",
"Grok 4.1": "#FFFFFF",
"MiniMax 2.5": "#FF3B30",
};
function getColor(name: string): string {
return MODEL_COLORS[name] ?? "#A1A1A1";
}
function getLogo(name: string): string | null {
if (name.includes("Gemini")) return "/assets/logos/gemini.svg";
if (name.includes("Kimi")) return "/assets/logos/kimi.svg";
if (name.includes("DeepSeek")) return "/assets/logos/deepseek.svg";
if (name.includes("GLM")) return "/assets/logos/glm.svg";
if (name.includes("GPT")) return "/assets/logos/openai.svg";
if (name.includes("Opus") || name.includes("Sonnet")) return "/assets/logos/claude.svg";
if (name.includes("Grok")) return "/assets/logos/grok.svg";
if (name.includes("MiniMax")) return "/assets/logos/minimax.svg";
return null;
}
// ── Components ──────────────────────────────────────────────────────────────
function Timer({ startedAt, finishedAt }: { startedAt: number; finishedAt?: number }) {
const [now, setNow] = useState(Date.now());
useEffect(() => {
if (finishedAt) return;
const id = setInterval(() => setNow(Date.now()), 100);
return () => clearInterval(id);
}, [finishedAt]);
const elapsed = ((finishedAt ?? now) - startedAt) / 1000;
return <span className="timer">{elapsed.toFixed(1)}s</span>;
}
function ModelName({ model, className = "", showLogo = true }: { model: Model; className?: string, showLogo?: boolean }) {
const logo = getLogo(model.name);
const color = getColor(model.name);
return (
<span className={`model-name ${className}`} style={{ color }}>
{showLogo && logo && <img src={logo} alt="" className="model-logo" />}
{model.name}
</span>
);
}
function PromptCard({ round }: { round: RoundState }) {
if (round.phase === "prompting" && !round.prompt) {
return (
<div className="prompt-card prompt-card--loading">
<div className="prompt-card__by">
<ModelName model={round.prompter} /> is cooking up a prompt
</div>
<div className="prompt-card__text prompt-card__text--loading">
<span className="dots"><span>.</span><span>.</span><span>.</span></span>
</div>
</div>
);
}
if (round.promptTask.error) {
return (
<div className="prompt-card prompt-card--error">
<div className="prompt-card__text" style={{ color: "#ef4444" }}>Prompt generation failed</div>
</div>
);
}
return (
<div className="prompt-card">
<div className="prompt-card__by">
Prompted by <ModelName model={round.prompter} />
</div>
<div className="prompt-card__text">{round.prompt}</div>
</div>
);
}
function ContestantPanel({
task,
voteCount,
totalVotes,
isWinner,
showVotes,
voters,
}: {
task: TaskInfo;
voteCount: number;
totalVotes: number;
isWinner: boolean;
showVotes: boolean;
voters: VoteInfo[];
}) {
const color = getColor(task.model.name);
const pct = totalVotes > 0 ? Math.round((voteCount / totalVotes) * 100) : 0;
return (
<div className={`contestant ${isWinner ? "contestant--winner" : ""}`} style={{ borderColor: color }}>
<div className="contestant__header">
<div className="contestant__name">
<ModelName model={task.model} />
</div>
{isWinner && <div className="contestant__winner-badge">WINNER</div>}
</div>
<div className="contestant__answer">
{!task.finishedAt ? (
<span className="contestant__thinking">
<span className="dots"><span>.</span><span>.</span><span>.</span></span>
</span>
) : task.error ? (
<span className="contestant__error"> {task.error}</span>
) : (
<span className="contestant__text">&ldquo;{task.result}&rdquo;</span>
)}
</div>
{showVotes && (
<div className="contestant__votes-container">
<div className="contestant__votes">
<div className="vote-bar">
<div className="vote-bar__fill" style={{ width: `${pct}%`, background: color }} />
</div>
<div className="vote-bar__label">
<span className="vote-bar__count" style={{ color }}>{voteCount}</span>
<span className="vote-bar__pct">{pct}%</span>
</div>
</div>
<div className="contestant__voters">
{voters.map((v, i) => (
<div key={i} className="voter-badge">
<ModelName model={v.voter} showLogo={true} />
</div>
))}
</div>
</div>
)}
</div>
);
}
function PendingVotes({ votes }: { votes: VoteInfo[] }) {
if (votes.length === 0) return null;
return (
<div className="pending-votes">
{votes.map((v, i) => (
<div key={i} className={`voter-badge ${!v.finishedAt ? 'voter-badge--pending' : 'voter-badge--error'}`}>
<ModelName model={v.voter} showLogo={true} />
{!v.finishedAt ? " deliberating…" : " abstained"}
</div>
))}
</div>
);
}
function Arena({ round, total }: { round: RoundState; total: number }) {
const [contA, contB] = round.contestants;
const showVotes = round.phase === "voting" || round.phase === "done";
const isDone = round.phase === "done";
let votesA = 0,
votesB = 0;
for (const v of round.votes) {
if (v.votedFor?.name === contA.name) votesA++;
else if (v.votedFor?.name === contB.name) votesB++;
}
const totalVotes = votesA + votesB;
const votersA = round.votes.filter(v => v.votedFor?.name === contA.name);
const votersB = round.votes.filter(v => v.votedFor?.name === contB.name);
const pendingOrAbstained = round.votes.filter(v => !v.finishedAt || v.error || !v.votedFor);
const phaseLabel =
round.phase === "prompting"
? "✍️ WRITING PROMPT"
: round.phase === "answering"
? "💭 ANSWERING"
: round.phase === "voting"
? "🗳️ JUDGES VOTING"
: "✅ ROUND COMPLETE";
return (
<div className="arena">
<div className="arena__header-row">
<div className="arena__round-badge">
ROUND {round.num} {total !== null && <span className="arena__round-of">/ {total}</span>}
</div>
<div className="arena__phase">{phaseLabel}</div>
</div>
<PromptCard round={round} />
{round.phase !== "prompting" && (
<>
<div className="showdown">
<ContestantPanel
task={round.answerTasks[0]}
voteCount={votesA}
totalVotes={totalVotes}
isWinner={isDone && votesA > votesB}
showVotes={showVotes}
voters={votersA}
/>
<ContestantPanel
task={round.answerTasks[1]}
voteCount={votesB}
totalVotes={totalVotes}
isWinner={isDone && votesB > votesA}
showVotes={showVotes}
voters={votersB}
/>
</div>
{showVotes && <PendingVotes votes={pendingOrAbstained} />}
{isDone && votesA === votesB && (
<div className="round-result">IT&rsquo;S A TIE!</div>
)}
</>
)}
</div>
);
}
function GameOver({ scores }: { scores: Record<string, number> }) {
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
const champion = sorted[0];
return (
<div className="game-over">
<div className="game-over__title">GAME OVER</div>
{champion && champion[1] > 0 && (
<div className="game-over__champion">
<div className="game-over__crown">👑</div>
<div className="game-over__name" style={{ color: getColor(champion[0]) }}>
{getLogo(champion[0]) && <img src={getLogo(champion[0])!} alt="" />}
{champion[0]}
</div>
<div className="game-over__subtitle">is the funniest AI!</div>
</div>
)}
</div>
);
}
function Sidebar({ scores, activeRound, completed }: { scores: Record<string, number>; activeRound: RoundState | null; completed: RoundState[] }) {
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
const maxScore = sorted[0]?.[1] || 1;
const competing = activeRound
? new Set([activeRound.contestants[0].name, activeRound.contestants[1].name])
: new Set<string>();
const judging = activeRound ? new Set(activeRound.votes.map((v) => v.voter.name)) : new Set<string>();
const prompting = activeRound?.prompter.name ?? null;
return (
<aside className="sidebar">
<div className="sidebar__section">
<div className="sidebar__header">STANDINGS</div>
<div className="sidebar__list">
{sorted.map(([name, score], i) => {
const pct = maxScore > 0 ? Math.round((score / maxScore) * 100) : 0;
const color = getColor(name);
const isActive = competing.has(name);
const isJudging = judging.has(name);
const isPrompting = name === prompting;
let role = "";
if (isActive) role = "⚔️";
else if (isPrompting) role = "✍️";
else if (isJudging) role = "🗳️";
return (
<div key={name} className={`standing ${isActive ? "standing--active" : ""}`}>
<div className="standing__rank">{i === 0 && score > 0 ? "👑" : `${i + 1}.`}</div>
<div className="standing__info">
<div className="standing__name-row">
<ModelName model={{id: name, name}} />
{role && <span className="standing__role">{role}</span>}
</div>
<div className="standing__bar-row">
<div className="standing__bar">
<div className="standing__bar-fill" style={{ width: `${pct}%`, background: color }} />
</div>
<span className="standing__score">{score} {score === 1 ? 'win' : 'wins'}</span>
</div>
</div>
</div>
);
})}
</div>
{activeRound && (
<div className="sidebar__legend">
<span> COMPETING</span>
<span> PROMPTING</span>
<span>🗳 JUDGING</span>
</div>
)}
</div>
<div className="sidebar__section sidebar__section--link">
<a href="/history" className="history-link">
<span>📚 View Past Games</span>
<span></span>
</a>
</div>
</aside>
);
}
function ConnectingScreen() {
return (
<div className="connecting">
<div className="connecting__logo">QUIPSLOP</div>
<div className="connecting__text">Connecting<span className="dots"><span>.</span><span>.</span><span>.</span></span></div>
</div>
);
}
// ── App ─────────────────────────────────────────────────────────────────────
function App() {
const [state, setState] = useState<GameState | null>(null);
const [totalRounds, setTotalRounds] = useState(5);
const [connected, setConnected] = useState(false);
const mainRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const wsUrl = `ws://${window.location.host}/ws`;
let ws: WebSocket;
let reconnectTimer: ReturnType<typeof setTimeout>;
function connect() {
ws = new WebSocket(wsUrl);
ws.onopen = () => setConnected(true);
ws.onclose = () => {
setConnected(false);
reconnectTimer = setTimeout(connect, 2000);
};
ws.onmessage = (e) => {
const msg: ServerMessage = JSON.parse(e.data);
if (msg.type === "state") {
setState(msg.data);
setTotalRounds(msg.totalRounds);
}
};
}
connect();
return () => {
clearTimeout(reconnectTimer);
ws?.close();
};
}, []);
if (!connected || !state) {
return <ConnectingScreen />;
}
return (
<div className="app">
<header className="header">
<h1 className="header__logo">QUIPSLOP</h1>
<p className="header__tagline">AI vs AI Comedy Showdown</p>
</header>
<div className="layout">
<main className="main" ref={mainRef}>
{(() => {
const isGeneratingNextPrompt = state.active && state.active.phase === "prompting" && !state.active.prompt;
const lastCompleted = state.completed[state.completed.length - 1];
if (isGeneratingNextPrompt && lastCompleted) {
return (
<>
<Arena round={lastCompleted} total={totalRounds} />
<div className="next-round-toast">
<ModelName model={state.active!.prompter} /> is cooking up the next prompt<span className="dots"><span>.</span><span>.</span><span>.</span></span>
</div>
</>
);
}
if (state.active) {
return <Arena round={state.active} total={totalRounds} />;
}
if (!state.active && !state.done && state.completed.length > 0) {
return <Arena round={lastCompleted} total={totalRounds} />;
}
if (!state.active && !state.done && state.completed.length === 0) {
return (
<div className="arena-waiting">
Game starting<span className="dots"><span>.</span><span>.</span><span>.</span></span>
</div>
);
}
return null;
})()}
{state.done && <GameOver scores={state.scores} />}
</main>
<Sidebar scores={state.scores} activeRound={state.active} completed={state.completed} />
</div>
</div>
);
}
// ── Mount ───────────────────────────────────────────────────────────────────
const root = createRoot(document.getElementById("root")!);
root.render(<App />);