Skip to content

add optional browser web UI with markdown rendering on both transports#1

Merged
cjus merged 6 commits into
mainfrom
carlos/solrac-web-ui
May 8, 2026
Merged

add optional browser web UI with markdown rendering on both transports#1
cjus merged 6 commits into
mainfrom
carlos/solrac-web-ui

Conversation

@cjus
Copy link
Copy Markdown
Owner

@cjus cjus commented May 8, 2026

Summary

Adds an optional browser-based chat interface alongside the Telegram bot, sharing the same agent loop, queue, audit log, cost caps, policy hooks, and slash commands. Off by default — enable with SOLRAC_WEB_ENABLED=true plus a token. Also fixes a long-standing UX gap: Claude/Ollama responses now render as proper markdown on both transports (Telegram users previously saw literal `bold` / `# headers` characters).

Why

  • Telegram is the only operator surface today. When the operator is at a desk, on a flaky cellular link, or wants the kind of formatting Telegram's HTML mode can't do (real headers, lists, tables, fenced code with language classes), there's no good option.
  • Claude and Ollama both emit markdown. `agent.ts` was `htmlEscape`ing it before sending to Telegram, which preserved the markdown syntax as literal characters in the chat. Fixing this for the new web UI was a natural moment to fix it for Telegram too.
  • Implementation reuses existing seams: `agent.ts` and `ollama.ts` already accept any `TelegramClient`. A new `WebClient` (`src/web-client.ts`) implements that interface and publishes to an in-process bus consumed by SSE. Anti-goals ("no HTTP framework", "no WebSocket framework") preserved — raw `Bun.serve` routes and a `ReadableStream` body.

What's in this PR

New transport

  • `src/web.ts` — second `Bun.serve` instance with `/api/{login,logout,message,stream,confirm,history}` and `/static/*`. Token-gated login → `HttpOnly + SameSite=Strict` cookie. SSE with 15 s keepalive and `idleTimeout: 0`.
  • `src/web-client.ts` — `TelegramClient` impl with an in-process subscriber bus.
  • `src/web-sanitize.ts` — small allowlist HTML sanitizer; transpiled to JS at boot via `Bun.Transpiler` and served as `/static/sanitize.js` (single source of truth, server-tested + browser-shipped).
  • `public/{index.html,app.js,style.css}` — vanilla-JS UI, no framework, no build step. Loads `marked` from `node_modules/marked/lib/marked.umd.js`.

Markdown rendering on both transports

  • New `src/markdown.ts::mdToTelegramHtml` — `marked` with a custom renderer that emits Telegram's small HTML subset (headers → ``, lists → `• item` lines, tables → ASCII inside `
    `, etc.). Wrapped in try/catch with fallback to `htmlEscapeText` so a parser glitch can't break the existing Telegram path.
  • New `markdownSource?: string` sidecar opt on `SendMessageOpts`/`EditMessageTextOpts`. The real Telegram client destructures-and-drops it before `tgCall` — never hits the wire. The `WebClient` reads it preferentially so the browser gets full markdown via `marked` + sanitizer.
  • `agent.ts`, `ollama.ts`, and the slash-command renders (`/help`, `/status`, `/context`, `/compact`) all author in markdown and convert for Telegram.

Wiring

  • `src/main.ts` — when `webEnabled`, build a parallel `WebClient` + `ConfirmationBroker` + `commandDeps` + `OllamaRunDeps`. The single turn queue dispatches by chat id so the synthetic `webChatId` (default `-1000`) routes to the web variants; the Telegram path is untouched.
  • `src/lifecycle.ts` — accepts the new `webServer` handle and stops it after the ops server but before tracker drain.

Config (5 new env vars)

`SOLRAC_WEB_ENABLED`, `SOLRAC_WEB_HOST` (default `127.0.0.1`), `SOLRAC_WEB_PORT` (default `8080`, must differ from `PORT`), `SOLRAC_WEB_TOKEN` (required when enabled, even on loopback), `SOLRAC_WEB_CHAT_ID` (default `-1000`, must be negative).

Docs (this is a fully-documented feature)

  • `README.md` — feature-list bullet, Quick-start paragraph, cross-link block under the documentation table.
  • `docs/SETUP.md` §11 — step-by-step enable walkthrough.
  • `docs/USAGE.md` — "Web UI (browser interface)" section.
  • `docs/CONFIG.md` — five env vars + validation rules.
  • `docs/OPERATIONS.md` — enable, curl verify, route table, audit query template.
  • `docs/ARCHITECTURE.md` — "Web UI transport" section, module map updates, anti-goal posture entry.
  • `docs/RUNBOOK.md` — two new scenarios (login won't take, streaming silent).
  • `docs/GLOSSARY.md` — four new terms.

Tests

~75 new tests (markdown 24, web-client 16, web-sanitize 19, web 6, config +7). 383/383 passing. `npm run typecheck` clean.

Anti-goals preserved

  • No HTTP framework. Raw `Bun.serve` `routes` only.
  • No WebSocket framework. SSE via `ReadableStream`.
  • No new heavy runtime deps. One new dep (`marked`), used by both transports — same library server-side and browser-side.
  • No webhook transport. Long-poll Telegram path is untouched.
  • HTML parse mode for Telegram. The new conversion emits the same `///
    //
    ` subset Telegram's `parse_mode: HTML` accepts.

Test plan

  • `npm run typecheck` clean
  • `bun test` — 383/383 pass
  • Boot with `SOLRAC_WEB_ENABLED=true`, login flow round-trips, SSE `: connected` frame, clean SIGTERM via `shutdown.web_server_stopped` log
  • Routes verified: `/`, `/static/{style.css,sanitize.js,marked.min.js}`, `/api/login` (bad → 401, good → cookie), `/api/history` (gated), `/api/stream` (SSE)
  • Cookie shape verified: `HttpOnly; SameSite=Strict; Path=/; Max-Age=86400`
  • Slash commands stream live to web (`/help` renders with proper `

    ` + `
      `)
    • Telegram path unchanged: existing audit table, session resume, cost caps all behave as before
    • Live test: tool-confirm Allow/Deny round-trip via `webBroker.resolve` (wired + unit-tested; live exercise during operator smoke)
    • Mobile UX pass (CSS uses simple flexbox + `max-width: 900px`; should reflow but unverified on real device)

    Commits (squash candidate or merge as-is)

    1. `550a13b` add web ui transport and markdown rendering for both transports
    2. `ec7669a` disable Bun.serve idleTimeout for the web SSE stream
    3. `180c221` filter system marker rows from web history hydration
    4. `2559244` author /help in markdown so the web UI renders headers and lists
    5. `a12ed92` author /status, /context, /compact in markdown
    6. `8348237` document the optional web UI across project docs

    Each is independently revertable; (2) and (3) were caught during a live boot smoke and are tagged accordingly.

cjus added 6 commits May 8, 2026 08:11
new files:
- markdown.ts: marked-based renderer producing telegram-safe HTML
  (headers→<b>, lists→•/1., tables→ASCII in <pre>, etc.)
- web-client.ts: TelegramClient sink that publishes to an in-process
  bus instead of hitting telegram's API
- web-sanitize.ts: allowlist HTML sanitizer (server tests + browser);
  transpiled to JS at boot via Bun.Transpiler and served as
  /static/sanitize.js
- web.ts: second Bun.serve with login (bearer token → HttpOnly cookie),
  SSE stream, message post, tool-confirm, history
- public/{index.html,app.js,style.css}: vanilla-JS browser UI; loads
  marked from /static/marked.min.js (node_modules/marked/lib/marked.umd.js)

config:
- 5 new env vars: SOLRAC_WEB_{ENABLED,HOST,PORT,TOKEN,CHAT_ID}
- boot fails if SOLRAC_WEB_ENABLED=true without a token (loopback too —
  a co-tenant on a shared host could otherwise reach the unauth UI)

markdown fix (both transports):
- agent.ts:495 and ollama body-render now run claude/ollama text
  through mdToTelegramHtml so telegram users see rendered formatting
  instead of literal **bold** / # headers / dash-list characters
- new markdownSource sidecar on SendMessageOpts carries raw markdown
  to the WebClient for full browser rendering; real telegram client
  strips it before tgCall — never hits the wire
- both wrapped in try/catch with fallback to htmlEscapeText so a parser
  glitch can't break the existing telegram path

wiring:
- main.ts builds a parallel WebClient + ConfirmationBroker +
  commandDeps + ollamaDeps when enabled; single turn queue dispatches
  by chatId so the synthetic webChatId routes to webRunTurn and the
  existing telegram path is unchanged
- lifecycle.ts stops the web server right after the ops server, before
  tracker drain — SSE writers terminate cleanly via req.signal abort

tests: ~75 new (markdown 24, web-client 16, web-sanitize 19, web 6,
config +7); 383/383 pass; npm run typecheck clean

anti-goals preserved: no HTTP framework, no WebSocket framework, one
new runtime dep (marked, shared across transports). docs/ARCHITECTURE.md
gains a "Web UI transport" section; docs/CONFIG.md and docs/USAGE.md
document the env vars and feature surface.
Bun.serve's default 10s per-request idle timeout was killing /api/stream
after the first 10s of silence — keepalive cadence was 25s, so the
connection died before the first heartbeat. Symptom: a turn would complete
(audit row written) but the response never appeared in the browser because
the SSE subscriber had already been torn down and EventSource hadn't yet
reconnected when the WebClient bus published.

Fix: set `idleTimeout: 0` on the web Bun.serve (SSE is long-poll by design)
and tighten KEEPALIVE_MS to 15s so the stream also stays open across the
typical reverse-proxy idle thresholds (nginx 60s, Cloudflare 100s) without
operator tweaks.

Existing in-flight responses are still in the audit table; reloading the
browser hydrates them via /api/history.
Slash-command audit rows store cryptic marker strings in the response
column (`help_shown`, `status_shown`, `cleared:primary,secondary`,
`context_shown:primary`) — these are useful for ops queries against
the audit table but show up as gibberish when the browser hydrates
the conversation via /api/history.

Filter rows with `model = "system"` out of the web's loadHistory
callback. The audit rows themselves are untouched. The user can
re-run any slash command live to see the rendered output.

Plain Claude/Ollama turns are tagged `claude:primary:…`,
`claude:secondary:…`, `ollama:…` so they pass the filter.
renderHelp previously emitted Telegram-flavored HTML directly (<b>, <code>,
literal "•" characters, "\n" line breaks). The web UI sanitized that as
HTML, but the result was a single flowing paragraph because <b> and "\n"
don't structure a browser the way <h2> and <ul>/<li> do.

Refactor: rename to renderHelpMarkdown, author the help card as proper
markdown (## headings, **bold**, `code`, - bullets, blank-line paragraph
breaks). runHelp converts to Telegram-safe HTML via mdToTelegramHtml for
the bot, and passes the markdown via the markdownSource sidecar so the web
transport renders it with marked + sanitizer.

sendOrLog gains an optional markdownSource param that's forwarded to
SendMessageOpts; existing call sites (clear, compact, status, context,
unknown, skills) continue to work without it.

Telegram audit row still records the "help_shown" marker — no change to
ops queries. The visible Telegram help card looks identical (the
markdown→HTML mapping reproduces the same <b>, <code>, "•" bullets, and
line-break structure Telegram's HTML mode supports).
Same pattern as the /help refactor (2559244): each render-* function now
returns markdown; the run-* function converts to Telegram-safe HTML via
mdToTelegramHtml for the bot path and forwards the markdown via the
markdownSource sidecar so the web UI gets full headers/lists rendering.

renames:
- renderStatus → renderStatusMarkdown
- renderContext → renderContextMarkdown
- renderTierLine / renderSummaryLine → *Markdown
- renderCompactError → renderCompactErrorMarkdown

Telegram output is preserved character-for-character: ## headings collapse
to <b>…</b>, **bold** → <b>, *italic* → <i>, `code` → <code>, and `- item`
lists flatten to "• item" lines. Verified manually with mdToTelegramHtml.

The unknown-command and skill-failure replies are short single-line strings
where markdown buys nothing, so they keep their existing HTML form.
/clear's confirmation is also a one-liner — left alone.

audit row contents unchanged ("status_shown", "context_shown:<tier>",
snippet of compact summary). all 383 tests still pass; npm run typecheck
clean.
The web UI feature was previously documented in CONFIG.md, USAGE.md, and
ARCHITECTURE.md (where the env vars + feature surface + architecture live).
This commit closes the documentation gap so users discover the feature
without reading the architecture doc:

- README.md: add a feature-list bullet + a "Optional — browser web UI"
  paragraph in Quick start; add a Web UI cross-link block under the
  Documentation table.
- docs/SETUP.md: add §11 "(Optional) Enable the browser web UI" with the
  step-by-step .env setup, smoke checks, and security notes (token
  required even on 127.0.0.1; HTTPS framing for 0.0.0.0). Renumber the
  systemd-deploy section to §12.
- docs/OPERATIONS.md: add a "Web UI (optional)" section with enable steps,
  curl verification, the route reference table, the security posture
  checklist, and an audit-table query template for chat_id = -1000.
- docs/RUNBOOK.md: add two scenarios — "Web UI not reachable / login
  won't take" (boot-validation failures, host/port mismatch, brute-force
  symptoms) and "Web UI streaming silent / messages don't appear"
  (EventSource disconnect, Bun idleTimeout, reverse-proxy buffering).
  Index entries linked.
- docs/GLOSSARY.md: add four entries (web transport, WebClient,
  markdownSource, mdToTelegramHtml) at the alphabetically correct
  positions.

All anchor links verified to resolve. No code changes; 383/383 tests
still pass.
@cjus cjus merged commit 50830d4 into main May 8, 2026
1 check passed
@cjus cjus deleted the carlos/solrac-web-ui branch May 8, 2026 17:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant