mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 01:48:06 -04:00
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:
@@ -826,3 +826,96 @@ test.describe("Models Gallery - Has Variants Filter", () => {
|
||||
).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("—");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useModels } from '../hooks/useModels'
|
||||
import { useGalleryEnrichment } from '../hooks/useGalleryEnrichment'
|
||||
import { useOperations } from '../hooks/useOperations'
|
||||
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 {
|
||||
CAP_CHAT, CAP_COMPLETION, CAP_IMAGE, CAP_VIDEO, CAP_TTS,
|
||||
@@ -121,6 +121,15 @@ function formatBackendVersion(metadata) {
|
||||
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() {
|
||||
const { addToast } = useOutletContext()
|
||||
const navigate = useNavigate()
|
||||
@@ -640,9 +649,7 @@ export default function Manage() {
|
||||
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 'var(--text-sm)' }}>{model.id}</span>
|
||||
{enriched?.description && (
|
||||
<span className="resource-row__desc" title={enriched.description}>
|
||||
{enriched.description}
|
||||
</span>
|
||||
<ResourceRowDesc description={enriched.description} />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
@@ -957,9 +964,7 @@ export default function Manage() {
|
||||
)}
|
||||
</div>
|
||||
{(enriched?.description) && (
|
||||
<span className="resource-row__desc" title={enriched.description}>
|
||||
{enriched.description}
|
||||
</span>
|
||||
<ResourceRowDesc description={enriched.description} />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -16,6 +16,7 @@ import ResponsiveTable from '../components/ResponsiveTable'
|
||||
import RecommendedModels from '../components/RecommendedModels'
|
||||
import Popover from '../components/Popover'
|
||||
import { formatBytes } from '../utils/format'
|
||||
import { renderMarkdown, stripMarkdown } from '../utils/markdown'
|
||||
import React from 'react'
|
||||
|
||||
|
||||
@@ -529,12 +530,19 @@ export default function Models() {
|
||||
|
||||
{/* Description */}
|
||||
<td>
|
||||
<div style={{
|
||||
fontSize: '0.8125rem', color: 'var(--color-text-secondary)',
|
||||
maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}} title={model.description}>
|
||||
{model.description || '—'}
|
||||
</div>
|
||||
{(() => {
|
||||
// Gallery descriptions are Markdown. This cell is a single
|
||||
// truncated line, so it gets the text without the syntax.
|
||||
const desc = stripMarkdown(model.description)
|
||||
return (
|
||||
<div style={{
|
||||
fontSize: '0.8125rem', color: 'var(--color-text-secondary)',
|
||||
maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}} title={desc}>
|
||||
{desc || '—'}
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</td>
|
||||
|
||||
{/* Backend */}
|
||||
@@ -796,7 +804,10 @@ function ModelDetail({ model, fit, sizeDisplay, vramDisplay, expandedFiles, setE
|
||||
<tbody>
|
||||
<DetailRow label={t('detail.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 label={t('detail.gallery')}>
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useVoiceCloningGallery, useVoiceProfiles } from '../hooks/useVoiceProfi
|
||||
import { CAP_TTS } from '../utils/capabilities'
|
||||
import { modelsApi, voiceProfilesApi } from '../utils/api'
|
||||
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]
|
||||
|
||||
@@ -317,7 +318,9 @@ JSON` : ''
|
||||
<div>
|
||||
<span className="voice-detail__eyebrow">{t('voiceLibrary.detail.eyebrow')}</span>
|
||||
<h2>{selected.name}</h2>
|
||||
{selected.description && <p>{selected.description}</p>}
|
||||
{selected.description && (
|
||||
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(selected.description) }} />
|
||||
)}
|
||||
</div>
|
||||
<StatusPill status="healthy" label={t('voiceLibrary.status.ready')} />
|
||||
</div>
|
||||
|
||||
47
core/http/react-ui/src/utils/markdown.js
vendored
47
core/http/react-ui/src/utils/markdown.js
vendored
@@ -20,6 +20,53 @@ export function renderMarkdown(text) {
|
||||
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) {
|
||||
if (!element) return
|
||||
element.querySelectorAll('pre code').forEach((block) => {
|
||||
|
||||
Reference in New Issue
Block a user