🤖 feat: Model Specs & Save Tools per Convo/Preset (#2578)

* WIP: first pass ModelSpecs

* refactor(onSelectEndpoint): use `getConvoSwitchLogic`

* feat: introduce iconURL, greeting, frontend fields for conversations/presets/messages

* feat: conversation.iconURL & greeting in Landing

* feat: conversation.iconURL & greeting in New Chat button

* feat: message.iconURL

* refactor: ConversationIcon -> ConvoIconURL

* WIP: add spec as a conversation field

* refactor: useAppStartup, set spec on initial load for new chat, allow undefined spec, add localStorage keys enum, additional type fields for spec

* feat: handle `showIconInMenu`, `showIconInHeader`, undefined `iconURL` and no specs on initial load

* chore: handle undefined or empty modelSpecs

* WIP: first pass, modelSpec schema for custom config

* refactor: move default filtered tools definition to ToolService

* feat: pass modelSpecs from backend via startupConfig

* refactor: modelSpecs config, return and define list

* fix: react error and include iconURL in responseMessage

* refactor: add iconURL to responseMessage only

* refactor: getIconEndpoint

* refactor: pass TSpecsConfig

* fix(assistants): differentiate compactAssistantSchema, correctly resets shared conversation state with other endpoints

* refactor: assistant id prefix localStorage key

* refactor: add more LocalStorageKeys and replace hardcoded values

* feat: prioritize spec on new chat behavior: last selected modelSpec behavior (localStorage)

* feat: first pass, interface config

* chore: WIP, todo: add warnings based on config.modelSpecs settings.

* feat: enforce modelSpecs if configured

* feat: show config file yaml errors

* chore: delete unused legacy Plugins component

* refactor: set tools to localStorage from recoil store

* chore: add stable recoil setter to useEffect deps

* refactor: save tools to conversation documents

* style(MultiSelectPop): dynamic height, remove unused import

* refactor(react-query): use localstorage keys and pass config to useAvailablePluginsQuery

* feat(utils): add mapPlugins

* refactor(Convo): use conversation.tools if defined, lastSelectedTools if not

* refactor: remove unused legacy code using `useSetOptions`, remove conditional flag `isMultiChat` for using legacy settings

* refactor(PluginStoreDialog): add exhaustive-deps which are stable react state setters

* fix(HeaderOptions): pass `popover` as true

* refactor(useSetStorage): use project enums

* refactor: use LocalStorageKeys enum

* fix: prevent setConversation from setting falsy values in lastSelectedTools

* refactor: use map for availableTools state and available Plugins query

* refactor(updateLastSelectedModel): organize logic better and add note on purpose

* fix(setAgentOption): prevent reseting last model to secondary model for gptPlugins

* refactor(buildDefaultConvo): use enum

* refactor: remove `useSetStorage` and consolidate areas where conversation state is saved to localStorage

* fix: conversations retain tools on refresh

* fix(gptPlugins): prevent nullish tools from being saved

* chore: delete useServerStream

* refactor: move initial plugins logic to useAppStartup

* refactor(MultiSelectDropDown): add more pass-in className props

* feat: use tools in presets

* chore: delete unused usePresetOptions

* refactor: new agentOptions default handling

* chore: note

* feat: add label and custom instructions to agents

* chore: remove 'disabled with tools' message

* style: move plugins to 2nd column in parameters

* fix: TPreset type for agentOptions

* fix: interface controls

* refactor: add interfaceConfig, use Separator within Switcher

* refactor: hide Assistants panel if interface.parameters are disabled

* fix(Header): only modelSpecs if list is greater than 0

* refactor: separate MessageIcon logic from useMessageHelpers for better react rule-following

* fix(AppService): don't use reserved keyword 'interface'

* feat: set existing Icon for custom endpoints through iconURL

* fix(ci): tests passing for App Service

* docs: refactor custom_config.md for readability and better organization, also include missing values

* docs: interface section and re-organize docs

* docs: update modelSpecs info

* chore: remove unused files

* chore: remove unused files

* chore: move useSetIndexOptions

* chore: remove unused file

* chore: move useConversation(s)

* chore: move useDefaultConvo

* chore: move useNavigateToConvo

* refactor: use plugin install hook so it can be used elsewhere

* chore: import order

* update docs

* refactor(OpenAI/Plugins): allow modelLabel as an initial value for chatGptLabel

* chore: remove unused EndpointOptionsPopover and hide 'Save as Preset' button if preset UI visibility disabled

* feat(loadDefaultInterface): issue warnings based on values

* feat: changelog for custom config file

* docs: add additional changelog note

* fix: prevent unavailable tool selection from preset and update availableTools on Plugin installations

* feat: add `filteredTools` option in custom config

* chore: changelog

* fix(MessageIcon): always overwrite conversation.iconURL in messageSettings

* fix(ModelSpecsMenu): icon edge cases

* fix(NewChat): dynamic icon

* fix(PluginsClient): always include endpoint in responseMessage

* fix: always include endpoint and iconURL in responseMessage across different response methods

* feat: interchangeable keys for modelSpec enforcing
This commit is contained in:
Danny Avila 2024-04-30 22:11:48 -04:00 committed by GitHub
parent a5cac03fa4
commit 0e50c07e3f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
130 changed files with 3934 additions and 2973 deletions

View file

@ -1,4 +1,9 @@
export { default as usePresets } from './usePresets';
export { default as useGetSender } from './useGetSender';
export { default as useDefaultConvo } from './useDefaultConvo';
export { default as useConversation } from './useConversation';
export { default as useConversations } from './useConversations';
export { default as useDebouncedInput } from './useDebouncedInput';
export { default as useNavigateToConvo } from './useNavigateToConvo';
export { default as useSetIndexOptions } from './useSetIndexOptions';
export { default as useParameterEffects } from './useParameterEffects';

View file

@ -0,0 +1,107 @@
import { useCallback } from 'react';
import { useSetRecoilState, useResetRecoilState, useRecoilCallback } from 'recoil';
import { useGetEndpointsQuery, useGetModelsQuery } from 'librechat-data-provider/react-query';
import type {
TConversation,
TMessagesAtom,
TSubmission,
TPreset,
TModelsConfig,
TEndpointsConfig,
} from 'librechat-data-provider';
import { buildDefaultConvo, getDefaultEndpoint, getEndpointField } from '~/utils';
import useOriginNavigate from '../useOriginNavigate';
import store from '~/store';
const useConversation = () => {
const navigate = useOriginNavigate();
const setConversation = useSetRecoilState(store.conversation);
const resetLatestMessage = useResetRecoilState(store.latestMessage);
const setMessages = useSetRecoilState<TMessagesAtom>(store.messages);
const setSubmission = useSetRecoilState<TSubmission | null>(store.submission);
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
const modelsQuery = useGetModelsQuery();
const switchToConversation = useRecoilCallback(
() =>
async (
conversation: TConversation,
messages: TMessagesAtom = null,
preset: TPreset | null = null,
modelsData?: TModelsConfig,
) => {
const modelsConfig = modelsData ?? modelsQuery.data;
const { endpoint = null } = conversation;
if (endpoint === null) {
const defaultEndpoint = getDefaultEndpoint({
convoSetup: preset ?? conversation,
endpointsConfig,
});
const endpointType = getEndpointField(endpointsConfig, defaultEndpoint, 'type');
if (!conversation.endpointType && endpointType) {
conversation.endpointType = endpointType;
}
const models = modelsConfig?.[defaultEndpoint] ?? [];
conversation = buildDefaultConvo({
conversation,
lastConversationSetup: preset as TConversation,
endpoint: defaultEndpoint,
models,
});
}
setConversation(conversation);
setMessages(messages);
setSubmission({} as TSubmission);
resetLatestMessage();
if (conversation.conversationId === 'new' && !modelsData) {
navigate('new');
}
},
[endpointsConfig, modelsQuery.data],
);
const newConversation = useCallback(
(template = {}, preset?: TPreset, modelsData?: TModelsConfig) => {
switchToConversation(
{
conversationId: 'new',
title: 'New Chat',
...template,
endpoint: null,
createdAt: '',
updatedAt: '',
},
[],
preset,
modelsData,
);
},
[switchToConversation],
);
const searchPlaceholderConversation = useCallback(() => {
switchToConversation(
{
conversationId: 'search',
title: 'Search',
endpoint: null,
createdAt: '',
updatedAt: '',
},
[],
);
}, [switchToConversation]);
return {
switchToConversation,
newConversation,
searchPlaceholderConversation,
};
};
export default useConversation;

View file

@ -0,0 +1,15 @@
import { useSetRecoilState } from 'recoil';
import { useCallback } from 'react';
import store from '~/store';
const useConversations = () => {
const setRefreshConversationsHint = useSetRecoilState(store.refreshConversationsHint);
const refreshConversations = useCallback(() => {
setRefreshConversationsHint((prevState) => prevState + 1);
}, [setRefreshConversationsHint]);
return { refreshConversations };
};
export default useConversations;

View file

@ -0,0 +1,35 @@
import { useGetEndpointsQuery, useGetModelsQuery } from 'librechat-data-provider/react-query';
import type {
TConversation,
TPreset,
TEndpointsConfig,
TModelsConfig,
} from 'librechat-data-provider';
import { getDefaultEndpoint, buildDefaultConvo } from '~/utils';
type TDefaultConvo = { conversation: Partial<TConversation>; preset?: Partial<TPreset> | null };
const useDefaultConvo = () => {
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
const { data: modelsConfig = {} as TModelsConfig } = useGetModelsQuery();
const getDefaultConversation = ({ conversation, preset }: TDefaultConvo) => {
const endpoint = getDefaultEndpoint({
convoSetup: preset as TPreset,
endpointsConfig,
});
const models = modelsConfig[endpoint] || [];
return buildDefaultConvo({
conversation: conversation as TConversation,
endpoint,
lastConversationSetup: preset as TConversation,
models,
});
};
return getDefaultConversation;
};
export default useDefaultConvo;

View file

@ -0,0 +1,59 @@
import { useQueryClient } from '@tanstack/react-query';
import { useSetRecoilState, useResetRecoilState } from 'recoil';
import { QueryKeys } from 'librechat-data-provider';
import type { TConversation, TEndpointsConfig, TModelsConfig } from 'librechat-data-provider';
import { buildDefaultConvo, getDefaultEndpoint, getEndpointField } from '~/utils';
import useOriginNavigate from '../useOriginNavigate';
import store from '~/store';
const useNavigateToConvo = (index = 0) => {
const queryClient = useQueryClient();
const navigate = useOriginNavigate();
const { setConversation } = store.useCreateConversationAtom(index);
const setSubmission = useSetRecoilState(store.submissionByIndex(index));
const resetLatestMessage = useResetRecoilState(store.latestMessageFamily(index));
const navigateToConvo = (conversation: TConversation, _resetLatestMessage = true) => {
if (!conversation) {
console.log('Conversation not provided');
return;
}
setSubmission(null);
if (_resetLatestMessage) {
resetLatestMessage();
}
let convo = { ...conversation };
if (!convo?.endpoint) {
/* undefined endpoint edge case */
const modelsConfig = queryClient.getQueryData<TModelsConfig>([QueryKeys.models]);
const endpointsConfig = queryClient.getQueryData<TEndpointsConfig>([QueryKeys.endpoints]);
const defaultEndpoint = getDefaultEndpoint({
convoSetup: conversation,
endpointsConfig,
});
const endpointType = getEndpointField(endpointsConfig, defaultEndpoint, 'type');
if (!conversation.endpointType && endpointType) {
conversation.endpointType = endpointType;
}
const models = modelsConfig?.[defaultEndpoint ?? ''] ?? [];
convo = buildDefaultConvo({
conversation,
endpoint: defaultEndpoint,
lastConversationSetup: conversation,
models,
});
}
setConversation(convo);
navigate(convo?.conversationId);
};
return {
navigateToConvo,
};
};
export default useNavigateToConvo;

View file

@ -0,0 +1,166 @@
import { useRecoilValue, useSetRecoilState } from 'recoil';
import type { TPreset, TPlugin } from 'librechat-data-provider';
import type { TSetOptionsPayload, TSetExample, TSetOption } from '~/common';
import { useChatContext } from '~/Providers/ChatContext';
import { cleanupPreset } from '~/utils';
import store from '~/store';
type TUsePresetOptions = (preset?: TPreset | boolean | null) => TSetOptionsPayload | boolean;
const usePresetIndexOptions: TUsePresetOptions = (_preset) => {
const setShowPluginStoreDialog = useSetRecoilState(store.showPluginStoreDialog);
const availableTools = useRecoilValue(store.availableTools);
const { preset, setPreset } = useChatContext();
if (!_preset) {
return false;
}
const getConversation: () => TPreset | null = () => preset;
const setOption: TSetOption = (param) => (newValue) => {
const update = {};
update[param] = newValue;
setPreset((prevState) =>
cleanupPreset({
preset: {
...prevState,
...update,
},
}),
);
};
const setExample: TSetExample = (i, type, newValue = null) => {
const update = {};
const current = preset?.examples?.slice() || [];
const currentExample = { ...current[i] } || {};
currentExample[type] = { content: newValue };
current[i] = currentExample;
update['examples'] = current;
setPreset((prevState) =>
cleanupPreset({
preset: {
...prevState,
...update,
},
}),
);
};
const addExample: () => void = () => {
const update = {};
const current = preset?.examples?.slice() || [];
current.push({ input: { content: '' }, output: { content: '' } });
update['examples'] = current;
setPreset((prevState) =>
cleanupPreset({
preset: {
...prevState,
...update,
},
}),
);
};
const removeExample: () => void = () => {
const update = {};
const current = preset?.examples?.slice() || [];
if (current.length <= 1) {
update['examples'] = [{ input: { content: '' }, output: { content: '' } }];
setPreset((prevState) =>
cleanupPreset({
preset: {
...prevState,
...update,
},
}),
);
return;
}
current.pop();
update['examples'] = current;
setPreset((prevState) =>
cleanupPreset({
preset: {
...prevState,
...update,
},
}),
);
};
const setAgentOption: TSetOption = (param) => (newValue) => {
const editablePreset = JSON.parse(JSON.stringify(_preset));
const { agentOptions } = editablePreset;
agentOptions[param] = newValue;
setPreset((prevState) =>
cleanupPreset({
preset: {
...prevState,
agentOptions,
},
}),
);
};
function checkPluginSelection(value: string) {
if (!preset?.tools) {
return false;
}
return preset.tools.find((el) => {
if (typeof el === 'string') {
return el === value;
}
return el.pluginKey === value;
})
? true
: false;
}
const setTools: (newValue: string, remove?: boolean) => void = (newValue, remove) => {
if (newValue === 'pluginStore') {
setShowPluginStoreDialog(true);
return;
}
const update = {};
const current =
preset?.tools
?.map((tool: string | TPlugin) => {
if (typeof tool === 'string') {
return availableTools[tool];
}
return tool;
})
?.filter((el) => !!el) || [];
const isSelected = checkPluginSelection(newValue);
const tool = availableTools[newValue];
if (isSelected || remove) {
update['tools'] = current.filter((el) => el.pluginKey !== newValue);
} else {
update['tools'] = [...current, tool];
}
setPreset((prevState) =>
cleanupPreset({
preset: {
...prevState,
...update,
},
}),
);
};
return {
setOption,
setExample,
addExample,
removeExample,
getConversation,
checkPluginSelection,
setAgentOption,
setTools,
};
};
export default usePresetIndexOptions;

View file

@ -11,9 +11,9 @@ import {
useDeletePresetMutation,
useGetPresetsQuery,
} from '~/data-provider';
import { cleanupPreset, getEndpointField, removeUnavailableTools } from '~/utils';
import useDefaultConvo from '~/hooks/Conversations/useDefaultConvo';
import { useChatContext, useToastContext } from '~/Providers';
import { cleanupPreset, getEndpointField } from '~/utils';
import useDefaultConvo from '~/hooks/useDefaultConvo';
import { useAuthContext } from '~/hooks/AuthContext';
import { NotificationSeverity } from '~/common';
import useLocalize from '~/hooks/useLocalize';
@ -28,6 +28,7 @@ export default function usePresets() {
const { user, isAuthenticated } = useAuthContext();
const modularChat = useRecoilValue(store.modularChat);
const availableTools = useRecoilValue(store.availableTools);
const setPresetModalVisible = useSetRecoilState(store.presetModalVisible);
const [_defaultPreset, setDefaultPreset] = useRecoilState(store.defaultPreset);
const presetsQuery = useGetPresetsQuery({ enabled: !!user && isAuthenticated });
@ -151,11 +152,13 @@ export default function usePresets() {
importPreset(jsonPreset);
};
const onSelectPreset = (newPreset: TPreset) => {
if (!newPreset) {
const onSelectPreset = (_newPreset: TPreset) => {
if (!_newPreset) {
return;
}
const newPreset = removeUnavailableTools(_newPreset, availableTools);
const toastTitle = newPreset.title
? `"${newPreset.title}"`
: localize('com_endpoint_preset_title');

View file

@ -0,0 +1,170 @@
import { useRecoilValue, useSetRecoilState } from 'recoil';
import { TPreset, TPlugin, TConversation, tConvoUpdateSchema } from 'librechat-data-provider';
import type { TSetExample, TSetOption, TSetOptionsPayload } from '~/common';
import usePresetIndexOptions from './usePresetIndexOptions';
import { useChatContext } from '~/Providers/ChatContext';
import store from '~/store';
type TUseSetOptions = (preset?: TPreset | boolean | null) => TSetOptionsPayload;
const useSetIndexOptions: TUseSetOptions = (preset = false) => {
const setShowPluginStoreDialog = useSetRecoilState(store.showPluginStoreDialog);
const availableTools = useRecoilValue(store.availableTools);
const { conversation, setConversation } = useChatContext();
const result = usePresetIndexOptions(preset);
if (result && typeof result !== 'boolean') {
return result;
}
const setOption: TSetOption = (param) => (newValue) => {
const update = {};
update[param] = newValue;
if (param === 'presetOverride') {
const currentOverride = conversation?.presetOverride || {};
update['presetOverride'] = {
...currentOverride,
...(newValue as unknown as Partial<TPreset>),
};
}
setConversation(
(prevState) =>
tConvoUpdateSchema.parse({
...prevState,
...update,
}) as TConversation,
);
};
const setExample: TSetExample = (i, type, newValue = null) => {
const update = {};
const current = conversation?.examples?.slice() || [];
const currentExample = { ...current[i] } || {};
currentExample[type] = { content: newValue };
current[i] = currentExample;
update['examples'] = current;
setConversation(
(prevState) =>
tConvoUpdateSchema.parse({
...prevState,
...update,
}) as TConversation,
);
};
const addExample: () => void = () => {
const update = {};
const current = conversation?.examples?.slice() || [];
current.push({ input: { content: '' }, output: { content: '' } });
update['examples'] = current;
setConversation(
(prevState) =>
tConvoUpdateSchema.parse({
...prevState,
...update,
}) as TConversation,
);
};
const removeExample: () => void = () => {
const update = {};
const current = conversation?.examples?.slice() || [];
if (current.length <= 1) {
update['examples'] = [{ input: { content: '' }, output: { content: '' } }];
setConversation(
(prevState) =>
tConvoUpdateSchema.parse({
...prevState,
...update,
}) as TConversation,
);
return;
}
current.pop();
update['examples'] = current;
setConversation(
(prevState) =>
tConvoUpdateSchema.parse({
...prevState,
...update,
}) as TConversation,
);
};
function checkPluginSelection(value: string) {
if (!conversation?.tools) {
return false;
}
return conversation.tools.find((el) => {
if (typeof el === 'string') {
return el === value;
}
return el.pluginKey === value;
})
? true
: false;
}
const setAgentOption: TSetOption = (param) => (newValue) => {
const editableConvo = JSON.stringify(conversation);
const convo = JSON.parse(editableConvo);
const { agentOptions } = convo;
agentOptions[param] = newValue;
setConversation(
(prevState) =>
tConvoUpdateSchema.parse({
...prevState,
agentOptions,
}) as TConversation,
);
};
const setTools: (newValue: string, remove?: boolean) => void = (newValue, remove) => {
if (newValue === 'pluginStore') {
setShowPluginStoreDialog(true);
return;
}
const update = {};
const current =
conversation?.tools
?.map((tool: string | TPlugin) => {
if (typeof tool === 'string') {
return availableTools[tool];
}
return tool;
})
?.filter((el) => !!el) || [];
const isSelected = checkPluginSelection(newValue);
const tool = availableTools[newValue];
if (isSelected || remove) {
update['tools'] = current.filter((el) => el.pluginKey !== newValue);
} else {
update['tools'] = [...current, tool];
}
setConversation(
(prevState) =>
tConvoUpdateSchema.parse({
...prevState,
...update,
}) as TConversation,
);
};
return {
setOption,
setExample,
addExample,
removeExample,
setAgentOption,
checkPluginSelection,
setTools,
};
};
export default useSetIndexOptions;