mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
💬 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:
parent
49859c04a2
commit
05eb986097
6 changed files with 115 additions and 11 deletions
|
|
@ -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 />}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
49
e2e/specs/mock/model-spec-starters.spec.ts
Normal file
49
e2e/specs/mock/model-spec-starters.spec.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue