mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-15 23:43:06 +03:00
✨ 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:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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) }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
47
e2e/specs/mock/model-spec-branding.spec.ts
Normal file
47
e2e/specs/mock/model-spec-branding.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user