mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 08:50:15 +01:00
✂️ fix: Trim Reasoning Tags from Titles and Delete Button Visibility (#10285)
* fix: Sanitize LLM titles by stripping <think> tags and fix modal overflow * chore: linting * chore: Simplify title sanitization by removing unnecessary variable assignment and import order --------- Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
parent
9fbc2afe40
commit
05c706137e
6 changed files with 288 additions and 3 deletions
|
|
@ -8,6 +8,7 @@ const {
|
||||||
Tokenizer,
|
Tokenizer,
|
||||||
checkAccess,
|
checkAccess,
|
||||||
logAxiosError,
|
logAxiosError,
|
||||||
|
sanitizeTitle,
|
||||||
resolveHeaders,
|
resolveHeaders,
|
||||||
getBalanceConfig,
|
getBalanceConfig,
|
||||||
memoryInstructions,
|
memoryInstructions,
|
||||||
|
|
@ -1275,7 +1276,7 @@ class AgentClient extends BaseClient {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
return titleResult.title;
|
return sanitizeTitle(titleResult.title);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('[api/server/controllers/agents/client.js #titleConvo] Error', err);
|
logger.error('[api/server/controllers/agents/client.js #titleConvo] Error', err);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ jest.mock('@librechat/agents', () => ({
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
jest.mock('@librechat/api', () => ({
|
||||||
|
...jest.requireActual('@librechat/api'),
|
||||||
|
}));
|
||||||
|
|
||||||
describe('AgentClient - titleConvo', () => {
|
describe('AgentClient - titleConvo', () => {
|
||||||
let client;
|
let client;
|
||||||
let mockRun;
|
let mockRun;
|
||||||
|
|
@ -252,6 +256,38 @@ describe('AgentClient - titleConvo', () => {
|
||||||
expect(result).toBe('Generated Title');
|
expect(result).toBe('Generated Title');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should sanitize the generated title by removing think blocks', async () => {
|
||||||
|
const titleWithThinkBlock = '<think>reasoning about the title</think> User Hi Greeting';
|
||||||
|
mockRun.generateTitle.mockResolvedValue({
|
||||||
|
title: titleWithThinkBlock,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = 'Test conversation text';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
const result = await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
// Should remove the <think> block and return only the clean title
|
||||||
|
expect(result).toBe('User Hi Greeting');
|
||||||
|
expect(result).not.toContain('<think>');
|
||||||
|
expect(result).not.toContain('</think>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return fallback title when sanitization results in empty string', async () => {
|
||||||
|
const titleOnlyThinkBlock = '<think>only reasoning no actual title</think>';
|
||||||
|
mockRun.generateTitle.mockResolvedValue({
|
||||||
|
title: titleOnlyThinkBlock,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = 'Test conversation text';
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
const result = await client.titleConvo({ text, abortController });
|
||||||
|
|
||||||
|
// Should return the fallback title since sanitization would result in empty string
|
||||||
|
expect(result).toBe('Untitled Conversation');
|
||||||
|
});
|
||||||
|
|
||||||
it('should handle errors gracefully and return undefined', async () => {
|
it('should handle errors gracefully and return undefined', async () => {
|
||||||
mockRun.generateTitle.mockRejectedValue(new Error('Title generation failed'));
|
mockRun.generateTitle.mockRejectedValue(new Error('Title generation failed'));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useCallback, useState } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import { QueryKeys } from 'librechat-data-provider';
|
import { QueryKeys } from 'librechat-data-provider';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
|
@ -82,7 +82,7 @@ export function DeleteConversationDialog({
|
||||||
<OGDialogHeader>
|
<OGDialogHeader>
|
||||||
<OGDialogTitle>{localize('com_ui_delete_conversation')}</OGDialogTitle>
|
<OGDialogTitle>{localize('com_ui_delete_conversation')}</OGDialogTitle>
|
||||||
</OGDialogHeader>
|
</OGDialogHeader>
|
||||||
<div>
|
<div className="w-full truncate">
|
||||||
{localize('com_ui_delete_confirm')} <strong>{title}</strong> ?
|
{localize('com_ui_delete_confirm')} <strong>{title}</strong> ?
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-4 pt-4">
|
<div className="flex justify-end gap-4 pt-4">
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ export * from './key';
|
||||||
export * from './llm';
|
export * from './llm';
|
||||||
export * from './math';
|
export * from './math';
|
||||||
export * from './openid';
|
export * from './openid';
|
||||||
|
export * from './sanitizeTitle';
|
||||||
export * from './tempChatRetention';
|
export * from './tempChatRetention';
|
||||||
export * from './text';
|
export * from './text';
|
||||||
export { default as Tokenizer } from './tokenizer';
|
export { default as Tokenizer } from './tokenizer';
|
||||||
|
|
|
||||||
217
packages/api/src/utils/sanitizeTitle.spec.ts
Normal file
217
packages/api/src/utils/sanitizeTitle.spec.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
||||||
|
import { sanitizeTitle } from './sanitizeTitle';
|
||||||
|
|
||||||
|
describe('sanitizeTitle', () => {
|
||||||
|
describe('Happy Path', () => {
|
||||||
|
it('should remove a single think block and return the clean title', () => {
|
||||||
|
const input = '<think>This is reasoning about the topic</think> User Hi Greeting';
|
||||||
|
expect(sanitizeTitle(input)).toBe('User Hi Greeting');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle thinking block at the start', () => {
|
||||||
|
const input = '<think>reasoning here</think> Clean Title Text';
|
||||||
|
expect(sanitizeTitle(input)).toBe('Clean Title Text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle thinking block at the end', () => {
|
||||||
|
const input = 'Clean Title Text <think>reasoning here</think>';
|
||||||
|
expect(sanitizeTitle(input)).toBe('Clean Title Text');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle title without any thinking blocks', () => {
|
||||||
|
const input = 'Just a Normal Title';
|
||||||
|
expect(sanitizeTitle(input)).toBe('Just a Normal Title');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Multiple Blocks', () => {
|
||||||
|
it('should remove multiple think blocks', () => {
|
||||||
|
const input =
|
||||||
|
'<think>reason 1</think> Intro <think>reason 2</think> Middle <think>reason 3</think> Final';
|
||||||
|
expect(sanitizeTitle(input)).toBe('Intro Middle Final');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle consecutive think blocks', () => {
|
||||||
|
const input = '<think>r1</think><think>r2</think>Title';
|
||||||
|
expect(sanitizeTitle(input)).toBe('Title');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Case Insensitivity', () => {
|
||||||
|
it('should handle uppercase THINK tags', () => {
|
||||||
|
const input = '<THINK>reasoning</THINK> Title';
|
||||||
|
expect(sanitizeTitle(input)).toBe('Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed case Think tags', () => {
|
||||||
|
const input = '<Think>reasoning</ThInk> Title';
|
||||||
|
expect(sanitizeTitle(input)).toBe('Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed case closing tag', () => {
|
||||||
|
const input = '<think>reasoning</THINK> Title';
|
||||||
|
expect(sanitizeTitle(input)).toBe('Title');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Attributes in Tags', () => {
|
||||||
|
it('should remove think tags with attributes', () => {
|
||||||
|
const input = '<think reason="complex logic">reasoning here</think> Title';
|
||||||
|
expect(sanitizeTitle(input)).toBe('Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple attributes', () => {
|
||||||
|
const input =
|
||||||
|
'<think reason="test" type="deep" id="1">reasoning</think> Title';
|
||||||
|
expect(sanitizeTitle(input)).toBe('Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single-quoted attributes', () => {
|
||||||
|
const input = "<think reason='explanation'>content</think> Title";
|
||||||
|
expect(sanitizeTitle(input)).toBe('Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle unquoted attributes', () => {
|
||||||
|
const input = '<think x=y>reasoning</think> Title';
|
||||||
|
expect(sanitizeTitle(input)).toBe('Title');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Newlines and Multiline Content', () => {
|
||||||
|
it('should handle newlines within the think block', () => {
|
||||||
|
const input = `<think>
|
||||||
|
This is a long reasoning
|
||||||
|
spanning multiple lines
|
||||||
|
with various thoughts
|
||||||
|
</think> Clean Title`;
|
||||||
|
expect(sanitizeTitle(input)).toBe('Clean Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle newlines around tags', () => {
|
||||||
|
const input = `
|
||||||
|
<think>reasoning</think>
|
||||||
|
My Title
|
||||||
|
`;
|
||||||
|
expect(sanitizeTitle(input)).toBe('My Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed whitespace', () => {
|
||||||
|
const input = '<think>\n\t reasoning \t\n</think>\n Title';
|
||||||
|
expect(sanitizeTitle(input)).toBe('Title');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Whitespace Normalization', () => {
|
||||||
|
it('should collapse multiple spaces', () => {
|
||||||
|
const input = '<think>x</think> Multiple Spaces';
|
||||||
|
expect(sanitizeTitle(input)).toBe('Multiple Spaces');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should collapse mixed whitespace', () => {
|
||||||
|
const input = 'Start \n\t Middle <think>x</think> \n End';
|
||||||
|
expect(sanitizeTitle(input)).toBe('Start Middle End');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim leading whitespace', () => {
|
||||||
|
const input = ' <think>reasoning</think> Title';
|
||||||
|
expect(sanitizeTitle(input)).toBe('Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim trailing whitespace', () => {
|
||||||
|
const input = 'Title <think>reasoning</think> \n ';
|
||||||
|
expect(sanitizeTitle(input)).toBe('Title');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Empty and Fallback Cases', () => {
|
||||||
|
it('should return fallback for empty string', () => {
|
||||||
|
expect(sanitizeTitle('')).toBe('Untitled Conversation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return fallback when only whitespace remains', () => {
|
||||||
|
const input = '<think>thinking</think> \n\t\r\n ';
|
||||||
|
expect(sanitizeTitle(input)).toBe('Untitled Conversation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return fallback when only think blocks exist', () => {
|
||||||
|
const input = '<think>just thinking</think><think>more thinking</think>';
|
||||||
|
expect(sanitizeTitle(input)).toBe('Untitled Conversation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return fallback for non-string whitespace', () => {
|
||||||
|
expect(sanitizeTitle(' ')).toBe('Untitled Conversation');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge Cases and Real-World', () => {
|
||||||
|
it('should handle long reasoning blocks', () => {
|
||||||
|
const longReasoning =
|
||||||
|
'This is a very long reasoning block ' + 'with lots of text. '.repeat(50);
|
||||||
|
const input = `<think>${longReasoning}</think> Final Title`;
|
||||||
|
expect(sanitizeTitle(input)).toBe('Final Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle nested-like patterns', () => {
|
||||||
|
const input = '<think>outer <think>inner</think> end</think> Title';
|
||||||
|
const result = sanitizeTitle(input);
|
||||||
|
expect(result).toContain('Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle malformed tags missing closing', () => {
|
||||||
|
const input = '<think>unclosed reasoning. Title';
|
||||||
|
const result = sanitizeTitle(input);
|
||||||
|
expect(result).toContain('Title');
|
||||||
|
expect(result).toContain('<think>');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle real-world LLM example', () => {
|
||||||
|
const input =
|
||||||
|
'<think>\nThe user is asking for a greeting. I should provide a friendly response.\n</think> User Hi Greeting';
|
||||||
|
expect(sanitizeTitle(input)).toBe('User Hi Greeting');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle real-world with attributes', () => {
|
||||||
|
const input =
|
||||||
|
'<think reasoning="multi-step">\nStep 1\nStep 2\n</think> Project Status';
|
||||||
|
expect(sanitizeTitle(input)).toBe('Project Status');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Idempotency', () => {
|
||||||
|
it('should be idempotent', () => {
|
||||||
|
const input = '<think>reasoning</think> My Title';
|
||||||
|
const once = sanitizeTitle(input);
|
||||||
|
const twice = sanitizeTitle(once);
|
||||||
|
expect(once).toBe(twice);
|
||||||
|
expect(once).toBe('My Title');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be idempotent with fallback', () => {
|
||||||
|
const input = '<think>only thinking</think>';
|
||||||
|
const once = sanitizeTitle(input);
|
||||||
|
const twice = sanitizeTitle(once);
|
||||||
|
expect(once).toBe(twice);
|
||||||
|
expect(once).toBe('Untitled Conversation');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Return Type Safety', () => {
|
||||||
|
it('should always return a string', () => {
|
||||||
|
expect(typeof sanitizeTitle('<think>x</think> Title')).toBe('string');
|
||||||
|
expect(typeof sanitizeTitle('No blocks')).toBe('string');
|
||||||
|
expect(typeof sanitizeTitle('')).toBe('string');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should never return empty', () => {
|
||||||
|
expect(sanitizeTitle('')).not.toBe('');
|
||||||
|
expect(sanitizeTitle(' ')).not.toBe('');
|
||||||
|
expect(sanitizeTitle('<think>x</think>')).not.toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should never return null or undefined', () => {
|
||||||
|
expect(sanitizeTitle('test')).not.toBeNull();
|
||||||
|
expect(sanitizeTitle('test')).not.toBeUndefined();
|
||||||
|
expect(sanitizeTitle('')).not.toBeNull();
|
||||||
|
expect(sanitizeTitle('')).not.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
30
packages/api/src/utils/sanitizeTitle.ts
Normal file
30
packages/api/src/utils/sanitizeTitle.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
/**
|
||||||
|
* Sanitizes LLM-generated chat titles by removing <think>...</think> reasoning blocks.
|
||||||
|
*
|
||||||
|
* This function strips out all reasoning blocks (with optional attributes and newlines)
|
||||||
|
* 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.
|
||||||
|
* @returns A sanitized title string, never empty (fallback used if needed).
|
||||||
|
*/
|
||||||
|
export function sanitizeTitle(rawTitle: string): string {
|
||||||
|
const DEFAULT_FALLBACK = 'Untitled Conversation';
|
||||||
|
|
||||||
|
// Step 1: Input Validation
|
||||||
|
if (!rawTitle || typeof rawTitle !== 'string') {
|
||||||
|
return DEFAULT_FALLBACK;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Build and apply the regex to remove all <think>...</think> blocks
|
||||||
|
const thinkBlockRegex = /<think\b[^>]*>[\s\S]*?<\/think>/gi;
|
||||||
|
const cleaned = rawTitle.replace(thinkBlockRegex, '');
|
||||||
|
|
||||||
|
// Step 3: Normalize whitespace (collapse multiple spaces/newlines to single space)
|
||||||
|
const normalized = cleaned.replace(/\s+/g, ' ');
|
||||||
|
|
||||||
|
// Step 4: Trim leading and trailing whitespace
|
||||||
|
const trimmed = normalized.trim();
|
||||||
|
|
||||||
|
// Step 5: Return trimmed result or fallback if empty
|
||||||
|
return trimmed.length > 0 ? trimmed : DEFAULT_FALLBACK;
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue