fix(ui): render gallery model descriptions as Markdown

Gallery descriptions are Markdown, but the React UI dumped them raw, so a
model whose description opens with an ATX heading showed a literal
"# Qwen3.6-27B [](https://chat.qwen.ai)" in the list.

Full-description areas now render through renderMarkdown (marked +
DOMPurify), matching how Backends.jsx and the Manage detail panels already
handle the same content:

  - Models.jsx expanded detail row
  - VoiceLibrary.jsx voice detail header

The truncated one-line previews must not render block Markdown: a leading
"#" would become an <h1> and wreck the row height and rhythm. They get a new
stripMarkdown() helper instead, which reduces Markdown to a single line of
readable plain text. It is used for the cell text and for the title tooltip,
since a tooltip full of "[](url)" is no better than a cell full of it:

  - Models.jsx gallery table description cell
  - Manage.jsx model and backend resource-row descriptions

stripMarkdown walks marked's lexer output rather than running regexes over
the source, so what it strips is by construction what renderMarkdown would
have rendered, and it needs no new dependency. Output lands in JSX text
nodes, so React escapes it; no new dangerouslySetInnerHTML beyond the two
full-description sites, both of which run DOMPurify.

Assisted-by: Claude:claude-opus-4-8
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
Ettore Di Giacinto
2026-07-19 11:05:05 +00:00
parent 831b127ded
commit b35d630cf6
5 changed files with 174 additions and 15 deletions

View File

@@ -826,3 +826,96 @@ test.describe("Models Gallery - Has Variants Filter", () => {
).toBeVisible(); ).toBeVisible();
}); });
}); });
// Gallery descriptions are third-party Markdown. They used to be dumped raw
// into the UI, so a model whose description opened with an ATX heading showed
// a literal "# Name [](url)" in the list.
const MARKDOWN_DESCRIPTION =
"# Qwen3.6-27B\n\nChat with it at [the Qwen site](https://chat.qwen.ai) for **free**.";
const MARKDOWN_MODELS_RESPONSE = {
...MOCK_MODELS_RESPONSE,
models: [
{
name: "markdown-model",
description: MARKDOWN_DESCRIPTION,
backend: "llama-cpp",
installed: false,
tags: ["chat"],
},
{
name: "no-description-model",
description: "",
backend: "llama-cpp",
installed: false,
tags: ["chat"],
},
],
availableModels: 2,
installedModels: 0,
};
test.describe("Models Gallery - Markdown descriptions", () => {
test.beforeEach(async ({ page }) => {
await page.route("**/api/models*", (route) => {
route.fulfill({
contentType: "application/json",
body: JSON.stringify(MARKDOWN_MODELS_RESPONSE),
});
});
await page.goto("/app/models");
await expect(page.locator("th", { hasText: "Backend" })).toBeVisible({
timeout: 10_000,
});
});
test("table cell shows the description as clean text, not raw Markdown", async ({
page,
}) => {
const row = page.locator("tr", { hasText: "markdown-model" });
const cell = row.locator("div[title]", { hasText: "Qwen3.6-27B" });
await expect(cell).toHaveText(
"Qwen3.6-27B Chat with it at the Qwen site for free.",
);
// The syntax itself must be gone, not merely rendered somewhere.
await expect(cell).not.toContainText("#");
await expect(cell).not.toContainText("[](");
await expect(cell).not.toContainText("**");
await expect(cell).not.toContainText("https://chat.qwen.ai");
// A block element here would blow up the row height.
await expect(cell.locator("h1")).toHaveCount(0);
});
test("title tooltip carries the stripped text, not raw Markdown", async ({
page,
}) => {
const row = page.locator("tr", { hasText: "markdown-model" });
const cell = row.locator("div[title]", { hasText: "Qwen3.6-27B" });
await expect(cell).toHaveAttribute(
"title",
"Qwen3.6-27B Chat with it at the Qwen site for free.",
);
});
test("expanded detail row renders the description as real markup", async ({
page,
}) => {
await page.locator("tr", { hasText: "markdown-model" }).click();
const detail = page.locator('td[colspan="8"]');
await expect(detail.locator("h1", { hasText: "Qwen3.6-27B" })).toBeVisible();
const link = detail.locator('a[href="https://chat.qwen.ai"]');
await expect(link).toBeVisible();
await expect(link).toHaveText("the Qwen site");
await expect(detail.locator("strong", { hasText: "free" })).toBeVisible();
});
test("a model without a description still shows the placeholder", async ({
page,
}) => {
const row = page.locator("tr", { hasText: "no-description-model" });
await expect(row).toBeVisible();
await expect(row.locator("div[title='']")).toHaveText("—");
});
});

View File

@@ -17,7 +17,7 @@ import { useModels } from '../hooks/useModels'
import { useGalleryEnrichment } from '../hooks/useGalleryEnrichment' import { useGalleryEnrichment } from '../hooks/useGalleryEnrichment'
import { useOperations } from '../hooks/useOperations' import { useOperations } from '../hooks/useOperations'
import { backendControlApi, modelsApi, backendsApi, systemApi, nodesApi } from '../utils/api' import { backendControlApi, modelsApi, backendsApi, systemApi, nodesApi } from '../utils/api'
import { renderMarkdown } from '../utils/markdown' import { renderMarkdown, stripMarkdown } from '../utils/markdown'
import { safeHref } from '../utils/url' import { safeHref } from '../utils/url'
import { import {
CAP_CHAT, CAP_COMPLETION, CAP_IMAGE, CAP_VIDEO, CAP_TTS, CAP_CHAT, CAP_COMPLETION, CAP_IMAGE, CAP_VIDEO, CAP_TTS,
@@ -121,6 +121,15 @@ function formatBackendVersion(metadata) {
return { label: '—', full: '' } return { label: '—', full: '' }
} }
// Gallery descriptions are Markdown. The row preview is a single truncated
// line, so it shows the text without the syntax; the full Markdown is rendered
// in the expanded detail panel instead.
function ResourceRowDesc({ description }) {
const text = stripMarkdown(description)
if (!text) return null
return <span className="resource-row__desc" title={text}>{text}</span>
}
export default function Manage() { export default function Manage() {
const { addToast } = useOutletContext() const { addToast } = useOutletContext()
const navigate = useNavigate() const navigate = useNavigate()
@@ -640,9 +649,7 @@ export default function Manage() {
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}> <div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
<span style={{ fontWeight: 600, fontSize: 'var(--text-sm)' }}>{model.id}</span> <span style={{ fontWeight: 600, fontSize: 'var(--text-sm)' }}>{model.id}</span>
{enriched?.description && ( {enriched?.description && (
<span className="resource-row__desc" title={enriched.description}> <ResourceRowDesc description={enriched.description} />
{enriched.description}
</span>
)} )}
</div> </div>
</td> </td>
@@ -957,9 +964,7 @@ export default function Manage() {
)} )}
</div> </div>
{(enriched?.description) && ( {(enriched?.description) && (
<span className="resource-row__desc" title={enriched.description}> <ResourceRowDesc description={enriched.description} />
{enriched.description}
</span>
)} )}
</div> </div>
</td> </td>

View File

@@ -16,6 +16,7 @@ import ResponsiveTable from '../components/ResponsiveTable'
import RecommendedModels from '../components/RecommendedModels' import RecommendedModels from '../components/RecommendedModels'
import Popover from '../components/Popover' import Popover from '../components/Popover'
import { formatBytes } from '../utils/format' import { formatBytes } from '../utils/format'
import { renderMarkdown, stripMarkdown } from '../utils/markdown'
import React from 'react' import React from 'react'
@@ -529,12 +530,19 @@ export default function Models() {
{/* Description */} {/* Description */}
<td> <td>
<div style={{ {(() => {
fontSize: '0.8125rem', color: 'var(--color-text-secondary)', // Gallery descriptions are Markdown. This cell is a single
maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', // truncated line, so it gets the text without the syntax.
}} title={model.description}> const desc = stripMarkdown(model.description)
{model.description || '—'} return (
</div> <div style={{
fontSize: '0.8125rem', color: 'var(--color-text-secondary)',
maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}} title={desc}>
{desc || '—'}
</div>
)
})()}
</td> </td>
{/* Backend */} {/* Backend */}
@@ -796,7 +804,10 @@ function ModelDetail({ model, fit, sizeDisplay, vramDisplay, expandedFiles, setE
<tbody> <tbody>
<DetailRow label={t('detail.description')}> <DetailRow label={t('detail.description')}>
{model.description && ( {model.description && (
<span style={{ color: 'var(--color-text-secondary)', lineHeight: 1.6 }}>{model.description}</span> <div
style={{ color: 'var(--color-text-secondary)', lineHeight: 1.6 }}
dangerouslySetInnerHTML={{ __html: renderMarkdown(model.description) }}
/>
)} )}
</DetailRow> </DetailRow>
<DetailRow label={t('detail.gallery')}> <DetailRow label={t('detail.gallery')}>

View File

@@ -14,6 +14,7 @@ import { useVoiceCloningGallery, useVoiceProfiles } from '../hooks/useVoiceProfi
import { CAP_TTS } from '../utils/capabilities' import { CAP_TTS } from '../utils/capabilities'
import { modelsApi, voiceProfilesApi } from '../utils/api' import { modelsApi, voiceProfilesApi } from '../utils/api'
import { copyToClipboard } from '../utils/clipboard' import { copyToClipboard } from '../utils/clipboard'
import { renderMarkdown } from '../utils/markdown'
const ROW_BARS = [35, 58, 78, 44, 68, 92, 55, 72, 38, 82, 64, 46, 74, 52, 88, 42, 66, 48] const ROW_BARS = [35, 58, 78, 44, 68, 92, 55, 72, 38, 82, 64, 46, 74, 52, 88, 42, 66, 48]
@@ -317,7 +318,9 @@ JSON` : ''
<div> <div>
<span className="voice-detail__eyebrow">{t('voiceLibrary.detail.eyebrow')}</span> <span className="voice-detail__eyebrow">{t('voiceLibrary.detail.eyebrow')}</span>
<h2>{selected.name}</h2> <h2>{selected.name}</h2>
{selected.description && <p>{selected.description}</p>} {selected.description && (
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(selected.description) }} />
)}
</div> </div>
<StatusPill status="healthy" label={t('voiceLibrary.status.ready')} /> <StatusPill status="healthy" label={t('voiceLibrary.status.ready')} />
</div> </div>

View File

@@ -20,6 +20,53 @@ export function renderMarkdown(text) {
return DOMPurify.sanitize(html) return DOMPurify.sanitize(html)
} }
// Recursively pull the human-readable text out of a marked token, dropping the
// syntax that carries it. Link/image tokens keep their label and lose the URL;
// code keeps its literal content; raw HTML is dropped rather than leaked as
// angle-bracket noise into a table cell.
function tokenToPlainText(token) {
if (!token) return ''
switch (token.type) {
case 'space':
case 'hr':
case 'br':
case 'html':
return ' '
case 'code':
case 'codespan':
case 'image':
return token.text || ''
case 'list':
return (token.items || []).map(tokenToPlainText).join(' ')
case 'table':
return [...(token.header || []), ...(token.rows || []).flat()]
.map(tokenToPlainText)
.join(' ')
default:
break
}
if (Array.isArray(token.tokens) && token.tokens.length > 0) {
return token.tokens.map(tokenToPlainText).join('')
}
return token.text || ''
}
// Reduce Markdown to a single line of readable plain text. Used by the
// truncated one-line description cells and their `title` tooltips, where
// rendering real Markdown would turn a leading `#` into an <h1> and wreck the
// row rhythm. Output lands in JSX text nodes, so React escapes it.
export function stripMarkdown(text) {
if (!text) return ''
let plain
try {
plain = marked.lexer(text).map(tokenToPlainText).join(' ')
} catch {
// A malformed gallery description must never break the row it labels.
plain = text
}
return plain.replace(/\s+/g, ' ').trim()
}
export function highlightAll(element) { export function highlightAll(element) {
if (!element) return if (!element) return
element.querySelectorAll('pre code').forEach((block) => { element.querySelectorAll('pre code').forEach((block) => {