💬 feat: assistant conversation starter (#3699)

* feat: initial UI convoStart

* fix: ConvoStarter UI

* fix: convoStarters bug

* feat: Add input field focus on conversation starters

* style: conversation starter UI update

* feat: apply fixes for starters

* style: update conversationStarters UI and fixed typo

* general UI update

* feat: Add onClick functionality to ConvoStarter component

* fix: quick fix test

* fix(AssistantSelect): remove object check

* fix: updateAssistant `conversation_starters` var

* chore: remove starter autofocus

* fix: no empty conversation starters, always show input, use Constants value for max count

* style: Update defaultTextPropsLabel styles, for a11y placeholder

* refactor: Update ConvoStarter component styles and class names for a11y and theme

* refactor: convostarter, move plus button to within persistent element

* fix: types

* chore: Update landing page assistant description styling with theming

* chore: assistant types

* refactor: documents routes

* refactor: optimize conversation starter mutations/queries

* refactor: Update listAllAssistants return type to Promise<Array<Assistant>>

* feat: edit existing starters

* feat(convo-starters): enhance ConvoStarter component and add animations

    - Update ConvoStarter component styling for better visual appeal
    - Implement fade-in animation for smoother appearance
    - Add hover effect with background color change
    - Improve text overflow handling with line-clamp and text-balance
    - Ensure responsive design for various screen sizes

* feat(assistant): add conversation starters to assistant builder

- Add localization strings for conversation starters
- Update mobile.css with shake animation for max starters reached
- Enhance user experience with tooltips and dynamic input handling

* refactor: select specific fields for assistant documents fetch

* refactor: remove endpoint query key, fetch all assistant docs for now, add conversation_starters to v1 methods

* refactor: add document filters based on endpoint config

* fix: starters not applied during creation

* refactor: update AssistantSelect component to handle undefined lastSelectedModels

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Marco Beretta 2024-08-31 13:42:20 -04:00 committed by GitHub
parent 63b80c3067
commit 79f9cd5a4d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 602 additions and 214 deletions

View file

@ -13,7 +13,7 @@ import type {
ValidationResult,
AssistantsEndpoint,
} from 'librechat-data-provider';
import type { ActionAuthForm } from '~/common';
import type { ActionAuthForm, ActionWithNullableMetadata } from '~/common';
import type { Spec } from './ActionsTable';
import { useAssistantsMapContext, useToastContext } from '~/Providers';
import { ActionsTable, columns } from './ActionsTable';
@ -37,7 +37,7 @@ export default function ActionsInput({
version,
setAction,
}: {
action?: Action;
action?: ActionWithNullableMetadata;
assistant_id?: string;
endpoint: AssistantsEndpoint;
version: number | string;
@ -62,12 +62,13 @@ export default function ActionsInput({
const [functions, setFunctions] = useState<FunctionTool[] | null>(null);
useEffect(() => {
if (!action?.metadata.raw_spec) {
const rawSpec = action?.metadata?.raw_spec ?? '';
if (!rawSpec) {
return;
}
setInputValue(action.metadata.raw_spec);
debouncedValidation(action.metadata.raw_spec, handleResult);
}, [action?.metadata.raw_spec]);
setInputValue(rawSpec);
debouncedValidation(rawSpec, handleResult);
}, [action?.metadata?.raw_spec]);
useEffect(() => {
if (!validationResult || !validationResult.status || !validationResult.spec) {
@ -100,7 +101,8 @@ export default function ActionsInput({
},
onError(error) {
showToast({
message: (error as Error).message ?? localize('com_assistants_update_actions_error'),
message:
(error as Error | undefined)?.message ?? localize('com_assistants_update_actions_error'),
status: 'error',
});
},
@ -108,7 +110,8 @@ export default function ActionsInput({
const saveAction = handleSubmit((authFormData) => {
console.log('authFormData', authFormData);
if (!assistant_id) {
const currentAssistantId = assistant_id ?? '';
if (!currentAssistantId) {
// alert user?
return;
}
@ -121,7 +124,10 @@ export default function ActionsInput({
return;
}
let { metadata = {} } = action ?? {};
let { metadata } = action ?? {};
if (!metadata) {
metadata = {};
}
const action_id = action?.action_id;
metadata.raw_spec = inputValue;
const parsedUrl = new URL(data[0].domain);
@ -177,10 +183,10 @@ export default function ActionsInput({
action_id,
metadata,
functions,
assistant_id,
assistant_id: currentAssistantId,
endpoint,
version,
model: assistantMap?.[endpoint][assistant_id].model ?? '',
model: assistantMap?.[endpoint][currentAssistantId].model ?? '',
});
});

View file

@ -40,7 +40,8 @@ export default function ActionsPanel({
},
onError(error) {
showToast({
message: (error as Error)?.message ?? localize('com_assistants_delete_actions_error'),
message:
(error as Error | undefined)?.message ?? localize('com_assistants_delete_actions_error'),
status: 'error',
});
},
@ -127,7 +128,7 @@ export default function ActionsPanel({
<div className="absolute right-0 top-6">
<button
type="button"
disabled={!assistant_id || !action.action_id}
disabled={!(assistant_id ?? '') || !action.action_id}
className="btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium"
>
<TrashIcon className="text-red-500" />
@ -145,16 +146,17 @@ export default function ActionsPanel({
}
selection={{
selectHandler: () => {
if (!assistant_id) {
const currentId = assistant_id ?? '';
if (!currentId) {
return showToast({
message: 'No assistant_id found, is the assistant created?',
status: 'error',
});
}
deleteAction.mutate({
model: assistantMap[endpoint][assistant_id].model,
model: assistantMap?.[endpoint][currentId].model ?? '',
action_id: action.action_id,
assistant_id,
assistant_id: currentId,
endpoint,
});
},

View file

@ -28,7 +28,7 @@ export default function AssistantAction({
{isHovering && (
<button
type="button"
className="transition-color flex h-9 w-9 min-w-9 items-center justify-center rounded-lg duration-200 hover:bg-gray-200 dark:hover:bg-gray-700"
className="transition-colors flex h-9 w-9 min-w-9 items-center justify-center rounded-lg duration-200 hover:bg-gray-200 dark:hover:bg-gray-700"
>
<GearIcon className="icon-sm" />
</button>

View file

@ -0,0 +1,169 @@
import React, { useRef, useState } from 'react';
import { Plus, X } from 'lucide-react';
import { Transition } from 'react-transition-group';
import { Constants } from 'librechat-data-provider';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '~/components/ui';
import { useLocalize } from '~/hooks';
interface AssistantConversationStartersProps {
field: {
value: string[];
onChange: (value: string[]) => void;
};
inputClass: string;
labelClass: string;
}
const AssistantConversationStarters: React.FC<AssistantConversationStartersProps> = ({
field,
inputClass,
labelClass,
}) => {
const localize = useLocalize();
const inputRefs = useRef<(HTMLInputElement | null)[]>([]);
const nodeRef = useRef(null);
const [newStarter, setNewStarter] = useState('');
const handleAddStarter = () => {
if (newStarter.trim() && field.value.length < Constants.MAX_CONVO_STARTERS) {
const newValues = [newStarter, ...field.value];
field.onChange(newValues);
setNewStarter('');
}
};
const handleDeleteStarter = (index: number) => {
const newValues = field.value.filter((_, i) => i !== index);
field.onChange(newValues);
};
const defaultStyle = {
transition: 'opacity 200ms ease-in-out',
opacity: 0,
};
const triggerShake = (element: HTMLElement) => {
element.classList.remove('shake');
void element.offsetWidth;
element.classList.add('shake');
setTimeout(() => {
element.classList.remove('shake');
}, 200);
};
const transitionStyles = {
entering: { opacity: 1 },
entered: { opacity: 1 },
exiting: { opacity: 0 },
exited: { opacity: 0 },
};
const hasReachedMax = field.value.length >= Constants.MAX_CONVO_STARTERS;
return (
<div className="relative">
<label className={labelClass} htmlFor="conversation_starters">
{localize('com_assistants_conversation_starters')}
</label>
<div className="mt-4 space-y-2">
{/* Persistent starter, used for creating only */}
<div className="relative">
<input
ref={(el) => (inputRefs.current[0] = el)}
value={newStarter}
maxLength={64}
className={`${inputClass} pr-10`}
type="text"
placeholder={
hasReachedMax
? localize('com_assistants_max_starters_reached')
: localize('com_assistants_conversation_starters_placeholder')
}
onChange={(e) => setNewStarter(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
if (hasReachedMax) {
triggerShake(e.currentTarget);
} else {
handleAddStarter();
}
}
}}
/>
<Transition
nodeRef={nodeRef}
in={field.value.length < Constants.MAX_CONVO_STARTERS}
timeout={200}
unmountOnExit
>
{(state: string) => (
<div
ref={nodeRef}
style={{
...defaultStyle,
...transitionStyles[state as keyof typeof transitionStyles],
transition: state === 'entering' ? 'none' : defaultStyle.transition,
}}
className="absolute right-1 top-1"
>
<TooltipProvider delayDuration={1000}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex size-7 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-surface-hover"
onClick={handleAddStarter}
disabled={hasReachedMax}
>
<Plus className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={0}>
{hasReachedMax
? localize('com_assistants_max_starters_reached')
: localize('com_ui_add')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</Transition>
</div>
{field.value.map((starter, index) => (
<div key={index} className="relative">
<input
ref={(el) => (inputRefs.current[index + 1] = el)}
value={starter}
onChange={(e) => {
const newValue = [...field.value];
newValue[index] = e.target.value;
field.onChange(newValue);
}}
className={`${inputClass} pr-10`}
type="text"
maxLength={64}
/>
<TooltipProvider delayDuration={1000}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="absolute right-1 top-1 flex size-7 items-center justify-center rounded-lg transition-colors duration-200 hover:bg-surface-hover"
onClick={() => handleDeleteStarter(index)}
>
<X className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={0}>
{localize('com_ui_delete')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
))}
</div>
</div>
);
};
export default AssistantConversationStarters;

View file

@ -14,6 +14,7 @@ import type { FunctionTool, TConfig, TPlugin } from 'librechat-data-provider';
import type { AssistantForm, AssistantPanelProps } from '~/common';
import { useCreateAssistantMutation, useUpdateAssistantMutation } from '~/data-provider';
import { cn, cardStyle, defaultTextProps, removeFocusOutlines } from '~/utils';
import AssistantConversationStarters from './AssistantConversationStarters';
import { useAssistantsMapContext, useToastContext } from '~/Providers';
import { useSelectAssistant, useLocalize } from '~/hooks';
import { ToolSelectDialog } from '~/components/Tools';
@ -31,7 +32,7 @@ import { Panel } from '~/common';
const labelClass = 'mb-2 text-token-text-primary block font-medium';
const inputClass = cn(
defaultTextProps,
'flex w-full px-3 py-2 dark:border-gray-800 dark:bg-gray-800',
'flex w-full px-3 py-2 dark:border-gray-800 dark:bg-gray-800 rounded-xl mb-2',
removeFocusOutlines,
);
@ -106,6 +107,7 @@ export default function AssistantPanel({
});
},
});
const create = useCreateAssistantMutation({
onSuccess: (data) => {
setCurrentAssistantId(data.id);
@ -139,7 +141,7 @@ export default function AssistantPanel({
return functionName;
} else {
const assistant = assistantMap?.[endpoint]?.[assistant_id];
const tool = assistant?.tools.find((tool) => tool.function?.name === functionName);
const tool = assistant?.tools?.find((tool) => tool.function?.name === functionName);
if (assistant && tool) {
return tool;
}
@ -148,7 +150,6 @@ export default function AssistantPanel({
return functionName;
});
console.log(data);
if (data.code_interpreter) {
tools.push({ type: Tools.code_interpreter });
}
@ -163,6 +164,7 @@ export default function AssistantPanel({
name,
description,
instructions,
conversation_starters: starters,
model,
// file_ids, // TODO: add file handling here
} = data;
@ -174,6 +176,7 @@ export default function AssistantPanel({
name,
description,
instructions,
conversation_starters: starters.filter((starter) => starter.trim() !== ''),
model,
tools,
endpoint,
@ -186,6 +189,7 @@ export default function AssistantPanel({
name,
description,
instructions,
conversation_starters: starters.filter((starter) => starter.trim() !== ''),
model,
tools,
endpoint,
@ -239,12 +243,12 @@ export default function AssistantPanel({
</button>
)}
</div>
<div className="h-auto bg-white px-4 pb-8 pt-3 dark:bg-transparent">
<div className="bg-surface-50 h-auto px-4 pb-8 pt-3 dark:bg-transparent">
{/* Avatar & Name */}
<div className="mb-4">
<AssistantAvatar
createMutation={create}
assistant_id={assistant_id ?? null}
assistant_id={assistant_id}
metadata={assistant['metadata'] ?? null}
endpoint={endpoint}
version={version}
@ -271,7 +275,7 @@ export default function AssistantPanel({
name="id"
control={control}
render={({ field }) => (
<p className="h-3 text-xs italic text-text-secondary">{field.value ?? ''}</p>
<p className="h-3 text-xs italic text-text-secondary">{field.value}</p>
)}
/>
</div>
@ -318,6 +322,23 @@ export default function AssistantPanel({
)}
/>
</div>
{/* Conversation Starters */}
<div className="relative mb-6">
{/* the label of conversation starters is in the component */}
<Controller
name="conversation_starters"
control={control}
defaultValue={[]}
render={({ field }) => (
<AssistantConversationStarters
field={field}
inputClass={inputClass}
labelClass={labelClass}
/>
)}
/>
</div>
{/* Model */}
<div className="mb-6">
<label className={labelClass} htmlFor="model">

View file

@ -11,7 +11,12 @@ import {
} from 'librechat-data-provider';
import type { UseFormReset } from 'react-hook-form';
import type { UseMutationResult } from '@tanstack/react-query';
import type { Assistant, AssistantCreateParams, AssistantsEndpoint } from 'librechat-data-provider';
import type {
Assistant,
AssistantCreateParams,
AssistantDocument,
AssistantsEndpoint,
} from 'librechat-data-provider';
import type {
Actions,
ExtendedFile,
@ -19,13 +24,20 @@ import type {
TAssistantOption,
LastSelectedModels,
} from '~/common';
import { useListAssistantsQuery, useGetAssistantDocsQuery } from '~/data-provider';
import SelectDropDown from '~/components/ui/SelectDropDown';
import { useListAssistantsQuery } from '~/data-provider';
import { useLocalize, useLocalStorage } from '~/hooks';
import { useFileMapContext } from '~/Providers';
import { cn } from '~/utils';
const keys = new Set(['name', 'id', 'description', 'instructions', 'model']);
const keys = new Set([
'name',
'id',
'description',
'instructions',
'conversation_starters',
'model',
]);
export default function AssistantSelect({
reset,
@ -45,11 +57,18 @@ export default function AssistantSelect({
const localize = useLocalize();
const fileMap = useFileMapContext();
const lastSelectedAssistant = useRef<string | null>(null);
const [lastSelectedModels] = useLocalStorage<LastSelectedModels>(
const [lastSelectedModels] = useLocalStorage<LastSelectedModels | undefined>(
LocalStorageKeys.LAST_MODEL,
{} as LastSelectedModels,
);
const { data: documentsMap = new Map<string, AssistantDocument>() } = useGetAssistantDocsQuery(
endpoint,
{
select: (data) => new Map(data.map((dbA) => [dbA.assistant_id, dbA])),
},
);
const assistants = useListAssistantsQuery(endpoint, undefined, {
select: (res) =>
res.data.map((_assistant) => {
@ -57,10 +76,10 @@ export default function AssistantSelect({
endpoint === EModelEndpoint.assistants ? FileSources.openai : FileSources.azure;
const assistant = {
..._assistant,
label: _assistant?.name ?? '',
label: _assistant.name ?? '',
value: _assistant.id,
files: _assistant?.file_ids ? ([] as Array<[string, ExtendedFile]>) : undefined,
code_files: _assistant?.tool_resources?.code_interpreter?.file_ids
files: _assistant.file_ids ? ([] as Array<[string, ExtendedFile]>) : undefined,
code_files: _assistant.tool_resources?.code_interpreter?.file_ids
? ([] as Array<[string, ExtendedFile]>)
: undefined,
};
@ -104,11 +123,17 @@ export default function AssistantSelect({
}
if (assistant.code_files && _assistant.tool_resources?.code_interpreter?.file_ids) {
_assistant.tool_resources?.code_interpreter?.file_ids?.forEach((file_id) =>
_assistant.tool_resources.code_interpreter.file_ids.forEach((file_id) =>
handleFile(file_id, assistant.code_files),
);
}
const assistantDoc = documentsMap.get(_assistant.id);
/* If no user updates, use the latest assistant docs */
if (assistantDoc && !assistant.conversation_starters) {
assistant.conversation_starters = assistantDoc.conversation_starters;
}
return assistant;
}),
});
@ -128,8 +153,8 @@ export default function AssistantSelect({
const update = {
...assistant,
label: assistant?.name ?? '',
value: assistant?.id ?? '',
label: assistant.name ?? '',
value: assistant.id ?? '',
};
const actions: Actions = {
@ -138,9 +163,9 @@ export default function AssistantSelect({
[Capabilities.retrieval]: false,
};
assistant?.tools
?.filter((tool) => tool.type !== 'function' || isImageVisionTool(tool))
?.map((tool) => tool?.function?.name || tool.type)
(assistant.tools ?? [])
.filter((tool) => tool.type !== 'function' || isImageVisionTool(tool))
.map((tool) => tool.function?.name || tool.type)
.forEach((tool) => {
if (tool === Tools.file_search) {
actions[Capabilities.retrieval] = true;
@ -148,10 +173,9 @@ export default function AssistantSelect({
actions[tool] = true;
});
const functions =
assistant?.tools
?.filter((tool) => tool.type === 'function' && !isImageVisionTool(tool))
?.map((tool) => tool.function?.name ?? '') ?? [];
const functions = (assistant.tools ?? [])
.filter((tool) => tool.type === 'function' && !isImageVisionTool(tool))
.map((tool) => tool.function?.name ?? '');
const formValues: Partial<AssistantForm & Actions> = {
functions,
@ -161,18 +185,26 @@ export default function AssistantSelect({
};
Object.entries(assistant).forEach(([name, value]) => {
if (typeof value === 'number') {
return;
} else if (typeof value === 'object') {
if (!keys.has(name)) {
return;
}
if (keys.has(name)) {
if (
name === 'conversation_starters' &&
Array.isArray(value) &&
value.every((item) => typeof item === 'string')
) {
formValues[name] = value;
return;
}
if (typeof value !== 'number' && typeof value !== 'object') {
formValues[name] = value;
}
});
reset(formValues);
setCurrentAssistantId(assistant?.id);
setCurrentAssistantId(assistant.id);
},
[assistants.data, reset, setCurrentAssistantId, createMutation, endpoint, lastSelectedModels],
);
@ -184,7 +216,7 @@ export default function AssistantSelect({
return;
}
if (selectedAssistant && assistants.data) {
if (selectedAssistant !== '' && selectedAssistant != null && assistants.data) {
timerId = setTimeout(() => {
lastSelectedAssistant.current = selectedAssistant;
onSelect(selectedAssistant);

View file

@ -78,7 +78,7 @@ export default function AssistantTool({
<OGDialogTrigger asChild>
<button
type="button"
className="transition-color flex h-9 w-9 min-w-9 items-center justify-center rounded-lg duration-200 hover:bg-gray-200 dark:hover:bg-gray-700"
className="transition-colors flex h-9 w-9 min-w-9 items-center justify-center rounded-lg duration-200 hover:bg-gray-200 dark:hover:bg-gray-700"
>
<TrashIcon />
</button>
@ -97,7 +97,7 @@ export default function AssistantTool({
selection={{
selectHandler: () => removeTool(currentTool.pluginKey),
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-colors duration-200 text-white',
selectText: localize('com_ui_delete'),
}}
/>