💬 feat: Conversation Starters for Model Specs (#13710)

* 💬 feat: Conversation Starters for Model Specs

Adds an optional conversation_starters field to model specs in
librechat.yaml. When the active conversation uses a spec that defines
starters (and no agent/assistant starters apply), the chat landing
renders clickable starter prompts between the landing content and the
chat input; clicking one submits it as the first message.

- data-provider: add conversation_starters to TModelSpec and
  tModelSpecSchema so the field survives strict config parsing
- client: ConversationStarters falls back to the active spec's
  starters via getModelSpec; entity (agent/assistant) starters
  take precedence; starter cards are centered, size to content,
  wrap at word boundaries, stagger their fade-in, and gain a
  focus-visible ring
- sanitizeModelSpecs passes the field through (denylist); covered
  by a new unit test
- e2e: mock spec + tests for rendering, absence, click-to-submit,
  and the MAX_CONVO_STARTERS cap

Closes #3619

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: Sort ChatView imports

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Michael Harvey 2026-06-13 11:38:49 -04:00 committed by GitHub
parent 49859c04a2
commit 05eb986097
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 115 additions and 11 deletions

View file

@ -6,7 +6,6 @@ import { useParams } from 'react-router-dom';
import { Constants, buildTree } from 'librechat-data-provider';
import type { TChatProject, TMessage } from 'librechat-data-provider';
import type { ChatFormValues } from '~/common';
import { ChatContext, AddedChatContext, ChatFormProvider, useFileMapContext } from '~/Providers';
import {
useAddedResponse,
useResumeOnLoad,
@ -14,11 +13,12 @@ import {
useChatHelpers,
useLocalize,
} from '~/hooks';
import { ChatContext, AddedChatContext, ChatFormProvider, useFileMapContext } from '~/Providers';
import ConversationStarters from './Input/ConversationStarters';
import { useGetMessagesByConvoId } from '~/data-provider';
import ProjectLandingChip from './ProjectLandingChip';
import MessagesView from './Messages/MessagesView';
import Presentation from './Presentation';
import ProjectLandingChip from './ProjectLandingChip';
import ChatForm from './Input/ChatForm';
import Landing from './Landing';
import Header from './Header';
@ -119,8 +119,9 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje
)}
>
{isProjectLandingPage && project && <ProjectLandingChip project={project} />}
{isLandingPage && <ConversationStarters />}
<ChatForm index={index} placeholder={chatFormPlaceholder} />
{isLandingPage ? <ConversationStarters /> : <Footer />}
{!isLandingPage && <Footer />}
</div>
</div>
{isLandingPage && <Footer />}

View file

@ -1,8 +1,12 @@
import { useMemo, useCallback } from 'react';
import { EModelEndpoint, Constants } from 'librechat-data-provider';
import {
useGetAssistantDocsQuery,
useGetEndpointsQuery,
useGetStartupConfig,
} from '~/data-provider';
import { useChatContext, useAgentsMapContext, useAssistantsMapContext } from '~/Providers';
import { useGetAssistantDocsQuery, useGetEndpointsQuery } from '~/data-provider';
import { getIconEndpoint, getEntity } from '~/utils';
import { getIconEndpoint, getEntity, getModelSpec } from '~/utils';
import { useSubmitMessage } from '~/hooks';
const ConversationStarters = () => {
@ -10,6 +14,7 @@ const ConversationStarters = () => {
const agentsMap = useAgentsMapContext();
const assistantMap = useAssistantsMapContext();
const { data: endpointsConfig } = useGetEndpointsQuery();
const { data: startupConfig } = useGetStartupConfig();
const endpointType = useMemo(() => {
let ep = conversation?.endpoint ?? '';
@ -35,17 +40,26 @@ const ConversationStarters = () => {
assistant_id: conversation?.assistant_id,
});
const modelSpec = useMemo(
() => getModelSpec({ specName: conversation?.spec, startupConfig }),
[conversation?.spec, startupConfig],
);
const conversation_starters = useMemo(() => {
if (entity?.conversation_starters?.length) {
return entity.conversation_starters;
}
if (modelSpec?.conversation_starters?.length) {
return modelSpec.conversation_starters;
}
if (isAgent) {
return [];
}
return documentsMap.get(entity?.id ?? '')?.conversation_starters ?? [];
}, [documentsMap, isAgent, entity]);
}, [documentsMap, isAgent, entity, modelSpec]);
const { submitMessage } = useSubmitMessage();
const sendConversationStarter = useCallback(
@ -58,18 +72,17 @@ const ConversationStarters = () => {
}
return (
<div className="mt-8 flex flex-wrap justify-center gap-3 px-4">
<div className="mb-8 mt-2 flex w-full flex-wrap items-stretch justify-center gap-2 px-4">
{conversation_starters
.slice(0, Constants.MAX_CONVO_STARTERS)
.map((text: string, index: number) => (
<button
key={index}
onClick={() => sendConversationStarter(text)}
className="relative flex w-40 cursor-pointer flex-col gap-2 rounded-2xl border border-border-medium px-3 pb-4 pt-3 text-start align-top text-[15px] shadow-[0_0_2px_0_rgba(0,0,0,0.05),0_4px_6px_0_rgba(0,0,0,0.02)] transition-colors duration-300 ease-in-out fade-in hover:bg-surface-tertiary"
style={{ animationDelay: `${index * 75}ms`, animationFillMode: 'backwards' }}
className="flex max-w-[16rem] cursor-pointer items-center justify-center rounded-2xl border border-border-medium bg-surface-secondary px-4 py-2.5 text-center text-sm text-text-secondary shadow-sm transition-colors duration-200 fade-in hover:border-border-heavy hover:bg-surface-tertiary hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring-primary"
>
<p className="break-word line-clamp-3 overflow-hidden text-balance break-all text-text-secondary">
{text}
</p>
<span className="line-clamp-2 text-balance break-words">{text}</span>
</button>
))}
</div>

View file

@ -117,3 +117,15 @@ modelSpecs:
preset:
endpoint: 'Mock Provider A'
model: 'mock-model-a'
- name: 'e2e-starters'
label: 'E2E Starters'
conversation_starters:
- 'E2E_REPLY:starter'
- 'Plan my week'
- 'Third starter prompt'
- 'Fourth starter prompt'
- 'Fifth starter beyond the cap'
preset:
endpoint: 'Mock Provider A'
model: 'mock-model-a'

View file

@ -0,0 +1,49 @@
import { expect, test } from '@playwright/test';
import { messagesView, NEW_CHAT_PATH, replyPrompt, replyText, selectModelSpec } from './helpers';
/** Spec with five `conversation_starters` in e2e/config/librechat.e2e.yaml; only four may render. */
const STARTER_SPEC_LABEL = 'E2E Starters';
const STARTER_PROMPTS = [
replyPrompt('starter'),
'Plan my week',
'Third starter prompt',
'Fourth starter prompt',
];
const STARTER_BEYOND_CAP = 'Fifth starter beyond the cap';
/** A spec without `conversation_starters`. */
const PLAIN_SPEC_LABEL = 'E2E Soft Default';
test.describe('model spec conversation starters', () => {
test('starter prompts render on the landing for a spec that defines them', async ({ page }) => {
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectModelSpec(page, STARTER_SPEC_LABEL);
for (const text of STARTER_PROMPTS) {
await expect(page.getByRole('button', { name: text })).toBeVisible();
}
await expect(page.getByRole('button', { name: STARTER_BEYOND_CAP })).toBeHidden();
});
test('a spec without starters shows none', async ({ page }) => {
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectModelSpec(page, PLAIN_SPEC_LABEL);
for (const text of STARTER_PROMPTS) {
await expect(page.getByRole('button', { name: text })).toBeHidden();
}
});
test('clicking a starter submits it as the first message', async ({ page }) => {
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
await selectModelSpec(page, STARTER_SPEC_LABEL);
const prompt = replyPrompt('starter');
await page.getByRole('button', { name: prompt }).click();
await expect(messagesView(page).getByText(prompt)).toBeVisible({ timeout: 30000 });
await expect(messagesView(page).getByText(replyText('starter'))).toBeVisible({
timeout: 30000,
});
});
});

View file

@ -47,6 +47,32 @@ describe('modelSpecs helpers', () => {
expect(sanitizedModelSpecs.list[0]).not.toHaveProperty('skills');
});
it('should preserve conversation starters on model specs', () => {
const modelSpecs = {
enforce: false,
prioritize: true,
list: [
{
name: 'starter-spec',
label: 'Starter Spec',
conversation_starters: ['Summarize an article', 'Plan my week'],
preset: {
endpoint: EModelEndpoint.openAI,
model: 'gpt-4o',
promptPrefix: 'private prompt prefix',
},
},
],
};
const sanitizedModelSpecs = sanitizeModelSpecs(modelSpecs);
expect(sanitizedModelSpecs.list[0].conversation_starters).toEqual([
'Summarize an article',
'Plan my week',
]);
expect(sanitizedModelSpecs.list[0].preset).not.toHaveProperty('promptPrefix');
});
it('should restore only private fields for non-enforced model specs', () => {
const modelSpec: TModelSpec = {
name: 'guarded-openai',

View file

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