Add Markdown support

This commit is contained in:
dwrz
2026-07-09 14:47:43 +00:00
parent 98986a884a
commit e0eed6a935
13 changed files with 184 additions and 14 deletions

View File

@@ -41,7 +41,9 @@
"files": {
"includes": [
"internal/service/static/**/*.js",
"internal/service/static/**/*.css"
"internal/service/static/**/*.css",
"!internal/service/static/js/purify-3.4.11.min.js",
"!internal/service/static/js/marked-18.0.5.umd.min.js"
]
}
}

View File

@@ -571,7 +571,6 @@ body {
.message__content {
padding: var(--s-2) var(--s-1);
white-space: pre-wrap;
word-wrap: break-word;
overflow-wrap: break-word;
}
@@ -581,7 +580,6 @@ body {
max-width: 100%;
height: auto;
margin-block: var(--s-1);
border: 1px solid var(--color-border-light);
border-radius: var(--radius);
}
@@ -1194,13 +1192,7 @@ body {
font-size: var(--s-1);
color: var(--color-text-muted);
}
/* .message__content inherits white-space: pre-wrap for plain text messages
(to preserve newlines from LLM output). For tool messages, the content is
structured HTML with newlines between child elements; pre-wrap renders those
as visible blank lines, causing large vertical gaps. Reset to normal. */
.message--tool .message__content {
white-space: normal;
display: flex;
flex-direction: column;
gap: var(--s-2);
@@ -1222,7 +1214,6 @@ body {
font-size: var(--s-1);
background: var(--color-bg);
border-radius: var(--radius);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
@@ -1267,3 +1258,128 @@ body {
text-overflow: ellipsis;
white-space: nowrap;
}
/* --- Markdown rendering --- */
.message__content p {
margin: 0;
}
.message__content p + p {
margin-top: 0.75em;
}
.message__content pre {
background: color-mix(in srgb, var(--color-gray1, #1a1a2e) 100%, white 5%);
border-radius: var(--radius, 6px);
padding: 0.75em 1em;
margin-block: 0.75em;
}
.message__content code {
font-family: var(--font-mono, "SF Mono", Menlo, Consolas, monospace);
font-size: 0.88em;
}
.message__content :not(pre) > code {
background: color-mix(in srgb, var(--color-gray1, #1a1a2e) 100%, white 8%);
padding: 0.1em 0.35em;
border-radius: 4px;
}
.message__content blockquote {
margin-block: 0.75em;
padding: 0 0.75em;
border-left: 2px solid var(--color-border, #333);
color: var(--color-text-muted, #888);
}
.message__content ul,
.message__content ol {
margin-block: 0.75em;
padding-left: 1.5em;
}
.message__content li + li {
margin-top: 0;
}
.message__content li > ul,
.message__content li > ol {
margin-top: 0;
}
.message__content dl {
margin-block: 0.75em;
}
.message__content dt {
font-weight: 600;
margin-top: 0.75em;
}
.message__content dt + dt {
margin-top: 0;
}
.message__content dd {
margin-left: 1.5em;
margin-top: 0.125em;
margin-bottom: 0;
}
.message__content table {
border-collapse: collapse;
width: 100%;
margin-block: 0.75em;
font-size: 0.92em;
}
.message__content th,
.message__content td {
border: 1px solid var(--color-border-light, #333);
padding: 0.4em 0.6em;
text-align: left;
}
.message__content th {
background: var(--color-gray1, #1a1a2e);
font-weight: 600;
}
.message__table-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.message__content a {
color: var(--color-accent, #5b9bd5);
}
.message__content hr {
border: none;
border-top: 2px solid var(--color-border-light, #333);
margin-block: 1em;
}
.message__content h1,
.message__content h2,
.message__content h3,
.message__content h4,
.message__content h5,
.message__content h6 {
margin-top: 0.75em;
margin-bottom: 0.25em;
font-weight: 600;
line-height: 1.3;
}
.message__content h1 {
font-size: 1.3em;
}
.message__content h2 {
font-size: 1.15em;
}
.message__content h3 {
font-size: 1.05em;
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -331,7 +331,7 @@ export class Renderer {
const $content = $msg.querySelector('.message__content');
const text = extractText(message.content);
if (text) {
$content.textContent = text;
$content.innerHTML = this._renderMarkdown(text);
} else {
$content.remove();
}
@@ -477,6 +477,39 @@ export class Renderer {
// --- Private ---
/**
* _renderMarkdown converts Markdown text to safe HTML.
* Uses marked for parsing and DOMPurify for sanitization.
* Falls back to plain text if libraries are unavailable.
* @param {string} text
* @returns {string}
*/
_renderMarkdown(text) {
if (!text) return '';
try {
if (typeof marked !== 'undefined' && typeof DOMPurify !== 'undefined') {
const html = marked.parse(text);
// Wrap tables in a scrollable container for mobile.
const container = document.createElement('div');
container.innerHTML = html;
for (const table of container.querySelectorAll('table')) {
const wrapper = document.createElement('div');
wrapper.className = 'message__table-scroll';
table.replaceWith(wrapper);
wrapper.appendChild(table);
}
return DOMPurify.sanitize(container.innerHTML);
}
} catch (e) {
console.error('failed to render markdown:', e);
}
// Fallback: escape and return plain text
const div = document.createElement('div');
div.textContent = text;
div.style.whiteSpace = 'pre-wrap';
return div.innerHTML;
}
_renderTree(messageId) {
const messages = [];
this._collectMessages(messageId, messages);
@@ -534,7 +567,7 @@ export class Renderer {
const $content = $msg.querySelector('.message__content');
const text = extractText(message.content);
if (text) {
$content.textContent = text;
$content.innerHTML = this._renderMarkdown(text);
} else {
$content.remove();
}

View File

@@ -8,7 +8,15 @@
<meta name="stt-url" content="{{ .STTURL }}">
<meta name="tts-default-voice" content="{{ .TTSDefaultVoice }}">
<title>Odidere</title>
<link rel="stylesheet" href="/static/main.css">
<script type="module" src="/static/main.js"></script>
<link rel="stylesheet" href="/static/css/main.css">
<!-- marked: Markdown parser. Source: https://github.com/markedjs/marked -->
<script src="/static/js/marked-18.0.5.umd.min.js"
integrity="sha384-MU0bL51BF1u980ujv+Pk0Z0qCjhzfBIEHIFPkUq81289nkiAr+q1jSxEkdlIeHLJ"
crossorigin="anonymous"></script>
<!-- DOMPurify: HTML sanitizer. Source: https://github.com/cure53/DOMPurify -->
<script src="/static/js/purify-3.4.11.min.js"
integrity="sha384-o44XUELLEnv/iSlA1NWxBweqbD4TSR0qgq2VzVsxtkHS989JJjGKSE9vkfo5MN4K"
crossorigin="anonymous"></script>
<script type="module" src="/static/js/main.js"></script>
</head>
{{ end }}