For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Build a Val Town app that lets users compare LLM outputs side-by-side across multiple models with multi-turn conversation support.
Architecture: Hono backend serves a React 18 frontend as static files. SQLite stores conversations/messages/responses. Backend proxies chat requests to OpenCode Zen, multiplexing multiple model responses into a single SSE stream. Simple password auth with session cookies.
Tech Stack: Deno (Val Town), Hono, React 18.2.0 (esm.sh), Tailwind (twind CDN), Val Town SQLite, OpenCode Zen API
Files:
shared/types.tsStep 1: Create shared type definitions
// shared/types.ts
export interface ModelInfo {
id: string;
name: string;
family: string;
reasoning: boolean;
cost: { input: number; output: number };
limit: { context: number; output: number };
}
export interface Conversation {
id: string;
title: string;
model_ids: string[];
created_at: string;
updated_at: string;
}
export interface Message {
id: string;
conversation_id: string;
role: "user";
content: string;
created_at: string;
}
export interface Response {
id: string;
message_id: string;
model_id: string;
content: string;
created_at: string;
}
export interface ConversationDetail extends Conversation {
messages: (Message & { responses: Response[] })[];
}
export interface SSEChunk {
model_id: string;
content: string;
}
export interface SSEDone {
model_id: string;
}
export interface SSEError {
model_id: string;
error: string;
}
Step 2: Commit
git add shared/types.ts git commit -m "feat: add shared type definitions"
Files:
backend/database/migrations.tsContext: Use https://esm.town/v/std/sqlite/main.ts for val-scoped SQLite.
See docs/vt-sqlite.md for API reference. The sqlite object is an initialized
@libsql/client instance supporting execute and batch.
Step 1: Create migrations file
// backend/database/migrations.ts
import { sqlite } from "https://esm.town/v/std/sqlite/main.ts";
export async function runMigrations() {
await sqlite.batch([
`CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
title TEXT,
model_ids TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
)`,
`CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS responses (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL,
model_id TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE CASCADE
)`,
]);
}
Step 2: Commit
git add backend/database/migrations.ts git commit -m "feat: add database migrations"
Files:
backend/database/queries.tsStep 1: Create typed query functions
// backend/database/queries.ts
import { sqlite } from "https://esm.town/v/std/sqlite/main.ts";
import type {
Conversation,
ConversationDetail,
Message,
Response,
} from "../../shared/types.ts";
export async function listConversations(): Promise<Conversation[]> {
const result = await sqlite.execute(
"SELECT id, title, model_ids, created_at, updated_at FROM conversations ORDER BY updated_at DESC",
);
return (result.rows as any[]).map((r) => ({
...r,
model_ids: JSON.parse(r.model_ids),
}));
}
export async function getConversation(
id: string,
): Promise<ConversationDetail | null> {
const convResult = await sqlite.execute({
sql: "SELECT * FROM conversations WHERE id = ?",
args: [id],
});
if (convResult.rows.length === 0) return null;
const conv = convResult.rows[0] as any;
const msgsResult = await sqlite.execute({
sql:
"SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at ASC",
args: [id],
});
const messages = [];
for (const msg of msgsResult.rows as any[]) {
const respResult = await sqlite.execute({
sql: "SELECT * FROM responses WHERE message_id = ? ORDER BY model_id ASC",
args: [msg.id],
});
messages.push({
...msg,
responses: respResult.rows as Response[],
});
}
return {
...conv,
model_ids: JSON.parse(conv.model_ids),
messages,
};
}
export async function createConversation(
id: string,
title: string,
modelIds: string[],
): Promise<void> {
await sqlite.execute({
sql: "INSERT INTO conversations (id, title, model_ids) VALUES (?, ?, ?)",
args: [id, title, JSON.stringify(modelIds)],
});
}
export async function deleteConversation(id: string): Promise<void> {
// Delete in order due to foreign keys (SQLite may not enforce CASCADE)
await sqlite.batch([
{
sql:
`DELETE FROM responses WHERE message_id IN (SELECT id FROM messages WHERE conversation_id = ?)`,
args: [id],
},
{ sql: "DELETE FROM messages WHERE conversation_id = ?", args: [id] },
{ sql: "DELETE FROM conversations WHERE id = ?", args: [id] },
]);
}
export async function updateConversationTitle(
id: string,
title: string,
): Promise<void> {
await sqlite.execute({
sql:
"UPDATE conversations SET title = ?, updated_at = datetime('now') WHERE id = ?",
args: [title, id],
});
}
export async function createMessage(
id: string,
conversationId: string,
content: string,
): Promise<void> {
await sqlite.execute({
sql:
"INSERT INTO messages (id, conversation_id, role, content) VALUES (?, ?, 'user', ?)",
args: [id, conversationId, content],
});
await sqlite.execute({
sql: "UPDATE conversations SET updated_at = datetime('now') WHERE id = ?",
args: [conversationId],
});
}
export async function createResponse(
id: string,
messageId: string,
modelId: string,
): Promise<void> {
await sqlite.execute({
sql: "INSERT INTO responses (id, message_id, model_id) VALUES (?, ?, ?)",
args: [id, messageId, modelId],
});
}
export async function updateResponseContent(
id: string,
content: string,
): Promise<void> {
await sqlite.execute({
sql: "UPDATE responses SET content = ? WHERE id = ?",
args: [content, id],
});
}
export async function getConversationHistory(
conversationId: string,
modelId: string,
): Promise<{ role: string; content: string }[]> {
const result = await sqlite.execute({
sql:
`SELECT m.role, m.content AS user_content, r.content AS assistant_content
FROM messages m
LEFT JOIN responses r ON r.message_id = m.id AND r.model_id = ?
WHERE m.conversation_id = ?
ORDER BY m.created_at ASC`,
args: [modelId, conversationId],
});
const history: { role: string; content: string }[] = [];
for (const row of result.rows as any[]) {
history.push({ role: "user", content: row.user_content });
if (row.assistant_content) {
history.push({ role: "assistant", content: row.assistant_content });
}
}
return history;
}
Step 2: Commit
git add backend/database/queries.ts git commit -m "feat: add database query functions"
Files:
backend/routes/auth.tsContext: Use Deno.env.get("PROMPTCOMPARE_PASSWORD") for the secret. Simple
approach: hash the password with a known salt using the Web Crypto API, store
the hash in a cookie. Middleware checks the cookie on protected routes.
Step 1: Create auth routes and middleware
// backend/routes/auth.ts
import { Hono } from "https://esm.sh/hono@4.7.4";
import {
deleteCookie,
getCookie,
setCookie,
} from "https://esm.sh/hono@4.7.4/cookie";
const COOKIE_NAME = "promptcompare_session";
const SESSION_MAX_AGE = 60 * 60 * 24 * 7; // 7 days
async function hashPassword(password: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(password + "_promptcompare_salt");
const hash = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hash))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export const authRoutes = new Hono();
authRoutes.post("/", async (c) => {
const { password } = await c.req.json();
const expected = Deno.env.get("PROMPTCOMPARE_PASSWORD");
if (!expected || password !== expected) {
return c.json({ error: "Invalid password" }, 401);
}
const token = await hashPassword(expected);
setCookie(c, COOKIE_NAME, token, {
httpOnly: true,
secure: true,
sameSite: "Lax",
maxAge: SESSION_MAX_AGE,
path: "/",
});
return c.json({ ok: true });
});
authRoutes.get("/check", async (c) => {
const token = getCookie(c, COOKIE_NAME);
const expected = Deno.env.get("PROMPTCOMPARE_PASSWORD");
if (!token || !expected) return c.json({ authenticated: false });
const expectedToken = await hashPassword(expected);
return c.json({ authenticated: token === expectedToken });
});
authRoutes.post("/logout", (c) => {
deleteCookie(c, COOKIE_NAME, { path: "/" });
return c.json({ ok: true });
});
export async function requireAuth(c: any, next: any) {
const token = getCookie(c, COOKIE_NAME);
const expected = Deno.env.get("PROMPTCOMPARE_PASSWORD");
if (!token || !expected) return c.json({ error: "Unauthorized" }, 401);
const expectedToken = await hashPassword(expected);
if (token !== expectedToken) return c.json({ error: "Unauthorized" }, 401);
await next();
}
Step 2: Commit
git add backend/routes/auth.ts git commit -m "feat: add auth routes and middleware"
Files:
backend/routes/api.tsContext: The models list comes from docs/models.json but at runtime we
need to read it via Val Town's readFile utility. For the streaming chat
endpoint, we need to proxy to OpenCode Zen endpoints. Each model has a different
endpoint based on its provider — see the endpoint table in docs/zen.md.
Step 1: Create API routes
// backend/routes/api.ts
import { Hono } from "https://esm.sh/hono@4.7.4";
import { streamSSE } from "https://esm.sh/hono@4.7.4/streaming";
import { readFile } from "https://esm.town/v/std/utils@85-main/index.ts";
import * as db from "../database/queries.ts";
const api = new Hono();
// --- Models ---
let cachedModels: any = null;
async function getModels() {
if (cachedModels) return cachedModels;
const raw = await readFile("/docs/models.json", import.meta.url);
const data = JSON.parse(raw);
const models = Object.values(data.models).filter((m: any) =>
m.status !== "deprecated"
);
cachedModels = models;
return models;
}
api.get("/models", async (c) => {
const models = await getModels();
return c.json(models);
});
// --- Conversations ---
api.get("/conversations", async (c) => {
const conversations = await db.listConversations();
return c.json(conversations);
});
api.get("/conversations/:id", async (c) => {
const conv = await db.getConversation(c.req.param("id"));
if (!conv) return c.json({ error: "Not found" }, 404);
return c.json(conv);
});
api.post("/conversations", async (c) => {
const { title, model_ids } = await c.req.json();
const id = crypto.randomUUID();
await db.createConversation(id, title || "New Conversation", model_ids);
return c.json({ id });
});
api.delete("/conversations/:id", async (c) => {
await db.deleteConversation(c.req.param("id"));
return c.json({ ok: true });
});
// --- Chat (SSE streaming) ---
function getZenEndpoint(model: any): string {
const npm = model.provider?.npm || "";
if (npm === "@ai-sdk/anthropic") return "https://opencode.ai/zen/v1/messages";
if (npm === "@ai-sdk/openai") return "https://opencode.ai/zen/v1/responses";
if (npm === "@ai-sdk/google") {
return `https://opencode.ai/zen/v1/models/${model.id}`;
}
// Default: openai-compatible chat/completions
return "https://opencode.ai/zen/v1/chat/completions";
}
function buildRequestBody(
model: any,
messages: { role: string; content: string }[],
): { endpoint: string; body: any; headers: Record<string, string> } {
const apiKey = Deno.env.get("OPENCODE_API_KEY") || "";
const npm = model.provider?.npm || "";
const endpoint = getZenEndpoint(model);
const headers: Record<string, string> = {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
};
if (npm === "@ai-sdk/anthropic") {
return {
endpoint,
headers: { ...headers, "anthropic-version": "2023-06-01" },
body: {
model: model.id,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
max_tokens: Math.min(4096, model.limit.output),
stream: true,
},
};
}
if (npm === "@ai-sdk/openai") {
return {
endpoint,
headers,
body: {
model: model.id,
input: messages.map((m) => ({ role: m.role, content: m.content })),
stream: true,
},
};
}
// Default: OpenAI-compatible chat/completions (GLM, Kimi, MiniMax, etc.)
return {
endpoint,
headers,
body: {
model: model.id,
messages: messages.map((m) => ({ role: m.role, content: m.content })),
max_tokens: Math.min(4096, model.limit.output),
stream: true,
},
};
}
async function* streamModel(
model: any,
messages: { role: string; content: string }[],
): AsyncGenerator<{ type: "chunk" | "done" | "error"; data: any }> {
const { endpoint, body, headers } = buildRequestBody(model, messages);
const npm = model.provider?.npm || "";
try {
const resp = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify(body),
});
if (!resp.ok) {
const errText = await resp.text();
yield {
type: "error",
data: { model_id: model.id, error: `${resp.status}: ${errText}` },
};
return;
}
const reader = resp.body?.getReader();
if (!reader) {
yield {
type: "error",
data: { model_id: model.id, error: "No response body" },
};
return;
}
const decoder = new TextDecoder();
let buffer = "";
let fullContent = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6).trim();
if (data === "[DONE]") continue;
try {
const parsed = JSON.parse(data);
let text = "";
if (npm === "@ai-sdk/anthropic") {
// Anthropic streaming: content_block_delta
if (parsed.type === "content_block_delta" && parsed.delta?.text) {
text = parsed.delta.text;
}
} else if (npm === "@ai-sdk/openai") {
// OpenAI Responses API streaming
if (parsed.type === "response.output_text.delta" && parsed.delta) {
text = parsed.delta;
}
} else {
// OpenAI-compatible chat/completions
text = parsed.choices?.[0]?.delta?.content || "";
}
if (text) {
fullContent += text;
yield {
type: "chunk",
data: { model_id: model.id, content: text },
};
}
} catch {
// Skip unparseable lines
}
}
}
yield {
type: "done",
data: { model_id: model.id, full_content: fullContent },
};
} catch (err: any) {
yield { type: "error", data: { model_id: model.id, error: err.message } };
}
}
api.post("/chat", async (c) => {
const { conversation_id, content } = await c.req.json();
const conv = await db.getConversation(conversation_id);
if (!conv) return c.json({ error: "Conversation not found" }, 404);
// Create the user message
const messageId = crypto.randomUUID();
await db.createMessage(messageId, conversation_id, content);
// Create response placeholders for each model
const allModels = await getModels();
const selectedModels = allModels.filter((m: any) =>
conv.model_ids.includes(m.id)
);
const responseIds: Record<string, string> = {};
for (const model of selectedModels) {
const responseId = crypto.randomUUID();
responseIds[model.id] = responseId;
await db.createResponse(responseId, messageId, model.id);
}
return streamSSE(c, async (stream) => {
// Send message_id so frontend knows it
await stream.writeSSE({
event: "message",
data: JSON.stringify({ message_id: messageId }),
});
// Build per-model conversation histories and stream in parallel
const streamPromises = selectedModels.map(async (model: any) => {
const history = await db.getConversationHistory(
conversation_id,
model.id,
);
// Add the current message
history.push({ role: "user", content });
let fullContent = "";
for await (const event of streamModel(model, history)) {
if (event.type === "chunk") {
fullContent += event.data.content;
await stream.writeSSE({
event: "chunk",
data: JSON.stringify(event.data),
});
} else if (event.type === "done") {
await db.updateResponseContent(
responseIds[model.id],
event.data.full_content,
);
await stream.writeSSE({
event: "done",
data: JSON.stringify({ model_id: model.id }),
});
} else if (event.type === "error") {
await stream.writeSSE({
event: "error",
data: JSON.stringify(event.data),
});
}
}
});
await Promise.all(streamPromises);
});
});
export { api };
Step 2: Commit
git add backend/routes/api.ts git commit -m "feat: add API routes for conversations, models, and streaming chat"
Files:
backend/index.tsStep 1: Create Hono app entry point
// backend/index.ts
import { Hono } from "https://esm.sh/hono@4.7.4";
import {
readFile,
serveFile,
} from "https://esm.town/v/std/utils@85-main/index.ts";
import { runMigrations } from "./database/migrations.ts";
import { authRoutes, requireAuth } from "./routes/auth.ts";
import { api } from "./routes/api.ts";
const app = new Hono();
// Unwrap Hono errors to see original error details
app.onError((err, c) => {
throw err;
});
// Run migrations on startup
await runMigrations();
// Auth routes (public)
app.route("/api/auth", authRoutes);
// Protected API routes
app.use("/api/*", requireAuth);
app.route("/api", api);
// Serve frontend static files
app.get("/frontend/*", (c) => serveFile(c.req.path, import.meta.url));
app.get("/shared/*", (c) => serveFile(c.req.path, import.meta.url));
// Serve index.html for all other routes
app.get("*", async (c) => {
const html = await readFile("/frontend/index.html", import.meta.url);
return c.html(html);
});
export default app.fetch;
Step 2: Commit
git add backend/index.ts git commit -m "feat: add backend entry point"
Files:
frontend/index.htmlfrontend/style.cssStep 1: Create HTML shell with React and Tailwind CDN
<!-- frontend/index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>PromptCompare</title> <script src="https://cdn.twind.style" crossorigin></script> <script src="https://esm.town/v/std/catch"></script> <link rel="stylesheet" href="/frontend/style.css" /> </head> <body> <div id="root"></div> <script type="module" src="/frontend/index.tsx"></script> </body> </html>
/* frontend/style.css */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
height: 100%;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
/* Markdown rendered in responses */
.prose pre {
background: #1e1e1e;
color: #d4d4d4;
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
}
.prose code {
font-size: 0.875rem;
}
.prose p {
margin-bottom: 0.5rem;
}
.prose ul, .prose ol {
margin-left: 1.5rem;
margin-bottom: 0.5rem;
}
Step 2: Commit
git add frontend/index.html frontend/style.css git commit -m "feat: add frontend HTML shell and base styles"
Files:
frontend/index.tsxfrontend/components/Login.tsxStep 1: Create app entry with auth state management
/** @jsxImportSource https://esm.sh/react@18.2.0 */
// frontend/index.tsx
import React, { useEffect, useState } from "https://esm.sh/react@18.2.0";
import { createRoot } from "https://esm.sh/react-dom@18.2.0/client";
import { Login } from "./components/Login.tsx";
import { ChatApp } from "./components/ChatApp.tsx";
function App() {
const [authenticated, setAuthenticated] = useState<boolean | null>(null);
useEffect(() => {
fetch("/api/auth/check")
.then((r) => r.json())
.then((d) => setAuthenticated(d.authenticated))
.catch(() => setAuthenticated(false));
}, []);
if (authenticated === null) {
return (
<div className="flex items-center justify-center h-screen text-gray-500">
Loading...
</div>
);
}
if (!authenticated) {
return <Login onSuccess={() => setAuthenticated(true)} />;
}
return <ChatApp onLogout={() => setAuthenticated(false)} />;
}
createRoot(document.getElementById("root")!).render(<App />);
Step 2: Create Login component
/** @jsxImportSource https://esm.sh/react@18.2.0 */
// frontend/components/Login.tsx
import React, { useState } from "https://esm.sh/react@18.2.0";
export function Login({ onSuccess }: { onSuccess: () => void }) {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError("");
try {
const res = await fetch("/api/auth", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
});
if (res.ok) {
onSuccess();
} else {
setError("Invalid password");
}
} catch {
setError("Connection error");
} finally {
setLoading(false);
}
};
return (
<div className="flex items-center justify-center h-screen bg-gray-50">
<form
onSubmit={handleSubmit}
className="bg-white p-8 rounded-lg shadow-md w-80"
>
<h1 className="text-xl font-semibold mb-6 text-center">
PromptCompare
</h1>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Password"
className="w-full px-3 py-2 border rounded-md mb-4 focus:outline-none focus:ring-2 focus:ring-blue-500"
autoFocus
/>
{error && <p className="text-red-500 text-sm mb-4">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 text-white py-2 rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{loading ? "..." : "Sign In"}
</button>
</form>
</div>
);
}
Step 3: Commit
git add frontend/index.tsx frontend/components/Login.tsx git commit -m "feat: add frontend app entry and login component"
Files:
frontend/components/ChatApp.tsxfrontend/components/Sidebar.tsxStep 1: Create ChatApp layout component
/** @jsxImportSource https://esm.sh/react@18.2.0 */
// frontend/components/ChatApp.tsx
import React, {
useCallback,
useEffect,
useState,
} from "https://esm.sh/react@18.2.0";
import type { Conversation, ModelInfo } from "../../shared/types.ts";
import { Sidebar } from "./Sidebar.tsx";
import { ModelPicker } from "./ModelPicker.tsx";
import { ConversationView } from "./ConversationView.tsx";
export function ChatApp({ onLogout }: { onLogout: () => void }) {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [activeConvId, setActiveConvId] = useState<string | null>(null);
const [models, setModels] = useState<ModelInfo[]>([]);
const [showModelPicker, setShowModelPicker] = useState(false);
const loadConversations = useCallback(async () => {
const res = await fetch("/api/conversations");
const data = await res.json();
setConversations(data);
}, []);
useEffect(() => {
loadConversations();
fetch("/api/models").then((r) => r.json()).then(setModels);
}, []);
const handleNewChat = () => {
setShowModelPicker(true);
setActiveConvId(null);
};
const handleCreateConversation = async (selectedModelIds: string[]) => {
const res = await fetch("/api/conversations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: "New Conversation",
model_ids: selectedModelIds,
}),
});
const { id } = await res.json();
setShowModelPicker(false);
setActiveConvId(id);
await loadConversations();
};
const handleDeleteConversation = async (id: string) => {
await fetch(`/api/conversations/${id}`, { method: "DELETE" });
if (activeConvId === id) setActiveConvId(null);
await loadConversations();
};
return (
<div className="flex h-screen bg-gray-50">
<Sidebar
conversations={conversations}
activeId={activeConvId}
onSelect={setActiveConvId}
onNewChat={handleNewChat}
onDelete={handleDeleteConversation}
onLogout={onLogout}
/>
<main className="flex-1 flex flex-col">
{showModelPicker
? (
<ModelPicker
models={models}
onConfirm={handleCreateConversation}
onCancel={() => setShowModelPicker(false)}
/>
)
: activeConvId
? (
<ConversationView
conversationId={activeConvId}
models={models}
onTitleUpdate={loadConversations}
/>
)
: (
<div className="flex items-center justify-center h-full text-gray-400">
Select a conversation or start a new one
</div>
)}
</main>
</div>
);
}
Step 2: Create Sidebar component
/** @jsxImportSource https://esm.sh/react@18.2.0 */
// frontend/components/Sidebar.tsx
import React from "https://esm.sh/react@18.2.0";
import type { Conversation } from "../../shared/types.ts";
interface SidebarProps {
conversations: Conversation[];
activeId: string | null;
onSelect: (id: string) => void;
onNewChat: () => void;
onDelete: (id: string) => void;
onLogout: () => void;
}
export function Sidebar(
{ conversations, activeId, onSelect, onNewChat, onDelete, onLogout }:
SidebarProps,
) {
return (
<div className="w-64 bg-gray-900 text-white flex flex-col h-full">
<div className="p-4">
<button
onClick={onNewChat}
className="w-full py-2 px-4 border border-gray-600 rounded-md hover:bg-gray-800 text-sm"
>
+ New Comparison
</button>
</div>
<div className="flex-1 overflow-y-auto px-2">
{conversations.map((conv) => (
<div
key={conv.id}
className={`group flex items-center px-3 py-2 rounded-md cursor-pointer text-sm mb-1 ${
conv.id === activeId ? "bg-gray-700" : "hover:bg-gray-800"
}`}
onClick={() => onSelect(conv.id)}
>
<span className="flex-1 truncate">{conv.title}</span>
<button
onClick={(e) => {
e.stopPropagation();
onDelete(conv.id);
}}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-400 ml-2"
>
x
</button>
</div>
))}
</div>
<div className="p-4 border-t border-gray-700">
<button
onClick={async () => {
await fetch("/api/auth/logout", { method: "POST" });
onLogout();
}}
className="text-sm text-gray-400 hover:text-white"
>
Sign out
</button>
</div>
</div>
);
}
Step 3: Commit
git add frontend/components/ChatApp.tsx frontend/components/Sidebar.tsx git commit -m "feat: add ChatApp layout and Sidebar"
Files:
frontend/components/ModelPicker.tsxStep 1: Create model picker with checkboxes
/** @jsxImportSource https://esm.sh/react@18.2.0 */
// frontend/components/ModelPicker.tsx
import React, { useState } from "https://esm.sh/react@18.2.0";
import type { ModelInfo } from "../../shared/types.ts";
interface ModelPickerProps {
models: ModelInfo[];
onConfirm: (modelIds: string[]) => void;
onCancel: () => void;
}
export function ModelPicker({ models, onConfirm, onCancel }: ModelPickerProps) {
const [selected, setSelected] = useState<Set<string>>(new Set());
const toggle = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else if (next.size < 4) {
next.add(id);
}
return next;
});
};
// Group by family
const families = new Map<string, ModelInfo[]>();
for (const m of models) {
const fam = m.family;
if (!families.has(fam)) families.set(fam, []);
families.get(fam)!.push(m);
}
return (
<div className="flex-1 flex items-center justify-center p-8">
<div className="bg-white rounded-lg shadow-lg p-6 max-w-2xl w-full max-h-[80vh] overflow-y-auto">
<h2 className="text-lg font-semibold mb-1">Select Models</h2>
<p className="text-sm text-gray-500 mb-4">
Choose up to 4 models to compare ({selected.size}/4)
</p>
{Array.from(families.entries()).map(([family, familyModels]) => (
<div key={family} className="mb-4">
<h3 className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-2">
{family}
</h3>
<div className="grid grid-cols-1 gap-1">
{familyModels.map((model) => (
<label
key={model.id}
className={`flex items-center gap-3 px-3 py-2 rounded-md cursor-pointer border ${
selected.has(model.id)
? "border-blue-500 bg-blue-50"
: "border-transparent hover:bg-gray-50"
} ${
!selected.has(model.id) && selected.size >= 4
? "opacity-50 cursor-not-allowed"
: ""
}`}
>
<input
type="checkbox"
checked={selected.has(model.id)}
onChange={() => toggle(model.id)}
disabled={!selected.has(model.id) && selected.size >= 4}
className="rounded"
/>
<div className="flex-1">
<span className="text-sm font-medium">{model.name}</span>
</div>
<span className="text-xs text-gray-400">
${model.cost.input}/{model.cost.output} per 1M
</span>
</label>
))}
</div>
</div>
))}
<div className="flex gap-3 mt-6">
<button
onClick={onCancel}
className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800"
>
Cancel
</button>
<button
onClick={() => onConfirm(Array.from(selected))}
disabled={selected.size === 0}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
Start Comparison
</button>
</div>
</div>
</div>
);
}
Step 2: Commit
git add frontend/components/ModelPicker.tsx git commit -m "feat: add ModelPicker component"
Files:
frontend/components/ConversationView.tsxfrontend/components/MessageRow.tsxfrontend/components/PromptInput.tsxThis is the core component. It loads conversation history, displays messages with side-by-side model responses, handles streaming, and renders markdown.
Step 1: Create PromptInput component
/** @jsxImportSource https://esm.sh/react@18.2.0 */
// frontend/components/PromptInput.tsx
import React, { useRef, useState } from "https://esm.sh/react@18.2.0";
interface PromptInputProps {
onSend: (content: string) => void;
disabled: boolean;
}
export function PromptInput({ onSend, disabled }: PromptInputProps) {
const [value, setValue] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const trimmed = value.trim();
if (!trimmed || disabled) return;
onSend(trimmed);
setValue("");
if (textareaRef.current) textareaRef.current.style.height = "auto";
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
const handleInput = () => {
const el = textareaRef.current;
if (el) {
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 200) + "px";
}
};
return (
<form onSubmit={handleSubmit} className="border-t bg-white p-4">
<div className="flex gap-2 max-w-4xl mx-auto">
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
onInput={handleInput}
placeholder="Type a message..."
disabled={disabled}
rows={1}
className="flex-1 resize-none border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500 text-sm"
/>
<button
type="submit"
disabled={disabled || !value.trim()}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 text-sm"
>
Send
</button>
</div>
</form>
);
}
Step 2: Create MessageRow component
/** @jsxImportSource https://esm.sh/react@18.2.0 */
// frontend/components/MessageRow.tsx
import React from "https://esm.sh/react@18.2.0";
import { marked } from "https://esm.sh/marked@15.0.6?deps=react@18.2.0,react-dom@18.2.0";
interface MessageRowProps {
userContent: string;
responses: { model_id: string; content: string }[];
modelNames: Record<string, string>;
streaming?: boolean;
}
export function MessageRow(
{ userContent, responses, modelNames, streaming }: MessageRowProps,
) {
return (
<div className="mb-6">
{/* User message */}
<div className="mb-3 flex justify-end">
<div className="bg-blue-600 text-white px-4 py-2 rounded-2xl rounded-br-md max-w-2xl text-sm whitespace-pre-wrap">
{userContent}
</div>
</div>
{/* Model responses side by side */}
<div
className="grid gap-3"
style={{ gridTemplateColumns: `repeat(${responses.length}, 1fr)` }}
>
{responses.map((resp) => (
<div
key={resp.model_id}
className="border rounded-lg overflow-hidden bg-white"
>
<div className="px-3 py-1.5 bg-gray-50 border-b text-xs font-medium text-gray-600">
{modelNames[resp.model_id] || resp.model_id}
</div>
<div
className="px-3 py-2 text-sm prose max-w-none"
dangerouslySetInnerHTML={{
__html: resp.content
? marked.parse(resp.content, { async: false }) as string
: streaming
? '<span class="animate-pulse text-gray-400">...</span>'
: '<span class="text-gray-400">No response</span>',
}}
/>
</div>
))}
</div>
</div>
);
}
Step 3: Create ConversationView component
/** @jsxImportSource https://esm.sh/react@18.2.0 */
// frontend/components/ConversationView.tsx
import React, {
useCallback,
useEffect,
useRef,
useState,
} from "https://esm.sh/react@18.2.0";
import type { ConversationDetail, ModelInfo } from "../../shared/types.ts";
import { MessageRow } from "./MessageRow.tsx";
import { PromptInput } from "./PromptInput.tsx";
interface ConversationViewProps {
conversationId: string;
models: ModelInfo[];
onTitleUpdate: () => void;
}
interface StreamingState {
[modelId: string]: string;
}
export function ConversationView(
{ conversationId, models, onTitleUpdate }: ConversationViewProps,
) {
const [conversation, setConversation] = useState<ConversationDetail | null>(
null,
);
const [streaming, setStreaming] = useState(false);
const [streamingResponses, setStreamingResponses] = useState<StreamingState>(
{},
);
const [streamingMessageContent, setStreamingMessageContent] = useState<
string | null
>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const modelNames = Object.fromEntries(models.map((m) => [m.id, m.name]));
const loadConversation = useCallback(async () => {
const res = await fetch(`/api/conversations/${conversationId}`);
const data = await res.json();
setConversation(data);
}, [conversationId]);
useEffect(() => {
loadConversation();
setStreamingResponses({});
setStreamingMessageContent(null);
}, [conversationId]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [conversation, streamingResponses]);
const handleSend = async (content: string) => {
if (!conversation) return;
setStreaming(true);
setStreamingMessageContent(content);
// Initialize streaming state for each model
const initialState: StreamingState = {};
for (const modelId of conversation.model_ids) {
initialState[modelId] = "";
}
setStreamingResponses(initialState);
try {
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ conversation_id: conversationId, content }),
});
const reader = response.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const eventLine = lines[lines.indexOf(line) - 1];
const eventType = eventLine?.startsWith("event: ")
? eventLine.slice(7)
: "";
try {
const data = JSON.parse(line.slice(6));
if (eventType === "chunk" || (!eventType && data.content)) {
setStreamingResponses((prev) => ({
...prev,
[data.model_id]: (prev[data.model_id] || "") + data.content,
}));
}
} catch {
// Skip
}
}
}
}
} catch (err) {
console.error("Stream error:", err);
} finally {
setStreaming(false);
setStreamingMessageContent(null);
setStreamingResponses({});
await loadConversation();
onTitleUpdate();
}
};
if (!conversation) {
return (
<div className="flex-1 flex items-center justify-center text-gray-400">
Loading...
</div>
);
}
return (
<div className="flex-1 flex flex-col h-full">
{/* Header */}
<div className="border-b bg-white px-4 py-3 flex items-center gap-2">
<h2 className="text-sm font-medium">{conversation.title}</h2>
<div className="flex gap-1 ml-auto">
{conversation.model_ids.map((id) => (
<span
key={id}
className="text-xs bg-gray-100 px-2 py-0.5 rounded-full text-gray-600"
>
{modelNames[id] || id}
</span>
))}
</div>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4">
{conversation.messages.map((msg) => (
<MessageRow
key={msg.id}
userContent={msg.content}
responses={conversation.model_ids.map((modelId) => {
const resp = msg.responses.find((r) => r.model_id === modelId);
return { model_id: modelId, content: resp?.content || "" };
})}
modelNames={modelNames}
/>
))}
{/* Currently streaming message */}
{streamingMessageContent && (
<MessageRow
userContent={streamingMessageContent}
responses={conversation.model_ids.map((modelId) => ({
model_id: modelId,
content: streamingResponses[modelId] || "",
}))}
modelNames={modelNames}
streaming
/>
)}
<div ref={messagesEndRef} />
</div>
<PromptInput onSend={handleSend} disabled={streaming} />
</div>
);
}
Step 4: Commit
git add frontend/components/ConversationView.tsx frontend/components/MessageRow.tsx frontend/components/PromptInput.tsx git commit -m "feat: add ConversationView, MessageRow, and PromptInput components"
Context: The SSE parsing in ConversationView needs to handle the event: +
data: line pairs correctly. The current implementation has a bug — it tries to
look up the event type by finding the previous line in the already-split array,
which is fragile. Fix the parser to properly track event types.
Step 1: Update the SSE parser in ConversationView.tsx
Replace the reader while-loop body in handleSend with a proper SSE parser:
// Replace the while(true) loop content in handleSend:
let currentEvent = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (line.startsWith("event: ")) {
currentEvent = line.slice(7).trim();
} else if (line.startsWith("data: ")) {
try {
const data = JSON.parse(line.slice(6));
if (currentEvent === "chunk" && data.content && data.model_id) {
setStreamingResponses((prev) => ({
...prev,
[data.model_id]: (prev[data.model_id] || "") + data.content,
}));
}
} catch {
// Skip unparseable
}
currentEvent = "";
} else if (line.trim() === "") {
currentEvent = "";
}
}
}
Step 2: Verify by deploying to Val Town and testing
vt pushglm-5-free,
gpt-5-nano)Step 3: Commit
git add frontend/components/ConversationView.tsx git commit -m "fix: proper SSE event parsing in ConversationView"
Context: After the first message, use the user's prompt to set a meaningful conversation title instead of "New Conversation".
Step 1: Update the chat endpoint in backend/routes/api.ts
After creating the message, check if this is the first message and auto-set the title:
// Add after createMessage call in the /chat handler:
const existingMessages = conv.messages || [];
if (existingMessages.length === 0) {
// First message — set title from content (truncated)
const title = content.length > 60 ? content.slice(0, 57) + "..." : content;
await db.updateConversationTitle(conversation_id, title);
}
Step 2: Commit
git add backend/routes/api.ts git commit -m "feat: auto-title conversations from first message"