diff --git a/core/http/react-ui/e2e/models-gallery.spec.js b/core/http/react-ui/e2e/models-gallery.spec.js index 8e0d58f00..389e8d0dd 100644 --- a/core/http/react-ui/e2e/models-gallery.spec.js +++ b/core/http/react-ui/e2e/models-gallery.spec.js @@ -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("—"); + }); +}); diff --git a/core/http/react-ui/src/pages/Manage.jsx b/core/http/react-ui/src/pages/Manage.jsx index a5e19dbe8..9f612ea77 100644 --- a/core/http/react-ui/src/pages/Manage.jsx +++ b/core/http/react-ui/src/pages/Manage.jsx @@ -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 {text} +} + export default function Manage() { const { addToast } = useOutletContext() const navigate = useNavigate() @@ -640,9 +649,7 @@ export default function Manage() {
{model.id} {enriched?.description && ( - - {enriched.description} - + )}
@@ -957,9 +964,7 @@ export default function Manage() { )} {(enriched?.description) && ( - - {enriched.description} - + )} diff --git a/core/http/react-ui/src/pages/Models.jsx b/core/http/react-ui/src/pages/Models.jsx index 7e211622d..1f0830bbc 100644 --- a/core/http/react-ui/src/pages/Models.jsx +++ b/core/http/react-ui/src/pages/Models.jsx @@ -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 */} -
- {model.description || '—'} -
+ {(() => { + // 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 ( +
+ {desc || '—'} +
+ ) + })()} {/* Backend */} @@ -796,7 +804,10 @@ function ModelDetail({ model, fit, sizeDisplay, vramDisplay, expandedFiles, setE {model.description && ( - {model.description} +
)} diff --git a/core/http/react-ui/src/pages/VoiceLibrary.jsx b/core/http/react-ui/src/pages/VoiceLibrary.jsx index 642042b52..921838aa8 100644 --- a/core/http/react-ui/src/pages/VoiceLibrary.jsx +++ b/core/http/react-ui/src/pages/VoiceLibrary.jsx @@ -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` : ''
{t('voiceLibrary.detail.eyebrow')}

{selected.name}

- {selected.description &&

{selected.description}

} + {selected.description && ( +
+ )}
diff --git a/core/http/react-ui/src/utils/markdown.js b/core/http/react-ui/src/utils/markdown.js index 24f253e2b..79f6d3013 100644 --- a/core/http/react-ui/src/utils/markdown.js +++ b/core/http/react-ui/src/utils/markdown.js @@ -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

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) => {