rename
This commit is contained in:
183
frontend.tsx
183
frontend.tsx
@@ -5,8 +5,20 @@ import "./frontend.css";
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
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 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";
|
||||
@@ -19,8 +31,18 @@ type RoundState = {
|
||||
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; viewerCount: number };
|
||||
type GameState = {
|
||||
completed: RoundState[];
|
||||
active: RoundState | null;
|
||||
scores: Record<string, number>;
|
||||
done: boolean;
|
||||
};
|
||||
type ServerMessage = {
|
||||
type: "state";
|
||||
data: GameState;
|
||||
totalRounds: number;
|
||||
viewerCount: number;
|
||||
};
|
||||
|
||||
// ── Model colors & logos ─────────────────────────────────────────────────────
|
||||
|
||||
@@ -46,7 +68,8 @@ function getLogo(name: string): string | null {
|
||||
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("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;
|
||||
@@ -55,14 +78,23 @@ function getLogo(name: string): string | null {
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function Dots() {
|
||||
return <span className="dots"><span>.</span><span>.</span><span>.</span></span>;
|
||||
return (
|
||||
<span className="dots">
|
||||
<span>.</span>
|
||||
<span>.</span>
|
||||
<span>.</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ModelTag({ model, small }: { model: Model; small?: boolean }) {
|
||||
const logo = getLogo(model.name);
|
||||
const color = getColor(model.name);
|
||||
return (
|
||||
<span className={`model-tag ${small ? "model-tag--sm" : ""}`} style={{ color }}>
|
||||
<span
|
||||
className={`model-tag ${small ? "model-tag--sm" : ""}`}
|
||||
style={{ color }}
|
||||
>
|
||||
{logo && <img src={logo} alt="" className="model-tag__logo" />}
|
||||
{model.name}
|
||||
</span>
|
||||
@@ -76,9 +108,12 @@ function PromptCard({ round }: { round: RoundState }) {
|
||||
return (
|
||||
<div className="prompt">
|
||||
<div className="prompt__by">
|
||||
<ModelTag model={round.prompter} small /> is writing a prompt<Dots />
|
||||
<ModelTag model={round.prompter} small /> is writing a prompt
|
||||
<Dots />
|
||||
</div>
|
||||
<div className="prompt__text prompt__text--loading">
|
||||
<Dots />
|
||||
</div>
|
||||
<div className="prompt__text prompt__text--loading"><Dots /></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -86,14 +121,18 @@ function PromptCard({ round }: { round: RoundState }) {
|
||||
if (round.promptTask.error) {
|
||||
return (
|
||||
<div className="prompt">
|
||||
<div className="prompt__text prompt__text--error">Prompt generation failed</div>
|
||||
<div className="prompt__text prompt__text--error">
|
||||
Prompt generation failed
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="prompt">
|
||||
<div className="prompt__by">Prompted by <ModelTag model={round.prompter} small /></div>
|
||||
<div className="prompt__by">
|
||||
Prompted by <ModelTag model={round.prompter} small />
|
||||
</div>
|
||||
<div className="prompt__text">{round.prompt}</div>
|
||||
</div>
|
||||
);
|
||||
@@ -102,7 +141,12 @@ function PromptCard({ round }: { round: RoundState }) {
|
||||
// ── Contestant ───────────────────────────────────────────────────────────────
|
||||
|
||||
function ContestantCard({
|
||||
task, voteCount, totalVotes, isWinner, showVotes, voters,
|
||||
task,
|
||||
voteCount,
|
||||
totalVotes,
|
||||
isWinner,
|
||||
showVotes,
|
||||
voters,
|
||||
}: {
|
||||
task: TaskInfo;
|
||||
voteCount: number;
|
||||
@@ -126,7 +170,9 @@ function ContestantCard({
|
||||
|
||||
<div className="contestant__body">
|
||||
{!task.finishedAt ? (
|
||||
<p className="answer answer--loading"><Dots /></p>
|
||||
<p className="answer answer--loading">
|
||||
<Dots />
|
||||
</p>
|
||||
) : task.error ? (
|
||||
<p className="answer answer--error">{task.error}</p>
|
||||
) : (
|
||||
@@ -137,18 +183,36 @@ function ContestantCard({
|
||||
{showVotes && (
|
||||
<div className="contestant__foot">
|
||||
<div className="vote-bar">
|
||||
<div className="vote-bar__fill" style={{ width: `${pct}%`, background: color }} />
|
||||
<div
|
||||
className="vote-bar__fill"
|
||||
style={{ width: `${pct}%`, background: color }}
|
||||
/>
|
||||
</div>
|
||||
<div className="vote-meta">
|
||||
<span className="vote-meta__count" style={{ color }}>{voteCount}</span>
|
||||
<span className="vote-meta__label">vote{voteCount !== 1 ? "s" : ""}</span>
|
||||
<span className="vote-meta__count" style={{ color }}>
|
||||
{voteCount}
|
||||
</span>
|
||||
<span className="vote-meta__label">
|
||||
vote{voteCount !== 1 ? "s" : ""}
|
||||
</span>
|
||||
<span className="vote-meta__dots">
|
||||
{voters.map((v, i) => {
|
||||
const logo = getLogo(v.voter.name);
|
||||
return logo ? (
|
||||
<img key={i} src={logo} alt={v.voter.name} title={v.voter.name} className="voter-dot" />
|
||||
<img
|
||||
key={i}
|
||||
src={logo}
|
||||
alt={v.voter.name}
|
||||
title={v.voter.name}
|
||||
className="voter-dot"
|
||||
/>
|
||||
) : (
|
||||
<span key={i} className="voter-dot voter-dot--letter" style={{ color: getColor(v.voter.name) }} title={v.voter.name}>
|
||||
<span
|
||||
key={i}
|
||||
className="voter-dot voter-dot--letter"
|
||||
style={{ color: getColor(v.voter.name) }}
|
||||
title={v.voter.name}
|
||||
>
|
||||
{v.voter.name[0]}
|
||||
</span>
|
||||
);
|
||||
@@ -168,26 +232,31 @@ function Arena({ round, total }: { round: RoundState; total: number | null }) {
|
||||
const showVotes = round.phase === "voting" || round.phase === "done";
|
||||
const isDone = round.phase === "done";
|
||||
|
||||
let votesA = 0, votesB = 0;
|
||||
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 votersA = round.votes.filter((v) => v.votedFor?.name === contA.name);
|
||||
const votersB = round.votes.filter((v) => v.votedFor?.name === contB.name);
|
||||
|
||||
const phaseText =
|
||||
round.phase === "prompting" ? "Writing prompt" :
|
||||
round.phase === "answering" ? "Answering" :
|
||||
round.phase === "voting" ? "Judges voting" :
|
||||
"Complete";
|
||||
round.phase === "prompting"
|
||||
? "Writing prompt"
|
||||
: round.phase === "answering"
|
||||
? "Answering"
|
||||
: round.phase === "voting"
|
||||
? "Judges voting"
|
||||
: "Complete";
|
||||
|
||||
return (
|
||||
<div className="arena">
|
||||
<div className="arena__meta">
|
||||
<span className="arena__round">
|
||||
Round {round.num}{total ? <span className="dim">/{total}</span> : null}
|
||||
Round {round.num}
|
||||
{total ? <span className="dim">/{total}</span> : null}
|
||||
</span>
|
||||
<span className="arena__phase">{phaseText}</span>
|
||||
</div>
|
||||
@@ -234,7 +303,10 @@ function GameOver({ scores }: { scores: Record<string, number> }) {
|
||||
{champion && champion[1] > 0 && (
|
||||
<div className="game-over__winner">
|
||||
<span className="game-over__crown">👑</span>
|
||||
<span className="game-over__name" style={{ color: getColor(champion[0]) }}>
|
||||
<span
|
||||
className="game-over__name"
|
||||
style={{ color: getColor(champion[0]) }}
|
||||
>
|
||||
{getLogo(champion[0]) && <img src={getLogo(champion[0])!} alt="" />}
|
||||
{champion[0]}
|
||||
</span>
|
||||
@@ -247,19 +319,30 @@ function GameOver({ scores }: { scores: Record<string, number> }) {
|
||||
|
||||
// ── Standings ────────────────────────────────────────────────────────────────
|
||||
|
||||
function Standings({ scores, activeRound }: { scores: Record<string, number>; activeRound: RoundState | null }) {
|
||||
function Standings({
|
||||
scores,
|
||||
activeRound,
|
||||
}: {
|
||||
scores: Record<string, number>;
|
||||
activeRound: RoundState | null;
|
||||
}) {
|
||||
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([
|
||||
activeRound.contestants[0].name,
|
||||
activeRound.contestants[1].name,
|
||||
])
|
||||
: new Set<string>();
|
||||
|
||||
return (
|
||||
<aside className="standings">
|
||||
<div className="standings__head">
|
||||
<span className="standings__title">Standings</span>
|
||||
<a href="/history" className="standings__link">History →</a>
|
||||
<a href="/history" className="standings__link">
|
||||
History →
|
||||
</a>
|
||||
</div>
|
||||
<div className="standings__list">
|
||||
{sorted.map(([name, score], i) => {
|
||||
@@ -267,11 +350,19 @@ function Standings({ scores, activeRound }: { scores: Record<string, number>; ac
|
||||
const color = getColor(name);
|
||||
const active = competing.has(name);
|
||||
return (
|
||||
<div key={name} className={`standing ${active ? "standing--active" : ""}`}>
|
||||
<span className="standing__rank">{i === 0 && score > 0 ? "👑" : i + 1}</span>
|
||||
<div
|
||||
key={name}
|
||||
className={`standing ${active ? "standing--active" : ""}`}
|
||||
>
|
||||
<span className="standing__rank">
|
||||
{i === 0 && score > 0 ? "👑" : i + 1}
|
||||
</span>
|
||||
<ModelTag model={{ id: name, name }} small />
|
||||
<div className="standing__bar">
|
||||
<div className="standing__fill" style={{ width: `${pct}%`, background: color }} />
|
||||
<div
|
||||
className="standing__fill"
|
||||
style={{ width: `${pct}%`, background: color }}
|
||||
/>
|
||||
</div>
|
||||
<span className="standing__score">{score}</span>
|
||||
</div>
|
||||
@@ -287,8 +378,13 @@ function Standings({ scores, activeRound }: { scores: Record<string, number>; ac
|
||||
function ConnectingScreen() {
|
||||
return (
|
||||
<div className="connecting">
|
||||
<div className="connecting__logo"><img src="/assets/logo.svg" alt="Qwipslop" /></div>
|
||||
<div className="connecting__sub">Connecting<Dots /></div>
|
||||
<div className="connecting__logo">
|
||||
<img src="/assets/logo.svg" alt="quipslop" />
|
||||
</div>
|
||||
<div className="connecting__sub">
|
||||
Connecting
|
||||
<Dots />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -334,8 +430,10 @@ function App() {
|
||||
if (!connected || !state) return <ConnectingScreen />;
|
||||
|
||||
const lastCompleted = state.completed[state.completed.length - 1];
|
||||
const isNextPrompting = state.active?.phase === "prompting" && !state.active.prompt;
|
||||
const displayRound = isNextPrompting && lastCompleted ? lastCompleted : state.active;
|
||||
const isNextPrompting =
|
||||
state.active?.phase === "prompting" && !state.active.prompt;
|
||||
const displayRound =
|
||||
isNextPrompting && lastCompleted ? lastCompleted : state.active;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
@@ -343,7 +441,7 @@ function App() {
|
||||
<main className="main">
|
||||
<header className="header">
|
||||
<a href="/" className="logo">
|
||||
<img src="/assets/logo.svg" alt="Qwipslop" />
|
||||
<img src="/assets/logo.svg" alt="quipslop" />
|
||||
</a>
|
||||
<div className="viewer-pill" aria-live="polite">
|
||||
<span className="viewer-pill__dot" />
|
||||
@@ -356,12 +454,17 @@ function App() {
|
||||
) : displayRound ? (
|
||||
<Arena round={displayRound} total={totalRounds} />
|
||||
) : (
|
||||
<div className="waiting">Starting<Dots /></div>
|
||||
<div className="waiting">
|
||||
Starting
|
||||
<Dots />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNextPrompting && lastCompleted && (
|
||||
<div className="next-toast">
|
||||
<ModelTag model={state.active!.prompter} small /> is writing the next prompt<Dots />
|
||||
<ModelTag model={state.active!.prompter} small /> is writing the
|
||||
next prompt
|
||||
<Dots />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
33
history.html
33
history.html
@@ -1,16 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Qwipslop History</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Inter:wght@400;500;600;700;900&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="./history.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./history.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>quipslop History</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Inter:wght@400;500;600;700;900&family=JetBrains+Mono:wght@400;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="./history.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./history.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
180
history.tsx
180
history.tsx
@@ -5,8 +5,20 @@ import "./history.css";
|
||||
// ── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
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 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";
|
||||
@@ -44,13 +56,20 @@ function getLogo(name: string): string | null {
|
||||
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("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;
|
||||
}
|
||||
|
||||
function ModelName({ model, className = "" }: { model: Model; className?: string }) {
|
||||
function ModelName({
|
||||
model,
|
||||
className = "",
|
||||
}: {
|
||||
model: Model;
|
||||
className?: string;
|
||||
}) {
|
||||
const logo = getLogo(model.name);
|
||||
const color = getColor(model.name);
|
||||
return (
|
||||
@@ -63,12 +82,12 @@ function ModelName({ model, className = "" }: { model: Model; className?: string
|
||||
|
||||
// ── Components ──────────────────────────────────────────────────────────────
|
||||
|
||||
function HistoryContestant({
|
||||
task,
|
||||
votes,
|
||||
voters
|
||||
}: {
|
||||
task: TaskInfo;
|
||||
function HistoryContestant({
|
||||
task,
|
||||
votes,
|
||||
voters,
|
||||
}: {
|
||||
task: TaskInfo;
|
||||
votes: number;
|
||||
voters: Model[];
|
||||
}) {
|
||||
@@ -83,13 +102,21 @@ function HistoryContestant({
|
||||
</div>
|
||||
<div className="history-contestant__votes">
|
||||
<div className="history-contestant__score" style={{ color }}>
|
||||
{votes} {votes === 1 ? 'vote' : 'votes'}
|
||||
{votes} {votes === 1 ? "vote" : "votes"}
|
||||
</div>
|
||||
<div className="history-contestant__voters">
|
||||
{voters.map(v => {
|
||||
{voters.map((v) => {
|
||||
const logo = getLogo(v.name);
|
||||
if (!logo) return null;
|
||||
return <img key={v.name} src={logo} title={v.name} alt={v.name} className="voter-mini-logo" />;
|
||||
return (
|
||||
<img
|
||||
key={v.name}
|
||||
src={logo}
|
||||
title={v.name}
|
||||
alt={v.name}
|
||||
className="voter-mini-logo"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
@@ -99,14 +126,20 @@ function HistoryContestant({
|
||||
|
||||
function HistoryCard({ round }: { round: RoundState }) {
|
||||
const [contA, contB] = round.contestants;
|
||||
|
||||
let votesA = 0, votesB = 0;
|
||||
|
||||
let votesA = 0,
|
||||
votesB = 0;
|
||||
const votersA: Model[] = [];
|
||||
const votersB: Model[] = [];
|
||||
|
||||
|
||||
for (const v of round.votes) {
|
||||
if (v.votedFor?.name === contA.name) { votesA++; votersA.push(v.voter); }
|
||||
else if (v.votedFor?.name === contB.name) { votesB++; votersB.push(v.voter); }
|
||||
if (v.votedFor?.name === contA.name) {
|
||||
votesA++;
|
||||
votersA.push(v.voter);
|
||||
} else if (v.votedFor?.name === contB.name) {
|
||||
votesB++;
|
||||
votersB.push(v.voter);
|
||||
}
|
||||
}
|
||||
|
||||
const isAWinner = votesA > votesB;
|
||||
@@ -119,44 +152,80 @@ function HistoryCard({ round }: { round: RoundState }) {
|
||||
<div className="history-card__prompter">
|
||||
Prompted by <ModelName model={round.prompter} />
|
||||
</div>
|
||||
<div className="history-card__prompt">
|
||||
{round.prompt}
|
||||
</div>
|
||||
<div className="history-card__prompt">{round.prompt}</div>
|
||||
</div>
|
||||
<div className="history-card__meta">
|
||||
<div>R{round.num}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="history-card__showdown">
|
||||
<div className={`history-contestant ${isAWinner ? "history-contestant--winner" : ""}`}>
|
||||
<div
|
||||
className={`history-contestant ${isAWinner ? "history-contestant--winner" : ""}`}
|
||||
>
|
||||
<div className="history-contestant__header">
|
||||
<ModelName model={contA} />
|
||||
{isAWinner && <div className="history-contestant__winner-badge">WINNER</div>}
|
||||
{isAWinner && (
|
||||
<div className="history-contestant__winner-badge">WINNER</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="history-contestant__answer">
|
||||
“{round.answerTasks[0].result}”
|
||||
</div>
|
||||
<div className="history-contestant__answer">“{round.answerTasks[0].result}”</div>
|
||||
<div className="history-contestant__votes">
|
||||
<div className="history-contestant__score" style={{ color: getColor(contA.name) }}>
|
||||
{votesA} {votesA === 1 ? 'vote' : 'votes'}
|
||||
<div
|
||||
className="history-contestant__score"
|
||||
style={{ color: getColor(contA.name) }}
|
||||
>
|
||||
{votesA} {votesA === 1 ? "vote" : "votes"}
|
||||
</div>
|
||||
<div className="history-contestant__voters">
|
||||
{votersA.map(v => getLogo(v.name) && <img key={v.name} src={getLogo(v.name)!} title={v.name} className="voter-mini-logo" />)}
|
||||
{votersA.map(
|
||||
(v) =>
|
||||
getLogo(v.name) && (
|
||||
<img
|
||||
key={v.name}
|
||||
src={getLogo(v.name)!}
|
||||
title={v.name}
|
||||
className="voter-mini-logo"
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`history-contestant ${isBWinner ? "history-contestant--winner" : ""}`}>
|
||||
<div
|
||||
className={`history-contestant ${isBWinner ? "history-contestant--winner" : ""}`}
|
||||
>
|
||||
<div className="history-contestant__header">
|
||||
<ModelName model={contB} />
|
||||
{isBWinner && <div className="history-contestant__winner-badge">WINNER</div>}
|
||||
{isBWinner && (
|
||||
<div className="history-contestant__winner-badge">WINNER</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="history-contestant__answer">
|
||||
“{round.answerTasks[1].result}”
|
||||
</div>
|
||||
<div className="history-contestant__answer">“{round.answerTasks[1].result}”</div>
|
||||
<div className="history-contestant__votes">
|
||||
<div className="history-contestant__score" style={{ color: getColor(contB.name) }}>
|
||||
{votesB} {votesB === 1 ? 'vote' : 'votes'}
|
||||
<div
|
||||
className="history-contestant__score"
|
||||
style={{ color: getColor(contB.name) }}
|
||||
>
|
||||
{votesB} {votesB === 1 ? "vote" : "votes"}
|
||||
</div>
|
||||
<div className="history-contestant__voters">
|
||||
{votersB.map(v => getLogo(v.name) && <img key={v.name} src={getLogo(v.name)!} title={v.name} className="voter-mini-logo" />)}
|
||||
{votersB.map(
|
||||
(v) =>
|
||||
getLogo(v.name) && (
|
||||
<img
|
||||
key={v.name}
|
||||
src={getLogo(v.name)!}
|
||||
title={v.name}
|
||||
className="voter-mini-logo"
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -177,13 +246,13 @@ function App() {
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch(`/api/history?page=${page}`)
|
||||
.then(res => res.json())
|
||||
.then(data => {
|
||||
.then((res) => res.json())
|
||||
.then((data) => {
|
||||
setRounds(data.rounds);
|
||||
setTotalPages(data.totalPages || 1);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(err => {
|
||||
.catch((err) => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
@@ -191,11 +260,15 @@ function App() {
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<a href="/" className="main-logo">QWIPSLOP</a>
|
||||
<a href="/" className="main-logo">
|
||||
quipslop
|
||||
</a>
|
||||
<main className="main">
|
||||
<div className="page-header">
|
||||
<div className="page-title">Past Rounds</div>
|
||||
<a href="/" className="back-link">← Back to Game</a>
|
||||
<a href="/" className="back-link">
|
||||
← Back to Game
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
@@ -206,26 +279,31 @@ function App() {
|
||||
<div className="empty">No past rounds found.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="history-list" style={{ display: 'flex', flexDirection: 'column', gap: '32px' }}>
|
||||
{rounds.map(r => (
|
||||
<div
|
||||
className="history-list"
|
||||
style={{ display: "flex", flexDirection: "column", gap: "32px" }}
|
||||
>
|
||||
{rounds.map((r) => (
|
||||
<HistoryCard key={r.num + "-" + Math.random()} round={r} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="pagination">
|
||||
<button
|
||||
className="pagination__btn"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
<button
|
||||
className="pagination__btn"
|
||||
disabled={page === 1}
|
||||
onClick={() => setPage((p) => p - 1)}
|
||||
>
|
||||
PREV
|
||||
</button>
|
||||
<span className="pagination__info">Page {page} of {totalPages}</span>
|
||||
<button
|
||||
className="pagination__btn"
|
||||
disabled={page === totalPages}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
<span className="pagination__info">
|
||||
Page {page} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
className="pagination__btn"
|
||||
disabled={page === totalPages}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
NEXT
|
||||
</button>
|
||||
@@ -241,4 +319,4 @@ function App() {
|
||||
// ── Mount ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const root = createRoot(document.getElementById("root")!);
|
||||
root.render(<App />);
|
||||
root.render(<App />);
|
||||
|
||||
33
index.html
33
index.html
@@ -1,17 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Qwipslop</title>
|
||||
<link rel="icon" type="image/svg+xml" href="./public/assets/logo.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Inter:wght@400;500;600;700;900&family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="./frontend.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./frontend.tsx"></script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>quipslop</title>
|
||||
<link rel="icon" type="image/svg+xml" href="./public/assets/logo.svg" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Inter:wght@400;500;600;700;900&family=JetBrains+Mono:wght@400;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<link rel="stylesheet" href="./frontend.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./frontend.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
15
quipslop.tsx
15
quipslop.tsx
@@ -50,7 +50,7 @@ function RoundView({ round, total }: { round: RoundState; total: number }) {
|
||||
{/* Header */}
|
||||
<Box>
|
||||
<Text bold backgroundColor="blueBright" color="black">
|
||||
{` ROUND ${round.num}${total !== Infinity && total !== null ? `/${total}` : ''} `}
|
||||
{` ROUND ${round.num}${total !== Infinity && total !== null ? `/${total}` : ""} `}
|
||||
</Text>
|
||||
</Box>
|
||||
<Text dimColor>{"─".repeat(50)}</Text>
|
||||
@@ -196,7 +196,9 @@ function Scoreboard({ scores }: { scores: Record<string, number> }) {
|
||||
{name.padEnd(NAME_PAD)}
|
||||
</Text>
|
||||
<Text color={MODEL_COLORS[name]}>{bar}</Text>
|
||||
<Text bold>{score} {score === 1 ? 'win' : 'wins'}</Text>
|
||||
<Text bold>
|
||||
{score} {score === 1 ? "win" : "wins"}
|
||||
</Text>
|
||||
<Text>{medal}</Text>
|
||||
</Box>
|
||||
);
|
||||
@@ -260,7 +262,8 @@ function Game({ runs }: { runs: number }) {
|
||||
|
||||
const runsArg = process.argv.find((a) => a.startsWith("runs="));
|
||||
const runsVal = runsArg ? runsArg.split("=")[1] : "infinite";
|
||||
const runs = runsVal === "infinite" ? Infinity : parseInt(runsVal || "infinite", 10);
|
||||
const runs =
|
||||
runsVal === "infinite" ? Infinity : parseInt(runsVal || "infinite", 10);
|
||||
|
||||
if (!process.env.OPENROUTER_API_KEY) {
|
||||
console.error("Error: Set OPENROUTER_API_KEY environment variable");
|
||||
@@ -272,10 +275,8 @@ log("INFO", "startup", `Game starting: ${runs} rounds`, {
|
||||
});
|
||||
|
||||
console.log(
|
||||
`\n\x1b[1m\x1b[45m\x1b[30m QWIPSLOP \x1b[0m \x1b[2m${runs} rounds\x1b[0m`,
|
||||
);
|
||||
console.log(
|
||||
`\x1b[2mModels: ${MODELS.map((m) => m.name).join(", ")}\x1b[0m\n`,
|
||||
`\n\x1b[1m\x1b[45m\x1b[30m quipslop \x1b[0m \x1b[2m${runs} rounds\x1b[0m`,
|
||||
);
|
||||
console.log(`\x1b[2mModels: ${MODELS.map((m) => m.name).join(", ")}\x1b[0m\n`);
|
||||
|
||||
render(<Game runs={runs} />);
|
||||
|
||||
80
server.ts
80
server.ts
@@ -16,7 +16,8 @@ import {
|
||||
|
||||
const runsArg = process.argv.find((a) => a.startsWith("runs="));
|
||||
const runsStr = runsArg ? runsArg.split("=")[1] : "infinite";
|
||||
const runs = runsStr === "infinite" ? Infinity : parseInt(runsStr || "infinite", 10);
|
||||
const runs =
|
||||
runsStr === "infinite" ? Infinity : parseInt(runsStr || "infinite", 10);
|
||||
|
||||
if (!process.env.OPENROUTER_API_KEY) {
|
||||
console.error("Error: Set OPENROUTER_API_KEY environment variable");
|
||||
@@ -31,9 +32,11 @@ if (allRounds.length > 0) {
|
||||
for (const round of allRounds) {
|
||||
if (round.scoreA !== undefined && round.scoreB !== undefined) {
|
||||
if (round.scoreA > round.scoreB) {
|
||||
initialScores[round.contestants[0].name] = (initialScores[round.contestants[0].name] || 0) + 1;
|
||||
initialScores[round.contestants[0].name] =
|
||||
(initialScores[round.contestants[0].name] || 0) + 1;
|
||||
} else if (round.scoreB > round.scoreA) {
|
||||
initialScores[round.contestants[1].name] = (initialScores[round.contestants[1].name] || 0) + 1;
|
||||
initialScores[round.contestants[1].name] =
|
||||
(initialScores[round.contestants[1].name] || 0) + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,15 +59,33 @@ const gameState: GameState = {
|
||||
type WsData = { ip: string };
|
||||
|
||||
const WINDOW_MS = 60_000;
|
||||
const WS_UPGRADE_LIMIT_PER_MIN = parsePositiveInt(process.env.WS_UPGRADE_LIMIT_PER_MIN, 20);
|
||||
const HISTORY_LIMIT_PER_MIN = parsePositiveInt(process.env.HISTORY_LIMIT_PER_MIN, 120);
|
||||
const ADMIN_LIMIT_PER_MIN = parsePositiveInt(process.env.ADMIN_LIMIT_PER_MIN, 10);
|
||||
const WS_UPGRADE_LIMIT_PER_MIN = parsePositiveInt(
|
||||
process.env.WS_UPGRADE_LIMIT_PER_MIN,
|
||||
20,
|
||||
);
|
||||
const HISTORY_LIMIT_PER_MIN = parsePositiveInt(
|
||||
process.env.HISTORY_LIMIT_PER_MIN,
|
||||
120,
|
||||
);
|
||||
const ADMIN_LIMIT_PER_MIN = parsePositiveInt(
|
||||
process.env.ADMIN_LIMIT_PER_MIN,
|
||||
10,
|
||||
);
|
||||
const MAX_WS_GLOBAL = parsePositiveInt(process.env.MAX_WS_GLOBAL, 2_000);
|
||||
const MAX_WS_PER_IP = parsePositiveInt(process.env.MAX_WS_PER_IP, 8);
|
||||
const MAX_HISTORY_PAGE = parsePositiveInt(process.env.MAX_HISTORY_PAGE, 100_000);
|
||||
const MAX_HISTORY_PAGE = parsePositiveInt(
|
||||
process.env.MAX_HISTORY_PAGE,
|
||||
100_000,
|
||||
);
|
||||
const MAX_HISTORY_LIMIT = parsePositiveInt(process.env.MAX_HISTORY_LIMIT, 50);
|
||||
const HISTORY_CACHE_TTL_MS = parsePositiveInt(process.env.HISTORY_CACHE_TTL_MS, 5_000);
|
||||
const MAX_HISTORY_CACHE_KEYS = parsePositiveInt(process.env.MAX_HISTORY_CACHE_KEYS, 500);
|
||||
const HISTORY_CACHE_TTL_MS = parsePositiveInt(
|
||||
process.env.HISTORY_CACHE_TTL_MS,
|
||||
5_000,
|
||||
);
|
||||
const MAX_HISTORY_CACHE_KEYS = parsePositiveInt(
|
||||
process.env.MAX_HISTORY_CACHE_KEYS,
|
||||
500,
|
||||
);
|
||||
|
||||
const requestWindows = new Map<string, number[]>();
|
||||
const wsByIp = new Map<string, number>();
|
||||
@@ -85,7 +106,9 @@ function isRateLimited(key: string, limit: number, windowMs: number): boolean {
|
||||
const now = Date.now();
|
||||
if (now - lastRateWindowSweep >= windowMs) {
|
||||
for (const [bucketKey, timestamps] of requestWindows) {
|
||||
const recent = timestamps.filter((timestamp) => now - timestamp <= windowMs);
|
||||
const recent = timestamps.filter(
|
||||
(timestamp) => now - timestamp <= windowMs,
|
||||
);
|
||||
if (recent.length === 0) {
|
||||
requestWindows.delete(bucketKey);
|
||||
} else {
|
||||
@@ -116,7 +139,8 @@ function secureCompare(a: string, b: string): boolean {
|
||||
function isAdminAuthorized(req: Request, url: URL): boolean {
|
||||
const expected = process.env.ADMIN_SECRET;
|
||||
if (!expected) return false;
|
||||
const provided = req.headers.get("x-admin-secret") ?? url.searchParams.get("secret") ?? "";
|
||||
const provided =
|
||||
req.headers.get("x-admin-secret") ?? url.searchParams.get("secret") ?? "";
|
||||
if (!provided) return false;
|
||||
return secureCompare(provided, expected);
|
||||
}
|
||||
@@ -203,9 +227,12 @@ const server = Bun.serve<WsData>({
|
||||
gameState.isPaused = false;
|
||||
}
|
||||
broadcast();
|
||||
return new Response(url.pathname === "/api/pause" ? "Paused" : "Resumed", {
|
||||
status: 200,
|
||||
});
|
||||
return new Response(
|
||||
url.pathname === "/api/pause" ? "Paused" : "Resumed",
|
||||
{
|
||||
status: 200,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/history") {
|
||||
@@ -214,8 +241,12 @@ const server = Bun.serve<WsData>({
|
||||
}
|
||||
const rawPage = parseInt(url.searchParams.get("page") || "1", 10);
|
||||
const rawLimit = parseInt(url.searchParams.get("limit") || "10", 10);
|
||||
const page = Number.isFinite(rawPage) ? Math.min(Math.max(rawPage, 1), MAX_HISTORY_PAGE) : 1;
|
||||
const limit = Number.isFinite(rawLimit) ? Math.min(Math.max(rawLimit, 1), MAX_HISTORY_LIMIT) : 10;
|
||||
const page = Number.isFinite(rawPage)
|
||||
? Math.min(Math.max(rawPage, 1), MAX_HISTORY_PAGE)
|
||||
: 1;
|
||||
const limit = Number.isFinite(rawLimit)
|
||||
? Math.min(Math.max(rawLimit, 1), MAX_HISTORY_LIMIT)
|
||||
: 10;
|
||||
const cacheKey = `${page}:${limit}`;
|
||||
const now = Date.now();
|
||||
if (now - lastHistoryCacheSweep >= HISTORY_CACHE_TTL_MS) {
|
||||
@@ -253,7 +284,9 @@ const server = Bun.serve<WsData>({
|
||||
headers: { Allow: "GET" },
|
||||
});
|
||||
}
|
||||
if (isRateLimited(`ws-upgrade:${ip}`, WS_UPGRADE_LIMIT_PER_MIN, WINDOW_MS)) {
|
||||
if (
|
||||
isRateLimited(`ws-upgrade:${ip}`, WS_UPGRADE_LIMIT_PER_MIN, WINDOW_MS)
|
||||
) {
|
||||
return new Response("Too Many Requests", { status: 429 });
|
||||
}
|
||||
if (clients.size >= MAX_WS_GLOBAL) {
|
||||
@@ -289,10 +322,13 @@ const server = Bun.serve<WsData>({
|
||||
broadcast();
|
||||
},
|
||||
},
|
||||
development: process.env.NODE_ENV === "production" ? false : {
|
||||
hmr: true,
|
||||
console: true,
|
||||
},
|
||||
development:
|
||||
process.env.NODE_ENV === "production"
|
||||
? false
|
||||
: {
|
||||
hmr: true,
|
||||
console: true,
|
||||
},
|
||||
error(error) {
|
||||
log("ERROR", "server", "Unhandled fetch/websocket error", {
|
||||
message: error.message,
|
||||
@@ -302,7 +338,7 @@ const server = Bun.serve<WsData>({
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`\n🎮 Qwipslop Web — http://localhost:${server.port}`);
|
||||
console.log(`\n🎮 quipslop Web — http://localhost:${server.port}`);
|
||||
console.log(`📡 WebSocket — ws://localhost:${server.port}/ws`);
|
||||
console.log(`🎯 ${runs} rounds with ${MODELS.length} models\n`);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user