add optional browser web UI with markdown rendering on both transports#1
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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=trueplus 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
What's in this PR
New transport
Markdown rendering on both transports
Wiring
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)
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
/Test plan
` + `
`)- 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)
Each is independently revertable; (2) and (3) were caught during a live boot smoke and are tagged accordingly.