diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 11638629ca..27da7d5cc1 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -8,6 +8,7 @@ const { Tokenizer, checkAccess, logAxiosError, + sanitizeTitle, resolveHeaders, getBalanceConfig, memoryInstructions, @@ -1275,7 +1276,7 @@ class AgentClient extends BaseClient { ); }); - return titleResult.title; + return sanitizeTitle(titleResult.title); } catch (err) { logger.error('[api/server/controllers/agents/client.js #titleConvo] Error', err); return; diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 2f6c60031e..524363e190 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -10,6 +10,10 @@ jest.mock('@librechat/agents', () => ({ }), })); +jest.mock('@librechat/api', () => ({ + ...jest.requireActual('@librechat/api'), +})); + describe('AgentClient - titleConvo', () => { let client; let mockRun; @@ -252,6 +256,38 @@ describe('AgentClient - titleConvo', () => { expect(result).toBe('Generated Title'); }); + it('should sanitize the generated title by removing think blocks', async () => { + const titleWithThinkBlock = 'reasoning about the title 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 block and return only the clean title + expect(result).toBe('User Hi Greeting'); + expect(result).not.toContain(''); + expect(result).not.toContain(''); + }); + + it('should return fallback title when sanitization results in empty string', async () => { + const titleOnlyThinkBlock = 'only reasoning no actual title'; + 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 () => { mockRun.generateTitle.mockRejectedValue(new Error('Title generation failed')); diff --git a/client/src/components/Conversations/ConvoOptions/DeleteButton.tsx b/client/src/components/Conversations/ConvoOptions/DeleteButton.tsx index 3c34cb8c3c..185556ab72 100644 --- a/client/src/components/Conversations/ConvoOptions/DeleteButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/DeleteButton.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react'; +import React, { useCallback } from 'react'; import { QueryKeys } from 'librechat-data-provider'; import { useQueryClient } from '@tanstack/react-query'; import { useParams, useNavigate } from 'react-router-dom'; @@ -82,7 +82,7 @@ export function DeleteConversationDialog({ {localize('com_ui_delete_conversation')} -
+
{localize('com_ui_delete_confirm')} {title} ?
diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index 4a5337fe39..9fd3b01885 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -10,6 +10,7 @@ export * from './key'; export * from './llm'; export * from './math'; export * from './openid'; +export * from './sanitizeTitle'; export * from './tempChatRetention'; export * from './text'; export { default as Tokenizer } from './tokenizer'; diff --git a/packages/api/src/utils/sanitizeTitle.spec.ts b/packages/api/src/utils/sanitizeTitle.spec.ts new file mode 100644 index 0000000000..df03d1b7b4 --- /dev/null +++ b/packages/api/src/utils/sanitizeTitle.spec.ts @@ -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 = 'This is reasoning about the topic User Hi Greeting'; + expect(sanitizeTitle(input)).toBe('User Hi Greeting'); + }); + + it('should handle thinking block at the start', () => { + const input = 'reasoning here Clean Title Text'; + expect(sanitizeTitle(input)).toBe('Clean Title Text'); + }); + + it('should handle thinking block at the end', () => { + const input = 'Clean Title Text reasoning here'; + 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 = + 'reason 1 Intro reason 2 Middle reason 3 Final'; + expect(sanitizeTitle(input)).toBe('Intro Middle Final'); + }); + + it('should handle consecutive think blocks', () => { + const input = 'r1r2Title'; + expect(sanitizeTitle(input)).toBe('Title'); + }); + }); + + describe('Case Insensitivity', () => { + it('should handle uppercase THINK tags', () => { + const input = 'reasoning Title'; + expect(sanitizeTitle(input)).toBe('Title'); + }); + + it('should handle mixed case Think tags', () => { + const input = 'reasoning Title'; + expect(sanitizeTitle(input)).toBe('Title'); + }); + + it('should handle mixed case closing tag', () => { + const input = 'reasoning Title'; + expect(sanitizeTitle(input)).toBe('Title'); + }); + }); + + describe('Attributes in Tags', () => { + it('should remove think tags with attributes', () => { + const input = 'reasoning here Title'; + expect(sanitizeTitle(input)).toBe('Title'); + }); + + it('should handle multiple attributes', () => { + const input = + 'reasoning Title'; + expect(sanitizeTitle(input)).toBe('Title'); + }); + + it('should handle single-quoted attributes', () => { + const input = "content Title"; + expect(sanitizeTitle(input)).toBe('Title'); + }); + + it('should handle unquoted attributes', () => { + const input = 'reasoning Title'; + expect(sanitizeTitle(input)).toBe('Title'); + }); + }); + + describe('Newlines and Multiline Content', () => { + it('should handle newlines within the think block', () => { + const input = ` + This is a long reasoning + spanning multiple lines + with various thoughts + Clean Title`; + expect(sanitizeTitle(input)).toBe('Clean Title'); + }); + + it('should handle newlines around tags', () => { + const input = ` + reasoning + My Title + `; + expect(sanitizeTitle(input)).toBe('My Title'); + }); + + it('should handle mixed whitespace', () => { + const input = '\n\t reasoning \t\n\n Title'; + expect(sanitizeTitle(input)).toBe('Title'); + }); + }); + + describe('Whitespace Normalization', () => { + it('should collapse multiple spaces', () => { + const input = 'x Multiple Spaces'; + expect(sanitizeTitle(input)).toBe('Multiple Spaces'); + }); + + it('should collapse mixed whitespace', () => { + const input = 'Start \n\t Middle x \n End'; + expect(sanitizeTitle(input)).toBe('Start Middle End'); + }); + + it('should trim leading whitespace', () => { + const input = ' reasoning Title'; + expect(sanitizeTitle(input)).toBe('Title'); + }); + + it('should trim trailing whitespace', () => { + const input = 'Title reasoning \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 = 'thinking \n\t\r\n '; + expect(sanitizeTitle(input)).toBe('Untitled Conversation'); + }); + + it('should return fallback when only think blocks exist', () => { + const input = 'just thinkingmore thinking'; + 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 = `${longReasoning} Final Title`; + expect(sanitizeTitle(input)).toBe('Final Title'); + }); + + it('should handle nested-like patterns', () => { + const input = 'outer inner end Title'; + const result = sanitizeTitle(input); + expect(result).toContain('Title'); + }); + + it('should handle malformed tags missing closing', () => { + const input = 'unclosed reasoning. Title'; + const result = sanitizeTitle(input); + expect(result).toContain('Title'); + expect(result).toContain(''); + }); + + it('should handle real-world LLM example', () => { + const input = + '\nThe user is asking for a greeting. I should provide a friendly response.\n User Hi Greeting'; + expect(sanitizeTitle(input)).toBe('User Hi Greeting'); + }); + + it('should handle real-world with attributes', () => { + const input = + '\nStep 1\nStep 2\n Project Status'; + expect(sanitizeTitle(input)).toBe('Project Status'); + }); + }); + + describe('Idempotency', () => { + it('should be idempotent', () => { + const input = 'reasoning 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 = 'only thinking'; + 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('x 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('x')).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(); + }); + }); +}); diff --git a/packages/api/src/utils/sanitizeTitle.ts b/packages/api/src/utils/sanitizeTitle.ts new file mode 100644 index 0000000000..ef03f071d0 --- /dev/null +++ b/packages/api/src/utils/sanitizeTitle.ts @@ -0,0 +1,30 @@ +/** + * Sanitizes LLM-generated chat titles by removing ... 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 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 ... blocks + const thinkBlockRegex = /]*>[\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; +} \ No newline at end of file