Sbboss commited on
Commit
135c42a
·
0 Parent(s):

LLM Arena: side-by-side model comparison app using Concentrate AI

Browse files

Features:
- 89 models across 10+ providers with real-time streaming
- Adjustable parameters (temperature, max tokens, top-p)
- PDF/DOCX/TXT document upload for context-aware prompts
- Markdown + LaTeX rendering, per-model stats comparison
- FastAPI backend (SSE proxy) + React/Vite/Tailwind frontend

Made-with: Cursor

.gitignore ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ backend/venv/
3
+ backend/__pycache__/
4
+ backend/.env
5
+ *.pyc
6
+
7
+ # Node
8
+ frontend/node_modules/
9
+ frontend/dist/
10
+
11
+ # IDE
12
+ .idea/
13
+ .vscode/
14
+ *.swp
15
+ .DS_Store
16
+
17
+ # Agent tools
18
+ agent-tools/
19
+
20
+ # Private docs
21
+ WRITEUP.md
FINDINGS.md ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # My Findings -- Concentrate AI Developer Experience
2
+
3
+ **Name:** Shiva
4
+ **Assignment:** Use the Concentrate AI APIs to build something and share feedback.
5
+
6
+ ---
7
+
8
+ ## What I Built
9
+
10
+ I built an "LLM Arena" -- a tool where you pick two models, type a prompt, and watch both respond side-by-side in real-time. The idea was to stress-test the core promise of Concentrate AI: one API, many providers, same behavior. A comparison tool is a good way to surface any inconsistencies because you're literally looking at two providers next to each other.
11
+
12
+ The app is a FastAPI backend proxying SSE streams from the Concentrate API, with a React frontend rendering both streams. It also supports document upload (PDF/DOCX), tunable parameters (temperature, top-p, max tokens), and tracks stats like time-to-first-token and token counts.
13
+
14
+ I used AI assistance to build the tool itself, but all the findings below are from my own experience integrating with and testing the API.
15
+
16
+ ---
17
+
18
+ ## The Good Stuff
19
+
20
+ **The unified format genuinely works.** This was the thing I was most skeptical about going in. Every AI gateway claims "one API for all providers" but the normalization usually breaks down somewhere -- different error formats, inconsistent token counting, streaming quirks. With Concentrate, I was able to swap `openai/gpt-5.2` for `anthropic/claude-opus-4-6` and literally nothing in my code changed. The request body is the same, the streaming events are the same shape, the usage object comes back with the same fields. That's not easy to pull off and it's clearly where a lot of engineering effort went.
21
+
22
+ **Streaming is rock solid.** I ran dozens of side-by-side comparisons across OpenAI, Anthropic, and Google models. Never saw a dropped event, never got an out-of-order sequence, never had a stream just hang. The event types are well-named and the lifecycle makes sense once you understand it. The `response.completed` event reliably carries the `usage` object, which made building the stats panel straightforward.
23
+
24
+ **The auto-routing idea is really compelling.** I didn't build auto-routing into my app, but reading through the docs, the `model: "auto"` with strategy/metric configuration is something I haven't seen elsewhere. Being able to say "give me the cheapest model right now" or "give me the lowest p50 latency" at the request level is a genuinely useful primitive. I'd love to build an explorer tool around this.
25
+
26
+ **The pricing data in the models API is a hidden gem.** The `/v1/models` endpoint returns per-provider pricing (input/output/cache costs per million tokens) which is incredibly useful. I only discovered this by inspecting the raw API response. More on that below.
27
+
28
+ ---
29
+
30
+ ## Where I Got Stuck
31
+
32
+ ### The `/v1/models` endpoint was my biggest blocker
33
+
34
+ This cost me the most time and was the most frustrating part of the integration. Here's what happened:
35
+
36
+ The docs show examples like `model: "openai/gpt-4o"` throughout, so I assumed the models endpoint would return objects with an `id` field in that same format. I wrote my parser expecting `{ id: "openai/gpt-4o", ... }`. Instead, the endpoint returns a flat array with a completely different shape:
37
+
38
+ ```json
39
+ {
40
+ "slug": "gpt-5.2",
41
+ "name": "ChatGPT 5.2",
42
+ "author": { "slug": "openai" },
43
+ "providers": {
44
+ "openai": { "pricing": {...}, ... }
45
+ }
46
+ }
47
+ ```
48
+
49
+ There's no `id` field. There's no `data` wrapper. The model identifier is `slug`, not `id`. And to construct the string that the `/v1/responses` endpoint actually accepts (like `openai/gpt-5.2`), you have to iterate the `providers` object and concatenate `provider_key + "/" + slug` yourself.
50
+
51
+ My dropdown was completely empty until I figured this out by `curl`-ing the endpoint directly and reading the raw JSON.
52
+
53
+ **My suggestion:** Add an example response to the List Models docs page. Even better, include a ready-to-use `id` field in each model object so developers don't have to reverse-engineer the ID format.
54
+
55
+ ### Some providers fail with opaque errors
56
+
57
+ Several models (e.g., `huggingface/qwen3.5-27b`) return a `response.failed` event with `"code": "server_error"` and `"message": "Internal Server Error"` -- no additional detail. Since the request goes through Concentrate's proxy, it's unclear whether the issue is on the provider side, a configuration problem, or a Concentrate routing issue. There's nothing actionable a developer can do with "Internal Server Error."
58
+
59
+ **My suggestion:** Surface more context in provider errors when possible -- even something like "upstream provider returned HTTP 500" would help developers know it's not their fault and they should try a different model.
60
+
61
+ ### No way to tell what parameters were silently dropped
62
+
63
+ Some parameters (like `search_context_size` for web search) are silently ignored for certain providers. I only know this because the docs mention it in a small note. In practice, if I send a parameter and it gets dropped, there's no way to tell from the response. A response header like `X-Ignored-Params: search_context_size` would make debugging much easier.
64
+
65
+ ---
66
+
67
+ ## Suggestions for the Docs
68
+
69
+ These are things that would have saved me time:
70
+
71
+ 1. **Show the actual `/v1/models` response body.** The List Models page has no response example. This was the single biggest source of confusion.
72
+
73
+ 2. **Add a streaming quick-reference.** The streaming docs are thorough but hard to scan. A one-liner showing the event order (`created → output_item.added → content_part.added → delta* → text.done → completed`) at the top of the page would be really helpful.
74
+
75
+ 3. **Document the pricing fields.** The models API returns detailed pricing that's super useful for building cost-aware apps, but the docs don't mention it.
76
+
77
+ 4. **Add a "Building a Streaming Client" guide.** The trickiest part of the integration was correctly parsing SSE (buffering partial chunks, handling the `event:` vs `data:` lines). A copy-paste streaming helper for Python and TypeScript would save every developer 30+ minutes.
78
+
79
+ 5. **Document input size limits.** I sent ~20K character prompts (extracted from PDFs) with no issues, but I had no idea what the limit was. The `context_window` field exists in the model metadata but the docs don't explain how it relates to input size.
80
+
81
+ ---
82
+
83
+ ## Ideas for Features I'd Love to See
84
+
85
+ - **Native document input** -- An `input_document` type (like the existing `input_image`) that accepts base64 PDF data and extracts text server-side. Right now every developer building document Q&A needs to bring their own extraction library.
86
+ - **Auto-routing fallback** -- If the selected provider fails, automatically try the next best option instead of returning a 424.
87
+ - **Streaming guardrails** -- Output redaction currently only works for non-streamed responses, but streaming is the default for chat UIs. Finding a way to make guardrails work with streaming would be a big deal for enterprise customers.
88
+ - **A lightweight SDK** -- Even a minimal Python/TypeScript client that handles SSE parsing and types the response objects would make the onboarding much smoother.
89
+
90
+ ---
91
+
92
+ ## Closing Thoughts
93
+
94
+ The core product is strong. The unified API format works, the streaming is reliable, and the model coverage is impressive. Most of my friction was with the docs and discoverability rather than the API itself -- which is a good sign because docs are fixable. The auto-routing and multi-provider abstraction are genuinely differentiated features that I think will matter a lot as teams start running AI workloads at scale.
95
+
96
+ I'd be excited to work on improving the developer experience from the inside -- whether that's the docs, SDKs, or the API surface itself.
README.md ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LLM Arena
2
+
3
+ Side-by-side LLM comparison app powered by [Concentrate AI](https://concentrate.ai). Pick any two models from 70+ providers, send the same prompt, and watch both responses stream in real-time with latency, token, and cost stats.
4
+
5
+ ## Features
6
+
7
+ - **Side-by-side streaming** -- two models respond to the same prompt simultaneously
8
+ - **89 models** across 10+ providers (OpenAI, Anthropic, Google, xAI, Mistral, Cohere, etc.)
9
+ - **Adjustable parameters** -- temperature, max output tokens, top-p via UI sliders
10
+ - **Document upload** -- attach a PDF, DOCX, or TXT file as context for your prompt
11
+ - **Markdown + LaTeX** -- responses render with full math support (KaTeX)
12
+ - **Live stats** -- time-to-first-token, total time, input/output token counts with winner highlighting
13
+
14
+ ## Prerequisites
15
+
16
+ - Python 3.10+
17
+ - Node.js 18+
18
+ - A Concentrate AI API key ([get one here](https://app.concentrate.ai))
19
+
20
+ ## Setup
21
+
22
+ ### 1. Backend
23
+
24
+ ```bash
25
+ cd backend
26
+ python -m venv venv
27
+ source venv/bin/activate # Windows: venv\Scripts\activate
28
+ pip install -r requirements.txt
29
+
30
+ # Create your .env file
31
+ cp .env.example .env
32
+ # Edit .env and set your CONCENTRATE_API_KEY
33
+ ```
34
+
35
+ Start the backend:
36
+
37
+ ```bash
38
+ uvicorn main:app --reload --port 8000
39
+ ```
40
+
41
+ ### 2. Frontend
42
+
43
+ ```bash
44
+ cd frontend
45
+ npm install
46
+ npm run dev
47
+ ```
48
+
49
+ The frontend runs at `http://localhost:5173` and proxies `/api/*` requests to the backend at `localhost:8000`.
50
+
51
+ ## Usage
52
+
53
+ 1. Open `http://localhost:5173`
54
+ 2. Pick two models from the dropdowns (e.g. `openai/gpt-5.2` vs `anthropic/claude-sonnet-4`)
55
+ 3. (Optional) Upload a PDF/DOCX/TXT document as context
56
+ 4. (Optional) Adjust temperature, max tokens, or top-p
57
+ 5. Type a prompt and click **Battle!**
58
+ 6. Watch both responses stream side-by-side
59
+ 7. Compare stats in the comparison bar at the bottom
60
+
61
+ ## Project Structure
62
+
63
+ ```
64
+ backend/
65
+ main.py FastAPI server with streaming proxy + file upload
66
+ requirements.txt Python dependencies
67
+ .env.example API key placeholder
68
+
69
+ frontend/
70
+ src/
71
+ App.tsx Main layout
72
+ components/
73
+ ModelPicker.tsx Model selection dropdown (grouped by provider)
74
+ ParamsPanel.tsx Temperature / max tokens / top-p sliders
75
+ FileUpload.tsx PDF/DOCX/TXT upload with text extraction
76
+ PromptInput.tsx Prompt textarea + example prompts
77
+ StreamPanel.tsx Streaming response display with Markdown + LaTeX
78
+ StatsBar.tsx Side-by-side stats comparison
79
+ hooks/
80
+ useStream.ts SSE streaming hook (POST-based)
81
+ lib/
82
+ api.ts API helper functions
83
+ ```
84
+
85
+ ## API Endpoints
86
+
87
+ | Endpoint | Method | Description |
88
+ |---|---|---|
89
+ | `/api/models` | GET | Lists available models (cached 5 min) |
90
+ | `/api/stream` | POST | SSE stream proxying Concentrate AI responses |
91
+ | `/api/upload` | POST | Extracts text from PDF, DOCX, or TXT files |
92
+ | `/api/health` | GET | Health check |
backend/.env.example ADDED
@@ -0,0 +1 @@
 
 
1
+ CONCENTRATE_API_KEY=sk-cn-your-api-key-here
backend/main.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import json
3
+ import os
4
+ import time
5
+ from typing import Optional
6
+
7
+ import httpx
8
+ import pdfplumber
9
+ from docx import Document as DocxDocument
10
+ from dotenv import load_dotenv
11
+ from fastapi import FastAPI, HTTPException, UploadFile
12
+ from fastapi.middleware.cors import CORSMiddleware
13
+ from fastapi.responses import StreamingResponse
14
+ from pydantic import BaseModel, Field
15
+
16
+ load_dotenv()
17
+
18
+ CONCENTRATE_API_KEY = os.getenv("CONCENTRATE_API_KEY", "")
19
+ CONCENTRATE_BASE_URL = "https://api.concentrate.ai/v1"
20
+
21
+ app = FastAPI(title="LLM Arena Backend")
22
+
23
+ app.add_middleware(
24
+ CORSMiddleware,
25
+ allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
26
+ allow_credentials=True,
27
+ allow_methods=["*"],
28
+ allow_headers=["*"],
29
+ )
30
+
31
+ # ── Model list cache ──────────────────────────────────────────────────────────
32
+
33
+ _models_cache: dict = {"data": None, "fetched_at": 0.0}
34
+ CACHE_TTL_SECONDS = 300
35
+
36
+
37
+ def _headers() -> dict[str, str]:
38
+ return {
39
+ "Authorization": f"Bearer {CONCENTRATE_API_KEY}",
40
+ "Content-Type": "application/json",
41
+ }
42
+
43
+
44
+ @app.get("/api/models")
45
+ async def list_models():
46
+ """Return available models, cached for 5 minutes."""
47
+ now = time.time()
48
+ if _models_cache["data"] and now - _models_cache["fetched_at"] < CACHE_TTL_SECONDS:
49
+ return _models_cache["data"]
50
+
51
+ if not CONCENTRATE_API_KEY:
52
+ raise HTTPException(status_code=500, detail="CONCENTRATE_API_KEY not set")
53
+
54
+ async with httpx.AsyncClient(timeout=15) as client:
55
+ resp = await client.get(f"{CONCENTRATE_BASE_URL}/models", headers=_headers())
56
+
57
+ if resp.status_code != 200:
58
+ raise HTTPException(status_code=resp.status_code, detail=resp.text)
59
+
60
+ raw = resp.json()
61
+ models_list = raw.get("data", raw) if isinstance(raw, dict) else raw
62
+
63
+ simplified = []
64
+ for m in models_list:
65
+ slug = m.get("slug", "")
66
+ display_name = m.get("name", slug)
67
+ author = m.get("author", {}).get("slug", "unknown")
68
+ providers = m.get("providers", {})
69
+
70
+ if providers:
71
+ for provider_slug in providers:
72
+ model_id = f"{provider_slug}/{slug}"
73
+ simplified.append({"id": model_id, "name": display_name, "provider": provider_slug})
74
+ else:
75
+ simplified.append({"id": slug, "name": display_name, "provider": author})
76
+
77
+ simplified.sort(key=lambda x: (x["provider"], x["name"]))
78
+ _models_cache["data"] = simplified
79
+ _models_cache["fetched_at"] = now
80
+ return simplified
81
+
82
+
83
+ # ── File upload ───────────────────────────────────────────────────────────────
84
+
85
+ MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
86
+ ALLOWED_EXTENSIONS = {".pdf", ".docx", ".doc", ".txt", ".md"}
87
+
88
+
89
+ def _extract_text_pdf(data: bytes) -> str:
90
+ with pdfplumber.open(io.BytesIO(data)) as pdf:
91
+ pages = []
92
+ for page in pdf.pages:
93
+ text = page.extract_text()
94
+ if text:
95
+ pages.append(text)
96
+ return "\n\n".join(pages)
97
+
98
+
99
+ def _extract_text_docx(data: bytes) -> str:
100
+ doc = DocxDocument(io.BytesIO(data))
101
+ return "\n\n".join(p.text for p in doc.paragraphs if p.text.strip())
102
+
103
+
104
+ def _extract_text_plain(data: bytes) -> str:
105
+ return data.decode("utf-8", errors="replace")
106
+
107
+
108
+ @app.post("/api/upload")
109
+ async def upload_file(file: UploadFile):
110
+ """Extract text from an uploaded PDF, DOCX, or TXT file."""
111
+ if not file.filename:
112
+ raise HTTPException(status_code=400, detail="No filename provided")
113
+
114
+ ext = os.path.splitext(file.filename)[1].lower()
115
+ if ext not in ALLOWED_EXTENSIONS:
116
+ raise HTTPException(
117
+ status_code=400,
118
+ detail=f"Unsupported file type '{ext}'. Allowed: {', '.join(sorted(ALLOWED_EXTENSIONS))}",
119
+ )
120
+
121
+ data = await file.read()
122
+ if len(data) > MAX_FILE_SIZE:
123
+ raise HTTPException(status_code=400, detail="File exceeds 10 MB limit")
124
+
125
+ try:
126
+ if ext == ".pdf":
127
+ text = _extract_text_pdf(data)
128
+ elif ext in (".docx", ".doc"):
129
+ text = _extract_text_docx(data)
130
+ else:
131
+ text = _extract_text_plain(data)
132
+ except Exception as e:
133
+ raise HTTPException(status_code=422, detail=f"Failed to extract text: {e}")
134
+
135
+ if not text.strip():
136
+ raise HTTPException(status_code=422, detail="No readable text found in file")
137
+
138
+ char_count = len(text)
139
+ word_count = len(text.split())
140
+
141
+ return {
142
+ "filename": file.filename,
143
+ "text": text,
144
+ "char_count": char_count,
145
+ "word_count": word_count,
146
+ }
147
+
148
+
149
+ # ── Streaming proxy ───────────────────────────────────────────────────────────
150
+
151
+
152
+ class StreamRequest(BaseModel):
153
+ model: str
154
+ prompt: str
155
+ document_text: Optional[str] = None
156
+ temperature: Optional[float] = Field(None, ge=0, le=2)
157
+ max_output_tokens: Optional[int] = Field(None, ge=1, le=16384)
158
+ top_p: Optional[float] = Field(None, ge=0, le=1)
159
+
160
+
161
+ @app.post("/api/stream")
162
+ async def stream_response(req: StreamRequest):
163
+ """SSE proxy: streams a Concentrate AI response back to the browser."""
164
+ if not CONCENTRATE_API_KEY:
165
+ raise HTTPException(status_code=500, detail="CONCENTRATE_API_KEY not set")
166
+
167
+ if req.document_text:
168
+ full_input = (
169
+ f"The user has provided the following document for context:\n\n"
170
+ f"---\n{req.document_text}\n---\n\n"
171
+ f"User's question/instruction: {req.prompt}"
172
+ )
173
+ else:
174
+ full_input = req.prompt
175
+
176
+ body: dict = {
177
+ "model": req.model,
178
+ "input": full_input,
179
+ "stream": True,
180
+ }
181
+ if req.temperature is not None:
182
+ body["temperature"] = req.temperature
183
+ if req.max_output_tokens is not None:
184
+ body["max_output_tokens"] = req.max_output_tokens
185
+ if req.top_p is not None:
186
+ body["top_p"] = req.top_p
187
+
188
+ async def event_generator():
189
+ start_time = time.time()
190
+ first_token_time: float | None = None
191
+
192
+ async with httpx.AsyncClient(timeout=httpx.Timeout(connect=10, read=120, write=10, pool=10)) as client:
193
+ async with client.stream(
194
+ "POST",
195
+ f"{CONCENTRATE_BASE_URL}/responses",
196
+ headers=_headers(),
197
+ json=body,
198
+ ) as resp:
199
+ if resp.status_code != 200:
200
+ error_body = await resp.aread()
201
+ yield f"event: error\ndata: {json.dumps({'error': error_body.decode()})}\n\n"
202
+ return
203
+
204
+ async for raw_line in resp.aiter_lines():
205
+ if not raw_line.strip():
206
+ continue
207
+
208
+ if raw_line.startswith("event: "):
209
+ yield raw_line + "\n"
210
+ continue
211
+
212
+ if raw_line.startswith("data: "):
213
+ data_str = raw_line[6:]
214
+ try:
215
+ event = json.loads(data_str)
216
+ except json.JSONDecodeError:
217
+ yield raw_line + "\n\n"
218
+ continue
219
+
220
+ event_type = event.get("type", "")
221
+
222
+ if event_type == "response.output_text.delta" and first_token_time is None:
223
+ first_token_time = time.time()
224
+
225
+ yield raw_line + "\n\n"
226
+
227
+ if event_type in ("response.completed", "response.failed", "response.incomplete"):
228
+ end_time = time.time()
229
+ stats = {
230
+ "type": "arena.stats",
231
+ "total_time_ms": round((end_time - start_time) * 1000),
232
+ "time_to_first_token_ms": (
233
+ round((first_token_time - start_time) * 1000)
234
+ if first_token_time
235
+ else None
236
+ ),
237
+ }
238
+ if event_type == "response.completed":
239
+ usage = event.get("response", {}).get("usage", {})
240
+ stats["usage"] = usage
241
+ yield f"event: arena.stats\ndata: {json.dumps(stats)}\n\n"
242
+ else:
243
+ yield raw_line + "\n\n"
244
+
245
+ return StreamingResponse(
246
+ event_generator(),
247
+ media_type="text/event-stream",
248
+ headers={
249
+ "Cache-Control": "no-cache",
250
+ "Connection": "keep-alive",
251
+ "X-Accel-Buffering": "no",
252
+ },
253
+ )
254
+
255
+
256
+ @app.get("/api/health")
257
+ async def health():
258
+ return {"status": "ok"}
backend/requirements.txt ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ fastapi
2
+ uvicorn[standard]
3
+ httpx
4
+ python-dotenv
5
+ pdfplumber
6
+ python-docx
7
+ python-multipart
frontend/.gitignore ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Logs
2
+ logs
3
+ *.log
4
+ npm-debug.log*
5
+ yarn-debug.log*
6
+ yarn-error.log*
7
+ pnpm-debug.log*
8
+ lerna-debug.log*
9
+
10
+ node_modules
11
+ dist
12
+ dist-ssr
13
+ *.local
14
+
15
+ # Editor directories and files
16
+ .vscode/*
17
+ !.vscode/extensions.json
18
+ .idea
19
+ .DS_Store
20
+ *.suo
21
+ *.ntvs*
22
+ *.njsproj
23
+ *.sln
24
+ *.sw?
frontend/eslint.config.js ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import js from '@eslint/js'
2
+ import globals from 'globals'
3
+ import reactHooks from 'eslint-plugin-react-hooks'
4
+ import reactRefresh from 'eslint-plugin-react-refresh'
5
+ import tseslint from 'typescript-eslint'
6
+ import { defineConfig, globalIgnores } from 'eslint/config'
7
+
8
+ export default defineConfig([
9
+ globalIgnores(['dist']),
10
+ {
11
+ files: ['**/*.{ts,tsx}'],
12
+ extends: [
13
+ js.configs.recommended,
14
+ tseslint.configs.recommended,
15
+ reactHooks.configs.flat.recommended,
16
+ reactRefresh.configs.vite,
17
+ ],
18
+ languageOptions: {
19
+ ecmaVersion: 2020,
20
+ globals: globals.browser,
21
+ },
22
+ },
23
+ ])
frontend/index.html ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
+ <title>LLM Arena</title>
8
+ </head>
9
+ <body>
10
+ <div id="root"></div>
11
+ <script type="module" src="/src/main.tsx"></script>
12
+ </body>
13
+ </html>
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "tsc -b && vite build",
9
+ "lint": "eslint .",
10
+ "preview": "vite preview"
11
+ },
12
+ "dependencies": {
13
+ "katex": "^0.16.38",
14
+ "react": "^19.2.4",
15
+ "react-dom": "^19.2.4",
16
+ "react-markdown": "^10.1.0",
17
+ "rehype-katex": "^7.0.1",
18
+ "remark-math": "^6.0.0"
19
+ },
20
+ "devDependencies": {
21
+ "@eslint/js": "^9.39.4",
22
+ "@tailwindcss/vite": "^4.2.1",
23
+ "@types/node": "^24.12.0",
24
+ "@types/react": "^19.2.14",
25
+ "@types/react-dom": "^19.2.3",
26
+ "@vitejs/plugin-react": "^6.0.0",
27
+ "eslint": "^9.39.4",
28
+ "eslint-plugin-react-hooks": "^7.0.1",
29
+ "eslint-plugin-react-refresh": "^0.5.2",
30
+ "globals": "^17.4.0",
31
+ "tailwindcss": "^4.2.1",
32
+ "typescript": "~5.9.3",
33
+ "typescript-eslint": "^8.56.1",
34
+ "vite": "^8.0.0"
35
+ }
36
+ }
frontend/src/App.tsx ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import { fetchModels, type Model } from "./lib/api";
3
+ import { useStream, type LLMParams } from "./hooks/useStream";
4
+ import FileUpload, { type UploadedFile } from "./components/FileUpload";
5
+ import ModelPicker from "./components/ModelPicker";
6
+ import ParamsPanel from "./components/ParamsPanel";
7
+ import PromptInput from "./components/PromptInput";
8
+ import StreamPanel from "./components/StreamPanel";
9
+ import StatsBar from "./components/StatsBar";
10
+
11
+ const DEFAULT_PARAMS: LLMParams = {
12
+ temperature: null,
13
+ maxOutputTokens: null,
14
+ topP: null,
15
+ };
16
+
17
+ export default function App() {
18
+ const [models, setModels] = useState<Model[]>([]);
19
+ const [modelsError, setModelsError] = useState<string | null>(null);
20
+ const [modelA, setModelA] = useState("");
21
+ const [modelB, setModelB] = useState("");
22
+ const [llmParams, setLlmParams] = useState<LLMParams>(DEFAULT_PARAMS);
23
+ const [uploadedFile, setUploadedFile] = useState<UploadedFile | null>(null);
24
+
25
+ const streamA = useStream();
26
+ const streamB = useStream();
27
+
28
+ const isStreaming =
29
+ streamA.status === "streaming" || streamB.status === "streaming";
30
+
31
+ useEffect(() => {
32
+ fetchModels()
33
+ .then(setModels)
34
+ .catch((err) => setModelsError(err.message));
35
+ }, []);
36
+
37
+ const handleBattle = useCallback(
38
+ (prompt: string) => {
39
+ if (!modelA || !modelB) return;
40
+ const docText = uploadedFile?.text ?? null;
41
+ streamA.start(modelA, prompt, llmParams, docText);
42
+ streamB.start(modelB, prompt, llmParams, docText);
43
+ },
44
+ [modelA, modelB, streamA, streamB, llmParams, uploadedFile],
45
+ );
46
+
47
+ return (
48
+ <div className="min-h-screen flex flex-col">
49
+ {/* Header */}
50
+ <header className="border-b border-arena-border bg-arena-surface/50 backdrop-blur-sm sticky top-0 z-10">
51
+ <div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
52
+ <div className="flex items-center gap-3">
53
+ <h1 className="text-lg font-bold tracking-tight">
54
+ <span className="text-arena-accent">LLM</span> Arena
55
+ </h1>
56
+ <span className="text-[10px] text-arena-muted border border-arena-border rounded px-1.5 py-0.5">
57
+ powered by Concentrate AI
58
+ </span>
59
+ </div>
60
+ <div className="flex items-center gap-4 text-xs text-arena-muted">
61
+ <span>{models.length} models available</span>
62
+ </div>
63
+ </div>
64
+ </header>
65
+
66
+ {/* Main content */}
67
+ <main className="flex-1 max-w-7xl w-full mx-auto px-4 py-6 flex flex-col gap-6">
68
+ {/* Error state */}
69
+ {modelsError && (
70
+ <div className="bg-arena-red/10 border border-arena-red/30 rounded-xl p-4 text-sm text-arena-red">
71
+ Failed to load models: {modelsError}. Make sure the backend is
72
+ running at <code className="font-mono">localhost:8000</code>.
73
+ </div>
74
+ )}
75
+
76
+ {/* Model pickers */}
77
+ <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
78
+ <ModelPicker
79
+ models={models}
80
+ value={modelA}
81
+ onChange={setModelA}
82
+ label="Model A (Left)"
83
+ disabled={isStreaming}
84
+ />
85
+ <ModelPicker
86
+ models={models}
87
+ value={modelB}
88
+ onChange={setModelB}
89
+ label="Model B (Right)"
90
+ disabled={isStreaming}
91
+ />
92
+ </div>
93
+
94
+ {/* LLM parameters */}
95
+ <ParamsPanel
96
+ params={llmParams}
97
+ onChange={setLlmParams}
98
+ disabled={isStreaming}
99
+ />
100
+
101
+ {/* File upload */}
102
+ <FileUpload
103
+ file={uploadedFile}
104
+ onFileChange={setUploadedFile}
105
+ disabled={isStreaming}
106
+ />
107
+
108
+ {/* Prompt input */}
109
+ <PromptInput
110
+ onSubmit={handleBattle}
111
+ disabled={isStreaming || !modelA || !modelB}
112
+ />
113
+
114
+ {/* Stream panels */}
115
+ <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 flex-1">
116
+ <StreamPanel
117
+ modelId={modelA}
118
+ text={streamA.text}
119
+ status={streamA.status}
120
+ stats={streamA.stats}
121
+ />
122
+ <StreamPanel
123
+ modelId={modelB}
124
+ text={streamB.text}
125
+ status={streamB.status}
126
+ stats={streamB.stats}
127
+ />
128
+ </div>
129
+
130
+ {/* Stats comparison bar */}
131
+ <StatsBar
132
+ modelA={modelA}
133
+ modelB={modelB}
134
+ statsA={streamA.stats}
135
+ statsB={streamB.stats}
136
+ statusA={streamA.status}
137
+ statusB={streamB.status}
138
+ />
139
+ </main>
140
+
141
+ {/* Footer */}
142
+ <footer className="border-t border-arena-border py-3 text-center text-[11px] text-arena-muted">
143
+ LLM Arena &middot; Testing Concentrate AI&apos;s unified model API
144
+ </footer>
145
+ </div>
146
+ );
147
+ }
frontend/src/components/FileUpload.tsx ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useRef, useState } from "react";
2
+
3
+ export interface UploadedFile {
4
+ filename: string;
5
+ text: string;
6
+ charCount: number;
7
+ wordCount: number;
8
+ }
9
+
10
+ interface FileUploadProps {
11
+ file: UploadedFile | null;
12
+ onFileChange: (file: UploadedFile | null) => void;
13
+ disabled: boolean;
14
+ }
15
+
16
+ export default function FileUpload({ file, onFileChange, disabled }: FileUploadProps) {
17
+ const [uploading, setUploading] = useState(false);
18
+ const [error, setError] = useState<string | null>(null);
19
+ const inputRef = useRef<HTMLInputElement>(null);
20
+
21
+ const handleUpload = useCallback(
22
+ async (e: React.ChangeEvent<HTMLInputElement>) => {
23
+ const selected = e.target.files?.[0];
24
+ if (!selected) return;
25
+
26
+ setError(null);
27
+ setUploading(true);
28
+
29
+ try {
30
+ const formData = new FormData();
31
+ formData.append("file", selected);
32
+
33
+ const res = await fetch("/api/upload", {
34
+ method: "POST",
35
+ body: formData,
36
+ });
37
+
38
+ if (!res.ok) {
39
+ const data = await res.json().catch(() => ({ detail: res.statusText }));
40
+ throw new Error(data.detail || `Upload failed (${res.status})`);
41
+ }
42
+
43
+ const data = await res.json();
44
+ onFileChange({
45
+ filename: data.filename,
46
+ text: data.text,
47
+ charCount: data.char_count,
48
+ wordCount: data.word_count,
49
+ });
50
+ } catch (err: unknown) {
51
+ setError(err instanceof Error ? err.message : String(err));
52
+ } finally {
53
+ setUploading(false);
54
+ if (inputRef.current) inputRef.current.value = "";
55
+ }
56
+ },
57
+ [onFileChange],
58
+ );
59
+
60
+ const handleRemove = () => {
61
+ onFileChange(null);
62
+ setError(null);
63
+ };
64
+
65
+ return (
66
+ <div className="border border-arena-border rounded-xl bg-arena-surface p-4">
67
+ <div className="flex items-center justify-between mb-2">
68
+ <h3 className="text-xs font-semibold text-arena-muted uppercase tracking-wider">
69
+ Document Context
70
+ </h3>
71
+ <span className="text-[10px] text-arena-muted">PDF, DOCX, TXT (max 10 MB)</span>
72
+ </div>
73
+
74
+ {!file ? (
75
+ <div className="flex flex-col items-center gap-2">
76
+ <label
77
+ className={`w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed
78
+ border-arena-border rounded-lg cursor-pointer transition-colors
79
+ hover:border-arena-accent/50 hover:bg-arena-accent/5
80
+ ${disabled || uploading ? "opacity-50 pointer-events-none" : ""}`}
81
+ >
82
+ <svg className="w-5 h-5 text-arena-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
83
+ <path strokeLinecap="round" strokeLinejoin="round" d="M12 16.5V9.75m0 0 3 3m-3-3-3 3M6.75 19.5a4.5 4.5 0 0 1-1.41-8.775 5.25 5.25 0 0 1 10.233-2.33 3 3 0 0 1 3.758 3.848A3.752 3.752 0 0 1 18 19.5H6.75Z" />
84
+ </svg>
85
+ <span className="text-sm text-arena-muted">
86
+ {uploading ? "Extracting text..." : "Upload a document"}
87
+ </span>
88
+ <input
89
+ ref={inputRef}
90
+ type="file"
91
+ accept=".pdf,.docx,.doc,.txt,.md"
92
+ onChange={handleUpload}
93
+ disabled={disabled || uploading}
94
+ className="hidden"
95
+ />
96
+ </label>
97
+ {error && (
98
+ <p className="text-xs text-arena-red">{error}</p>
99
+ )}
100
+ </div>
101
+ ) : (
102
+ <div className="flex items-center gap-3 bg-arena-bg rounded-lg px-3 py-2">
103
+ <svg className="w-5 h-5 text-arena-accent shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
104
+ <path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z" />
105
+ </svg>
106
+ <div className="flex-1 min-w-0">
107
+ <p className="text-sm font-medium text-arena-text truncate">{file.filename}</p>
108
+ <p className="text-[11px] text-arena-muted">
109
+ {file.wordCount.toLocaleString()} words &middot; {file.charCount.toLocaleString()} chars
110
+ </p>
111
+ </div>
112
+ <button
113
+ onClick={handleRemove}
114
+ disabled={disabled}
115
+ className="p-1 rounded hover:bg-arena-border transition-colors text-arena-muted hover:text-arena-red
116
+ disabled:opacity-50 disabled:cursor-not-allowed"
117
+ title="Remove file"
118
+ >
119
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
120
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
121
+ </svg>
122
+ </button>
123
+ </div>
124
+ )}
125
+ </div>
126
+ );
127
+ }
frontend/src/components/ModelPicker.tsx ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { Model } from "../lib/api";
2
+
3
+ interface ModelPickerProps {
4
+ models: Model[];
5
+ value: string;
6
+ onChange: (modelId: string) => void;
7
+ label: string;
8
+ disabled?: boolean;
9
+ }
10
+
11
+ const PROVIDER_COLORS: Record<string, string> = {
12
+ openai: "bg-green-600",
13
+ anthropic: "bg-orange-600",
14
+ google: "bg-blue-600",
15
+ xai: "bg-purple-600",
16
+ mistral: "bg-cyan-600",
17
+ cohere: "bg-pink-600",
18
+ aws: "bg-yellow-600",
19
+ azure: "bg-sky-600",
20
+ cloudflare: "bg-amber-600",
21
+ };
22
+
23
+ function providerColor(provider: string): string {
24
+ return PROVIDER_COLORS[provider] ?? "bg-gray-600";
25
+ }
26
+
27
+ export default function ModelPicker({
28
+ models,
29
+ value,
30
+ onChange,
31
+ label,
32
+ disabled,
33
+ }: ModelPickerProps) {
34
+ const grouped = models.reduce<Record<string, Model[]>>((acc, m) => {
35
+ (acc[m.provider] ??= []).push(m);
36
+ return acc;
37
+ }, {});
38
+
39
+ const providers = Object.keys(grouped).sort();
40
+
41
+ return (
42
+ <div className="flex flex-col gap-1.5">
43
+ <label className="text-xs font-medium text-arena-muted uppercase tracking-wider">
44
+ {label}
45
+ </label>
46
+ <select
47
+ value={value}
48
+ onChange={(e) => onChange(e.target.value)}
49
+ disabled={disabled}
50
+ className="bg-arena-surface border border-arena-border rounded-lg px-3 py-2.5 text-sm
51
+ text-arena-text focus:outline-none focus:border-arena-accent
52
+ disabled:opacity-50 disabled:cursor-not-allowed appearance-none
53
+ cursor-pointer"
54
+ >
55
+ <option value="">Select a model...</option>
56
+ {providers.map((provider) => (
57
+ <optgroup key={provider} label={provider.toUpperCase()}>
58
+ {grouped[provider].map((m) => (
59
+ <option key={m.id} value={m.id}>
60
+ {m.name}
61
+ </option>
62
+ ))}
63
+ </optgroup>
64
+ ))}
65
+ </select>
66
+ {value && (
67
+ <span
68
+ className={`inline-flex self-start items-center px-2 py-0.5 rounded text-[10px] font-semibold uppercase text-white ${providerColor(value.split("/")[0])}`}
69
+ >
70
+ {value.split("/")[0]}
71
+ </span>
72
+ )}
73
+ </div>
74
+ );
75
+ }
frontend/src/components/ParamsPanel.tsx ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { LLMParams } from "../hooks/useStream";
2
+
3
+ interface ParamsPanelProps {
4
+ params: LLMParams;
5
+ onChange: (params: LLMParams) => void;
6
+ disabled: boolean;
7
+ }
8
+
9
+ function Slider({
10
+ label,
11
+ value,
12
+ defaultValue,
13
+ min,
14
+ max,
15
+ step,
16
+ onChange,
17
+ disabled,
18
+ format,
19
+ }: {
20
+ label: string;
21
+ value: number | null;
22
+ defaultValue: number;
23
+ min: number;
24
+ max: number;
25
+ step: number;
26
+ onChange: (v: number | null) => void;
27
+ disabled: boolean;
28
+ format?: (v: number) => string;
29
+ }) {
30
+ const active = value != null;
31
+ const displayValue = value ?? defaultValue;
32
+ const fmt = format ?? ((v: number) => String(v));
33
+
34
+ return (
35
+ <div className="flex flex-col gap-1.5">
36
+ <div className="flex items-center justify-between">
37
+ <label className="flex items-center gap-2">
38
+ <input
39
+ type="checkbox"
40
+ checked={active}
41
+ onChange={(e) => onChange(e.target.checked ? defaultValue : null)}
42
+ disabled={disabled}
43
+ className="rounded border-arena-border bg-arena-surface text-arena-accent
44
+ focus:ring-arena-accent focus:ring-offset-0 w-3.5 h-3.5"
45
+ />
46
+ <span className="text-xs text-arena-muted">{label}</span>
47
+ </label>
48
+ {active && (
49
+ <span className="text-xs font-mono text-arena-text">
50
+ {fmt(displayValue)}
51
+ </span>
52
+ )}
53
+ </div>
54
+ {active && (
55
+ <input
56
+ type="range"
57
+ min={min}
58
+ max={max}
59
+ step={step}
60
+ value={displayValue}
61
+ onChange={(e) => onChange(parseFloat(e.target.value))}
62
+ disabled={disabled}
63
+ className="w-full h-1.5 rounded-full appearance-none cursor-pointer
64
+ bg-arena-border accent-arena-accent
65
+ disabled:opacity-50 disabled:cursor-not-allowed"
66
+ />
67
+ )}
68
+ </div>
69
+ );
70
+ }
71
+
72
+ export default function ParamsPanel({
73
+ params,
74
+ onChange,
75
+ disabled,
76
+ }: ParamsPanelProps) {
77
+ return (
78
+ <div className="border border-arena-border rounded-xl bg-arena-surface p-4">
79
+ <h3 className="text-xs font-semibold text-arena-muted uppercase tracking-wider mb-3">
80
+ Parameters
81
+ </h3>
82
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
83
+ <Slider
84
+ label="Temperature"
85
+ value={params.temperature}
86
+ defaultValue={1.0}
87
+ min={0}
88
+ max={2}
89
+ step={0.05}
90
+ onChange={(v) => onChange({ ...params, temperature: v })}
91
+ disabled={disabled}
92
+ />
93
+ <Slider
94
+ label="Max Output Tokens"
95
+ value={params.maxOutputTokens}
96
+ defaultValue={2048}
97
+ min={64}
98
+ max={16384}
99
+ step={64}
100
+ onChange={(v) => onChange({ ...params, maxOutputTokens: v != null ? Math.round(v) : null })}
101
+ disabled={disabled}
102
+ format={(v) => v.toLocaleString()}
103
+ />
104
+ <Slider
105
+ label="Top P"
106
+ value={params.topP}
107
+ defaultValue={1.0}
108
+ min={0}
109
+ max={1}
110
+ step={0.05}
111
+ onChange={(v) => onChange({ ...params, topP: v })}
112
+ disabled={disabled}
113
+ />
114
+ </div>
115
+ </div>
116
+ );
117
+ }
frontend/src/components/PromptInput.tsx ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState } from "react";
2
+
3
+ interface PromptInputProps {
4
+ onSubmit: (prompt: string) => void;
5
+ disabled: boolean;
6
+ }
7
+
8
+ const EXAMPLE_PROMPTS = [
9
+ "Explain quantum entanglement to a 10-year-old",
10
+ "Write a Python function to find the longest palindromic substring",
11
+ "Compare the pros and cons of microservices vs monoliths",
12
+ "Write a short poem about artificial intelligence",
13
+ "Explain how a transformer neural network works",
14
+ ];
15
+
16
+ export default function PromptInput({ onSubmit, disabled }: PromptInputProps) {
17
+ const [prompt, setPrompt] = useState("");
18
+
19
+ const handleSubmit = () => {
20
+ const trimmed = prompt.trim();
21
+ if (!trimmed) return;
22
+ onSubmit(trimmed);
23
+ };
24
+
25
+ const handleKeyDown = (e: React.KeyboardEvent) => {
26
+ if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
27
+ e.preventDefault();
28
+ handleSubmit();
29
+ }
30
+ };
31
+
32
+ return (
33
+ <div className="flex flex-col gap-3">
34
+ <div className="relative">
35
+ <textarea
36
+ value={prompt}
37
+ onChange={(e) => setPrompt(e.target.value)}
38
+ onKeyDown={handleKeyDown}
39
+ placeholder="Enter your prompt here..."
40
+ disabled={disabled}
41
+ rows={3}
42
+ className="w-full bg-arena-surface border border-arena-border rounded-xl px-4 py-3
43
+ text-sm text-arena-text placeholder:text-arena-muted/50
44
+ focus:outline-none focus:border-arena-accent resize-none
45
+ disabled:opacity-50 disabled:cursor-not-allowed"
46
+ />
47
+ <div className="absolute bottom-2 right-2 flex items-center gap-2">
48
+ <span className="text-[10px] text-arena-muted hidden sm:inline">
49
+ {navigator.platform.includes("Mac") ? "Cmd" : "Ctrl"}+Enter
50
+ </span>
51
+ <button
52
+ onClick={handleSubmit}
53
+ disabled={disabled || !prompt.trim()}
54
+ className="px-4 py-1.5 bg-arena-accent hover:bg-arena-accent-dim rounded-lg
55
+ text-xs font-semibold text-white transition-colors
56
+ disabled:opacity-40 disabled:cursor-not-allowed"
57
+ >
58
+ {disabled ? "Streaming..." : "Battle!"}
59
+ </button>
60
+ </div>
61
+ </div>
62
+
63
+ <div className="flex flex-wrap gap-2">
64
+ <span className="text-[10px] text-arena-muted uppercase tracking-wider self-center">
65
+ Try:
66
+ </span>
67
+ {EXAMPLE_PROMPTS.map((ex) => (
68
+ <button
69
+ key={ex}
70
+ onClick={() => setPrompt(ex)}
71
+ disabled={disabled}
72
+ className="text-[11px] px-2.5 py-1 rounded-full border border-arena-border
73
+ text-arena-muted hover:text-arena-text hover:border-arena-accent/50
74
+ transition-colors disabled:opacity-40 truncate max-w-[200px]"
75
+ >
76
+ {ex}
77
+ </button>
78
+ ))}
79
+ </div>
80
+ </div>
81
+ );
82
+ }
frontend/src/components/StatsBar.tsx ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { StreamStats, StreamStatus } from "../hooks/useStream";
2
+
3
+ interface StatsBarProps {
4
+ modelA: string;
5
+ modelB: string;
6
+ statsA: StreamStats;
7
+ statsB: StreamStats;
8
+ statusA: StreamStatus;
9
+ statusB: StreamStatus;
10
+ }
11
+
12
+ function Metric({
13
+ label,
14
+ valueA,
15
+ valueB,
16
+ format,
17
+ lowerIsBetter = true,
18
+ }: {
19
+ label: string;
20
+ valueA: number | null;
21
+ valueB: number | null;
22
+ format: (v: number) => string;
23
+ lowerIsBetter?: boolean;
24
+ }) {
25
+ if (valueA == null && valueB == null) return null;
26
+
27
+ let winnerA = false;
28
+ let winnerB = false;
29
+
30
+ if (valueA != null && valueB != null) {
31
+ if (lowerIsBetter) {
32
+ winnerA = valueA < valueB;
33
+ winnerB = valueB < valueA;
34
+ } else {
35
+ winnerA = valueA > valueB;
36
+ winnerB = valueB > valueA;
37
+ }
38
+ }
39
+
40
+ return (
41
+ <div className="flex flex-col items-center gap-1 min-w-[120px]">
42
+ <span className="text-[10px] text-arena-muted uppercase tracking-wider">
43
+ {label}
44
+ </span>
45
+ <div className="flex items-center gap-4">
46
+ <span
47
+ className={`text-sm font-mono font-semibold ${winnerA ? "text-arena-green" : "text-arena-text"}`}
48
+ >
49
+ {valueA != null ? format(valueA) : "-"}
50
+ {winnerA && " *"}
51
+ </span>
52
+ <span className="text-arena-border text-xs">vs</span>
53
+ <span
54
+ className={`text-sm font-mono font-semibold ${winnerB ? "text-arena-green" : "text-arena-text"}`}
55
+ >
56
+ {valueB != null ? format(valueB) : "-"}
57
+ {winnerB && " *"}
58
+ </span>
59
+ </div>
60
+ </div>
61
+ );
62
+ }
63
+
64
+ export default function StatsBar({
65
+ modelA,
66
+ modelB,
67
+ statsA,
68
+ statsB,
69
+ statusA,
70
+ statusB,
71
+ }: StatsBarProps) {
72
+ const bothDone = statusA === "done" && statusB === "done";
73
+ const anyStarted = statusA !== "idle" || statusB !== "idle";
74
+
75
+ if (!anyStarted) return null;
76
+
77
+ return (
78
+ <div className="border border-arena-border rounded-xl bg-arena-surface p-4">
79
+ <div className="flex items-center justify-between mb-3">
80
+ <h3 className="text-xs font-semibold text-arena-muted uppercase tracking-wider">
81
+ Comparison
82
+ </h3>
83
+ {bothDone && (
84
+ <span className="text-[10px] text-arena-green font-medium">
85
+ * = winner in category
86
+ </span>
87
+ )}
88
+ </div>
89
+
90
+ <div className="flex items-center justify-between mb-2 px-4">
91
+ <span className="text-xs font-medium text-arena-accent w-[120px] text-center truncate">
92
+ {modelA.split("/").pop()}
93
+ </span>
94
+ <div className="flex-1" />
95
+ <span className="text-xs font-medium text-arena-accent w-[120px] text-center truncate">
96
+ {modelB.split("/").pop()}
97
+ </span>
98
+ </div>
99
+
100
+ <div className="flex flex-wrap justify-center gap-6">
101
+ <Metric
102
+ label="Time to First Token"
103
+ valueA={statsA.timeToFirstTokenMs}
104
+ valueB={statsB.timeToFirstTokenMs}
105
+ format={(v) => `${v}ms`}
106
+ lowerIsBetter
107
+ />
108
+ <Metric
109
+ label="Total Time"
110
+ valueA={statsA.totalTimeMs}
111
+ valueB={statsB.totalTimeMs}
112
+ format={(v) => `${(v / 1000).toFixed(1)}s`}
113
+ lowerIsBetter
114
+ />
115
+ <Metric
116
+ label="Output Tokens"
117
+ valueA={statsA.outputTokens}
118
+ valueB={statsB.outputTokens}
119
+ format={(v) => `${v}`}
120
+ lowerIsBetter={false}
121
+ />
122
+ <Metric
123
+ label="Total Tokens"
124
+ valueA={statsA.totalTokens}
125
+ valueB={statsB.totalTokens}
126
+ format={(v) => `${v}`}
127
+ lowerIsBetter
128
+ />
129
+ </div>
130
+ </div>
131
+ );
132
+ }
frontend/src/components/StreamPanel.tsx ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useMemo } from "react";
2
+ import ReactMarkdown from "react-markdown";
3
+ import remarkMath from "remark-math";
4
+ import rehypeKatex from "rehype-katex";
5
+ import "katex/dist/katex.min.css";
6
+ import type { StreamStatus, StreamStats } from "../hooks/useStream";
7
+
8
+ /** Normalize all LaTeX delimiter styles to $/$$ so remark-math can parse them. */
9
+ function normalizeLatex(text: string): string {
10
+ // \[ ... \] → $$ ... $$ (display math)
11
+ let result = text.replace(/\\\[([\s\S]*?)\\\]/g, (_match, inner) => `$$${inner}$$`);
12
+ // \( ... \) → $ ... $ (inline math)
13
+ result = result.replace(/\\\(([\s\S]*?)\\\)/g, (_match, inner) => `$${inner}$`);
14
+ return result;
15
+ }
16
+
17
+ interface StreamPanelProps {
18
+ modelId: string;
19
+ text: string;
20
+ status: StreamStatus;
21
+ stats: StreamStats;
22
+ }
23
+
24
+ function StatusBadge({ status }: { status: StreamStatus }) {
25
+ const config: Record<StreamStatus, { label: string; className: string }> = {
26
+ idle: { label: "Ready", className: "bg-arena-border text-arena-muted" },
27
+ streaming: {
28
+ label: "Streaming",
29
+ className: "bg-arena-accent/20 text-arena-accent",
30
+ },
31
+ done: {
32
+ label: "Complete",
33
+ className: "bg-arena-green/20 text-arena-green",
34
+ },
35
+ error: { label: "Error", className: "bg-arena-red/20 text-arena-red" },
36
+ };
37
+
38
+ const { label, className } = config[status];
39
+
40
+ return (
41
+ <span
42
+ className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded text-[11px] font-medium ${className}`}
43
+ >
44
+ {status === "streaming" && (
45
+ <span className="w-1.5 h-1.5 rounded-full bg-arena-accent animate-pulse-dot" />
46
+ )}
47
+ {label}
48
+ </span>
49
+ );
50
+ }
51
+
52
+ export default function StreamPanel({
53
+ modelId,
54
+ text,
55
+ status,
56
+ stats,
57
+ }: StreamPanelProps) {
58
+ const provider = modelId.split("/")[0] || "";
59
+ const modelName = modelId.split("/").slice(1).join("/") || modelId;
60
+ const normalizedText = useMemo(() => normalizeLatex(text), [text]);
61
+
62
+ return (
63
+ <div className="flex flex-col h-full border border-arena-border rounded-xl bg-arena-surface overflow-hidden">
64
+ {/* Header */}
65
+ <div className="flex items-center justify-between px-4 py-3 border-b border-arena-border bg-arena-surface/50">
66
+ <div className="flex items-center gap-2">
67
+ <span className="font-semibold text-sm">{modelName || "No model"}</span>
68
+ {provider && (
69
+ <span className="text-[10px] text-arena-muted uppercase tracking-wide">
70
+ {provider}
71
+ </span>
72
+ )}
73
+ </div>
74
+ <StatusBadge status={status} />
75
+ </div>
76
+
77
+ {/* Content */}
78
+ <div className="flex-1 overflow-y-auto p-4 min-h-[300px]">
79
+ {status === "idle" && !text && (
80
+ <p className="text-arena-muted text-sm italic">
81
+ Select a model and enter a prompt to begin...
82
+ </p>
83
+ )}
84
+ {text && (
85
+ <div className="markdown-body text-sm leading-relaxed">
86
+ <ReactMarkdown remarkPlugins={[remarkMath]} rehypePlugins={[rehypeKatex]}>
87
+ {normalizedText}
88
+ </ReactMarkdown>
89
+ </div>
90
+ )}
91
+ {status === "streaming" && (
92
+ <span className="inline-block w-2 h-4 bg-arena-accent animate-pulse-dot ml-0.5 align-text-bottom" />
93
+ )}
94
+ </div>
95
+
96
+ {/* Stats footer */}
97
+ {status !== "idle" && (
98
+ <div className="flex items-center gap-4 px-4 py-2 border-t border-arena-border text-[11px] text-arena-muted">
99
+ {stats.timeToFirstTokenMs != null && (
100
+ <span>
101
+ TTFT: <strong className="text-arena-text">{stats.timeToFirstTokenMs}ms</strong>
102
+ </span>
103
+ )}
104
+ {stats.totalTimeMs != null && (
105
+ <span>
106
+ Total: <strong className="text-arena-text">{(stats.totalTimeMs / 1000).toFixed(1)}s</strong>
107
+ </span>
108
+ )}
109
+ {stats.inputTokens != null && (
110
+ <span>
111
+ In: <strong className="text-arena-text">{stats.inputTokens}</strong>
112
+ </span>
113
+ )}
114
+ {stats.outputTokens != null && (
115
+ <span>
116
+ Out: <strong className="text-arena-text">{stats.outputTokens}</strong>
117
+ </span>
118
+ )}
119
+ </div>
120
+ )}
121
+ </div>
122
+ );
123
+ }
frontend/src/hooks/useStream.ts ADDED
@@ -0,0 +1,149 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useCallback, useRef, useState } from "react";
2
+
3
+ export type StreamStatus = "idle" | "streaming" | "done" | "error";
4
+
5
+ export interface StreamStats {
6
+ timeToFirstTokenMs: number | null;
7
+ totalTimeMs: number | null;
8
+ inputTokens: number | null;
9
+ outputTokens: number | null;
10
+ totalTokens: number | null;
11
+ }
12
+
13
+ const EMPTY_STATS: StreamStats = {
14
+ timeToFirstTokenMs: null,
15
+ totalTimeMs: null,
16
+ inputTokens: null,
17
+ outputTokens: null,
18
+ totalTokens: null,
19
+ };
20
+
21
+ export interface LLMParams {
22
+ temperature: number | null;
23
+ maxOutputTokens: number | null;
24
+ topP: number | null;
25
+ }
26
+
27
+ export function useStream() {
28
+ const [text, setText] = useState("");
29
+ const [status, setStatus] = useState<StreamStatus>("idle");
30
+ const [stats, setStats] = useState<StreamStats>(EMPTY_STATS);
31
+ const abortRef = useRef<AbortController | null>(null);
32
+
33
+ const stop = useCallback(() => {
34
+ abortRef.current?.abort();
35
+ abortRef.current = null;
36
+ }, []);
37
+
38
+ const start = useCallback(
39
+ (model: string, prompt: string, llmParams?: LLMParams, documentText?: string | null) => {
40
+ stop();
41
+
42
+ setText("");
43
+ setStatus("streaming");
44
+ setStats(EMPTY_STATS);
45
+
46
+ const controller = new AbortController();
47
+ abortRef.current = controller;
48
+
49
+ const body: Record<string, unknown> = { model, prompt };
50
+ if (documentText) body.document_text = documentText;
51
+ if (llmParams?.temperature != null) body.temperature = llmParams.temperature;
52
+ if (llmParams?.maxOutputTokens != null) body.max_output_tokens = llmParams.maxOutputTokens;
53
+ if (llmParams?.topP != null) body.top_p = llmParams.topP;
54
+
55
+ (async () => {
56
+ try {
57
+ const res = await fetch("/api/stream", {
58
+ method: "POST",
59
+ headers: { "Content-Type": "application/json" },
60
+ body: JSON.stringify(body),
61
+ signal: controller.signal,
62
+ });
63
+
64
+ if (!res.ok || !res.body) {
65
+ const errText = await res.text();
66
+ setText(`Error ${res.status}: ${errText}`);
67
+ setStatus("error");
68
+ return;
69
+ }
70
+
71
+ const reader = res.body.getReader();
72
+ const decoder = new TextDecoder();
73
+ let buffer = "";
74
+
75
+ while (true) {
76
+ const { done, value } = await reader.read();
77
+ if (done) break;
78
+
79
+ buffer += decoder.decode(value, { stream: true });
80
+ const lines = buffer.split("\n");
81
+ buffer = lines.pop() ?? "";
82
+
83
+ for (const line of lines) {
84
+ if (!line.startsWith("data: ")) continue;
85
+ const dataStr = line.slice(6);
86
+
87
+ let event: Record<string, unknown>;
88
+ try {
89
+ event = JSON.parse(dataStr);
90
+ } catch {
91
+ continue;
92
+ }
93
+
94
+ const type = event.type as string;
95
+
96
+ if (type === "response.output_text.delta") {
97
+ const delta = event.delta as string;
98
+ setText((prev) => prev + delta);
99
+ }
100
+
101
+ if (type === "response.failed" || type === "error") {
102
+ const resp = event.response as Record<string, unknown> | undefined;
103
+ const errObj = resp?.error as Record<string, unknown> | undefined;
104
+ const code = errObj?.code ?? (event.code as string | undefined) ?? "unknown";
105
+ const msg = errObj?.message
106
+ ?? (event.message as string | undefined)
107
+ ?? "Request failed";
108
+ const model = (resp?.model ?? event.model ?? "") as string;
109
+ setText((prev) =>
110
+ prev + `\n\n**Error (${code}):** ${msg}` + (model ? `\n\nModel: \`${model}\`` : ""),
111
+ );
112
+ setStatus("error");
113
+ }
114
+
115
+ if (type === "arena.stats") {
116
+ const usage = (event.usage ?? {}) as Record<string, number>;
117
+ setStats({
118
+ timeToFirstTokenMs:
119
+ (event.time_to_first_token_ms as number) ?? null,
120
+ totalTimeMs: (event.total_time_ms as number) ?? null,
121
+ inputTokens: usage.input_tokens ?? null,
122
+ outputTokens: usage.output_tokens ?? null,
123
+ totalTokens: usage.total_tokens ?? null,
124
+ });
125
+ }
126
+
127
+ if (
128
+ type === "response.completed" ||
129
+ type === "response.incomplete"
130
+ ) {
131
+ setStatus("done");
132
+ }
133
+ }
134
+ }
135
+
136
+ setStatus((prev) => (prev === "streaming" ? "done" : prev));
137
+ } catch (err: unknown) {
138
+ if (err instanceof DOMException && err.name === "AbortError") {
139
+ setStatus("done");
140
+ return;
141
+ }
142
+ setText(`Connection error: ${err}`);
143
+ setStatus("error");
144
+ }
145
+ })();
146
+ }, [stop]);
147
+
148
+ return { text, status, stats, start, stop };
149
+ }
frontend/src/index.css ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @import "tailwindcss";
2
+
3
+ @theme {
4
+ --color-arena-bg: #0f1117;
5
+ --color-arena-surface: #1a1d27;
6
+ --color-arena-border: #2a2d3a;
7
+ --color-arena-accent: #6366f1;
8
+ --color-arena-accent-dim: #4f46e5;
9
+ --color-arena-green: #22c55e;
10
+ --color-arena-red: #ef4444;
11
+ --color-arena-text: #e2e8f0;
12
+ --color-arena-muted: #94a3b8;
13
+ }
14
+
15
+ body {
16
+ background-color: var(--color-arena-bg);
17
+ color: var(--color-arena-text);
18
+ font-family: "Inter", system-ui, -apple-system, sans-serif;
19
+ }
20
+
21
+ @keyframes pulse-dot {
22
+ 0%, 100% { opacity: 1; }
23
+ 50% { opacity: 0.4; }
24
+ }
25
+
26
+ .animate-pulse-dot {
27
+ animation: pulse-dot 1.2s ease-in-out infinite;
28
+ }
29
+
30
+ .markdown-body h1, .markdown-body h2, .markdown-body h3 {
31
+ font-weight: 600;
32
+ margin-top: 1em;
33
+ margin-bottom: 0.5em;
34
+ }
35
+
36
+ .markdown-body p {
37
+ margin-bottom: 0.75em;
38
+ line-height: 1.7;
39
+ }
40
+
41
+ .markdown-body code {
42
+ background: var(--color-arena-border);
43
+ padding: 0.15em 0.4em;
44
+ border-radius: 4px;
45
+ font-size: 0.875em;
46
+ }
47
+
48
+ .markdown-body pre {
49
+ background: var(--color-arena-bg);
50
+ padding: 1em;
51
+ border-radius: 8px;
52
+ overflow-x: auto;
53
+ margin-bottom: 1em;
54
+ }
55
+
56
+ .markdown-body pre code {
57
+ background: none;
58
+ padding: 0;
59
+ }
60
+
61
+ .markdown-body ul, .markdown-body ol {
62
+ padding-left: 1.5em;
63
+ margin-bottom: 0.75em;
64
+ }
65
+
66
+ .markdown-body li {
67
+ margin-bottom: 0.25em;
68
+ }
69
+
70
+ .markdown-body .katex-display {
71
+ margin: 1em 0;
72
+ overflow-x: auto;
73
+ overflow-y: hidden;
74
+ }
75
+
76
+ .markdown-body .katex {
77
+ color: var(--color-arena-text);
78
+ font-size: 1.1em;
79
+ }
80
+
81
+ .markdown-body .katex-display > .katex {
82
+ font-size: 1.21em;
83
+ }
frontend/src/lib/api.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface Model {
2
+ id: string;
3
+ name: string;
4
+ provider: string;
5
+ }
6
+
7
+ export async function fetchModels(): Promise<Model[]> {
8
+ const res = await fetch("/api/models");
9
+ if (!res.ok) {
10
+ throw new Error(`Failed to fetch models: ${res.status}`);
11
+ }
12
+ return res.json();
13
+ }
frontend/src/main.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import "./index.css";
4
+ import App from "./App";
5
+
6
+ createRoot(document.getElementById("root")!).render(
7
+ <StrictMode>
8
+ <App />
9
+ </StrictMode>,
10
+ );
frontend/tsconfig.app.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4
+ "target": "ES2023",
5
+ "useDefineForClassFields": true,
6
+ "lib": ["ES2023", "DOM", "DOM.Iterable"],
7
+ "module": "ESNext",
8
+ "types": ["vite/client"],
9
+ "skipLibCheck": true,
10
+
11
+ /* Bundler mode */
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "moduleDetection": "force",
16
+ "noEmit": true,
17
+ "jsx": "react-jsx",
18
+
19
+ /* Linting */
20
+ "strict": true,
21
+ "noUnusedLocals": true,
22
+ "noUnusedParameters": true,
23
+ "erasableSyntaxOnly": true,
24
+ "noFallthroughCasesInSwitch": true,
25
+ "noUncheckedSideEffectImports": true
26
+ },
27
+ "include": ["src"]
28
+ }
frontend/tsconfig.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "files": [],
3
+ "references": [
4
+ { "path": "./tsconfig.app.json" },
5
+ { "path": "./tsconfig.node.json" }
6
+ ]
7
+ }
frontend/tsconfig.node.json ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4
+ "target": "ES2023",
5
+ "lib": ["ES2023"],
6
+ "module": "ESNext",
7
+ "types": ["node"],
8
+ "skipLibCheck": true,
9
+
10
+ /* Bundler mode */
11
+ "moduleResolution": "bundler",
12
+ "allowImportingTsExtensions": true,
13
+ "verbatimModuleSyntax": true,
14
+ "moduleDetection": "force",
15
+ "noEmit": true,
16
+
17
+ /* Linting */
18
+ "strict": true,
19
+ "noUnusedLocals": true,
20
+ "noUnusedParameters": true,
21
+ "erasableSyntaxOnly": true,
22
+ "noFallthroughCasesInSwitch": true,
23
+ "noUncheckedSideEffectImports": true
24
+ },
25
+ "include": ["vite.config.ts"]
26
+ }
frontend/vite.config.ts ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+ import tailwindcss from '@tailwindcss/vite'
4
+
5
+ export default defineConfig({
6
+ plugins: [react(), tailwindcss()],
7
+ server: {
8
+ proxy: {
9
+ '/api': 'http://localhost:8000',
10
+ },
11
+ },
12
+ })