The UI server sits between the browser and LM Studio. No browser ever talks directly to LM Studio — all requests are proxied, logged, and broadcast to debug clients.
┌─────────────────────────────────────────────────────────────┐
│ Local Network │
│ │
│ ┌──────────────┐ ┌──────────────────────────────┐ │
│ │ Browser A │ │ This machine │ │
│ │ (any device) │◄──────►│ ┌──────────────────────┐ │ │
│ └──────────────┘ │ │ Node.js proxy server │ │ │
│ │ │ :3000 │ │ │
│ ┌──────────────┐ │ └──────────┬───────────┘ │ │
│ │ Browser B │◄──────►│ │ │ │
│ │ (any device) │ │ ┌──────────▼───────────┐ │ │
│ └──────────────┘ │ │ LM Studio │ │ │
│ │ │ (OpenAI API :1234) │ │ │
│ ┌──────────────┐ │ └──────────────────────┘ │ │
│ │ Browser C │◄──────►│ │ │
│ └──────────────┘ └──────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Multiple browsers can connect simultaneously. Each receives the same live debug event stream.
harness-this/
├── server.js # Express proxy — all server logic lives here
├── package.json
└── public/ # Static files served by Express
├── index.html # Shell: DOM structure, CDN script tags
├── styles.css # Dark theme, layout, component styles
└── app.js # All frontend logic (vanilla JS, no framework)
CDN dependencies loaded at runtime (no build step):
| Library | Version | Used for |
|---|---|---|
highlight.js |
11.9.0 | Syntax highlighting of code blocks in chat |
marked |
12.x | Markdown → HTML rendering of assistant messages |
sequenceDiagram
participant B as Browser
participant S as Proxy Server (Node.js)
participant D as Debug Clients (SSE)
participant L as LM Studio
B->>S: POST /api/chat (JSON payload)
S->>D: broadcast REQ event
S->>L: POST /v1/chat/completions (stream:true)
L-->>S: HTTP 200, Content-Type: text/event-stream
S->>D: broadcast RES event (status 200, streaming:true)
S-->>B: HTTP 200, Content-Type: text/event-stream
loop For each SSE chunk from LM Studio
L-->>S: data: {"choices":[{"delta":{"content":"..."}}]}
S-->>B: data: (same chunk, relayed verbatim)
Note over S: tracks firstChunkTime, chunkCount,<br/>finishReason, usage
end
L-->>S: data: [DONE]
Note over S: compute stats object
S-->>B: data: {"__stats":true, tokensPerSec:..., ttft:..., ...}
S-->>B: data: [DONE]
S->>D: broadcast STAT event (same stats)
sequenceDiagram
participant B as Browser
participant S as Proxy Server
participant D as Debug Clients
participant L as LM Studio
B->>S: GET /api/models
S->>D: broadcast REQ event
S->>L: GET /v1/models
L-->>S: {"data": [{id, object, owned_by}, ...]}
S->>D: broadcast RES event (count: N)
S-->>B: same JSON response
sequenceDiagram
participant B as Browser
participant S as Proxy Server
B->>S: GET /api/events/stream (EventSource)
S-->>B: HTTP 200, Content-Type: text/event-stream
Note over S: adds res to debugClients Set
S-->>B: broadcast SYS "Client connected (total: N)"
loop Every 20 seconds
S-->>B: : ping (SSE comment, keeps connection alive)
end
Note over B: user closes tab / navigates away
B--xS: connection close
Note over S: removes res from debugClients Set
S-->>B: (to remaining clients) SYS "Client disconnected"
flowchart TD
A([User clicks Stop / presses Esc]) --> B[frontend calls abortController.abort]
B --> C[fetch request is cancelled]
C --> D[server: req.on 'close' fires]
D --> E[server calls ac.abort on upstream fetch]
E --> F[LM Studio connection is dropped]
F --> G[generation stops — no more tokens burned]
C --> H[frontend renders partial content as final]
H --> I[removes stream cursor, re-enables Send button]
flowchart TD
Start([Page load]) --> A[init]
A --> B[setupEventHandlers]
A --> C[setupParamSync — link sliders to number inputs]
A --> D[setupTabs — right panel tab switching]
A --> E[setupRawTabs — Request / Response sub-tabs]
A --> F[connectDebugStream — open EventSource to /api/events/stream]
A --> G[loadConfig — fetch /api/config, show LM Studio URL]
A --> H[loadModels — fetch /api/models]
H --> I{Models found?}
I -- Yes --> J[Populate model dropdown\nSet status: connected\nEnable Send button]
I -- No --> K[Set status: error\nShow message in console]
J --> L[showEmptyState — render welcome screen]
flowchart TD
Send([User submits message]) --> A[Append user message to state.messages]
A --> B[Render user bubble in DOM]
B --> C[Build payload from state.messages + params]
C --> D[Store payload in state.lastRequest\nRender in Raw → Request tab]
D --> E[Create placeholder assistant bubble with typing dots]
E --> F[POST /api/chat with AbortController signal]
F --> G[Get ReadableStream from response]
G --> H{Read chunk}
H -- chunk contains __stats --> I[Store stats\nRender footer on message\nUpdate Stats panel\nBroadcast to session totals]
H -- chunk contains delta.content --> J[Append to fullContent string]
J --> K[Re-render bodyEl with renderMarkdown\nApply hljs highlighting\nAdd copy buttons to new code blocks\nUpdate live token counter]
K --> H
H -- DONE --> L[Remove stream cursor\nStore message in state.messages]
L --> M[setGenerating false\nRe-enable Send button]
erDiagram
STATE {
string model
string systemPrompt
boolean isGenerating
boolean renderMarkdown
object abortController
object lastRequest
object lastStats
}
MESSAGE {
string role
string content
integer ts
object stats
}
STATS {
integer streamChunks
integer promptTokens
integer completionTokens
integer totalTokens
integer elapsed
integer ttft
integer tokensPerSec
string finishReason
string model
}
PARAMS {
float temperature
float top_p
integer top_k
float repeat_penalty
integer max_tokens
integer seed
array stop
}
SESSION {
integer requests
integer tokens
integer time
}
PRESET {
string name
float temperature
float top_p
integer top_k
float repeat_penalty
}
STATE ||--o{ MESSAGE : "messages[]"
STATE ||--|| PARAMS : "params"
STATE ||--|| SESSION : "session"
STATE ||--o| STATS : "lastStats"
MESSAGE ||--o| STATS : "stats"
PRESET ||--|| PARAMS : "applied to"
| Method | Path | Description | Proxied to |
|---|---|---|---|
GET |
/api/models |
List loaded models | GET /v1/models |
POST |
/api/chat |
Chat completion (SSE stream) | POST /v1/chat/completions |
GET |
/api/config |
Returns { lmBase, port } |
— (local) |
GET |
/api/events/stream |
SSE stream of server-side events | — (local pub/sub) |
GET |
/* |
Serve public/ static files |
— (local) |
The server relays LM Studio chunks verbatim, then injects one extra chunk before [DONE]:
data: {"id":"...","choices":[{"delta":{"content":"Hello"},"finish_reason":null}]}
data: {"id":"...","choices":[{"delta":{},"finish_reason":"stop"}],"usage":{...}}
data: {"__stats":true,"streamChunks":47,"promptTokens":23,"completionTokens":47,
"totalTokens":70,"elapsed":2341,"ttft":145,"tokensPerSec":21,"finishReason":"stop","model":"..."}
data: [DONE]
// type: "req"
{ "ts": 1711234567890, "type": "req", "method": "POST",
"path": "/v1/chat/completions", "model": "...", "messages": 3 }
// type: "res"
{ "ts": 1711234567900, "type": "res", "status": 200, "streaming": true }
// type: "stat"
{ "ts": 1711234570231, "type": "stat", "streamChunks": 47,
"tokensPerSec": 21, "elapsed": 2341, "ttft": 145, "finishReason": "stop" }
// type: "err"
{ "ts": 1711234567890, "type": "err", "msg": "fetch failed" }
// type: "sys"
{ "ts": 1711234560000, "type": "sys", "msg": "Client connected (total: 2)" }Why a proxy instead of direct browser → LM Studio calls?
Direct calls would fail with CORS errors since the browser origin (http://<server>:3000) differs from LM Studio (http://localhost:1234). The proxy also gives us a single place to log, measure, and broadcast events to all clients.
Why SSE instead of WebSockets for the debug stream? SSE is unidirectional (server → client), which is all that's needed here. It's simpler to implement, reconnects automatically, and works through most proxies and firewalls without special configuration.
Why inject __stats into the chat SSE stream rather than a separate endpoint?
The stats are computed from the stream itself (first-chunk timing, usage fields). Injecting them in-band means the frontend receives everything it needs from a single fetch call with no polling or out-of-band coordination.
Why no frontend framework? The app is a single-page interface with a modest DOM footprint. Vanilla JS with direct DOM manipulation keeps the project dependency-free (aside from two CDN libraries for markdown and syntax highlighting), making it trivially deployable anywhere Node.js runs.
{ "model": "qwen/qwen3-coder-30b", "messages": [ { "role": "system", "content": "..." }, { "role": "user", "content": "..." }, { "role": "assistant", "content": "..." } ], "temperature": 0.7, "top_p": 0.95, "top_k": 40, "repeat_penalty": 1.1, "max_tokens": 2048, "seed": -1, // omitted if -1 "stop": ["</s>"] // omitted if empty }