better
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -32,3 +32,4 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||||||
|
|
||||||
# Finder (MacOS) folder config
|
# Finder (MacOS) folder config
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
*.sqlite
|
||||||
|
|||||||
32
db.ts
Normal file
32
db.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
import type { RoundState } from "./game.ts";
|
||||||
|
|
||||||
|
export const db = new Database("quipslop.sqlite", { create: true });
|
||||||
|
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS rounds (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
num INTEGER,
|
||||||
|
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
data TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
export function saveRound(round: RoundState) {
|
||||||
|
const insert = db.prepare("INSERT INTO rounds (num, data) VALUES ($num, $data)");
|
||||||
|
insert.run({ $num: round.num, $data: JSON.stringify(round) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRounds(page: number = 1, limit: number = 10) {
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
const countQuery = db.query("SELECT COUNT(*) as count FROM rounds").get() as { count: number };
|
||||||
|
const rows = db.query("SELECT data FROM rounds ORDER BY id DESC LIMIT $limit OFFSET $offset")
|
||||||
|
.all({ $limit: limit, $offset: offset }) as { data: string }[];
|
||||||
|
return {
|
||||||
|
rounds: rows.map(r => JSON.parse(r.data) as RoundState),
|
||||||
|
total: countQuery.count,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(countQuery.count / limit)
|
||||||
|
};
|
||||||
|
}
|
||||||
101
frontend.css
101
frontend.css
@@ -93,13 +93,38 @@ body {
|
|||||||
.sidebar__section {
|
.sidebar__section {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__section--standings {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar__section--link {
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding: 24px 32px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__section--history {
|
.history-link {
|
||||||
flex: 1;
|
display: flex;
|
||||||
border-top: 1px solid var(--border);
|
justify-content: space-between;
|
||||||
min-height: 0;
|
align-items: center;
|
||||||
|
padding: 16px 20px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-link:hover {
|
||||||
|
background: var(--border);
|
||||||
|
border-color: var(--border-light);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar__header {
|
.sidebar__header {
|
||||||
@@ -117,15 +142,8 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar__history-list {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 0 16px 24px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.standing {
|
.standing {
|
||||||
@@ -219,67 +237,6 @@ body {
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Past Rounds Mini ─────────────────────────────────────────── */
|
|
||||||
|
|
||||||
.past-round-mini {
|
|
||||||
background: var(--surface-2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.past-round-mini__top {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: flex-start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.past-round-mini__num {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.past-round-mini__prompt {
|
|
||||||
font-family: 'Inter', sans-serif;
|
|
||||||
font-size: 13px;
|
|
||||||
color: var(--text);
|
|
||||||
line-height: 1.4;
|
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 2;
|
|
||||||
-webkit-box-orient: vertical;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.past-round-mini__winner {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
padding-left: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.small-model-name {
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.small-model-name .model-logo {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.past-round-mini__tie {
|
|
||||||
font-family: 'JetBrains Mono', monospace;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Arena ─────────────────────────────────────────────────────── */
|
/* ── Arena ─────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
.arena {
|
.arena {
|
||||||
|
|||||||
38
frontend.tsx
38
frontend.tsx
@@ -252,28 +252,6 @@ function Arena({ round, total }: { round: RoundState; total: number }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PastRoundMini({ round }: { round: RoundState }) {
|
|
||||||
const [contA, contB] = round.contestants;
|
|
||||||
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 winner = votesA > votesB ? contA : votesB > votesA ? contB : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="past-round-mini">
|
|
||||||
<div className="past-round-mini__top">
|
|
||||||
<span className="past-round-mini__num">R{round.num}</span>
|
|
||||||
<span className="past-round-mini__prompt">"{round.prompt}"</span>
|
|
||||||
</div>
|
|
||||||
<div className="past-round-mini__winner">
|
|
||||||
{winner ? <><ModelName model={winner} showLogo={true} className="small-model-name" /> won</> : <span className="past-round-mini__tie">Tie</span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function GameOver({ scores }: { scores: Record<string, number> }) {
|
function GameOver({ scores }: { scores: Record<string, number> }) {
|
||||||
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
|
const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]);
|
||||||
const champion = sorted[0];
|
const champion = sorted[0];
|
||||||
@@ -350,16 +328,12 @@ function Sidebar({ scores, activeRound, completed }: { scores: Record<string, nu
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{completed.length > 0 && (
|
<div className="sidebar__section sidebar__section--link">
|
||||||
<div className="sidebar__section sidebar__section--history">
|
<a href="/history" className="history-link">
|
||||||
<div className="sidebar__header">PAST ROUNDS</div>
|
<span>📚 View Past Games</span>
|
||||||
<div className="sidebar__history-list">
|
<span>→</span>
|
||||||
{[...completed].reverse().map(round => (
|
</a>
|
||||||
<PastRoundMini key={round.num} round={round} />
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
5
game.ts
5
game.ts
@@ -10,7 +10,7 @@ export const MODELS = [
|
|||||||
{ id: "moonshotai/kimi-k2", name: "Kimi K2" },
|
{ id: "moonshotai/kimi-k2", name: "Kimi K2" },
|
||||||
// { id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" },
|
// { id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" },
|
||||||
{ id: "deepseek/deepseek-v3.2", name: "DeepSeek 3.2" },
|
{ id: "deepseek/deepseek-v3.2", name: "DeepSeek 3.2" },
|
||||||
{ id: "z-ai/glm-5", name: "GLM-5" },
|
// { id: "z-ai/glm-5", name: "GLM-5" },
|
||||||
{ id: "openai/gpt-5.2", name: "GPT-5.2" },
|
{ id: "openai/gpt-5.2", name: "GPT-5.2" },
|
||||||
{ id: "anthropic/claude-opus-4.6", name: "Opus 4.6" },
|
{ id: "anthropic/claude-opus-4.6", name: "Opus 4.6" },
|
||||||
{ id: "anthropic/claude-sonnet-4.6", name: "Sonnet 4.6" },
|
{ id: "anthropic/claude-sonnet-4.6", name: "Sonnet 4.6" },
|
||||||
@@ -249,6 +249,8 @@ export async function callVote(
|
|||||||
return cleaned.startsWith("A") ? "A" : "B";
|
return cleaned.startsWith("A") ? "A" : "B";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import { saveRound } from "./db.ts";
|
||||||
|
|
||||||
// ── Game loop ───────────────────────────────────────────────────────────────
|
// ── Game loop ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function runGame(
|
export async function runGame(
|
||||||
@@ -391,6 +393,7 @@ export async function runGame(
|
|||||||
await new Promise((r) => setTimeout(r, 2000));
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
|
||||||
// Archive round
|
// Archive round
|
||||||
|
saveRound(round);
|
||||||
state.completed = [...state.completed, round];
|
state.completed = [...state.completed, round];
|
||||||
state.active = null;
|
state.active = null;
|
||||||
rerender();
|
rerender();
|
||||||
|
|||||||
300
history.css
Normal file
300
history.css
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
/* ── Base ─────────────────────────────────────────────── */
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #050505;
|
||||||
|
--surface: #0a0a0a;
|
||||||
|
--surface-2: #141414;
|
||||||
|
--border: #222222;
|
||||||
|
--border-light: #333333;
|
||||||
|
--text: #ffffff;
|
||||||
|
--text-dim: #a1a1a1;
|
||||||
|
--text-muted: #555555;
|
||||||
|
--primary: #4285F4;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'Inter', -apple-system, sans-serif;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.5;
|
||||||
|
min-height: 100vh;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Header ───────────────────────────────────────────────────── */
|
||||||
|
.header {
|
||||||
|
padding: 24px 48px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__logo {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -1px;
|
||||||
|
color: var(--text);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__tagline {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__nav a {
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-decoration: none;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
transition: color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header__nav a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Main Layout ──────────────────────────────────────────────── */
|
||||||
|
.main {
|
||||||
|
max-width: 900px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 64px 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding-bottom: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading / Error ──────────────────────────────────────────── */
|
||||||
|
.loading, .error, .empty {
|
||||||
|
text-align: center;
|
||||||
|
padding: 64px 0;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error { color: #ef4444; }
|
||||||
|
|
||||||
|
/* ── History Card ─────────────────────────────────────────────── */
|
||||||
|
.history-card {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 32px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card__prompt-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card__prompter {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card__prompt {
|
||||||
|
font-family: 'DM Serif Display', serif;
|
||||||
|
font-size: 32px;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: -0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card__meta {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-card__showdown {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 24px;
|
||||||
|
margin-top: 16px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
padding-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-contestant {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--surface-2);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-contestant--winner {
|
||||||
|
border-color: var(--border-light);
|
||||||
|
background: var(--bg);
|
||||||
|
box-shadow: inset 0 0 0 1px var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-contestant__header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-contestant__winner-badge {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 2px 6px;
|
||||||
|
background: var(--text);
|
||||||
|
color: var(--bg);
|
||||||
|
border-radius: 4px;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-contestant__answer {
|
||||||
|
font-family: 'DM Serif Display', serif;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-contestant--winner .history-contestant__answer {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-contestant__votes {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px dashed var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-contestant__score {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.history-contestant__voters {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.voter-mini-logo {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Pagination ───────────────────────────────────────────────── */
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
margin-top: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination__btn {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination__btn:hover:not(:disabled) {
|
||||||
|
background: var(--surface-2);
|
||||||
|
border-color: var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination__btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination__info {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Utility ──────────────────────────────────────────────────── */
|
||||||
|
.model-name {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-family: 'Inter', sans-serif;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-logo {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.history-card__showdown {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
16
history.html
Normal file
16
history.html
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<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>
|
||||||
248
history.tsx
Normal file
248
history.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
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 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Shared UI Utils ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
|
||||||
|
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";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModelName({ model, className = "" }: { model: Model; className?: string }) {
|
||||||
|
const logo = getLogo(model.name);
|
||||||
|
const color = getColor(model.name);
|
||||||
|
return (
|
||||||
|
<span className={`model-name ${className}`} style={{ color }}>
|
||||||
|
{logo && <img src={logo} alt="" className="model-logo" />}
|
||||||
|
{model.name}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Components ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function HistoryContestant({
|
||||||
|
task,
|
||||||
|
votes,
|
||||||
|
voters
|
||||||
|
}: {
|
||||||
|
task: TaskInfo;
|
||||||
|
votes: number;
|
||||||
|
voters: Model[];
|
||||||
|
}) {
|
||||||
|
const color = getColor(task.model.name);
|
||||||
|
return (
|
||||||
|
<div className={`history-contestant`} style={{ borderColor: color }}>
|
||||||
|
<div className="history-contestant__header">
|
||||||
|
<ModelName model={task.model} />
|
||||||
|
</div>
|
||||||
|
<div className="history-contestant__answer">
|
||||||
|
“{task.result}”
|
||||||
|
</div>
|
||||||
|
<div className="history-contestant__votes">
|
||||||
|
<div className="history-contestant__score" style={{ color }}>
|
||||||
|
{votes} {votes === 1 ? 'vote' : 'votes'}
|
||||||
|
</div>
|
||||||
|
<div className="history-contestant__voters">
|
||||||
|
{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" />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HistoryCard({ round }: { round: RoundState }) {
|
||||||
|
const [contA, contB] = round.contestants;
|
||||||
|
|
||||||
|
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); }
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAWinner = votesA > votesB;
|
||||||
|
const isBWinner = votesB > votesA;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="history-card">
|
||||||
|
<div className="history-card__header">
|
||||||
|
<div className="history-card__prompt-section">
|
||||||
|
<div className="history-card__prompter">
|
||||||
|
Prompted by <ModelName model={round.prompter} />
|
||||||
|
</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__header">
|
||||||
|
<ModelName model={contA} />
|
||||||
|
{isAWinner && <div className="history-contestant__winner-badge">WINNER</div>}
|
||||||
|
</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>
|
||||||
|
<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" />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>}
|
||||||
|
</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>
|
||||||
|
<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" />)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [rounds, setRounds] = useState<RoundState[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
fetch(`/api/history?page=${page}`)
|
||||||
|
.then(res => res.json())
|
||||||
|
.then(data => {
|
||||||
|
setRounds(data.rounds);
|
||||||
|
setTotalPages(data.totalPages || 1);
|
||||||
|
setLoading(false);
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
setError(err.message);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="app">
|
||||||
|
<header className="header">
|
||||||
|
<div className="header__left">
|
||||||
|
<a href="/" className="header__logo">QUIPSLOP</a>
|
||||||
|
<span className="header__tagline">History</span>
|
||||||
|
</div>
|
||||||
|
<nav className="header__nav">
|
||||||
|
<a href="/">← Back to Game</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="main">
|
||||||
|
<div className="page-title">Past Rounds</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="loading">Loading...</div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="error">{error}</div>
|
||||||
|
) : rounds.length === 0 ? (
|
||||||
|
<div className="empty">No past rounds found.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<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)}
|
||||||
|
>
|
||||||
|
PREV
|
||||||
|
</button>
|
||||||
|
<span className="pagination__info">Page {page} of {totalPages}</span>
|
||||||
|
<button
|
||||||
|
className="pagination__btn"
|
||||||
|
disabled={page === totalPages}
|
||||||
|
onClick={() => setPage(p => p + 1)}
|
||||||
|
>
|
||||||
|
NEXT
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mount ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const root = createRoot(document.getElementById("root")!);
|
||||||
|
root.render(<App />);
|
||||||
15
server.ts
15
server.ts
@@ -1,5 +1,7 @@
|
|||||||
import type { ServerWebSocket } from "bun";
|
import type { ServerWebSocket } from "bun";
|
||||||
import index from "./index.html";
|
import indexHtml from "./index.html";
|
||||||
|
import historyHtml from "./history.html";
|
||||||
|
import { getRounds } from "./db.ts";
|
||||||
import {
|
import {
|
||||||
MODELS,
|
MODELS,
|
||||||
LOG_FILE,
|
LOG_FILE,
|
||||||
@@ -39,12 +41,13 @@ function broadcast() {
|
|||||||
|
|
||||||
// ── Server ──────────────────────────────────────────────────────────────────
|
// ── Server ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const port = parseInt(process.env.PORT ?? "3000", 10);
|
const port = parseInt(process.env.PORT ?? "5109", 10); // 5109 = SLOP
|
||||||
|
|
||||||
const server = Bun.serve({
|
const server = Bun.serve({
|
||||||
port,
|
port,
|
||||||
routes: {
|
routes: {
|
||||||
"/": index,
|
"/": indexHtml,
|
||||||
|
"/history": historyHtml,
|
||||||
},
|
},
|
||||||
fetch(req, server) {
|
fetch(req, server) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
@@ -53,6 +56,12 @@ const server = Bun.serve({
|
|||||||
const file = Bun.file(path);
|
const file = Bun.file(path);
|
||||||
return new Response(file);
|
return new Response(file);
|
||||||
}
|
}
|
||||||
|
if (url.pathname === "/api/history") {
|
||||||
|
const page = parseInt(url.searchParams.get("page") || "1", 10);
|
||||||
|
return new Response(JSON.stringify(getRounds(page)), {
|
||||||
|
headers: { "Content-Type": "application/json" }
|
||||||
|
});
|
||||||
|
}
|
||||||
if (url.pathname === "/ws") {
|
if (url.pathname === "/ws") {
|
||||||
const upgraded = server.upgrade(req);
|
const upgraded = server.upgrade(req);
|
||||||
if (!upgraded) {
|
if (!upgraded) {
|
||||||
|
|||||||
Reference in New Issue
Block a user