mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-03 06:40:20 +01:00
✂️ fix: Unicode-Safe Title Truncation and Shared View Layout Polish (#12003)
* fix: title sanitization with max length truncation and update ShareView for better text display - Added functionality to `sanitizeTitle` to truncate titles exceeding 200 characters with an ellipsis, ensuring consistent title length. - Updated `ShareView` component to apply a line clamp on the title, improving text display and preventing overflow in the UI. * refactor: Update layout and styling in MessagesView and ShareView components - Removed unnecessary padding in MessagesView to streamline the layout. - Increased bottom padding in the message container for better spacing. - Enhanced ShareView footer positioning and styling for improved visibility. - Adjusted section and div classes in ShareView for better responsiveness and visual consistency. * fix: Correct title fallback and enhance sanitization logic in sanitizeTitle - Updated the fallback title in sanitizeTitle to use DEFAULT_TITLE_FALLBACK instead of a hardcoded string. - Improved title truncation logic to ensure proper handling of maximum length and whitespace, including edge cases for emoji and whitespace-only titles. - Added tests to validate the new sanitization behavior, ensuring consistent and expected results across various input scenarios.
This commit is contained in:
parent
ce1338285c
commit
5be90706b0
4 changed files with 85 additions and 31 deletions
|
|
@ -13,7 +13,7 @@ export default function MessagesView({
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
|
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
|
||||||
return (
|
return (
|
||||||
<div className="min-h-0 flex-1 overflow-hidden pb-[50px]">
|
<div className="min-h-0 flex-1 overflow-hidden">
|
||||||
<div className="dark:gpt-dark-gray relative h-full">
|
<div className="dark:gpt-dark-gray relative h-full">
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -22,8 +22,8 @@ export default function MessagesView({
|
||||||
width: '100%',
|
width: '100%',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col pb-9 text-sm dark:bg-transparent">
|
<div className="flex flex-col pb-16 text-sm dark:bg-transparent">
|
||||||
{(_messagesTree && _messagesTree.length == 0) || _messagesTree === null ? (
|
{(_messagesTree && _messagesTree.length === 0) || _messagesTree === null ? (
|
||||||
<div className="flex w-full items-center justify-center gap-1 bg-gray-50 p-3 text-sm text-gray-500 dark:border-gray-800/50 dark:bg-gray-800 dark:text-gray-300">
|
<div className="flex w-full items-center justify-center gap-1 bg-gray-50 p-3 text-sm text-gray-500 dark:border-gray-800/50 dark:bg-gray-800 dark:text-gray-300">
|
||||||
{localize('com_ui_nothing_found')}
|
{localize('com_ui_nothing_found')}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -123,14 +123,14 @@ function SharedView() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const footer = (
|
const footer = (
|
||||||
<div className="w-full border-t-0 pl-0 pt-2 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-0 md:pt-0 md:dark:border-transparent">
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-10 bg-gradient-to-t from-surface-secondary from-40% to-transparent">
|
||||||
<Footer className="relative mx-auto mt-4 flex max-w-[55rem] flex-wrap items-center justify-center gap-2 px-3 pb-4 pt-2 text-center text-xs text-text-secondary" />
|
<Footer className="pointer-events-auto relative mx-auto flex max-w-[55rem] flex-wrap items-center justify-center gap-2 px-3 pb-4 pt-6 text-center text-xs text-text-secondary" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const mainContent = (
|
const mainContent = (
|
||||||
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden pt-0 dark:bg-surface-secondary">
|
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden pt-0 dark:bg-surface-secondary">
|
||||||
<div className="flex h-full min-h-0 flex-col text-text-primary" role="presentation">
|
<div className="relative flex h-full min-h-0 flex-col text-text-primary" role="presentation">
|
||||||
{content}
|
{content}
|
||||||
{footer}
|
{footer}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -189,11 +189,13 @@ function ShareHeader({
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="mx-auto w-full px-3 pb-4 pt-6 md:px-5">
|
<section className="mx-auto w-full px-2 pb-3 pt-4 md:px-5 md:pb-4 md:pt-6">
|
||||||
<div className="bg-surface-primary/80 relative mx-auto flex w-full max-w-[60rem] flex-col gap-4 rounded-3xl border border-border-light px-6 py-5 shadow-xl backdrop-blur">
|
<div className="bg-surface-primary/80 relative mx-auto flex w-full max-w-[60rem] flex-col gap-3 rounded-2xl border border-border-light px-4 py-4 shadow-xl backdrop-blur md:gap-4 md:rounded-3xl md:px-6 md:py-5">
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||||
<div className="space-y-2">
|
<div className="min-w-0 space-y-1.5 md:space-y-2">
|
||||||
<h1 className="text-4xl font-semibold text-text-primary">{title}</h1>
|
<h1 className="line-clamp-2 break-words text-2xl font-semibold text-text-primary md:text-4xl">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
{formattedDate && (
|
{formattedDate && (
|
||||||
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
<div className="flex items-center gap-2 text-sm text-text-secondary">
|
||||||
<CalendarDays className="size-4" aria-hidden="true" />
|
<CalendarDays className="size-4" aria-hidden="true" />
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { sanitizeTitle } from './sanitizeTitle';
|
import { sanitizeTitle, MAX_TITLE_LENGTH, DEFAULT_TITLE_FALLBACK } from './sanitizeTitle';
|
||||||
|
|
||||||
describe('sanitizeTitle', () => {
|
describe('sanitizeTitle', () => {
|
||||||
describe('Happy Path', () => {
|
describe('Happy Path', () => {
|
||||||
|
|
@ -123,21 +123,21 @@ describe('sanitizeTitle', () => {
|
||||||
|
|
||||||
describe('Empty and Fallback Cases', () => {
|
describe('Empty and Fallback Cases', () => {
|
||||||
it('should return fallback for empty string', () => {
|
it('should return fallback for empty string', () => {
|
||||||
expect(sanitizeTitle('')).toBe('Untitled Conversation');
|
expect(sanitizeTitle('')).toBe(DEFAULT_TITLE_FALLBACK);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return fallback when only whitespace remains', () => {
|
it('should return fallback when only whitespace remains', () => {
|
||||||
const input = '<think>thinking</think> \n\t\r\n ';
|
const input = '<think>thinking</think> \n\t\r\n ';
|
||||||
expect(sanitizeTitle(input)).toBe('Untitled Conversation');
|
expect(sanitizeTitle(input)).toBe(DEFAULT_TITLE_FALLBACK);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return fallback when only think blocks exist', () => {
|
it('should return fallback when only think blocks exist', () => {
|
||||||
const input = '<think>just thinking</think><think>more thinking</think>';
|
const input = '<think>just thinking</think><think>more thinking</think>';
|
||||||
expect(sanitizeTitle(input)).toBe('Untitled Conversation');
|
expect(sanitizeTitle(input)).toBe(DEFAULT_TITLE_FALLBACK);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return fallback for non-string whitespace', () => {
|
it('should return fallback for non-string whitespace', () => {
|
||||||
expect(sanitizeTitle(' ')).toBe('Untitled Conversation');
|
expect(sanitizeTitle(' ')).toBe(DEFAULT_TITLE_FALLBACK);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -174,6 +174,53 @@ describe('sanitizeTitle', () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Max Length Truncation', () => {
|
||||||
|
const ellipsis = '...';
|
||||||
|
const maxContent = MAX_TITLE_LENGTH - ellipsis.length;
|
||||||
|
|
||||||
|
it('should pass through a title exactly at max length unchanged', () => {
|
||||||
|
const input = 'A'.repeat(MAX_TITLE_LENGTH);
|
||||||
|
expect(sanitizeTitle(input)).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should truncate a title over max length with ellipsis', () => {
|
||||||
|
const input = 'A'.repeat(MAX_TITLE_LENGTH + 50);
|
||||||
|
const result = sanitizeTitle(input);
|
||||||
|
expect(result).toBe('A'.repeat(maxContent) + ellipsis);
|
||||||
|
expect(result.length).toBeLessThanOrEqual(MAX_TITLE_LENGTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should truncate after think-block removal', () => {
|
||||||
|
const input = '<think>reasoning</think> ' + 'B'.repeat(MAX_TITLE_LENGTH + 50);
|
||||||
|
const result = sanitizeTitle(input);
|
||||||
|
expect(result).toBe('B'.repeat(maxContent) + ellipsis);
|
||||||
|
expect(result.length).toBeLessThanOrEqual(MAX_TITLE_LENGTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trimEnd before appending ellipsis when slice ends with whitespace', () => {
|
||||||
|
const input = 'A'.repeat(maxContent - 1) + ' B' + 'C'.repeat(MAX_TITLE_LENGTH);
|
||||||
|
const result = sanitizeTitle(input);
|
||||||
|
expect(result).toBe('A'.repeat(maxContent - 1) + ellipsis);
|
||||||
|
expect(result).not.toMatch(/ \.\.\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not produce lone surrogates when truncating emoji titles', () => {
|
||||||
|
const input = 'A'.repeat(MAX_TITLE_LENGTH - 2) + '\u{1F389}rest';
|
||||||
|
const result = sanitizeTitle(input);
|
||||||
|
expect(result.isWellFormed()).toBe(true);
|
||||||
|
expect([...result].length).toBeLessThanOrEqual(MAX_TITLE_LENGTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle a title composed entirely of emoji', () => {
|
||||||
|
const emoji = '\u{1F680}';
|
||||||
|
const input = emoji.repeat(MAX_TITLE_LENGTH + 10);
|
||||||
|
const result = sanitizeTitle(input);
|
||||||
|
expect(result.isWellFormed()).toBe(true);
|
||||||
|
expect(result.endsWith(ellipsis)).toBe(true);
|
||||||
|
expect([...result].length).toBeLessThanOrEqual(MAX_TITLE_LENGTH);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Idempotency', () => {
|
describe('Idempotency', () => {
|
||||||
it('should be idempotent', () => {
|
it('should be idempotent', () => {
|
||||||
const input = '<think>reasoning</think> My Title';
|
const input = '<think>reasoning</think> My Title';
|
||||||
|
|
@ -188,7 +235,7 @@ describe('sanitizeTitle', () => {
|
||||||
const once = sanitizeTitle(input);
|
const once = sanitizeTitle(input);
|
||||||
const twice = sanitizeTitle(once);
|
const twice = sanitizeTitle(once);
|
||||||
expect(once).toBe(twice);
|
expect(once).toBe(twice);
|
||||||
expect(once).toBe('Untitled Conversation');
|
expect(once).toBe(DEFAULT_TITLE_FALLBACK);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,35 @@
|
||||||
|
/** Max character length for sanitized titles (the output will never exceed this). */
|
||||||
|
export const MAX_TITLE_LENGTH = 200;
|
||||||
|
export const DEFAULT_TITLE_FALLBACK = 'Untitled Conversation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sanitizes LLM-generated chat titles by removing <think>...</think> reasoning blocks.
|
* Sanitizes LLM-generated chat titles by removing {@link https://en.wikipedia.org/wiki/Chain-of-thought_prompting <think>}
|
||||||
|
* reasoning blocks, normalizing whitespace, and truncating to {@link MAX_TITLE_LENGTH} characters.
|
||||||
*
|
*
|
||||||
* This function strips out all reasoning blocks (with optional attributes and newlines)
|
* Titles exceeding the limit are truncated at a code-point-safe boundary and suffixed with `...`.
|
||||||
* and returns a clean title. If the result is empty, a fallback is returned.
|
|
||||||
*
|
*
|
||||||
* @param rawTitle - The raw LLM-generated title string, potentially containing <think> blocks.
|
* @param rawTitle - The raw LLM-generated title string, potentially containing <think> blocks.
|
||||||
* @returns A sanitized title string, never empty (fallback used if needed).
|
* @returns A sanitized, potentially truncated title string, never empty (fallback used if needed).
|
||||||
*/
|
*/
|
||||||
export function sanitizeTitle(rawTitle: string): string {
|
export function sanitizeTitle(rawTitle: string): string {
|
||||||
const DEFAULT_FALLBACK = 'Untitled Conversation';
|
|
||||||
|
|
||||||
// Step 1: Input Validation
|
|
||||||
if (!rawTitle || typeof rawTitle !== 'string') {
|
if (!rawTitle || typeof rawTitle !== 'string') {
|
||||||
return DEFAULT_FALLBACK;
|
return DEFAULT_TITLE_FALLBACK;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Build and apply the regex to remove all <think>...</think> blocks
|
|
||||||
const thinkBlockRegex = /<think\b[^>]*>[\s\S]*?<\/think>/gi;
|
const thinkBlockRegex = /<think\b[^>]*>[\s\S]*?<\/think>/gi;
|
||||||
const cleaned = rawTitle.replace(thinkBlockRegex, '');
|
const cleaned = rawTitle.replace(thinkBlockRegex, '');
|
||||||
|
|
||||||
// Step 3: Normalize whitespace (collapse multiple spaces/newlines to single space)
|
|
||||||
const normalized = cleaned.replace(/\s+/g, ' ');
|
const normalized = cleaned.replace(/\s+/g, ' ');
|
||||||
|
|
||||||
// Step 4: Trim leading and trailing whitespace
|
|
||||||
const trimmed = normalized.trim();
|
const trimmed = normalized.trim();
|
||||||
|
|
||||||
// Step 5: Return trimmed result or fallback if empty
|
if (trimmed.length === 0) {
|
||||||
return trimmed.length > 0 ? trimmed : DEFAULT_FALLBACK;
|
return DEFAULT_TITLE_FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
const codePoints = [...trimmed];
|
||||||
|
if (codePoints.length > MAX_TITLE_LENGTH) {
|
||||||
|
const truncateAt = MAX_TITLE_LENGTH - 3;
|
||||||
|
return codePoints.slice(0, truncateAt).join('').trimEnd() + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue