feat: Surface Model Spec Branding on Landing and Selector (#13662)

Adds an opt-in showOnLanding flag to model specs. When set, the chat
landing shows the spec's label and description in place of the
time-of-day greeting; specs without the flag are unaffected, so existing
deployments see no behavior change. HTML-valued descriptions (inline
icons + markup) render sanitized via the shared config-HTML sanitizer
with a new media tag/attribute allowlist, both on the landing and in
model selector items. Excludes e2e specs from the typed client lint
block so staged e2e files no longer fail pre-commit with 'file not
found in project'.
This commit is contained in:
Danny Avila
2026-06-10 21:02:22 -04:00
committed by GitHub
parent 7a8a18f07d
commit 470be2395f
11 changed files with 194 additions and 15 deletions

View File

@@ -2,11 +2,18 @@ import { useMemo, useCallback, useState, useEffect, useRef } from 'react';
import { easings } from '@react-spring/web';
import { EModelEndpoint } from 'librechat-data-provider';
import { BirthdayIcon, TooltipAnchor, SplitText } from '@librechat/client';
import {
getIconEndpoint,
getEntity,
getModelSpec,
createConfigHtmlSanitizer,
CONFIG_HTML_MEDIA_TAGS,
CONFIG_HTML_MEDIA_ATTR,
} from '~/utils';
import { useChatContext, useAgentsMapContext, useAssistantsMapContext } from '~/Providers';
import { useGetEndpointsQuery, useGetStartupConfig } from '~/data-provider';
import ConvoIcon from '~/components/Endpoints/ConvoIcon';
import { useLocalize, useAuthContext } from '~/hooks';
import { getIconEndpoint, getEntity } from '~/utils';
const containerClassName =
'shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white dark:bg-presentation dark:text-white text-black dark:after:shadow-none ';
@@ -61,8 +68,26 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
assistant_id: conversation?.assistant_id,
});
const name = entity?.name ?? '';
const description = (entity?.description || conversation?.greeting) ?? '';
const modelSpec = useMemo(
() => getModelSpec({ specName: conversation?.spec, startupConfig }),
[conversation?.spec, startupConfig],
);
const brandedSpecLabel = modelSpec?.showOnLanding ? modelSpec.label : '';
const brandedSpecDescription = (modelSpec?.showOnLanding && modelSpec.description) || '';
const name = entity?.name ?? brandedSpecLabel;
const description =
(entity?.description || brandedSpecDescription || conversation?.greeting) ?? '';
const descriptionIsHTML = description.trim().startsWith('<');
const sanitizeDescription = useMemo(
() =>
createConfigHtmlSanitizer({
allowedTags: CONFIG_HTML_MEDIA_TAGS,
allowedAttr: CONFIG_HTML_MEDIA_ATTR,
}),
[],
);
const getGreeting = useCallback(() => {
if (typeof startupConfig?.interface?.customWelcome === 'string') {
@@ -198,11 +223,17 @@ export default function Landing({ centerFormOnLanding }: { centerFormOnLanding:
/>
)}
</div>
{description && (
<div className="animate-fadeIn mt-4 max-w-md text-center text-sm font-normal text-text-primary">
{description}
</div>
)}
{description &&
(descriptionIsHTML ? (
<div
className="animate-fadeIn mt-4 flex max-w-md items-center justify-center gap-2 text-center text-sm font-normal text-text-primary [&_img]:inline-block [&_img]:h-4 [&_img]:w-4"
dangerouslySetInnerHTML={{ __html: sanitizeDescription(description) }}
/>
) : (
<div className="animate-fadeIn mt-4 max-w-md text-center text-sm font-normal text-text-primary">
{description}
</div>
))}
</div>
</div>
);

View File

@@ -5,6 +5,7 @@ import type { TModelSpec } from 'librechat-data-provider';
import { useFavorites, useLocalize, useIsActiveItem } from '~/hooks';
import { useModelSelectorContext } from '../ModelSelectorContext';
import { CustomMenuItem as MenuItem } from '../CustomMenu';
import SpecDescription from './SpecDescription';
import SpecIcon from './SpecIcon';
import { cn } from '~/utils';
@@ -48,9 +49,7 @@ export function ModelSpecItem({ spec, isSelected }: ModelSpecItemProps) {
)}
<div className="flex min-w-0 flex-col gap-1">
<span className="truncate text-left">{spec.label}</span>
{spec.description && (
<span className="break-words text-xs font-normal">{spec.description}</span>
)}
<SpecDescription description={spec.description} />
</div>
</div>
<button

View File

@@ -8,6 +8,7 @@ import MarketplaceItem, { marketplaceSearchMatches } from './Marketplace';
import { useModelSelectorContext } from '../ModelSelectorContext';
import { CustomMenuItem as MenuItem } from '../CustomMenu';
import { shouldRenderEndpointOption } from '../utils';
import SpecDescription from './SpecDescription';
import SpecIcon from './SpecIcon';
import { cn } from '~/utils';
@@ -82,9 +83,7 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP
)}
<div className="flex min-w-0 flex-col gap-1">
<span className="truncate text-left">{spec.label}</span>
{spec.description && (
<span className="break-words text-xs font-normal">{spec.description}</span>
)}
<SpecDescription description={spec.description} />
</div>
</div>
{selectedSpec === spec.name && (

View File

@@ -0,0 +1,32 @@
import { useMemo } from 'react';
import { createConfigHtmlSanitizer, CONFIG_HTML_MEDIA_TAGS, CONFIG_HTML_MEDIA_ATTR } from '~/utils';
interface SpecDescriptionProps {
description?: string;
}
export default function SpecDescription({ description }: SpecDescriptionProps) {
const sanitize = useMemo(
() =>
createConfigHtmlSanitizer({
allowedTags: CONFIG_HTML_MEDIA_TAGS,
allowedAttr: CONFIG_HTML_MEDIA_ATTR,
}),
[],
);
if (!description) {
return null;
}
if (!description.trim().startsWith('<')) {
return <span className="break-words text-xs font-normal">{description}</span>;
}
return (
<span
className="inline-flex items-center gap-1 break-words text-xs font-normal [&_img]:inline-block [&_img]:h-3.5 [&_img]:w-3.5"
dangerouslySetInnerHTML={{ __html: sanitize(description) }}
/>
);
}

View File

@@ -0,0 +1,39 @@
import { render, screen } from '@testing-library/react';
import SpecDescription from '../SpecDescription';
describe('SpecDescription', () => {
it('renders nothing without a description', () => {
const { container } = render(<SpecDescription />);
expect(container).toBeEmptyDOMElement();
});
it('renders plain text descriptions without interpreting markup', () => {
render(<SpecDescription description="Fast & accurate < 1s responses" />);
expect(screen.getByText('Fast & accurate < 1s responses')).toBeInTheDocument();
});
it('renders HTML descriptions with inline images', () => {
const { container } = render(
<SpecDescription description='<span>Powered by <img src="/assets/claude.png" alt="Claude" /> Claude</span>' />,
);
const image = container.querySelector('img');
expect(image).toHaveAttribute('src', '/assets/claude.png');
expect(image).toHaveAttribute('alt', 'Claude');
expect(container).toHaveTextContent('Powered by Claude');
});
it('strips scripts, event handlers, and unsafe URLs from HTML descriptions', () => {
const { container } = render(
<SpecDescription description='<span onclick="alert(1)">Safe<script>alert(1)</script><img src="javascript:alert(1)" onerror="alert(1)"></span>' />,
);
expect(container).toHaveTextContent('Safe');
expect(container.querySelector('script')).toBeNull();
expect(container.querySelector('[onclick]')).toBeNull();
expect(container.querySelector('[onerror]')).toBeNull();
expect(container.querySelector('img')?.getAttribute('src') ?? '').not.toContain('javascript');
});
});

View File

@@ -2,6 +2,8 @@ import {
CONFIG_HTML_BLOCK_TAGS,
CONFIG_HTML_CLASS_ATTR,
CONFIG_HTML_INLINE_TAGS,
CONFIG_HTML_MEDIA_ATTR,
CONFIG_HTML_MEDIA_TAGS,
createConfigHtmlSanitizer,
sanitizeConfigHtml,
} from '../configHtml';
@@ -43,4 +45,18 @@ describe('configHtml', () => {
'<a href="/docs" target="_blank" rel="noopener noreferrer">Docs</a> <a target="_blank" rel="noopener noreferrer">Remote</a>',
);
});
it('keeps inline images with safe sources under media tags', () => {
const sanitize = createConfigHtmlSanitizer({
allowedTags: CONFIG_HTML_MEDIA_TAGS,
allowedAttr: CONFIG_HTML_MEDIA_ATTR,
});
const sanitized = sanitize(
'<span>Powered by <img src="/assets/brand.svg" alt="Brand" onerror="alert(1)"> AI</span><img src="javascript:alert(1)">',
);
expect(sanitized).toBe(
'<span>Powered by <img src="/assets/brand.svg" alt="Brand"> AI</span><img>',
);
});
});

View File

@@ -3,8 +3,10 @@ import DOMPurify from 'dompurify';
export const CONFIG_HTML_INLINE_TAGS = ['a', 'strong', 'b', 'em', 'i', 'br', 'code'] as const;
export const CONFIG_HTML_TEXT_TAGS = [...CONFIG_HTML_INLINE_TAGS, 'span'] as const;
export const CONFIG_HTML_BLOCK_TAGS = [...CONFIG_HTML_TEXT_TAGS, 'p'] as const;
export const CONFIG_HTML_MEDIA_TAGS = [...CONFIG_HTML_TEXT_TAGS, 'img'] as const;
export const CONFIG_HTML_LINK_ATTR = ['href', 'target', 'rel'] as const;
export const CONFIG_HTML_CLASS_ATTR = [...CONFIG_HTML_LINK_ATTR, 'class'] as const;
export const CONFIG_HTML_MEDIA_ATTR = [...CONFIG_HTML_CLASS_ATTR, 'src', 'alt'] as const;
const CONFIG_HTML_SAFE_URI =
/^(?:(?:https?|mailto|tel):|(?!(?:\s*[a-z][a-z0-9+.-]*:|\s*\/\/))[\s\S])/i;

View File

@@ -96,3 +96,11 @@ modelSpecs:
preset:
endpoint: 'Mock Provider A'
model: 'mock-model-a'
- name: 'e2e-branded'
label: 'E2E Branded'
description: '<strong>Branded</strong> answers <img src="/assets/openai.svg" alt="brand icon">'
showOnLanding: true
preset:
endpoint: 'Mock Provider A'
model: 'mock-model-a'

View File

@@ -0,0 +1,47 @@
import { expect, test } from '@playwright/test';
import { getPrimaryE2EUser } from '../../setup/users.mock';
import { NEW_CHAT_PATH, selectModelSpec } from './helpers';
/** Spec with `showOnLanding: true` and an HTML `description` in e2e/config/librechat.e2e.yaml. */
const BRANDED_SPEC = {
label: 'E2E Branded',
descriptionText: 'Branded answers',
descriptionIcon: '/assets/openai.svg',
};
/** The `softDefault: true` spec does not set `showOnLanding`, so it is unbranded. */
const UNBRANDED_SPEC_LABEL = 'E2E Soft Default';
test.describe('model spec branding on landing', () => {
test('branded spec replaces the greeting with its label and rendered description', async ({
page,
}) => {
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectModelSpec(page, BRANDED_SPEC.label);
const main = page.getByRole('main');
await expect(main).toContainText(BRANDED_SPEC.label);
await expect(main).toContainText(BRANDED_SPEC.descriptionText);
await expect(main.locator(`img[src$="${BRANDED_SPEC.descriptionIcon}"]`)).toBeVisible();
const user = getPrimaryE2EUser();
await expect(main).not.toContainText(user.name);
});
test('unbranded spec keeps the personalized greeting', async ({ page }) => {
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectModelSpec(page, UNBRANDED_SPEC_LABEL);
const user = getPrimaryE2EUser();
await expect(page.getByRole('main')).toContainText(user.name);
});
test('branded spec renders its description in the model selector', async ({ page }) => {
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await page.getByRole('button', { name: 'Select a model' }).first().click();
const option = page.getByRole('option', { name: new RegExp(BRANDED_SPEC.label) });
await expect(option).toContainText(BRANDED_SPEC.descriptionText);
await expect(option.locator(`img[src$="${BRANDED_SPEC.descriptionIcon}"]`)).toBeVisible();
});
});

View File

@@ -219,7 +219,10 @@ export default [
})),
{
files: ['**/*.ts', '**/*.tsx'],
ignores: ['packages/**/*', 'client/vite.config.ts'],
// e2e specs are not part of `client/tsconfig.json`'s program, so typed
// linting them errors with "file not found in project"; they still get
// the non-type-checked recommended rules from the block above.
ignores: ['packages/**/*', 'client/vite.config.ts', 'e2e/**/*'],
plugins: {
'@typescript-eslint': typescriptEslintEslintPlugin,
jest: fixupPluginRules(jest),

View File

@@ -33,6 +33,8 @@ export type TModelSpec = {
groupIcon?: string | EModelEndpoint;
showIconInMenu?: boolean;
showIconInHeader?: boolean;
/** Show this spec's label and description on the chat landing in place of the greeting. */
showOnLanding?: boolean;
iconURL?: string | EModelEndpoint; // Allow using project-included icons
authType?: AuthType;
/** Hide the chat input tool badge row while this model spec is active. */
@@ -64,6 +66,7 @@ export const tModelSpecSchema = z.object({
groupIcon: z.union([z.string(), eModelEndpointSchema]).optional(),
showIconInMenu: z.boolean().optional(),
showIconInHeader: z.boolean().optional(),
showOnLanding: z.boolean().optional(),
iconURL: z.union([z.string(), eModelEndpointSchema]).optional(),
authType: authTypeSchema.optional(),
hideBadgeRow: z.boolean().optional(),