diff --git a/src/ifcchat/app.js b/src/ifcchat/app.js
index ae1d7b0ae9..c0046491b5 100644
--- a/src/ifcchat/app.js
+++ b/src/ifcchat/app.js
@@ -162,17 +162,190 @@ function setBusy(isBusy, reason = "") {
setStatus(isBusy ? (reason || "Working…") : "Ready");
}
+function escapeHtml(text) {
+ return text
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+}
+
+function sanitizeUrl(url) {
+ try {
+ const parsed = new URL(url, window.location.href);
+ if (["http:", "https:", "mailto:"].includes(parsed.protocol)) {
+ return parsed.href;
+ }
+ } catch {
+ }
+ return null;
+}
+
+function renderInlineMarkdown(text) {
+ const placeholders = [];
+ const addPlaceholder = (html) => {
+ const token = `@@MD${placeholders.length}@@`;
+ placeholders.push({ token, html });
+ return token;
+ };
+
+ let rendered = text;
+
+ rendered = rendered.replace(/`([^`]+)`/g, (_, code) => addPlaceholder(`${escapeHtml(code)}`));
+ rendered = rendered.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label, url) => {
+ const href = sanitizeUrl(url);
+ if (!href) {
+ return `${label} (${url})`;
+ }
+ return addPlaceholder(
+ `${escapeHtml(label)}`
+ );
+ });
+
+ rendered = escapeHtml(rendered);
+ rendered = rendered.replace(/\*\*([^*]+)\*\*/g, "$1");
+ rendered = rendered.replace(/\*([^*]+)\*/g, "$1");
+ rendered = rendered.replace(/_([^_]+)_/g, "$1");
+
+ for (const placeholder of placeholders) {
+ rendered = rendered.replaceAll(placeholder.token, placeholder.html);
+ }
+
+ return rendered;
+}
+
+function renderMarkdown(text) {
+ const lines = String(text).replace(/\r\n?/g, "\n").split("\n");
+ const html = [];
+ let paragraphLines = [];
+ let quoteLines = [];
+ let listType = null;
+ let listItems = [];
+
+ const flushParagraph = () => {
+ if (!paragraphLines.length) return;
+ html.push(`
${renderInlineMarkdown(paragraphLines.join(" "))}
`);
+ paragraphLines = [];
+ };
+
+ const flushQuote = () => {
+ if (!quoteLines.length) return;
+ const quoteBody = quoteLines.map((line) => renderInlineMarkdown(line)).join("
");
+ html.push(`${quoteBody}
`);
+ quoteLines = [];
+ };
+
+ const flushList = () => {
+ if (!listItems.length || !listType) return;
+ const items = listItems.map((item) => `${renderInlineMarkdown(item)}`).join("");
+ html.push(`<${listType}>${items}${listType}>`);
+ listType = null;
+ listItems = [];
+ };
+
+ const flushAll = () => {
+ flushParagraph();
+ flushQuote();
+ flushList();
+ };
+
+ for (let index = 0; index < lines.length; index++) {
+ const line = lines[index];
+ const trimmed = line.trim();
+
+ if (trimmed.startsWith("```")) {
+ flushAll();
+ const language = trimmed.slice(3).trim();
+ const codeLines = [];
+ index += 1;
+ while (index < lines.length && !lines[index].trim().startsWith("```")) {
+ codeLines.push(lines[index]);
+ index += 1;
+ }
+ const languageClass = language ? ` class="language-${escapeHtml(language)}"` : "";
+ html.push(`${escapeHtml(codeLines.join("\n"))}
`);
+ continue;
+ }
+
+ if (!trimmed) {
+ flushAll();
+ continue;
+ }
+
+ const headingMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
+ if (headingMatch) {
+ flushAll();
+ const level = headingMatch[1].length;
+ html.push(`${renderInlineMarkdown(headingMatch[2])}`);
+ continue;
+ }
+
+ const quoteMatch = trimmed.match(/^>\s?(.*)$/);
+ if (quoteMatch) {
+ flushParagraph();
+ flushList();
+ quoteLines.push(quoteMatch[1]);
+ continue;
+ }
+
+ if (quoteLines.length) {
+ flushQuote();
+ }
+
+ const unorderedListMatch = trimmed.match(/^[-*]\s+(.+)$/);
+ if (unorderedListMatch) {
+ flushParagraph();
+ if (listType && listType !== "ul") {
+ flushList();
+ }
+ listType = "ul";
+ listItems.push(unorderedListMatch[1]);
+ continue;
+ }
+
+ const orderedListMatch = trimmed.match(/^\d+\.\s+(.+)$/);
+ if (orderedListMatch) {
+ flushParagraph();
+ if (listType && listType !== "ol") {
+ flushList();
+ }
+ listType = "ol";
+ listItems.push(orderedListMatch[1]);
+ continue;
+ }
+
+ if (listItems.length) {
+ flushList();
+ }
+
+ paragraphLines.push(trimmed);
+ }
+
+ flushAll();
+
+ return html.join("");
+}
+
function addMessage(role, text) {
if (text.ok) {
text = text.data;
}
+ if (typeof text !== "string") {
+ text = JSON.stringify(text, null, 2);
+ }
const wrap = document.createElement("div");
wrap.className = `msg ${role}`;
wrap.innerHTML = `
${role}${role === "tool" ? 'â–¶' : ''}
`;
const bubble = wrap.querySelector(".bubble");
- bubble.textContent = text;
+ if (role === "assistant") {
+ bubble.classList.add("markdown-content");
+ bubble.innerHTML = renderMarkdown(text);
+ } else {
+ bubble.textContent = text;
+ }
bubble.onclick = function () {
if (bubble.scrollHeight > 100 && role === "tool") {
const expanded = bubble.style.maxHeight === 'none';
diff --git a/src/ifcchat/style.css b/src/ifcchat/style.css
index 9d3ba064c9..1dfd5335b8 100644
--- a/src/ifcchat/style.css
+++ b/src/ifcchat/style.css
@@ -86,6 +86,73 @@ main {
white-space: pre-wrap;
}
+.msg.assistant .bubble {
+ padding: 10px 14px;
+ line-height: 1.5;
+}
+
+.markdown-content > :first-child {
+ margin-top: 0;
+}
+
+.markdown-content > :last-child {
+ margin-bottom: 0;
+}
+
+.markdown-content p,
+.markdown-content ul,
+.markdown-content ol,
+.markdown-content blockquote,
+.markdown-content pre {
+ margin: 0 0 12px 0;
+}
+
+.markdown-content h1,
+.markdown-content h2,
+.markdown-content h3,
+.markdown-content h4,
+.markdown-content h5,
+.markdown-content h6 {
+ margin: 0 0 12px 0;
+ line-height: 1.25;
+}
+
+.markdown-content ul,
+.markdown-content ol {
+ padding-left: 24px;
+}
+
+.markdown-content blockquote {
+ margin-left: 0;
+ padding-left: 12px;
+ border-left: 3px solid #ddd;
+ color: #555;
+}
+
+.markdown-content code {
+ padding: 1px 4px;
+ border-radius: 4px;
+ background: #f2f2f2;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 90%;
+}
+
+.markdown-content pre {
+ overflow-x: auto;
+ padding: 12px;
+ border-radius: 10px;
+ background: #f4f4f4;
+}
+
+.markdown-content pre code {
+ padding: 0;
+ background: transparent;
+}
+
+.markdown-content a {
+ color: inherit;
+}
+
.msg.user .bubble {
padding: 10px 20px;
background: #eee;