From a8dc0195a50e46397fdcd0d317332da75cc43a49 Mon Sep 17 00:00:00 2001 From: Tejender Upadhyay Date: Wed, 13 May 2026 16:39:10 +0200 Subject: [PATCH] fix(webui): Improve web UI: fix chat Enter key, auto-refresh node status, and render assistant markdown - Chat input now submits on Enter (Shift+Enter for newline), matching standard chat UX. - Node status auto-refreshes every 5 seconds by re-fetching /fleet/peers, ensuring accurate status even if SSE drops or heartbeats lag. - Assistant responses in the chat are now rendered as formatted HTML (not raw markdown), with code blocks, lists, headers, and links styled for readability and safety. - Updated chat input placeholder to reflect new keyboard shortcuts. - Added CSS for markdown rendering inside assistant bubbles. --- internal/webui/static/app.js | 93 ++++++++++++++++++++++++++++++-- internal/webui/static/index.html | 2 +- internal/webui/static/style.css | 67 +++++++++++++++++++++++ 3 files changed, 156 insertions(+), 6 deletions(-) diff --git a/internal/webui/static/app.js b/internal/webui/static/app.js index f3eab5a..0968167 100644 --- a/internal/webui/static/app.js +++ b/internal/webui/static/app.js @@ -82,6 +82,82 @@ function setStatus(elem, state, text) { elem.textContent = text; } +// ---- lightweight markdown → HTML ---- + +function renderMarkdown(text) { + // Escape HTML so LLM output can never inject raw tags. + let html = text + .replace(/&/g, "&") + .replace(//g, ">"); + + // Protect fenced code blocks (``` … ```) + const codeBlocks = []; + html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => { + codeBlocks.push(`
${code}
`); + return `\x00CB${codeBlocks.length - 1}\x00`; + }); + + // Protect inline code (`…`) + const inlineCodes = []; + html = html.replace(/`([^`\n]+)`/g, (_, code) => { + inlineCodes.push(`${code}`); + return `\x00IC${inlineCodes.length - 1}\x00`; + }); + + // Bold then italic (order matters) + html = html.replace(/\*\*(.+?)\*\*/g, "$1"); + html = html.replace(/(?$1"); + + // Headers + html = html.replace(/^#### (.+)$/gm, "
$1
"); + html = html.replace(/^### (.+)$/gm, "

$1

"); + html = html.replace(/^## (.+)$/gm, "

$1

"); + html = html.replace(/^# (.+)$/gm, "

$1

"); + + // Horizontal rules + html = html.replace(/^---+$/gm, "
"); + + // Links — only allow http/https to prevent javascript: URIs + html = html.replace( + /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g, + '$1', + ); + + // Unordered lists: consecutive lines starting with - or * + html = html.replace(/(?:^[*\-] .+(?:\n|$))+/gm, (match) => { + const items = match + .trim() + .split("\n") + .map((l) => `
  • ${l.replace(/^[*\-] /, "")}
  • `) + .join(""); + return ``; + }); + + // Ordered lists: consecutive lines starting with 1. 2. etc. + html = html.replace(/(?:^\d+\. .+(?:\n|$))+/gm, (match) => { + const items = match + .trim() + .split("\n") + .map((l) => `
  • ${l.replace(/^\d+\. /, "")}
  • `) + .join(""); + return `
      ${items}
    `; + }); + + // Convert remaining newlines to
    + html = html.replace(/\n/g, "
    "); + + // Clean stray
    adjacent to block elements + html = html.replace(/
    (<\/?(?:h[2-5]|pre|ul|ol|li|hr))/g, "$1"); + html = html.replace(/((?:<\/(?:h[2-5]|pre|ul|ol|li)>|
    ))
    /g, "$1"); + + // Restore protected code + html = html.replace(/\x00CB(\d+)\x00/g, (_, i) => codeBlocks[i]); + html = html.replace(/\x00IC(\d+)\x00/g, (_, i) => inlineCodes[i]); + + return html; +} + // ---- settings panel wiring ---- function initSettings() { @@ -278,9 +354,11 @@ const fleet = (() => { if (err.name !== "AbortError") console.warn("fleet stream:", err); } - // Refresh heartbeat ages so the pills change colour even when no events - // arrive (e.g. a totally idle solo fleet). - heartbeatTimer = setInterval(render, 5000); + // Periodically re-fetch peer data so status stays accurate even if the + // SSE stream drops silently or heartbeats lag. + heartbeatTimer = setInterval(() => { + loadInitial(); + }, 5000); } function disconnect() { @@ -445,7 +523,12 @@ const chat = (() => { let busy = false; function appendBubble(role, text) { - const node = el("div", { class: `bubble ${role}` }, text); + const node = el("div", { class: `bubble ${role}` }); + if (role === "assistant") { + node.innerHTML = renderMarkdown(text); + } else { + node.textContent = text; + } $("#chat-transcript").appendChild(node); scrollToEnd(); return node; @@ -721,7 +804,7 @@ function initChat() { }); input.addEventListener("keydown", (e) => { - if ((e.metaKey || e.ctrlKey) && e.key === "Enter") { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); form.requestSubmit(); } diff --git a/internal/webui/static/index.html b/internal/webui/static/index.html index 4f2d023..df2ba45 100644 --- a/internal/webui/static/index.html +++ b/internal/webui/static/index.html @@ -73,7 +73,7 @@

    Debug chat

    - +
    diff --git a/internal/webui/static/style.css b/internal/webui/static/style.css index 8a4d772..ab32587 100644 --- a/internal/webui/static/style.css +++ b/internal/webui/static/style.css @@ -277,8 +277,75 @@ input:focus, select:focus, textarea:focus { .bubble.assistant { align-self: flex-start; background: var(--assistant); + white-space: normal; } +/* ---- rendered markdown inside assistant bubbles ---- */ + +.bubble.assistant h2, +.bubble.assistant h3, +.bubble.assistant h4, +.bubble.assistant h5 { + margin: 8px 0 4px; + line-height: 1.3; +} +.bubble.assistant h2 { font-size: 16px; } +.bubble.assistant h3 { font-size: 15px; } +.bubble.assistant h4 { font-size: 14px; font-weight: 600; } +.bubble.assistant h5 { font-size: 13px; font-weight: 600; } + +.bubble.assistant h2:first-child, +.bubble.assistant h3:first-child, +.bubble.assistant h4:first-child, +.bubble.assistant h5:first-child { margin-top: 0; } + +.bubble.assistant code { + background: var(--bg); + padding: 1px 5px; + border-radius: 4px; + font-size: 12px; +} + +.bubble.assistant pre { + background: var(--bg); + padding: 10px 12px; + border-radius: 6px; + overflow-x: auto; + margin: 6px 0; + white-space: pre-wrap; + word-wrap: break-word; +} + +.bubble.assistant pre code { + background: none; + padding: 0; + border-radius: 0; +} + +.bubble.assistant ul, +.bubble.assistant ol { + margin: 4px 0; + padding-left: 20px; +} + +.bubble.assistant li { + margin: 2px 0; +} + +.bubble.assistant hr { + border: none; + border-top: 1px solid var(--border); + margin: 8px 0; +} + +.bubble.assistant a { + color: var(--accent); + text-decoration: underline; +} + +.bubble.assistant strong { font-weight: 600; } +.bubble.assistant em { font-style: italic; } + .bubble.error { align-self: stretch; background: var(--bad);