Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 88 additions & 5 deletions internal/webui/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, "&lt;")
.replace(/>/g, "&gt;");

// Protect fenced code blocks (``` … ```)
const codeBlocks = [];
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang, code) => {
codeBlocks.push(`<pre><code>${code}</code></pre>`);
return `\x00CB${codeBlocks.length - 1}\x00`;
});

// Protect inline code (`…`)
const inlineCodes = [];
html = html.replace(/`([^`\n]+)`/g, (_, code) => {
inlineCodes.push(`<code>${code}</code>`);
return `\x00IC${inlineCodes.length - 1}\x00`;
});

// Bold then italic (order matters)
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
html = html.replace(/(?<!\*)\*([^*\n]+)\*(?!\*)/g, "<em>$1</em>");

// Headers
html = html.replace(/^#### (.+)$/gm, "<h5>$1</h5>");
html = html.replace(/^### (.+)$/gm, "<h4>$1</h4>");
html = html.replace(/^## (.+)$/gm, "<h3>$1</h3>");
html = html.replace(/^# (.+)$/gm, "<h2>$1</h2>");

// Horizontal rules
html = html.replace(/^---+$/gm, "<hr>");

// Links — only allow http/https to prevent javascript: URIs
html = html.replace(
/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
);

// Unordered lists: consecutive lines starting with - or *
html = html.replace(/(?:^[*\-] .+(?:\n|$))+/gm, (match) => {
const items = match
.trim()
.split("\n")
.map((l) => `<li>${l.replace(/^[*\-] /, "")}</li>`)
.join("");
return `<ul>${items}</ul>`;
});

// Ordered lists: consecutive lines starting with 1. 2. etc.
html = html.replace(/(?:^\d+\. .+(?:\n|$))+/gm, (match) => {
const items = match
.trim()
.split("\n")
.map((l) => `<li>${l.replace(/^\d+\. /, "")}</li>`)
.join("");
return `<ol>${items}</ol>`;
});

// Convert remaining newlines to <br>
html = html.replace(/\n/g, "<br>");

// Clean stray <br> adjacent to block elements
html = html.replace(/<br>(<\/?(?:h[2-5]|pre|ul|ol|li|hr))/g, "$1");
html = html.replace(/((?:<\/(?:h[2-5]|pre|ul|ol|li)>|<hr>))<br>/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() {
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
}
Expand Down
2 changes: 1 addition & 1 deletion internal/webui/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ <h2>Debug chat</h2>
<div id="chat-transcript" class="transcript" aria-live="polite"></div>

<form id="chat-form" class="composer">
<textarea id="chat-input" rows="3" placeholder="Ask about CPU, memory, processes, sensors, journal… (⌘/Ctrl+Enter to send)"></textarea>
<textarea id="chat-input" rows="3" placeholder="Ask about CPU, memory, processes, sensors, journal… (Enter to send, Shift+Enter for newline)"></textarea>
<button id="chat-send" type="submit">Send</button>
</form>
</section>
Expand Down
67 changes: 67 additions & 0 deletions internal/webui/static/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading