Spaces:
Running
Running
Commit ·
135c42a
0
Parent(s):
LLM Arena: side-by-side model comparison app using Concentrate AI
Browse filesFeatures:
- 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 +21 -0
- FINDINGS.md +96 -0
- README.md +92 -0
- backend/.env.example +1 -0
- backend/main.py +258 -0
- backend/requirements.txt +7 -0
- frontend/.gitignore +24 -0
- frontend/eslint.config.js +23 -0
- frontend/index.html +13 -0
- frontend/package-lock.json +0 -0
- frontend/package.json +36 -0
- frontend/src/App.tsx +147 -0
- frontend/src/components/FileUpload.tsx +127 -0
- frontend/src/components/ModelPicker.tsx +75 -0
- frontend/src/components/ParamsPanel.tsx +117 -0
- frontend/src/components/PromptInput.tsx +82 -0
- frontend/src/components/StatsBar.tsx +132 -0
- frontend/src/components/StreamPanel.tsx +123 -0
- frontend/src/hooks/useStream.ts +149 -0
- frontend/src/index.css +83 -0
- frontend/src/lib/api.ts +13 -0
- frontend/src/main.tsx +10 -0
- frontend/tsconfig.app.json +28 -0
- frontend/tsconfig.json +7 -0
- frontend/tsconfig.node.json +26 -0
- frontend/vite.config.ts +12 -0
.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 · Testing Concentrate AI'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 · {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 |
+
})
|