A lightweight, local-first chat UI for OpenAI-compatible APIs.
It runs entirely in the browser, supports streaming responses, and stores conversations locally.
The clip below shows SD 1.5 text-to-image and image-to-image generation, then PaddleOCR performing OCR on an uploaded image — all from the same interface without any page reload or configuration change.
If the video does not play inline, click here to download / view it.
- OpenAI-compatible chat interface
- Streaming responses
- Markdown rendering with code highlighting and KaTeX math
- Collapsible thinking / reasoning blocks (
<think>tags) - Image generation (Draw mode) and image-to-image via compatible image APIs
- Image upload and paste for vision-capable models
- Audio transcription workflow for compatible APIs
- Multiple API endpoints with per-endpoint model management
- Model capability configuration (Vision, Audio, Draw) per endpoint
- Generated images stored as data URLs — persist after backend restart
- Local conversation history in the browser
- Configurable context window and auto-reset threshold
- Per-response performance metrics — TTFT and decode speed — via an optional backend interface
- Light and dark themes
- Node.js 18+
- An OpenAI-compatible API that supports:
GET /v1/modelsPOST /v1/chat/completions
- Optional image generation:
POST /v1/images/generationsorPOST /v1/images/edits
- Optional audio support:
POST /v1/audio/transcriptions
npm install
npm run devOpen:
http://localhost:5173
- Open Settings.
- Enter your API Base URL.
- Enter your API Key if your provider requires one.
- Set Max Context Tokens to the real limit of your model.
- Adjust Auto-reset Threshold (%) if needed.
- Click Fetch Models — this saves the endpoint and fetches available models in one step.
- Enable the capabilities (Vision, Audio, Draw) that apply to each model.
- Select a model from the top bar.
Use the server root URL. Do not append /v1.
Correct:
http://127.0.0.1:8000
http://127.0.0.1:11434
https://your-api.example.com
Incorrect:
http://127.0.0.1:8000/v1
http://127.0.0.1:11434/v1
Add more than one endpoint in Settings to aggregate models from different servers. The model picker groups models by endpoint and switches the active endpoint automatically when you select a model.
- Send:
Enter - New line:
Shift+Enter - Reset API context only:
/reset - Clear current conversation and API context:
/clean
- Enable Draw for the model in Settings.
- Click the Draw button in the input bar (auto-enabled for Draw-capable models).
- Optionally attach a reference image for image-to-image generation.
- Set a seed for reproducible results.
- Click Regenerate below any generated image to redraw with a new seed.
- Upload an image with the image button, or paste an image into the input box.
- The current model must have Vision enabled.
- Attach an audio file from the input bar.
- The current model must have Audio enabled.
- Your API must support
POST /v1/audio/transcriptions.
The ctx x/y badge in the input bar shows:
- current estimated context usage
- configured context window limit
When usage approaches the configured threshold, the app automatically resets API context before the next send.
After each streamed response, a small metrics row appears under the assistant message. It is fully data-driven: the frontend renders one pill per metric the backend reported, in a fixed order, and nothing when none are present:
⚡ TTFT 40 ms · ⤴ prefill 220 tok/s · ⏱ decode 36 tok/s · in 43 · new 15 · out 512
This is a display UI: every number comes directly from the backend. The frontend never estimates or derives any value — client-side wall-clock timing includes network / proxy overhead and would misrepresent real inference speed (e.g. ~100 ms measured in the browser vs. ~40 ms actual prefill), and token counts are tokenizer/model-specific, not a character count. Send a field and its pill appears; omit it and the pill is not shown.
Request (already sent by the frontend). Every streaming request includes
stream_options.include_usage, asking the backend to emit a final usage chunk:
Response (what the backend provides). In the final SSE chunk (before
[DONE]), put the fields below on the standard usage object. Unknown fields
are ignored by generic OpenAI clients, so this stays fully backward-compatible:
data: {"choices":[],"usage":{
"prompt_tokens": 43, // input tokens → "in 43" (standard field)
"completion_tokens": 512, // output tokens → "out 512" (standard field)
"total_tokens": 555, // not displayed (redundant with in/out)
"ttft_ms": 39.96, // ★ time to first token, milliseconds (server prefill latency)
"prefill_tps": 220.0, // ★ prefill throughput, tokens per second
"decode_tps": 36.13, // ★ decode throughput, tokens per second
"prefill_tokens": 15 // ★ tokens actually prefilled this turn (rest reused from KV cache)
}}
data: [DONE]| Field | Shown as | Meaning |
|---|---|---|
usage.ttft_ms |
TTFT |
Time to first token in milliseconds (server-side prefill latency) |
usage.prefill_tps |
prefill |
Prefill (prompt-processing) throughput in tokens per second |
usage.decode_tps |
decode |
Decode/generation throughput in tokens per second |
usage.prompt_tokens |
in |
Input token count (standard OpenAI field; shown when > 0) |
usage.prefill_tokens |
new |
Tokens actually prefilled this turn; shown only when < in (i.e. KV cache reused a prefix). Lets prefill_tps × prefill_time reconcile with what was really computed. |
usage.completion_tokens |
out |
Output token count (standard OpenAI field; shown when > 0) |
Field aliases also accepted: time_to_first_token_ms (TTFT),
prefill_tokens_per_second (prefill), tokens_per_second (decode),
input_tokens (in), prefilled_tokens (new), output_tokens (out).
total_tokens and any other keys are ignored.
Each pill is independent, so partial data is fine — e.g. sending only ttft_ms
shows just the TTFT pill. Emitting none of the recognized fields shows no metrics
row at all.
npm run devDevelopment mode includes a built-in proxy, which is useful when your API does not allow browser CORS requests during local development.
npm run build
npm run previewThe production build is static. Your API must allow browser access directly, be served behind a reverse proxy, or share the same origin as the frontend.
npm run dev
npm run build
npm run preview
npm testSettings, model capability overrides, theme preference, and conversations are stored in your browser with localStorage.
Generated images are converted to base64 data URLs on receipt and stored alongside the conversation, so they remain visible even after the backend is restarted or the page is reloaded.
Clearing site storage resets the app.
- Make sure API Base URL does not include
/v1. - Confirm your API supports
GET /v1/models. - If it works in
npm run devbut fails in preview or production, check CORS.
- Click Fetch Models.
- Verify the request succeeded.
- Confirm your API returns models from
GET /v1/models.
- Enable Vision for the selected model in Settings.
- Enable Draw for the selected model in Settings.
- Confirm your backend supports
POST /v1/images/generations.
- Enable Audio for the selected model in Settings.
- Confirm your backend supports audio transcription.
Make sure your backend is compatible with the OpenAI Chat Completions format, especially model, messages, and streaming responses.
