Skip to content
This repository was archived by the owner on Sep 13, 2026. It is now read-only.

Latest commit

 

History

History
326 lines (264 loc) · 11.4 KB

File metadata and controls

326 lines (264 loc) · 11.4 KB

Architecture Overview

System Topology

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.


Component Map

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

Request / Response Flow

Chat completion (streaming)

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)
Loading

Model listing

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
Loading

Debug event stream connection

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"
Loading

Abort / Cancellation Flow

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]
Loading

Frontend Initialisation Flow

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]
Loading

Streaming Message Render Loop

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]
Loading

Data Structures (Entity Relationship)

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"
Loading

API Surface

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)

/api/chat request body

{
  "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
}

/api/chat SSE stream format

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]

Debug event payload shapes

// 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)" }

Key Design Decisions

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.