🤖 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

@ -655,6 +655,9 @@ class AnthropicClient extends BaseClient {
promptPrefix: this.options.promptPrefix,
modelLabel: this.options.modelLabel,
resendFiles: this.options.resendFiles,
iconURL: this.options.iconURL,
greeting: this.options.greeting,
spec: this.options.spec,
...this.modelOptions,
};
}

View file

@ -456,6 +456,8 @@ class BaseClient {
sender: this.sender,
text: addSpaceIfNeeded(generation) + completion,
promptTokens,
iconURL: this.options.iconURL,
endpoint: this.options.endpoint,
...(this.metadata ?? {}),
};
@ -525,8 +527,19 @@ class BaseClient {
return _messages;
}
/**
* Save a message to the database.
* @param {TMessage} message
* @param {Partial<TConversation>} endpointOptions
* @param {string | null} user
*/
async saveMessageToDatabase(message, endpointOptions, user = null) {
await saveMessage({ ...message, endpoint: this.options.endpoint, user, unfinished: false });
await saveMessage({
...message,
endpoint: this.options.endpoint,
unfinished: false,
user,
});
await saveConvo(user, {
conversationId: message.conversationId,
endpoint: this.options.endpoint,

View file

@ -708,6 +708,9 @@ class GoogleClient extends BaseClient {
return {
promptPrefix: this.options.promptPrefix,
modelLabel: this.options.modelLabel,
iconURL: this.options.iconURL,
greeting: this.options.greeting,
spec: this.options.spec,
...this.modelOptions,
};
}

View file

@ -381,6 +381,9 @@ class OpenAIClient extends BaseClient {
promptPrefix: this.options.promptPrefix,
resendFiles: this.options.resendFiles,
imageDetail: this.options.imageDetail,
iconURL: this.options.iconURL,
greeting: this.options.greeting,
spec: this.options.spec,
...this.modelOptions,
};
}

View file

@ -42,8 +42,12 @@ class PluginsClient extends OpenAIClient {
return {
chatGptLabel: this.options.chatGptLabel,
promptPrefix: this.options.promptPrefix,
tools: this.options.tools,
...this.modelOptions,
agentOptions: this.agentOptions,
iconURL: this.options.iconURL,
greeting: this.options.greeting,
spec: this.options.spec,
};
}
@ -144,9 +148,11 @@ class PluginsClient extends OpenAIClient {
signal,
pastMessages,
tools: this.tools,
currentDateString: this.currentDateString,
verbose: this.options.debug,
returnIntermediateSteps: true,
customName: this.options.chatGptLabel,
currentDateString: this.currentDateString,
customInstructions: this.options.promptPrefix,
callbackManager: CallbackManager.fromHandlers({
async handleAgentAction(action, runId) {
handleAction(action, runId, onAgentAction);
@ -304,6 +310,8 @@ class PluginsClient extends OpenAIClient {
}
const responseMessage = {
endpoint: EModelEndpoint.gptPlugins,
iconURL: this.options.iconURL,
messageId: responseMessageId,
conversationId,
parentMessageId: userMessage.messageId,

View file

@ -13,10 +13,18 @@ const initializeCustomAgent = async ({
tools,
model,
pastMessages,
customName,
customInstructions,
currentDateString,
...rest
}) => {
let prompt = CustomAgent.createPrompt(tools, { currentDateString, model: model.modelName });
if (customName) {
prompt = `You are "${customName}".\n${prompt}`;
}
if (customInstructions) {
prompt = `${prompt}\n${customInstructions}`;
}
const chatPrompt = ChatPromptTemplate.fromMessages([
new SystemMessagePromptTemplate(prompt),

View file

@ -10,6 +10,8 @@ const initializeFunctionsAgent = async ({
tools,
model,
pastMessages,
customName,
customInstructions,
currentDateString,
...rest
}) => {
@ -24,7 +26,13 @@ const initializeFunctionsAgent = async ({
returnMessages: true,
});
const prefix = addToolDescriptions(`Current Date: ${currentDateString}\n${PREFIX}`, tools);
let prefix = addToolDescriptions(`Current Date: ${currentDateString}\n${PREFIX}`, tools);
if (customName) {
prefix = `You are "${customName}".\n${prefix}`;
}
if (customInstructions) {
prefix = `${prefix}\n${customInstructions}`;
}
return await initializeAgentExecutorWithOptions(tools, model, {
agentType: 'openai-functions',

View file

@ -10,6 +10,7 @@ module.exports = {
async saveMessage({
user,
endpoint,
iconURL,
messageId,
newMessageId,
conversationId,
@ -35,6 +36,7 @@ module.exports = {
const update = {
user,
iconURL,
endpoint,
messageId: newMessageId || messageId,
conversationId,

View file

@ -39,6 +39,12 @@ module.exports = {
try {
const setter = { $set: {} };
const update = { presetId, ...preset };
if (preset.tools && Array.isArray(preset.tools)) {
update.tools =
preset.tools
.map((tool) => tool?.pluginKey ?? tool)
.filter((toolName) => typeof toolName === 'string') ?? [];
}
if (newPresetId) {
update.presetId = newPresetId;
}

View file

@ -89,6 +89,17 @@ const conversationPreset = {
type: String,
},
stop: { type: [{ type: String }], default: undefined },
/* UI Components */
iconURL: {
type: String,
},
greeting: {
type: String,
},
spec: {
type: String,
},
tools: { type: [{ type: String }], default: undefined },
};
const agentOptions = {

View file

@ -110,6 +110,10 @@ const messageSchema = mongoose.Schema(
thread_id: {
type: String,
},
/* frontend components */
iconURL: {
type: String,
},
},
{ timestamps: true },
);

View file

@ -55,6 +55,9 @@ const getAvailablePluginsController = async (req, res) => {
return;
}
/** @type {{ filteredTools: string[] }} */
const { filteredTools = [] } = req.app.locals;
const pluginManifest = await fs.readFile(req.app.locals.paths.pluginManifest, 'utf8');
const jsonData = JSON.parse(pluginManifest);
@ -67,7 +70,10 @@ const getAvailablePluginsController = async (req, res) => {
return plugin;
}
});
const plugins = await addOpenAPISpecs(authenticatedPlugins);
let plugins = await addOpenAPISpecs(authenticatedPlugins);
plugins = plugins.filter((plugin) => !filteredTools.includes(plugin.pluginKey));
await cache.set(CacheKeys.PLUGINS, plugins);
res.status(200).json(plugins);
} catch (error) {

View file

@ -73,6 +73,8 @@ const createAbortController = (req, res, getAbortData) => {
...responseData,
conversationId,
finish_reason: 'incomplete',
endpoint: endpointOption.endpoint,
iconURL: endpointOption.iconURL,
model: endpointOption.modelOptions.model,
unfinished: false,
error: false,

View file

@ -7,6 +7,8 @@ const anthropic = require('~/server/services/Endpoints/anthropic');
const openAI = require('~/server/services/Endpoints/openAI');
const custom = require('~/server/services/Endpoints/custom');
const google = require('~/server/services/Endpoints/google');
const enforceModelSpec = require('./enforceModelSpec');
const { handleError } = require('~/server/utils');
const buildFunction = {
[EModelEndpoint.openAI]: openAI.buildOptions,
@ -21,6 +23,31 @@ const buildFunction = {
async function buildEndpointOption(req, res, next) {
const { endpoint, endpointType } = req.body;
const parsedBody = parseConvo({ endpoint, endpointType, conversation: req.body });
if (req.app.locals.modelSpecs?.list && req.app.locals.modelSpecs?.enforce) {
/** @type {{ list: TModelSpec[] }}*/
const { list } = req.app.locals.modelSpecs;
const { spec } = parsedBody;
if (!spec) {
return handleError(res, { text: 'No model spec selected' });
}
const currentModelSpec = list.find((s) => s.name === spec);
if (!currentModelSpec) {
return handleError(res, { text: 'Invalid model spec' });
}
if (endpoint !== currentModelSpec.preset.endpoint) {
return handleError(res, { text: 'Model spec mismatch' });
}
const isValidModelSpec = enforceModelSpec(currentModelSpec, parsedBody);
if (!isValidModelSpec) {
return handleError(res, { text: 'Model spec mismatch' });
}
}
req.body.endpointOption = buildFunction[endpointType ?? endpoint](
endpoint,
parsedBody,

View file

@ -0,0 +1,47 @@
const interchangeableKeys = new Map([
['chatGptLabel', ['modelLabel']],
['modelLabel', ['chatGptLabel']],
]);
/**
* Middleware to enforce the model spec for a conversation
* @param {TModelSpec} modelSpec - The model spec to enforce
* @param {TConversation} parsedBody - The parsed body of the conversation
* @returns {boolean} - Whether the model spec is enforced
*/
const enforceModelSpec = (modelSpec, parsedBody) => {
for (const [key, value] of Object.entries(modelSpec.preset)) {
if (key === 'endpoint') {
continue;
}
if (!checkMatch(key, value, parsedBody)) {
return false;
}
}
return true;
};
/**
* Checks if there is a match for the given key and value in the parsed body
* or any of its interchangeable keys.
* @param {string} key
* @param {any} value
* @param {TConversation} parsedBody
* @returns {boolean}
*/
const checkMatch = (key, value, parsedBody) => {
if (parsedBody[key] === value) {
return true;
}
if (interchangeableKeys.has(key)) {
return interchangeableKeys
.get(key)
.some((interchangeableKey) => parsedBody[interchangeableKey] === value);
}
return false;
};
module.exports = enforceModelSpec;

View file

@ -14,6 +14,7 @@ router.get('/', async function (req, res) {
};
try {
/** @type {TStartupConfig} */
const payload = {
appTitle: process.env.APP_TITLE || 'LibreChat',
socialLogins: req.app.locals.socialLogins ?? defaultSocialLogins,
@ -44,7 +45,8 @@ router.get('/', async function (req, res) {
isEnabled(process.env.SHOW_BIRTHDAY_ICON) ||
process.env.SHOW_BIRTHDAY_ICON === '',
helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai',
interface: req.app.locals.interface,
interface: req.app.locals.interfaceConfig,
modelSpecs: req.app.locals.modelSpecs,
};
if (typeof process.env.CUSTOM_FOOTER === 'string') {

View file

@ -1,14 +1,10 @@
const {
FileSources,
EModelEndpoint,
EImageOutputType,
defaultSocialLogins,
} = require('librechat-data-provider');
const { FileSources, EModelEndpoint, getConfigDefaults } = require('librechat-data-provider');
const { checkVariables, checkHealth, checkConfig, checkAzureVariables } = require('./start/checks');
const { azureAssistantsDefaults, assistantsConfigSetup } = require('./start/assistants');
const { initializeFirebase } = require('./Files/Firebase/initialize');
const loadCustomConfig = require('./Config/loadCustomConfig');
const handleRateLimits = require('./Config/handleRateLimits');
const { loadDefaultInterface } = require('./start/interface');
const { azureConfigSetup } = require('./start/azureOpenAI');
const { loadAndFormatTools } = require('./ToolService');
const paths = require('~/config/paths');
@ -22,9 +18,12 @@ const paths = require('~/config/paths');
const AppService = async (app) => {
/** @type {TCustomConfig}*/
const config = (await loadCustomConfig()) ?? {};
const configDefaults = getConfigDefaults();
const filteredTools = config.filteredTools;
const fileStrategy = config.fileStrategy ?? configDefaults.fileStrategy;
const imageOutputType = config?.imageOutputType ?? configDefaults.imageOutputType;
const fileStrategy = config.fileStrategy ?? FileSources.local;
const imageOutputType = config?.imageOutputType ?? EImageOutputType.PNG;
process.env.CDN_PROVIDER = fileStrategy;
checkVariables();
@ -37,24 +36,22 @@ const AppService = async (app) => {
/** @type {Record<string, FunctionTool} */
const availableTools = loadAndFormatTools({
directory: paths.structuredTools,
filter: new Set([
'ChatTool.js',
'CodeSherpa.js',
'CodeSherpaTools.js',
'E2BTools.js',
'extractionChain.js',
]),
adminFilter: filteredTools,
});
const socialLogins = config?.registration?.socialLogins ?? defaultSocialLogins;
const socialLogins =
config?.registration?.socialLogins ?? configDefaults?.registration?.socialLogins;
const interfaceConfig = loadDefaultInterface(config, configDefaults);
if (!Object.keys(config).length) {
app.locals = {
paths,
fileStrategy,
socialLogins,
filteredTools,
availableTools,
imageOutputType,
interfaceConfig,
};
return;
@ -85,9 +82,11 @@ const AppService = async (app) => {
paths,
socialLogins,
fileStrategy,
filteredTools,
availableTools,
imageOutputType,
interface: config?.interface,
interfaceConfig,
modelSpecs: config.modelSpecs,
fileConfig: config?.fileConfig,
secureImageLinks: config?.secureImageLinks,
...endpointLocals,

View file

@ -93,6 +93,16 @@ describe('AppService', () => {
expect(app.locals).toEqual({
socialLogins: ['testLogin'],
fileStrategy: 'testStrategy',
interfaceConfig: expect.objectContaining({
privacyPolicy: undefined,
termsOfService: undefined,
endpointsMenu: true,
modelSelect: true,
parameters: true,
sidePanel: true,
presets: true,
}),
modelSpecs: undefined,
availableTools: {
ExampleTool: {
type: 'function',
@ -109,7 +119,6 @@ describe('AppService', () => {
},
paths: expect.anything(),
imageOutputType: expect.any(String),
interface: undefined,
fileConfig: undefined,
secureImageLinks: undefined,
});
@ -181,7 +190,6 @@ describe('AppService', () => {
expect(loadAndFormatTools).toHaveBeenCalledWith({
directory: expect.anything(),
filter: expect.anything(),
});
expect(app.locals.availableTools.ExampleTool).toBeDefined();

View file

@ -42,6 +42,12 @@ async function loadCustomConfig() {
i === 0 && i++;
return null;
}
if (customConfig.reason || customConfig.stack) {
i === 0 && logger.error('Config file YAML format is invalid:', customConfig);
i === 0 && i++;
return null;
}
}
if (typeof customConfig === 'string') {
@ -84,6 +90,10 @@ Please specify a correct \`imageOutputType\` value (case-sensitive).
await cache.set(CacheKeys.CUSTOM_CONFIG, customConfig);
}
if (result.data.modelSpecs) {
customConfig.modelSpecs = result.data.modelSpecs;
}
return customConfig;
}

View file

@ -1,10 +1,13 @@
const buildOptions = (endpoint, parsedBody) => {
const { modelLabel, promptPrefix, resendFiles, ...rest } = parsedBody;
const { modelLabel, promptPrefix, resendFiles, iconURL, greeting, spec, ...rest } = parsedBody;
const endpointOption = {
endpoint,
modelLabel,
promptPrefix,
resendFiles,
iconURL,
greeting,
spec,
modelOptions: {
...rest,
},

View file

@ -1,10 +1,13 @@
const buildOptions = (endpoint, parsedBody) => {
// eslint-disable-next-line no-unused-vars
const { promptPrefix, assistant_id, ...rest } = parsedBody;
const { promptPrefix, assistant_id, iconURL, greeting, spec, ...rest } = parsedBody;
const endpointOption = {
endpoint,
promptPrefix,
assistant_id,
iconURL,
greeting,
spec,
modelOptions: {
...rest,
},

View file

@ -1,5 +1,6 @@
const buildOptions = (endpoint, parsedBody, endpointType) => {
const { chatGptLabel, promptPrefix, resendFiles, imageDetail, ...rest } = parsedBody;
const { chatGptLabel, promptPrefix, resendFiles, imageDetail, iconURL, greeting, spec, ...rest } =
parsedBody;
const endpointOption = {
endpoint,
endpointType,
@ -7,6 +8,9 @@ const buildOptions = (endpoint, parsedBody, endpointType) => {
promptPrefix,
resendFiles,
imageDetail,
iconURL,
greeting,
spec,
modelOptions: {
...rest,
},

View file

@ -1,10 +1,13 @@
const buildOptions = (endpoint, parsedBody) => {
const { examples, modelLabel, promptPrefix, ...rest } = parsedBody;
const { examples, modelLabel, promptPrefix, iconURL, greeting, spec, ...rest } = parsedBody;
const endpointOption = {
examples,
endpoint,
modelLabel,
promptPrefix,
iconURL,
greeting,
spec,
modelOptions: {
...rest,
},

View file

@ -4,25 +4,24 @@ const buildOptions = (endpoint, parsedBody) => {
promptPrefix,
agentOptions,
tools,
model,
temperature,
top_p,
presence_penalty,
frequency_penalty,
iconURL,
greeting,
spec,
...modelOptions
} = parsedBody;
const endpointOption = {
endpoint,
tools: tools.map((tool) => tool.pluginKey) ?? [],
tools:
tools
.map((tool) => tool?.pluginKey ?? tool)
.filter((toolName) => typeof toolName === 'string') ?? [],
chatGptLabel,
promptPrefix,
agentOptions,
modelOptions: {
model,
temperature,
top_p,
presence_penalty,
frequency_penalty,
},
iconURL,
greeting,
spec,
modelOptions,
};
return endpointOption;

View file

@ -1,11 +1,15 @@
const buildOptions = (endpoint, parsedBody) => {
const { chatGptLabel, promptPrefix, resendFiles, imageDetail, ...rest } = parsedBody;
const { chatGptLabel, promptPrefix, resendFiles, imageDetail, iconURL, greeting, spec, ...rest } =
parsedBody;
const endpointOption = {
endpoint,
chatGptLabel,
promptPrefix,
resendFiles,
imageDetail,
iconURL,
greeting,
spec,
modelOptions: {
...rest,
},

View file

@ -20,6 +20,14 @@ const { redactMessage } = require('~/config/parsers');
const { sleep } = require('~/server/utils');
const { logger } = require('~/config');
const filteredTools = new Set([
'ChatTool.js',
'CodeSherpa.js',
'CodeSherpaTools.js',
'E2BTools.js',
'extractionChain.js',
]);
/**
* Loads and formats tools from the specified tool directory.
*
@ -30,10 +38,11 @@ const { logger } = require('~/config');
*
* @param {object} params - The parameters for the function.
* @param {string} params.directory - The directory path where the tools are located.
* @param {Set<string>} [params.filter=new Set()] - A set of filenames to exclude from loading.
* @param {Array<string>} [params.adminFilter=[]] - Array of admin-defined tool keys to exclude from loading.
* @returns {Record<string, FunctionTool>} An object mapping each tool's plugin key to its instance.
*/
function loadAndFormatTools({ directory, filter = new Set() }) {
function loadAndFormatTools({ directory, adminFilter = [] }) {
const filter = new Set([...adminFilter, ...filteredTools]);
const tools = [];
/* Structured Tools Directory */
const files = fs.readdirSync(directory);

View file

@ -0,0 +1,74 @@
const { logger } = require('~/config');
/**
* Loads the default interface object.
* @param {TCustomConfig | undefined} config - The loaded custom configuration.
* @param {TConfigDefaults} configDefaults - The custom configuration default values.
* @returns {TCustomConfig['interface']} The default interface object.
*/
function loadDefaultInterface(config, configDefaults) {
const { interface: interfaceConfig } = config ?? {};
const { interface: defaults } = configDefaults;
const hasModelSpecs = config?.modelSpecs?.list?.length > 0;
const loadedInterface = {
endpointsMenu:
interfaceConfig?.endpointsMenu ?? (hasModelSpecs ? false : defaults.endpointsMenu),
modelSelect: interfaceConfig?.modelSelect ?? (hasModelSpecs ? false : defaults.modelSelect),
parameters: interfaceConfig?.parameters ?? (hasModelSpecs ? false : defaults.parameters),
presets: interfaceConfig?.presets ?? (hasModelSpecs ? false : defaults.presets),
sidePanel: interfaceConfig?.sidePanel ?? defaults.sidePanel,
privacyPolicy: interfaceConfig?.privacyPolicy ?? defaults.privacyPolicy,
termsOfService: interfaceConfig?.termsOfService ?? defaults.termsOfService,
};
let i = 0;
const logSettings = () => {
// log interface object and model specs object (without list) for reference
logger.warn(`\`interface\` settings:\n${JSON.stringify(loadedInterface, null, 2)}`);
logger.warn(
`\`modelSpecs\` settings:\n${JSON.stringify(
{ ...(config?.modelSpecs ?? {}), list: undefined },
null,
2,
)}`,
);
};
// warn about config.modelSpecs.prioritize if true and presets are enabled, that default presets will conflict with prioritizing model specs.
if (config?.modelSpecs?.prioritize && loadedInterface.presets) {
logger.warn(
'Note: Prioritizing model specs can conflict with default presets if a default preset is set. It\'s recommended to disable presets from the interface or disable use of a default preset.',
);
i === 0 && i++;
}
// warn about config.modelSpecs.enforce if true and if any of these, endpointsMenu, modelSelect, presets, or parameters are enabled, that enforcing model specs can conflict with these options.
if (
config?.modelSpecs?.enforce &&
(loadedInterface.endpointsMenu ||
loadedInterface.modelSelect ||
loadedInterface.presets ||
loadedInterface.parameters)
) {
logger.warn(
'Note: Enforcing model specs can conflict with the interface options: endpointsMenu, modelSelect, presets, and parameters. It\'s recommended to disable these options from the interface or disable enforcing model specs.',
);
i === 0 && i++;
}
// warn if enforce is true and prioritize is not, that enforcing model specs without prioritizing them can lead to unexpected behavior.
if (config?.modelSpecs?.enforce && !config?.modelSpecs?.prioritize) {
logger.warn(
'Note: Enforcing model specs without prioritizing them can lead to unexpected behavior. It\'s recommended to enable prioritizing model specs if enforcing them.',
);
i === 0 && i++;
}
if (i > 0) {
logSettings();
}
return loadedInterface;
}
module.exports = { loadDefaultInterface };

View file

@ -300,6 +300,18 @@
* @memberof typedefs
*/
/**
* @exports TStartupConfig
* @typedef {import('librechat-data-provider').TStartupConfig} TStartupConfig
* @memberof typedefs
*/
/**
* @exports TConfigDefaults
* @typedef {import('librechat-data-provider').TConfigDefaults} TConfigDefaults
* @memberof typedefs
*/
/**
* @exports TPlugin
* @typedef {import('librechat-data-provider').TPlugin} TPlugin
@ -342,6 +354,18 @@
* @memberof typedefs
*/
/**
* @exports TConversation
* @typedef {import('librechat-data-provider').TConversation} TConversation
* @memberof typedefs
*/
/**
* @exports TModelSpec
* @typedef {import('librechat-data-provider').TModelSpec} TModelSpec
* @memberof typedefs
*/
/**
* @exports TPlugin
* @typedef {import('librechat-data-provider').TPlugin} TPlugin

View file

@ -6,7 +6,7 @@ function loadYaml(filepath) {
let fileContents = fs.readFileSync(filepath, 'utf8');
return yaml.load(fileContents);
} catch (e) {
// console.error(e);
return e;
}
}

View file

@ -2,21 +2,24 @@ import { FileSources } from 'librechat-data-provider';
import type { ColumnDef } from '@tanstack/react-table';
import type { SetterOrUpdater } from 'recoil';
import type {
TSetOption as SetOption,
TConversation,
TMessage,
TPreset,
TLoginUser,
TUser,
EModelEndpoint,
Action,
TPreset,
TPlugin,
TMessage,
TLoginUser,
AuthTypeEnum,
TConversation,
EModelEndpoint,
AuthorizationTypeEnum,
TSetOption as SetOption,
TokenExchangeMethodEnum,
} from 'librechat-data-provider';
import type { UseMutationResult } from '@tanstack/react-query';
import type { LucideIcon } from 'lucide-react';
export type TPluginMap = Record<string, TPlugin>;
export type GenericSetter<T> = (value: T | ((currentValue: T) => T)) => void;
export type LastSelectedModels = Record<EModelEndpoint, string>;
@ -32,6 +35,16 @@ export enum IconContext {
message = 'message',
}
export type IconMapProps = {
className?: string;
iconURL?: string;
context?: 'landing' | 'menu-item' | 'nav' | 'message';
endpoint?: string | null;
assistantName?: string;
avatar?: string;
size?: number;
};
export type NavLink = {
title: string;
label?: string;

View file

@ -1,17 +1,30 @@
import { useMemo } from 'react';
import { useOutletContext } from 'react-router-dom';
import { getConfigDefaults } from 'librechat-data-provider';
import { useGetStartupConfig } from 'librechat-data-provider/react-query';
import type { ContextType } from '~/common';
import { EndpointsMenu, PresetsMenu, HeaderNewChat } from './Menus';
import { EndpointsMenu, ModelSpecsMenu, PresetsMenu, HeaderNewChat } from './Menus';
import HeaderOptions from './Input/HeaderOptions';
const defaultInterface = getConfigDefaults().interface;
export default function Header() {
const { data: startupConfig } = useGetStartupConfig();
const { navVisible } = useOutletContext<ContextType>();
const modelSpecs = useMemo(() => startupConfig?.modelSpecs?.list ?? [], [startupConfig]);
const interfaceConfig = useMemo(
() => startupConfig?.interface ?? defaultInterface,
[startupConfig],
);
return (
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold dark:bg-gray-800 dark:text-white">
<div className="hide-scrollbar flex items-center gap-2 overflow-x-auto">
{!navVisible && <HeaderNewChat />}
<EndpointsMenu />
<HeaderOptions />
<PresetsMenu />
{interfaceConfig.endpointsMenu && <EndpointsMenu />}
{modelSpecs?.length > 0 && <ModelSpecsMenu modelSpecs={modelSpecs} />}
{<HeaderOptions interfaceConfig={interfaceConfig} />}
{interfaceConfig.presets && <PresetsMenu />}
</div>
{/* Empty div for spacing */}
<div />

View file

@ -3,7 +3,7 @@ import { Settings2 } from 'lucide-react';
import { Root, Anchor } from '@radix-ui/react-popover';
import { useState, useEffect, useMemo } from 'react';
import { tPresetUpdateSchema, EModelEndpoint } from 'librechat-data-provider';
import type { TPreset } from 'librechat-data-provider';
import type { TPreset, TInterfaceConfig } from 'librechat-data-provider';
import { EndpointSettings, SaveAsPresetDialog, AlternativeSettings } from '~/components/Endpoints';
import { ModelSelect } from '~/components/Input/ModelSelect';
import { PluginStoreDialog } from '~/components';
@ -15,7 +15,11 @@ import { Button } from '~/components/ui';
import { cn, cardStyle } from '~/utils/';
import store from '~/store';
export default function HeaderOptions() {
export default function HeaderOptions({
interfaceConfig,
}: {
interfaceConfig?: Partial<TInterfaceConfig>;
}) {
const [saveAsDialogShow, setSaveAsDialogShow] = useState<boolean>(false);
const [showPluginStoreDialog, setShowPluginStoreDialog] = useRecoilState(
store.showPluginStoreDialog,
@ -70,13 +74,15 @@ export default function HeaderOptions() {
<div className="my-auto lg:max-w-2xl xl:max-w-3xl">
<span className="flex w-full flex-col items-center justify-center gap-0 md:order-none md:m-auto md:gap-2">
<div className="z-[61] flex w-full items-center justify-center gap-2">
<ModelSelect
conversation={conversation}
setOption={setOption}
isMultiChat={true}
showAbove={false}
/>
{!noSettings[endpoint] && (
{interfaceConfig?.modelSelect && (
<ModelSelect
conversation={conversation}
setOption={setOption}
showAbove={false}
popover={true}
/>
)}
{!noSettings[endpoint] && interfaceConfig?.parameters && (
<Button
type="button"
className={cn(
@ -90,35 +96,41 @@ export default function HeaderOptions() {
</Button>
)}
</div>
<OptionsPopover
visible={showPopover}
saveAsPreset={saveAsPreset}
closePopover={() => setShowPopover(false)}
PopoverButtons={<PopoverButtons />}
>
<div className="px-4 py-4">
<EndpointSettings
className="[&::-webkit-scrollbar]:w-2"
conversation={conversation}
setOption={setOption}
isMultiChat={true}
/>
<AlternativeSettings conversation={conversation} setOption={setOption} />
</div>
</OptionsPopover>
<SaveAsPresetDialog
open={saveAsDialogShow}
onOpenChange={setSaveAsDialogShow}
preset={
tPresetUpdateSchema.parse({
...conversation,
}) as TPreset
}
/>
<PluginStoreDialog
isOpen={showPluginStoreDialog}
setIsOpen={setShowPluginStoreDialog}
/>
{interfaceConfig?.parameters && (
<OptionsPopover
visible={showPopover}
saveAsPreset={saveAsPreset}
presetsDisabled={!interfaceConfig?.presets}
PopoverButtons={<PopoverButtons />}
closePopover={() => setShowPopover(false)}
>
<div className="px-4 py-4">
<EndpointSettings
className="[&::-webkit-scrollbar]:w-2"
conversation={conversation}
setOption={setOption}
/>
<AlternativeSettings conversation={conversation} setOption={setOption} />
</div>
</OptionsPopover>
)}
{interfaceConfig?.presets && (
<SaveAsPresetDialog
open={saveAsDialogShow}
onOpenChange={setSaveAsDialogShow}
preset={
tPresetUpdateSchema.parse({
...conversation,
}) as TPreset
}
/>
)}
{interfaceConfig?.parameters && (
<PluginStoreDialog
isOpen={showPluginStoreDialog}
setIsOpen={setShowPluginStoreDialog}
/>
)}
</span>
</div>
</Anchor>

View file

@ -13,6 +13,7 @@ type TOptionsPopoverProps = {
saveAsPreset: () => void;
closePopover: () => void;
PopoverButtons: ReactNode;
presetsDisabled: boolean;
};
export default function OptionsPopover({
@ -22,6 +23,7 @@ export default function OptionsPopover({
saveAsPreset,
closePopover,
PopoverButtons,
presetsDisabled,
}: TOptionsPopoverProps) {
const popoverRef = useRef(null);
useOnClickOutside(
@ -61,14 +63,16 @@ export default function OptionsPopover({
)}
>
<div className="flex w-full items-center bg-gray-50 px-2 py-2 dark:bg-gray-700">
<Button
type="button"
className="h-auto w-[150px] justify-start rounded-md border-2 border-gray-300/50 bg-transparent px-2 py-1 text-xs font-medium font-normal text-black hover:bg-gray-100 hover:text-black focus:ring-1 focus:ring-green-500/90 dark:border-gray-500/50 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:focus:ring-green-500"
onClick={saveAsPreset}
>
<Save className="mr-1 w-[14px]" />
{localize('com_endpoint_save_as_preset')}
</Button>
{presetsDisabled ? null : (
<Button
type="button"
className="h-auto w-[150px] justify-start rounded-md border-2 border-gray-300/50 bg-transparent px-2 py-1 text-xs font-medium font-normal text-black hover:bg-gray-100 hover:text-black focus:ring-1 focus:ring-green-500/90 dark:border-gray-500/50 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:focus:ring-green-500"
onClick={saveAsPreset}
>
<Save className="mr-1 w-[14px]" />
{localize('com_endpoint_save_as_preset')}
</Button>
)}
{PopoverButtons}
<Button
type="button"

View file

@ -2,21 +2,22 @@ import { EModelEndpoint } from 'librechat-data-provider';
import { useGetEndpointsQuery, useGetStartupConfig } from 'librechat-data-provider/react-query';
import type { ReactNode } from 'react';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
import { getEndpointField, getIconEndpoint, getIconKey } from '~/utils';
import { useChatContext, useAssistantsMapContext } from '~/Providers';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { icons } from './Menus/Endpoints/Icons';
import { BirthdayIcon } from '~/components/svg';
import { getEndpointField } from '~/utils';
import { useLocalize } from '~/hooks';
export default function Landing({ Header }: { Header?: ReactNode }) {
const { conversation } = useChatContext();
const { data: endpointsConfig } = useGetEndpointsQuery();
const { data: startupConfig } = useGetStartupConfig();
const assistantMap = useAssistantsMapContext();
const { data: startupConfig } = useGetStartupConfig();
const { data: endpointsConfig } = useGetEndpointsQuery();
const localize = useLocalize();
let { endpoint } = conversation ?? {};
let { endpoint = '' } = conversation ?? {};
const { assistant_id = null } = conversation ?? {};
if (
@ -27,9 +28,11 @@ export default function Landing({ Header }: { Header?: ReactNode }) {
endpoint = EModelEndpoint.openAI;
}
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
const iconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
const iconKey = endpointType ? 'unknown' : endpoint ?? 'unknown';
const iconURL = conversation?.iconURL;
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointIconURL });
const Icon = icons[iconKey];
const assistant = endpoint === EModelEndpoint.assistants && assistantMap?.[assistant_id ?? ''];
@ -51,19 +54,29 @@ export default function Landing({ Header }: { Header?: ReactNode }) {
<div className="absolute left-0 right-0">{Header && Header}</div>
<div className="flex h-full flex-col items-center justify-center">
<div className="relative mb-3 h-[72px] w-[72px]">
<div className={className}>
{endpoint &&
Icon &&
Icon({
size: 41,
context: 'landing',
className: 'h-2/3 w-2/3',
endpoint: endpoint,
iconURL: iconURL,
assistantName,
avatar,
})}
</div>
{iconURL && iconURL.includes('http') ? (
<ConvoIconURL
preset={conversation}
endpointIconURL={endpointIconURL}
assistantName={assistantName}
assistantAvatar={avatar}
context="landing"
/>
) : (
<div className={className}>
{endpoint &&
Icon &&
Icon({
size: 41,
context: 'landing',
className: 'h-2/3 w-2/3',
iconURL: endpointIconURL,
assistantName,
endpoint,
avatar,
})}
</div>
)}
<TooltipTrigger>
{(startupConfig?.showBirthdayIcon ?? false) && (
<BirthdayIcon className="absolute bottom-12 right-5" />
@ -88,8 +101,8 @@ export default function Landing({ Header }: { Header?: ReactNode }) {
) : (
<div className="mb-5 text-2xl font-medium dark:text-white">
{endpoint === EModelEndpoint.assistants
? localize('com_nav_welcome_assistant')
: localize('com_nav_welcome_message')}
? conversation?.greeting ?? localize('com_nav_welcome_assistant')
: conversation?.greeting ?? localize('com_nav_welcome_message')}
</div>
)}
</div>

View file

@ -1,4 +1,5 @@
import { EModelEndpoint } from 'librechat-data-provider';
import type { IconMapProps } from '~/common';
import {
MinimalPlugin,
GPTIcon,
@ -23,17 +24,7 @@ export const icons = {
[EModelEndpoint.google]: GoogleMinimalIcon,
[EModelEndpoint.bingAI]: BingAIMinimalIcon,
[EModelEndpoint.custom]: CustomMinimalIcon,
[EModelEndpoint.assistants]: ({
className = '',
assistantName,
avatar,
size,
}: {
className?: string;
assistantName?: string;
avatar?: string;
size?: number;
}) => {
[EModelEndpoint.assistants]: ({ className = '', assistantName, avatar, size }: IconMapProps) => {
if (assistantName && avatar) {
return (
<img

View file

@ -1,13 +1,13 @@
import { useState } from 'react';
import { Settings } from 'lucide-react';
import { useRecoilValue } from 'recoil';
import { EModelEndpoint, modularEndpoints } from 'librechat-data-provider';
import { EModelEndpoint } from 'librechat-data-provider';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import type { TPreset, TConversation } from 'librechat-data-provider';
import type { TConversation } from 'librechat-data-provider';
import type { FC } from 'react';
import { cn, getConvoSwitchLogic, getEndpointField, getIconKey } from '~/utils';
import { useLocalize, useUserKey, useDefaultConvo } from '~/hooks';
import { SetKeyDialog } from '~/components/Input/SetKeyDialog';
import { cn, getEndpointField } from '~/utils';
import { useChatContext } from '~/Providers';
import { icons } from './Icons';
import store from '~/store';
@ -43,58 +43,44 @@ const MenuItem: FC<MenuItemProps> = ({
const onSelectEndpoint = (newEndpoint: EModelEndpoint) => {
if (!newEndpoint) {
return;
} else {
if (!expiryTime) {
setDialogOpen(true);
}
const currentEndpoint = conversation?.endpoint;
const template: Partial<TPreset> = {
...conversation,
endpoint: newEndpoint,
conversationId: 'new',
};
const isAssistantSwitch =
newEndpoint === EModelEndpoint.assistants &&
currentEndpoint === EModelEndpoint.assistants &&
currentEndpoint === newEndpoint;
const { conversationId } = conversation ?? {};
const isExistingConversation = conversationId && conversationId !== 'new';
const currentEndpointType =
getEndpointField(endpointsConfig, currentEndpoint, 'type') ?? currentEndpoint;
const newEndpointType = getEndpointField(endpointsConfig, newEndpoint, 'type') ?? newEndpoint;
const hasEndpoint = modularEndpoints.has(currentEndpoint ?? '');
const hasCurrentEndpointType = modularEndpoints.has(currentEndpointType ?? '');
const isCurrentModular = hasEndpoint || hasCurrentEndpointType || isAssistantSwitch;
const hasNewEndpoint = modularEndpoints.has(newEndpoint ?? '');
const hasNewEndpointType = modularEndpoints.has(newEndpointType ?? '');
const isNewModular = hasNewEndpoint || hasNewEndpointType || isAssistantSwitch;
const endpointsMatch = currentEndpoint === newEndpoint;
const shouldSwitch = endpointsMatch || modularChat || isAssistantSwitch;
if (isExistingConversation && isCurrentModular && isNewModular && shouldSwitch) {
template.endpointType = newEndpointType;
const currentConvo = getDefaultConversation({
/* target endpointType is necessary to avoid endpoint mixing */
conversation: { ...(conversation ?? {}), endpointType: template.endpointType },
preset: template,
});
/* We don't reset the latest message, only when changing settings mid-converstion */
newConversation({ template: currentConvo, preset: currentConvo, keepLatestMessage: true });
return;
}
newConversation({ template: { ...(template as Partial<TConversation>) } });
}
if (!expiryTime) {
setDialogOpen(true);
}
const {
shouldSwitch,
isNewModular,
isCurrentModular,
isExistingConversation,
newEndpointType,
template,
} = getConvoSwitchLogic({
newEndpoint,
modularChat,
conversation,
endpointsConfig,
});
if (isExistingConversation && isCurrentModular && isNewModular && shouldSwitch) {
template.endpointType = newEndpointType;
const currentConvo = getDefaultConversation({
/* target endpointType is necessary to avoid endpoint mixing */
conversation: { ...(conversation ?? {}), endpointType: template.endpointType },
preset: template,
});
/* We don't reset the latest message, only when changing settings mid-converstion */
newConversation({ template: currentConvo, preset: currentConvo, keepLatestMessage: true });
return;
}
newConversation({ template: { ...(template as Partial<TConversation>) } });
};
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
const iconKey = endpointType ? 'unknown' : endpoint ?? 'unknown';
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointType });
const Icon = icons[iconKey];
return (

View file

@ -0,0 +1,49 @@
import { Trigger } from '@radix-ui/react-popover';
import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
import SpecIcon from './SpecIcon';
export default function MenuButton({
selected,
primaryText = '',
secondaryText = '',
endpointsConfig,
}: {
selected?: TModelSpec;
primaryText?: string;
secondaryText?: string;
endpointsConfig: TEndpointsConfig;
}) {
const localize = useLocalize();
return (
<Trigger asChild>
<div
className="group flex cursor-pointer items-center gap-1 rounded-xl px-3 py-2 text-lg font-medium hover:bg-gray-50 radix-state-open:bg-gray-50 dark:hover:bg-gray-700 dark:radix-state-open:bg-gray-700"
// type="button"
>
{selected && selected.showIconInHeader && (
<SpecIcon currentSpec={selected} endpointsConfig={endpointsConfig} />
)}
<div>
{!selected ? localize('com_ui_none_selected') : primaryText}{' '}
{!!secondaryText && <span className="text-token-text-secondary">{secondaryText}</span>}
</div>
<svg
width="16"
height="17"
viewBox="0 0 16 17"
fill="none"
className="text-token-text-tertiary"
>
<path
d="M11.3346 7.83203L8.00131 11.1654L4.66797 7.83203"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
</Trigger>
);
}

View file

@ -0,0 +1,130 @@
import { useState, useMemo } from 'react';
import { Settings } from 'lucide-react';
import type { FC } from 'react';
import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider';
import { SetKeyDialog } from '~/components/Input/SetKeyDialog';
import { useLocalize, useUserKey } from '~/hooks';
import { cn, getEndpointField } from '~/utils';
import SpecIcon from './SpecIcon';
type MenuItemProps = {
title: string;
spec: TModelSpec;
selected: boolean;
description?: string;
userProvidesKey: boolean;
endpointsConfig: TEndpointsConfig;
onClick?: () => void;
// iconPath: string;
// hoverContent?: string;
};
const MenuItem: FC<MenuItemProps> = ({
title,
spec,
selected,
description,
userProvidesKey,
endpointsConfig,
onClick,
...rest
}) => {
const { endpoint } = spec.preset;
const [isDialogOpen, setDialogOpen] = useState(false);
const { getExpiry } = useUserKey(endpoint ?? '');
const localize = useLocalize();
const expiryTime = getExpiry();
const clickHandler = () => {
if (!expiryTime) {
setDialogOpen(true);
}
if (onClick) {
onClick();
}
};
const endpointType = useMemo(
() => spec.preset.endpointType ?? getEndpointField(endpointsConfig, endpoint, 'type'),
[spec, endpointsConfig, endpoint],
);
const { showIconInMenu = true } = spec;
return (
<>
<div
role="menuitem"
className="group m-1.5 flex cursor-pointer gap-2 rounded px-1 py-2.5 !pr-3 text-sm !opacity-100 hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5"
tabIndex={-1}
{...rest}
onClick={clickHandler}
>
<div className="flex grow items-center justify-between gap-2">
<div>
<div className="flex items-center gap-2">
{showIconInMenu && <SpecIcon currentSpec={spec} endpointsConfig={endpointsConfig} />}
<div>
{title}
<div className="text-token-text-tertiary">{description}</div>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{userProvidesKey ? (
<div className="text-token-text-primary" key={`set-key-${endpoint}`}>
<button
className={cn(
'invisible flex gap-x-1 group-hover:visible',
selected ? 'visible' : '',
expiryTime
? 'w-full rounded-lg p-2 hover:bg-gray-200 dark:hover:bg-gray-900'
: '',
)}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setDialogOpen(true);
}}
>
<div className={cn('invisible group-hover:visible', expiryTime ? 'text-xs' : '')}>
{localize('com_endpoint_config_key')}
</div>
<Settings className={cn(expiryTime ? 'icon-sm' : 'icon-md stroke-1')} />
</button>
</div>
) : null}
{selected && (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="icon-md block"
// className="icon-md block group-hover:hidden"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
fill="currentColor"
/>
</svg>
)}
</div>
</div>
</div>
{userProvidesKey && (
<SetKeyDialog
open={isDialogOpen}
onOpenChange={setDialogOpen}
endpoint={endpoint ?? ''}
endpointType={endpointType}
/>
)}
</>
);
};
export default MenuItem;

View file

@ -0,0 +1,44 @@
import type { FC } from 'react';
import { Close } from '@radix-ui/react-popover';
import { AuthType } from 'librechat-data-provider';
import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider';
import MenuSeparator from '~/components/Chat/Menus/UI/MenuSeparator';
import ModelSpec from './ModelSpec';
const ModelSpecs: FC<{
specs?: TModelSpec[];
selected?: TModelSpec;
setSelected?: (spec: TModelSpec) => void;
endpointsConfig: TEndpointsConfig;
}> = ({ specs = [], selected, setSelected = () => ({}), endpointsConfig }) => {
return (
<>
{specs &&
specs.map((spec, i) => {
if (!spec) {
return null;
}
return (
<Close asChild key={`spec-${spec.name}`}>
<div key={`spec-${spec.name}`}>
<ModelSpec
spec={spec}
title={spec.label}
key={`spec-item-${spec.name}`}
description={spec.description}
onClick={() => setSelected(spec)}
data-testid={`spec-item-${spec.name}`}
selected={selected?.name === spec.name}
userProvidesKey={spec.authType === AuthType.USER_PROVIDED}
endpointsConfig={endpointsConfig}
/>
{i !== specs.length - 1 && <MenuSeparator />}
</div>
</Close>
);
})}
</>
);
};
export default ModelSpecs;

View file

@ -0,0 +1,106 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { EModelEndpoint } from 'librechat-data-provider';
import { Content, Portal, Root } from '@radix-ui/react-popover';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import type { TModelSpec, TConversation, TEndpointsConfig } from 'librechat-data-provider';
import { getConvoSwitchLogic, getModelSpecIconURL } from '~/utils';
import { useDefaultConvo, useNewConvo } from '~/hooks';
import { useChatContext } from '~/Providers';
import MenuButton from './MenuButton';
import ModelSpecs from './ModelSpecs';
import store from '~/store';
export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs: TModelSpec[] }) {
const { conversation } = useChatContext();
const { newConversation } = useNewConvo();
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
const modularChat = useRecoilValue(store.modularChat);
const getDefaultConversation = useDefaultConvo();
const onSelectSpec = (spec: TModelSpec) => {
const { preset } = spec;
preset.iconURL = getModelSpecIconURL(spec);
preset.spec = spec.name;
const { endpoint: newEndpoint } = preset;
if (!newEndpoint) {
return;
}
const {
shouldSwitch,
isNewModular,
isCurrentModular,
isExistingConversation,
newEndpointType,
template,
} = getConvoSwitchLogic({
newEndpoint,
modularChat,
conversation,
endpointsConfig,
});
if (isExistingConversation && isCurrentModular && isNewModular && shouldSwitch) {
template.endpointType = newEndpointType as EModelEndpoint | undefined;
const currentConvo = getDefaultConversation({
/* target endpointType is necessary to avoid endpoint mixing */
conversation: { ...(conversation ?? {}), endpointType: template.endpointType },
preset: template,
});
/* We don't reset the latest message, only when changing settings mid-converstion */
newConversation({ template: currentConvo, preset, keepLatestMessage: true });
return;
}
newConversation({ template: { ...(template as Partial<TConversation>) }, preset });
};
const selected = useMemo(() => {
const spec = modelSpecs?.find((spec) => spec.name === conversation?.spec);
if (!spec) {
return undefined;
}
return spec;
}, [modelSpecs, conversation?.spec]);
return (
<Root>
<MenuButton
primaryText={selected?.label ?? ''}
selected={selected}
endpointsConfig={endpointsConfig}
/>
<Portal>
{modelSpecs && modelSpecs?.length && (
<div
style={{
position: 'fixed',
left: '0px',
top: '0px',
transform: 'translate3d(268px, 50px, 0px)',
minWidth: 'max-content',
zIndex: 'auto',
}}
>
<Content
side="bottom"
align="start"
className="models-scrollbar mt-2 max-h-[65vh] min-w-[340px] max-w-xs overflow-y-auto rounded-lg border border-gray-100 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white lg:max-h-[75vh]"
>
<ModelSpecs
specs={modelSpecs}
selected={selected}
setSelected={onSelectSpec}
endpointsConfig={endpointsConfig}
/>
</Content>
</div>
)}
</Portal>
</Root>
);
}

View file

@ -0,0 +1,50 @@
import React from 'react';
import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider';
import type { IconMapProps } from '~/common';
import { getModelSpecIconURL, getIconKey, getEndpointField } from '~/utils';
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
interface SpecIconProps {
currentSpec: TModelSpec;
endpointsConfig: TEndpointsConfig;
}
const SpecIcon: React.FC<SpecIconProps> = ({ currentSpec, endpointsConfig }) => {
const iconURL = getModelSpecIconURL(currentSpec);
const { endpoint } = currentSpec.preset;
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointIconURL });
let Icon: (props: IconMapProps) => React.JSX.Element;
if (!iconURL?.includes('http')) {
Icon = icons[iconKey] ?? icons.unknown;
} else {
Icon = iconURL
? () => (
<div
className="icon-xl mr-1 shrink-0 overflow-hidden rounded-full "
style={{ width: '20', height: '20' }}
>
<img
src={iconURL}
alt={currentSpec.name}
style={{ width: '100%', height: '100%' }}
className="object-cover"
/>
</div>
)
: icons[endpoint ?? ''] ?? icons.unknown;
}
return (
<Icon
size={20}
endpoint={endpoint}
context="menu-item"
iconURL={endpointIconURL}
className="icon-lg mr-1 shrink-0 dark:text-white"
/>
);
};
export default SpecIcon;

View file

@ -0,0 +1,46 @@
import { EModelEndpoint, AuthType } from 'librechat-data-provider';
import type { TModelSpec } from 'librechat-data-provider';
export const data: TModelSpec[] = [
{
name: 'commander_01',
label: 'Commander in Chief',
description:
'Salute your president, soldier! Salute your president, soldier! Salute your president, soldier!',
iconURL: 'https://i.kym-cdn.com/entries/icons/facebook/000/017/252/2f0.jpg',
// iconURL: EModelEndpoint.openAI,
preset: {
endpoint: 'Ollama',
greeting: 'My fellow Americans,',
// 'endpointType': EModelEndpoint.custom,
frequency_penalty: 0,
// 'imageDetail': 'auto',
model: 'command-r',
presence_penalty: 0,
promptPrefix: null,
resendFiles: false,
temperature: 0.8,
top_p: 0.5,
},
authType: AuthType.SYSTEM_DEFINED,
},
{
name: 'vision_pro',
label: 'Vision Pro',
description:
'Salute your president, soldier! Salute your president, soldier! Salute your president, soldier!',
// iconURL: 'https://i.ytimg.com/vi/SaneSRqePVY/maxresdefault.jpg',
iconURL: EModelEndpoint.openAI, // Allow using project-included icons
preset: {
chatGptLabel: 'Vision Helper',
greeting: 'What\'s up!!',
endpoint: EModelEndpoint.openAI,
model: 'gpt-4-turbo',
promptPrefix:
'Examine images closely to understand its style, colors, composition, and other elements. Then, craft a detailed prompt to that closely resemble the original. Your focus is on accuracy in replicating the style, colors, techniques, and details of the original image in written form. Your prompt must be excruciatingly detailed as it will be given to an image generating AI for image generation. \n',
temperature: 0.8,
top_p: 1,
},
authType: AuthType.SYSTEM_DEFINED,
},
];

View file

@ -105,7 +105,6 @@ const EditPresetDialog = ({
conversation={preset}
setOption={setOption}
isPreset={true}
isMultiChat={true}
className="h-full md:mb-4 md:h-[440px]"
/>
</div>

View file

@ -4,11 +4,11 @@ import { Flipper, Flipped } from 'react-flip-toolkit';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import type { FC } from 'react';
import type { TPreset } from 'librechat-data-provider';
import { getPresetTitle, getEndpointField, getIconKey } from '~/utils';
import FileUpload from '~/components/Chat/Input/Files/FileUpload';
import { PinIcon, EditIcon, TrashIcon } from '~/components/svg';
import { Dialog, DialogTrigger, Label } from '~/components/ui';
import DialogTemplate from '~/components/ui/DialogTemplate';
import { getPresetTitle, getEndpointField } from '~/utils';
import { Dialog, DialogTrigger, Label } from '~/components/ui/';
import { MenuSeparator, MenuItem } from '../UI';
import { icons } from '../Endpoints/Icons';
import { useLocalize } from '~/hooks';
@ -115,9 +115,7 @@ const PresetItems: FC<{
return null;
}
const iconKey = getEndpointField(endpointsConfig, preset.endpoint, 'type')
? 'unknown'
: preset.endpointType ?? preset.endpoint ?? 'unknown';
const iconKey = getIconKey({ endpoint: preset.endpoint, endpointsConfig });
const Icon = icons[iconKey];
return (

View file

@ -1,3 +1,4 @@
export { default as EndpointsMenu } from './EndpointsMenu';
export { default as PresetsMenu } from './PresetsMenu';
export { default as EndpointsMenu } from './EndpointsMenu';
export { default as HeaderNewChat } from './HeaderNewChat';
export { default as ModelSpecsMenu } from './Models/ModelSpecsMenu';

View file

@ -1,6 +1,7 @@
import { useRecoilValue } from 'recoil';
import { useAuthContext, useMessageHelpers, useLocalize } from '~/hooks';
import type { TMessageProps } from '~/common';
import Icon from '~/components/Chat/Messages/MessageIcon';
import { Plugin } from '~/components/Messages/Content';
import MessageContent from './Content/MessageContent';
import SiblingSwitch from './SiblingSwitch';
@ -18,7 +19,6 @@ export default function Message(props: TMessageProps) {
const {
ask,
icon,
edit,
isLast,
enterEdit,
@ -60,11 +60,7 @@ export default function Message(props: TMessageProps) {
<div>
<div className="pt-0.5">
<div className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
{typeof icon === 'string' && /[^\\x00-\\x7F]+/.test(icon as string) ? (
<span className=" direction-rtl w-40 overflow-x-scroll">{icon}</span>
) : (
icon
)}
<Icon message={message} conversation={conversation} />
</div>
</div>
</div>

View file

@ -0,0 +1,58 @@
import { useMemo } from 'react';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import type { TMessage, TPreset, Assistant } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { getEndpointField, getIconEndpoint } from '~/utils';
import Icon from '~/components/Endpoints/Icon';
export default function MessageIcon(
props: Pick<TMessageProps, 'message' | 'conversation'> & {
assistant?: false | Assistant;
},
) {
const { data: endpointsConfig } = useGetEndpointsQuery();
const { message, conversation, assistant } = props;
const assistantName = assistant ? (assistant.name as string | undefined) : '';
const assistantAvatar = assistant ? (assistant.metadata?.avatar as string | undefined) : '';
const messageSettings = useMemo(
() => ({
...(conversation ?? {}),
...({
...message,
iconURL: message?.iconURL ?? '',
} as TMessage),
}),
[conversation, message],
);
const iconURL = messageSettings?.iconURL;
let endpoint = messageSettings?.endpoint;
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
if (!message?.isCreatedByUser && iconURL && iconURL.includes('http')) {
return (
<ConvoIconURL
preset={messageSettings as typeof messageSettings & TPreset}
context="message"
assistantAvatar={assistantAvatar}
endpointIconURL={endpointIconURL}
assistantName={assistantName}
/>
);
}
return (
<Icon
{...messageSettings}
endpoint={endpoint}
iconURL={!assistant ? endpointIconURL : assistantAvatar}
model={message?.model ?? conversation?.model}
assistantName={assistantName}
size={28.8}
/>
);
}

View file

@ -1,5 +1,6 @@
import ContentParts from './Content/ContentParts';
import type { TMessageProps } from '~/common';
import Icon from '~/components/Chat/Messages/MessageIcon';
import ContentParts from './Content/ContentParts';
import SiblingSwitch from './SiblingSwitch';
import { useMessageHelpers } from '~/hooks';
// eslint-disable-next-line import/no-cycle
@ -14,7 +15,6 @@ export default function Message(props: TMessageProps) {
const {
ask,
icon,
edit,
isLast,
enterEdit,
@ -47,11 +47,7 @@ export default function Message(props: TMessageProps) {
<div>
<div className="pt-0.5">
<div className="shadow-stroke flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
{typeof icon === 'string' && /[^\\x00-\\x7F]+/.test(icon as string) ? (
<span className=" direction-rtl w-40 overflow-x-scroll">{icon}</span>
) : (
icon
)}
<Icon message={message} conversation={conversation} assistant={assistant} />
</div>
</div>
</div>

View file

@ -1,6 +1,7 @@
import { useEffect, useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import { FileSources } from 'librechat-data-provider';
import { useEffect, useMemo } from 'react';
import { useGetStartupConfig } from 'librechat-data-provider/react-query';
import { FileSources, LocalStorageKeys, getConfigDefaults } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import { useDragHelpers, useSetFilesToDelete } from '~/hooks';
import DragDropOverlay from './Input/Files/DragDropOverlay';
@ -8,6 +9,8 @@ import { useDeleteFilesMutation } from '~/data-provider';
import { SidePanel } from '~/components/SidePanel';
import store from '~/store';
const defaultInterface = getConfigDefaults().interface;
export default function Presentation({
children,
useSidePanel = false,
@ -17,9 +20,16 @@ export default function Presentation({
panel?: React.ReactNode;
useSidePanel?: boolean;
}) {
const { data: startupConfig } = useGetStartupConfig();
const hideSidePanel = useRecoilValue(store.hideSidePanel);
const { isOver, canDrop, drop } = useDragHelpers();
const interfaceConfig = useMemo(
() => startupConfig?.interface ?? defaultInterface,
[startupConfig],
);
const setFilesToDelete = useSetFilesToDelete();
const { isOver, canDrop, drop } = useDragHelpers();
const { mutateAsync } = useDeleteFilesMutation({
onSuccess: () => {
console.log('Temporary Files deleted');
@ -31,7 +41,7 @@ export default function Presentation({
});
useEffect(() => {
const filesToDelete = localStorage.getItem('filesToDelete');
const filesToDelete = localStorage.getItem(LocalStorageKeys.FILES_TO_DELETE);
const map = JSON.parse(filesToDelete ?? '{}') as Record<string, ExtendedFile>;
const files = Object.values(map)
.filter((file) => file.filepath && file.source && !file.embedded && file.temp_file_id)
@ -69,7 +79,7 @@ export default function Presentation({
</div>
);
if (useSidePanel && !hideSidePanel) {
if (useSidePanel && !hideSidePanel && interfaceConfig.sidePanel) {
return (
<div
ref={drop}

View file

@ -1,16 +1,16 @@
import { useRecoilValue } from 'recoil';
import { useParams } from 'react-router-dom';
import { useState, useRef, useMemo } from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import { EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react';
import { useConversations, useNavigateToConvo } from '~/hooks';
import { MinimalIcon, ConvoIconURL } from '~/components/Endpoints';
import { useUpdateConversationMutation } from '~/data-provider';
import { MinimalIcon } from '~/components/Endpoints';
import { useConversations, useNavigateToConvo } from '~/hooks';
import { getEndpointField, getIconEndpoint } from '~/utils';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
import DeleteButton from './DeleteButton';
import { getEndpointField } from '~/utils';
import RenameButton from './RenameButton';
import store from '~/store';
@ -51,11 +51,15 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
if (conversation?.endpoint === EModelEndpoint.gptPlugins) {
let lastSelectedTools = [];
try {
lastSelectedTools = JSON.parse(localStorage.getItem('lastSelectedTools') ?? '') ?? [];
lastSelectedTools =
JSON.parse(localStorage.getItem(LocalStorageKeys.LAST_TOOLS) ?? '') ?? [];
} catch (e) {
// console.error(e);
}
navigateToConvo({ ...conversation, tools: lastSelectedTools });
navigateToConvo({
...conversation,
tools: conversation?.tools?.length ? conversation?.tools : lastSelectedTools,
});
} else {
navigateToConvo(conversation);
}
@ -95,19 +99,35 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
);
};
const icon = MinimalIcon({
size: 20,
iconURL: getEndpointField(endpointsConfig, conversation.endpoint, 'iconURL'),
endpoint: conversation.endpoint,
endpointType: conversation.endpointType,
model: conversation.model,
error: false,
className: 'mr-0',
isCreatedByUser: false,
chatGptLabel: undefined,
modelLabel: undefined,
jailbreak: undefined,
});
const iconURL = conversation.iconURL ?? '';
let endpoint = conversation.endpoint;
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
let icon: React.ReactNode | null = null;
if (iconURL && iconURL.includes('http')) {
icon = ConvoIconURL({
preset: conversation,
context: 'menu-item',
endpointIconURL,
});
} else {
icon = MinimalIcon({
size: 20,
iconURL: endpointIconURL,
endpoint,
endpointType,
model: conversation.model,
error: false,
className: 'mr-0',
isCreatedByUser: false,
chatGptLabel: undefined,
modelLabel: undefined,
jailbreak: undefined,
});
}
const handleKeyDown = (e: KeyEvent) => {
if (e.key === 'Enter') {

View file

@ -10,7 +10,7 @@ export default function AlternativeSettings({
setOption,
isPreset = false,
className = '',
}: TSettingsProps & { isMultiChat?: boolean }) {
}: TSettingsProps) {
const currentSettingsView = useRecoilValue(store.currentSettingsView);
if (!conversation?.endpoint || currentSettingsView === SettingsViews.default) {
return null;

View file

@ -0,0 +1,78 @@
import React from 'react';
import type { TPreset } from 'librechat-data-provider';
import type { IconMapProps } from '~/common';
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
interface ConvoIconURLProps {
preset: TPreset | null;
endpointIconURL?: string;
assistantName?: string;
context?: 'landing' | 'menu-item' | 'nav' | 'message';
assistantAvatar?: string;
}
const classMap = {
'menu-item': 'relative flex h-full items-center justify-center overflow-hidden rounded-full',
message: 'icon-md',
default: 'icon-xl relative flex h-full overflow-hidden rounded-full',
};
const styleMap = {
'menu-item': { width: '20px', height: '20px' },
default: { width: '100%', height: '100%' },
};
const styleImageMap = {
default: { width: '100%', height: '100%' },
};
const ConvoIconURL: React.FC<ConvoIconURLProps> = ({
preset,
endpointIconURL,
assistantAvatar,
assistantName,
context,
}) => {
const { iconURL = '' } = preset ?? {};
let Icon: (
props: IconMapProps & {
context?: string;
iconURL?: string;
},
) => React.JSX.Element;
if (!iconURL?.includes('http')) {
Icon = icons[iconURL] ?? icons.unknown;
} else {
Icon = () => (
<div
className={classMap[context ?? 'default'] ?? classMap.default}
style={styleMap[context ?? 'default'] ?? styleMap.default}
>
<img
src={iconURL}
alt={preset?.chatGptLabel ?? preset?.modelLabel ?? ''}
style={styleImageMap[context ?? 'default'] ?? styleImageMap.default}
className="object-cover"
/>
</div>
);
return <Icon />;
}
return (
<div className="shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black">
<Icon
size={41}
context={context}
className="h-2/3 w-2/3"
iconURL={endpointIconURL}
assistantName={assistantName}
avatar={assistantAvatar}
/>
</div>
);
};
export default ConvoIconURL;

View file

@ -1,71 +0,0 @@
import { Save } from 'lucide-react';
import type { ReactNode } from 'react';
// import { EModelEndpoint } from 'librechat-data-provider';
import { cn, removeFocusOutlines } from '~/utils';
// import PopoverButtons from './PopoverButtons';
import { CrossIcon } from '~/components/svg';
import { Button } from '~/components/ui';
import { useLocalize } from '~/hooks';
type TEndpointOptionsPopoverProps = {
children: ReactNode;
visible: boolean;
// endpoint: EModelEndpoint;
saveAsPreset: () => void;
closePopover: () => void;
PopoverButtons: ReactNode;
};
export default function EndpointOptionsPopover({
children,
// endpoint,
visible,
saveAsPreset,
closePopover,
PopoverButtons,
}: TEndpointOptionsPopoverProps) {
const localize = useLocalize();
const cardStyle =
'shadow-xl rounded-md min-w-[75px] font-normal bg-white border-black/10 border dark:bg-gray-700 text-black dark:text-white';
return (
<>
<div
className={cn(
'endpointOptionsPopover-container absolute bottom-[-10px] z-0 flex w-full flex-col items-center md:px-4',
visible ? ' show' : '',
)}
>
<div
className={cn(
cardStyle,
'border-d-0 flex w-full flex-col overflow-hidden rounded-none border-s-0 border-t bg-white px-0 pb-[10px] dark:border-white/10 md:rounded-md md:border lg:w-[736px]',
)}
>
<div className="flex w-full items-center bg-gray-100 px-2 py-2 dark:bg-gray-800/60">
<Button
type="button"
className="h-auto justify-start bg-transparent px-2 py-1 text-xs font-medium font-normal text-black hover:bg-gray-100 hover:text-black focus:ring-0 dark:bg-transparent dark:text-white dark:hover:bg-gray-700 dark:hover:text-white dark:focus:outline-none dark:focus:ring-offset-0"
onClick={saveAsPreset}
>
<Save className="mr-1 w-[14px]" />
{localize('com_endpoint_save_as_preset')}
</Button>
{PopoverButtons}
<Button
type="button"
className={cn(
'ml-auto h-auto bg-transparent px-3 py-2 text-xs font-medium font-normal text-black hover:bg-gray-100 hover:text-black dark:bg-transparent dark:text-white dark:hover:bg-gray-700 dark:hover:text-white',
removeFocusOutlines,
)}
onClick={closePopover}
>
<CrossIcon />
</Button>
</div>
<div>{children}</div>
</div>
</div>
</>
);
}

View file

@ -11,15 +11,14 @@ export default function Settings({
setOption,
isPreset = false,
className = '',
isMultiChat = false,
}: TSettingsProps & { isMultiChat?: boolean }) {
}: TSettingsProps) {
const modelsQuery = useGetModelsQuery();
const currentSettingsView = useRecoilValue(store.currentSettingsView);
if (!conversation?.endpoint || currentSettingsView !== SettingsViews.default) {
return null;
}
const { settings, multiViewSettings } = getSettings(isMultiChat);
const { settings, multiViewSettings } = getSettings();
const { endpoint: _endpoint, endpointType } = conversation;
const models = modelsQuery?.data?.[_endpoint] ?? [];
const endpoint = endpointType ?? _endpoint;

View file

@ -21,14 +21,15 @@ import { cn } from '~/utils';
const Icon: React.FC<IconProps> = (props) => {
const { user } = useAuthContext();
const {
size = 30,
isCreatedByUser,
button,
model = '',
endpoint,
error,
button,
iconURL,
endpoint,
jailbreak,
size = 30,
model = '',
assistantName,
isCreatedByUser,
} = props;
const avatarSrc = useAvatar(user);
@ -167,9 +168,13 @@ const Icon: React.FC<IconProps> = (props) => {
},
};
const { icon, bg, name } =
let { icon, bg, name } =
endpoint && endpointIcons[endpoint] ? endpointIcons[endpoint] : endpointIcons.default;
if (iconURL && endpointIcons[iconURL]) {
({ icon, bg, name } = endpointIcons[iconURL]);
}
if (endpoint === EModelEndpoint.assistants) {
return icon;
}

View file

@ -55,7 +55,10 @@ const MinimalIcon: React.FC<IconProps> = (props) => {
},
};
const { icon, name } = endpointIcons[endpoint] ?? endpointIcons.default;
let { icon, name } = endpointIcons[endpoint] ?? endpointIcons.default;
if (props.iconURL && endpointIcons[props.iconURL]) {
({ icon, name } = endpointIcons[props.iconURL]);
}
return (
<div

View file

@ -1,28 +0,0 @@
import Settings from '../Google';
import Examples from '../Examples';
import { useSetOptions } from '~/hooks';
import { useRecoilValue } from 'recoil';
import store from '~/store';
export default function GoogleView({ conversation, models, isPreset = false }) {
const optionSettings = useRecoilValue(store.optionSettings);
const { setOption, setExample, addExample, removeExample } = useSetOptions(
isPreset ? conversation : null,
);
if (!conversation) {
return null;
}
const { examples } = conversation;
const { showExamples, isCodeChat } = optionSettings;
return showExamples && !isCodeChat ? (
<Examples
examples={examples ?? []}
setExample={setExample}
addExample={addExample}
removeExample={removeExample}
/>
) : (
<Settings conversation={conversation} setOption={setOption} models={models} />
);
}

View file

@ -5,7 +5,9 @@ import { useChatContext } from '~/Providers';
export default function PluginsView({ conversation, models, isPreset = false }) {
const { showAgentSettings } = useChatContext();
const { setOption, setAgentOption } = useSetIndexOptions(isPreset ? conversation : null);
const { setOption, setTools, setAgentOption, checkPluginSelection } = useSetIndexOptions(
isPreset ? conversation : null,
);
if (!conversation) {
return null;
}
@ -13,6 +15,12 @@ export default function PluginsView({ conversation, models, isPreset = false })
return showAgentSettings ? (
<AgentSettings conversation={conversation} setOption={setAgentOption} models={models} />
) : (
<Settings conversation={conversation} setOption={setOption} models={models} />
<Settings
conversation={conversation}
setOption={setOption}
setTools={setTools}
checkPluginSelection={checkPluginSelection}
models={models}
/>
);
}

View file

@ -1,19 +0,0 @@
import Settings from '../Plugins';
import AgentSettings from '../AgentSettings';
import { useSetOptions } from '~/hooks';
import { useRecoilValue } from 'recoil';
import store from '~/store';
export default function PluginsView({ conversation, models, isPreset = false }) {
const showAgentSettings = useRecoilValue(store.showAgentSettings);
const { setOption, setAgentOption } = useSetOptions(isPreset ? conversation : null);
if (!conversation) {
return null;
}
return showAgentSettings ? (
<AgentSettings conversation={conversation} setOption={setAgentOption} models={models} />
) : (
<Settings conversation={conversation} setOption={setOption} models={models} />
);
}

View file

@ -1,4 +1,2 @@
export { default as Google } from './Google';
export { default as Plugins } from './Plugins';
export { default as GoogleSettings } from './GoogleSettings';
export { default as PluginSettings } from './PluginSettings';

View file

@ -32,6 +32,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
endpoint,
endpointType,
model,
modelLabel,
chatGptLabel,
promptPrefix,
temperature,
@ -41,32 +42,33 @@ export default function Settings({ conversation, setOption, models, readonly }:
resendFiles,
imageDetail,
} = conversation ?? {};
const [setChatGptLabel, chatGptLabelValue] = useDebouncedInput({
const [setChatGptLabel, chatGptLabelValue] = useDebouncedInput<string | null | undefined>({
setOption,
optionKey: 'chatGptLabel',
initialValue: chatGptLabel,
initialValue: modelLabel ?? chatGptLabel,
});
const [setPromptPrefix, promptPrefixValue] = useDebouncedInput({
const [setPromptPrefix, promptPrefixValue] = useDebouncedInput<string | null | undefined>({
setOption,
optionKey: 'promptPrefix',
initialValue: promptPrefix,
});
const [setTemperature, temperatureValue] = useDebouncedInput({
const [setTemperature, temperatureValue] = useDebouncedInput<number | null | undefined>({
setOption,
optionKey: 'temperature',
initialValue: temperature,
});
const [setTopP, topPValue] = useDebouncedInput({
const [setTopP, topPValue] = useDebouncedInput<number | null | undefined>({
setOption,
optionKey: 'top_p',
initialValue: topP,
});
const [setFreqP, freqPValue] = useDebouncedInput({
const [setFreqP, freqPValue] = useDebouncedInput<number | null | undefined>({
setOption,
optionKey: 'frequency_penalty',
initialValue: freqP,
});
const [setPresP, presPValue] = useDebouncedInput({
const [setPresP, presPValue] = useDebouncedInput<number | null | undefined>({
setOption,
optionKey: 'presence_penalty',
initialValue: presP,

View file

@ -1,44 +1,105 @@
import { useMemo } from 'react';
import { useRecoilValue } from 'recoil';
import TextareaAutosize from 'react-textarea-autosize';
import { useAvailablePluginsQuery } from 'librechat-data-provider/react-query';
import type { TPlugin } from 'librechat-data-provider';
import type { TModelSelectProps } from '~/common';
import {
SelectDropDown,
Input,
Label,
Slider,
InputNumber,
HoverCard,
InputNumber,
SelectDropDown,
HoverCardTrigger,
} from '~/components';
MultiSelectDropDown,
} from '~/components/ui';
import { cn, defaultTextProps, optionText, removeFocusOutlines } from '~/utils';
import { useLocalize, useDebouncedInput } from '~/hooks';
import { processPlugins, selectPlugins } from '~/utils';
import OptionHover from './OptionHover';
import type { TModelSelectProps } from '~/common';
import { ESide } from '~/common';
import { cn, defaultTextProps, optionText, removeFocusOutlines } from '~/utils/';
import { useLocalize } from '~/hooks';
import store from '~/store';
export default function Settings({ conversation, setOption, models, readonly }: TModelSelectProps) {
export default function Settings({
conversation,
setOption,
setTools,
checkPluginSelection,
models,
readonly,
}: TModelSelectProps & {
setTools: (newValue: string, remove?: boolean | undefined) => void;
checkPluginSelection: (value: string) => boolean;
}) {
const localize = useLocalize();
if (!conversation) {
return null;
}
const availableTools = useRecoilValue(store.availableTools);
const { data: allPlugins } = useAvailablePluginsQuery({
select: selectPlugins,
});
const conversationTools: TPlugin[] = useMemo(() => {
if (!conversation?.tools) {
return [];
}
return processPlugins(conversation.tools, allPlugins?.map);
}, [conversation, allPlugins]);
const availablePlugins = useMemo(() => {
if (!availableTools) {
return [];
}
return Object.values(availableTools);
}, [availableTools]);
const {
model,
modelLabel,
chatGptLabel,
promptPrefix,
temperature,
top_p: topP,
frequency_penalty: freqP,
presence_penalty: presP,
tools,
} = conversation;
} = conversation ?? {};
const [setChatGptLabel, chatGptLabelValue] = useDebouncedInput<string | null | undefined>({
setOption,
optionKey: 'chatGptLabel',
initialValue: modelLabel ?? chatGptLabel,
});
const [setPromptPrefix, promptPrefixValue] = useDebouncedInput<string | null | undefined>({
setOption,
optionKey: 'promptPrefix',
initialValue: promptPrefix,
});
const [setTemperature, temperatureValue] = useDebouncedInput<number | null | undefined>({
setOption,
optionKey: 'temperature',
initialValue: temperature,
});
const [setTopP, topPValue] = useDebouncedInput<number | null | undefined>({
setOption,
optionKey: 'top_p',
initialValue: topP,
});
const [setFreqP, freqPValue] = useDebouncedInput<number | null | undefined>({
setOption,
optionKey: 'frequency_penalty',
initialValue: freqP,
});
const [setPresP, presPValue] = useDebouncedInput<number | null | undefined>({
setOption,
optionKey: 'presence_penalty',
initialValue: presP,
});
const setModel = setOption('model');
const setChatGptLabel = setOption('chatGptLabel');
const setPromptPrefix = setOption('promptPrefix');
const setTemperature = setOption('temperature');
const setTopP = setOption('top_p');
const setFreqP = setOption('frequency_penalty');
const setPresP = setOption('presence_penalty');
const toolsSelected = tools && tools.length > 0;
if (!conversation) {
return null;
}
return (
<div className="grid grid-cols-5 gap-6">
@ -58,21 +119,14 @@ export default function Settings({ conversation, setOption, models, readonly }:
<div className="grid w-full items-center gap-2">
<Label htmlFor="chatGptLabel" className="text-left text-sm font-medium">
{localize('com_endpoint_custom_name')}{' '}
<small className="opacity-40">
({localize('com_endpoint_default_empty')} |{' '}
{localize('com_endpoint_disabled_with_tools')})
</small>
<small className="opacity-40">{localize('com_endpoint_default_empty')}</small>
</Label>
<Input
id="chatGptLabel"
disabled={readonly || toolsSelected}
value={chatGptLabel || ''}
disabled={readonly}
value={chatGptLabelValue || ''}
onChange={(e) => setChatGptLabel(e.target.value ?? null)}
placeholder={
toolsSelected
? localize('com_endpoint_disabled_with_tools_placeholder')
: localize('com_endpoint_openai_custom_name_placeholder')
}
placeholder={localize('com_endpoint_openai_custom_name_placeholder')}
className={cn(
defaultTextProps,
'flex h-10 max-h-10 w-full resize-none px-3 py-2',
@ -83,21 +137,16 @@ export default function Settings({ conversation, setOption, models, readonly }:
<div className="grid w-full items-center gap-2">
<Label htmlFor="promptPrefix" className="text-left text-sm font-medium">
{localize('com_endpoint_prompt_prefix')}{' '}
<small className="opacity-40">
({localize('com_endpoint_default_empty')} |{' '}
{localize('com_endpoint_disabled_with_tools')})
</small>
<small className="opacity-40">{localize('com_endpoint_default_empty')}</small>
</Label>
<TextareaAutosize
id="promptPrefix"
disabled={readonly || toolsSelected}
value={promptPrefix || ''}
disabled={readonly}
value={promptPrefixValue || ''}
onChange={(e) => setPromptPrefix(e.target.value ?? null)}
placeholder={
toolsSelected
? localize('com_endpoint_disabled_with_tools_placeholder')
: localize('com_endpoint_plug_set_custom_instructions_for_gpt_placeholder')
}
placeholder={localize(
'com_endpoint_plug_set_custom_instructions_for_gpt_placeholder',
)}
className={cn(
defaultTextProps,
'flex max-h-[138px] min-h-[100px] w-full resize-none px-3 py-2 ',
@ -107,6 +156,20 @@ export default function Settings({ conversation, setOption, models, readonly }:
</>
</div>
<div className="col-span-5 flex flex-col items-center justify-start gap-6 px-3 sm:col-span-2">
<MultiSelectDropDown
showAbove={false}
showLabel={false}
setSelected={setTools}
value={conversationTools}
optionValueKey="pluginKey"
availableValues={availablePlugins}
isSelected={checkPluginSelection}
searchPlaceholder={localize('com_ui_select_search_plugin')}
className={cn(defaultTextProps, 'flex w-full resize-none', removeFocusOutlines)}
optionsClassName="w-full max-h-[275px] dark:bg-gray-700 z-10 border dark:border-gray-600"
containerClassName="flex w-full resize-none border border-transparent"
labelClassName="dark:text-white"
/>
<HoverCard openDelay={300}>
<HoverCardTrigger className="grid w-full items-center gap-2">
<div className="flex justify-between">
@ -119,7 +182,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
<InputNumber
id="temp-int"
disabled={readonly}
value={temperature}
value={temperatureValue}
onChange={(value) => setTemperature(Number(value))}
max={2}
min={0}
@ -136,7 +199,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
</div>
<Slider
disabled={readonly}
value={[temperature ?? 0.8]}
value={[temperatureValue ?? 0.8]}
onValueChange={(value) => setTemperature(value[0])}
doubleClickHandler={() => setTemperature(0.8)}
max={2}
@ -159,7 +222,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
<InputNumber
id="top-p-int"
disabled={readonly}
value={topP}
value={topPValue}
onChange={(value) => setTopP(Number(value))}
max={1}
min={0}
@ -176,7 +239,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
</div>
<Slider
disabled={readonly}
value={[topP ?? 1]}
value={[topPValue ?? 1]}
onValueChange={(value) => setTopP(value[0])}
doubleClickHandler={() => setTopP(1)}
max={1}
@ -200,7 +263,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
<InputNumber
id="freq-penalty-int"
disabled={readonly}
value={freqP}
value={freqPValue}
onChange={(value) => setFreqP(Number(value))}
max={2}
min={-2}
@ -217,7 +280,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
</div>
<Slider
disabled={readonly}
value={[freqP ?? 0]}
value={[freqPValue ?? 0]}
onValueChange={(value) => setFreqP(value[0])}
doubleClickHandler={() => setFreqP(0)}
max={2}
@ -241,7 +304,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
<InputNumber
id="pres-penalty-int"
disabled={readonly}
value={presP}
value={presPValue}
onChange={(value) => setPresP(Number(value))}
max={2}
min={-2}
@ -258,7 +321,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
</div>
<Slider
disabled={readonly}
value={[presP ?? 0]}
value={[presPValue ?? 0]}
onValueChange={(value) => setPresP(value[0])}
doubleClickHandler={() => setPresP(0)}
max={2}

View file

@ -1,7 +1,7 @@
import { EModelEndpoint } from 'librechat-data-provider';
import type { FC } from 'react';
import type { TModelSelectProps, TBaseSettingsProps, TModels } from '~/common';
import { Google, Plugins, GoogleSettings, PluginSettings } from './MultiView';
import type { TModelSelectProps } from '~/common';
import { GoogleSettings, PluginSettings } from './MultiView';
import AssistantsSettings from './Assistants';
import AnthropicSettings from './Anthropic';
import BingAISettings from './BingAI';
@ -16,19 +16,7 @@ const settings: { [key: string]: FC<TModelSelectProps> } = {
[EModelEndpoint.anthropic]: AnthropicSettings,
};
const multiViewSettings: { [key: string]: FC<TBaseSettingsProps & TModels> } = {
[EModelEndpoint.google]: Google,
[EModelEndpoint.gptPlugins]: Plugins,
};
export const getSettings = (isMultiChat = false) => {
if (!isMultiChat) {
return {
settings,
multiViewSettings,
};
}
export const getSettings = () => {
return {
settings,
multiViewSettings: {

View file

@ -1,6 +1,6 @@
export { default as Icon } from './Icon';
export { default as MinimalIcon } from './MinimalIcon';
export { default as ConvoIconURL } from './ConvoIconURL';
export { default as EndpointSettings } from './EndpointSettings';
export { default as SaveAsPresetDialog } from './SaveAsPresetDialog';
export { default as AlternativeSettings } from './AlternativeSettings';
export { default as EndpointOptionsPopover } from './EndpointOptionsPopover';

View file

@ -1,83 +0,0 @@
import { useEffect, useState } from 'react';
import type { TMessage } from 'librechat-data-provider';
import { useMessageHandler, useMediaQuery, useGenerations } from '~/hooks';
import { cn } from '~/utils';
import Regenerate from './Regenerate';
import Continue from './Continue';
import Stop from './Stop';
type GenerationButtonsProps = {
endpoint: string;
showPopover: boolean;
opacityClass: string;
};
export default function GenerationButtons({
endpoint,
showPopover,
opacityClass,
}: GenerationButtonsProps) {
const {
messages,
isSubmitting,
latestMessage,
handleContinue,
handleRegenerate,
handleStopGenerating,
} = useMessageHandler();
const isSmallScreen = useMediaQuery('(max-width: 768px)');
const { continueSupported, regenerateEnabled } = useGenerations({
endpoint,
message: latestMessage as TMessage,
isSubmitting,
});
const [userStopped, setUserStopped] = useState(false);
const handleStop = (e: React.MouseEvent<HTMLButtonElement>) => {
setUserStopped(true);
handleStopGenerating(e);
};
useEffect(() => {
let timer: NodeJS.Timeout;
if (userStopped) {
timer = setTimeout(() => {
setUserStopped(false);
}, 200);
}
return () => {
clearTimeout(timer);
};
}, [userStopped]);
if (isSmallScreen) {
return null;
}
let button: React.ReactNode = null;
if (isSubmitting) {
button = <Stop onClick={handleStop} />;
} else if (userStopped || continueSupported) {
button = <Continue onClick={handleContinue} />;
} else if (messages && messages.length > 0 && regenerateEnabled) {
button = <Regenerate onClick={handleRegenerate} />;
}
return (
<div className="absolute bottom-4 right-0 z-[62]">
<div className="grow" />
<div className="flex items-center md:items-end">
<div
className={cn('option-buttons', showPopover ? '' : opacityClass)}
data-projection-id="173"
>
{button}
</div>
</div>
</div>
);
}

View file

@ -1 +0,0 @@
export { default as GenerationButtons } from './GenerationButtons';

View file

@ -1,7 +1,7 @@
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
import type { TConversation } from 'librechat-data-provider';
import type { TSetOption } from '~/common';
import { options, multiChatOptions } from './options';
import { useGetModelsQuery } from 'librechat-data-provider/react-query';
import { multiChatOptions } from './options';
type TGoogleProps = {
showExamples: boolean;
@ -12,14 +12,14 @@ type TSelectProps = {
conversation: TConversation | null;
setOption: TSetOption;
extraProps?: TGoogleProps;
isMultiChat?: boolean;
showAbove?: boolean;
popover?: boolean;
};
export default function ModelSelect({
conversation,
setOption,
isMultiChat = false,
popover = false,
showAbove = true,
}: TSelectProps) {
const modelsQuery = useGetModelsQuery();
@ -32,7 +32,7 @@ export default function ModelSelect({
const models = modelsQuery?.data?.[_endpoint] ?? [];
const endpoint = endpointType ?? _endpoint;
const OptionComponent = isMultiChat ? multiChatOptions[endpoint] : options[endpoint];
const OptionComponent = multiChatOptions[endpoint];
if (!OptionComponent) {
return null;
@ -44,7 +44,7 @@ export default function ModelSelect({
setOption={setOption}
models={models}
showAbove={showAbove}
popover={isMultiChat}
popover={popover}
/>
);
}

View file

@ -1,118 +0,0 @@
import { useRecoilState } from 'recoil';
import { useState, useEffect } from 'react';
import { ChevronDownIcon } from 'lucide-react';
import { useAvailablePluginsQuery } from 'librechat-data-provider/react-query';
import type { TPlugin } from 'librechat-data-provider';
import type { TModelSelectProps } from '~/common';
import { SelectDropDown, MultiSelectDropDown, SelectDropDownPop, Button } from '~/components/ui';
import { useSetOptions, useAuthContext, useMediaQuery, useLocalize } from '~/hooks';
import { cn, cardStyle } from '~/utils/';
import store from '~/store';
const pluginStore: TPlugin = {
name: 'Plugin store',
pluginKey: 'pluginStore',
isButton: true,
description: '',
icon: '',
authConfig: [],
authenticated: false,
};
export default function Plugins({
conversation,
setOption,
models,
showAbove,
popover = false,
}: TModelSelectProps) {
const localize = useLocalize();
const { data: allPlugins } = useAvailablePluginsQuery();
const [visible, setVisibility] = useState<boolean>(true);
const [availableTools, setAvailableTools] = useRecoilState(store.availableTools);
const { checkPluginSelection, setTools } = useSetOptions();
const { user } = useAuthContext();
const isSmallScreen = useMediaQuery('(max-width: 640px)');
const Menu = popover ? SelectDropDownPop : SelectDropDown;
useEffect(() => {
if (isSmallScreen) {
setVisibility(false);
}
}, [isSmallScreen]);
useEffect(() => {
if (!user) {
return;
}
if (!allPlugins) {
return;
}
if (!user.plugins || user.plugins.length === 0) {
setAvailableTools([pluginStore]);
return;
}
const tools = [...user.plugins]
.map((el) => allPlugins.find((plugin: TPlugin) => plugin.pluginKey === el))
.filter((el): el is TPlugin => el !== undefined);
/* Filter Last Selected Tools */
const localStorageItem = localStorage.getItem('lastSelectedTools');
if (!localStorageItem) {
return setAvailableTools([...tools, pluginStore]);
}
const lastSelectedTools = JSON.parse(localStorageItem);
const filteredTools = lastSelectedTools.filter((tool: TPlugin) =>
tools.some((existingTool) => existingTool.pluginKey === tool.pluginKey),
);
localStorage.setItem('lastSelectedTools', JSON.stringify(filteredTools));
setAvailableTools([...tools, pluginStore]);
// setAvailableTools is a recoil state setter, so it's safe to use it in useEffect
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allPlugins, user]);
if (!conversation) {
return null;
}
return (
<>
<Button
type="button"
className={cn(
cardStyle,
'z-40 flex h-[40px] min-w-4 flex-none items-center justify-center px-3 hover:bg-white focus:ring-0 focus:ring-offset-0 dark:hover:bg-gray-700',
)}
onClick={() => setVisibility((prev) => !prev)}
>
<ChevronDownIcon
className={cn(
!visible ? 'rotate-180 transform' : '',
'w-4 text-gray-600 dark:text-white',
)}
/>
</Button>
<Menu
value={conversation.model ?? ''}
setValue={setOption('model')}
availableValues={models}
showAbove={showAbove}
className={cn(cardStyle, 'z-40 flex w-64 min-w-60 sm:w-48', visible ? '' : 'hidden')}
/>
<MultiSelectDropDown
value={conversation.tools || []}
isSelected={checkPluginSelection}
setSelected={setTools}
availableValues={availableTools}
optionValueKey="pluginKey"
showAbove={showAbove}
className={cn(cardStyle, 'z-50 w-64 min-w-60 sm:w-48', visible ? '' : 'hidden')}
searchPlaceholder={localize('com_ui_select_search_plugin')}
/>
</>
);
}

View file

@ -1,30 +1,20 @@
import { useRecoilState } from 'recoil';
import { useState, useEffect } from 'react';
import { useRecoilValue } from 'recoil';
import { ChevronDownIcon } from 'lucide-react';
import { useState, useEffect, useMemo } from 'react';
import { useAvailablePluginsQuery } from 'librechat-data-provider/react-query';
import type { TPlugin } from 'librechat-data-provider';
import type { TModelSelectProps } from '~/common';
import {
Button,
MultiSelectPop,
SelectDropDown,
SelectDropDownPop,
MultiSelectDropDown,
MultiSelectPop,
Button,
} from '~/components/ui';
import { useSetIndexOptions, useAuthContext, useMediaQuery, useLocalize } from '~/hooks';
import { cn, cardStyle } from '~/utils/';
import { cn, cardStyle, selectPlugins, processPlugins } from '~/utils';
import store from '~/store';
const pluginStore: TPlugin = {
name: 'Plugin store',
pluginKey: 'pluginStore',
isButton: true,
description: '',
icon: '',
authConfig: [],
authenticated: false,
};
export default function PluginsByIndex({
conversation,
setOption,
@ -33,12 +23,16 @@ export default function PluginsByIndex({
popover = false,
}: TModelSelectProps) {
const localize = useLocalize();
const { data: allPlugins } = useAvailablePluginsQuery();
const [visible, setVisibility] = useState<boolean>(true);
const [availableTools, setAvailableTools] = useRecoilState(store.availableTools);
const { checkPluginSelection, setTools } = useSetIndexOptions();
const { user } = useAuthContext();
const [visible, setVisibility] = useState<boolean>(true);
const isSmallScreen = useMediaQuery('(max-width: 640px)');
const availableTools = useRecoilValue(store.availableTools);
const { checkPluginSelection, setTools } = useSetIndexOptions();
const { data: allPlugins } = useAvailablePluginsQuery({
enabled: !!user?.plugins,
select: selectPlugins,
});
useEffect(() => {
if (isSmallScreen) {
@ -46,39 +40,20 @@ export default function PluginsByIndex({
}
}, [isSmallScreen]);
useEffect(() => {
if (!user) {
return;
const conversationTools: TPlugin[] = useMemo(() => {
if (!conversation?.tools) {
return [];
}
return processPlugins(conversation.tools, allPlugins?.map);
}, [conversation, allPlugins]);
const availablePlugins = useMemo(() => {
if (!availableTools) {
return [];
}
if (!allPlugins) {
return;
}
if (!user.plugins || user.plugins.length === 0) {
setAvailableTools([pluginStore]);
return;
}
const tools = [...user.plugins]
.map((el) => allPlugins.find((plugin: TPlugin) => plugin.pluginKey === el))
.filter((el): el is TPlugin => el !== undefined);
/* Filter Last Selected Tools */
const localStorageItem = localStorage.getItem('lastSelectedTools');
if (!localStorageItem) {
return setAvailableTools([...tools, pluginStore]);
}
const lastSelectedTools = JSON.parse(localStorageItem);
const filteredTools = lastSelectedTools.filter((tool: TPlugin) =>
tools.some((existingTool) => existingTool.pluginKey === tool.pluginKey),
);
localStorage.setItem('lastSelectedTools', JSON.stringify(filteredTools));
setAvailableTools([...tools, pluginStore]);
// setAvailableTools is a recoil state setter, so it's safe to use it in useEffect
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allPlugins, user]);
return Object.values(availableTools);
}, [availableTools]);
if (!conversation) {
return null;
@ -112,15 +87,19 @@ export default function PluginsByIndex({
availableValues={models}
showAbove={showAbove}
showLabel={false}
className={cn(
cardStyle,
'z-50 flex h-[40px] w-48 min-w-48 flex-none items-center justify-center px-4 hover:cursor-pointer',
)}
/>
<PluginsMenu
value={conversation.tools || []}
isSelected={checkPluginSelection}
setSelected={setTools}
availableValues={availableTools}
optionValueKey="pluginKey"
showAbove={false}
showLabel={false}
setSelected={setTools}
value={conversationTools}
optionValueKey="pluginKey"
availableValues={availablePlugins}
isSelected={checkPluginSelection}
searchPlaceholder={localize('com_ui_select_search_plugin')}
/>
</>

View file

@ -5,7 +5,6 @@ import type { FC } from 'react';
import OpenAI from './OpenAI';
import BingAI from './BingAI';
import Google from './Google';
import Plugins from './Plugins';
import ChatGPT from './ChatGPT';
import Anthropic from './Anthropic';
import PluginsByIndex from './PluginsByIndex';
@ -16,7 +15,6 @@ export const options: { [key: string]: FC<TModelSelectProps> } = {
[EModelEndpoint.azureOpenAI]: OpenAI,
[EModelEndpoint.bingAI]: BingAI,
[EModelEndpoint.google]: Google,
[EModelEndpoint.gptPlugins]: Plugins,
[EModelEndpoint.anthropic]: Anthropic,
[EModelEndpoint.chatGPTBrowser]: ChatGPT,
};

View file

@ -1,227 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useGetConversationByIdQuery } from 'librechat-data-provider/react-query';
import { useEffect } from 'react';
import { useSetRecoilState, useRecoilState, useRecoilValue } from 'recoil';
import copy from 'copy-to-clipboard';
import { SubRow, Plugin, MessageContent } from './Content';
// eslint-disable-next-line import/no-cycle
import MultiMessage from './MultiMessage';
import HoverButtons from './HoverButtons';
import SiblingSwitch from './SiblingSwitch';
import { Icon } from '~/components/Endpoints';
import { useMessageHandler, useConversation } from '~/hooks';
import type { TMessageProps } from '~/common';
import { cn } from '~/utils';
import store from '~/store';
import { useParams } from 'react-router-dom';
export default function Message(props: TMessageProps) {
const {
conversation,
message,
scrollToBottom,
currentEditId,
setCurrentEditId,
siblingIdx,
siblingCount,
setSiblingIdx,
} = props;
const setLatestMessage = useSetRecoilState(store.latestMessage);
const [abortScroll, setAbortScroll] = useRecoilState(store.abortScroll);
const { isSubmitting, ask, regenerate, handleContinue } = useMessageHandler();
const { switchToConversation } = useConversation();
const { conversationId } = useParams();
const isSearching = useRecoilValue(store.isSearching);
const {
text,
children,
messageId = null,
searchResult,
isCreatedByUser,
error,
unfinished,
} = message ?? {};
const isLast = !children?.length;
const edit = messageId === currentEditId;
const getConversationQuery = useGetConversationByIdQuery(message?.conversationId ?? '', {
enabled: false,
});
const autoScroll = useRecoilValue(store.autoScroll);
useEffect(() => {
if (isSubmitting && scrollToBottom && !abortScroll) {
scrollToBottom();
}
}, [isSubmitting, text, scrollToBottom, abortScroll]);
useEffect(() => {
if (scrollToBottom && autoScroll && !isSearching && conversationId !== 'new') {
scrollToBottom();
}
}, [autoScroll, conversationId, scrollToBottom, isSearching]);
useEffect(() => {
if (!message) {
return;
} else if (isLast) {
setLatestMessage({ ...message });
}
}, [isLast, message, setLatestMessage]);
if (!message) {
return null;
}
const enterEdit = (cancel?: boolean) =>
setCurrentEditId && setCurrentEditId(cancel ? -1 : messageId);
const handleScroll = () => {
if (isSubmitting) {
setAbortScroll(true);
} else {
setAbortScroll(false);
}
};
const commonClasses =
'w-full border-b text-gray-800 group border-black/10 dark:border-gray-800/50 dark:text-gray-200';
const uniqueClasses = isCreatedByUser
? 'bg-white dark:bg-gray-800 dark:text-gray-20'
: 'bg-gray-50 dark:bg-gray-700 dark:text-gray-100';
const messageProps = {
className: cn(commonClasses, uniqueClasses),
titleclass: '',
};
const icon = Icon({
...conversation,
...message,
model: message?.model ?? conversation?.model,
size: 36,
});
if (message?.bg && searchResult) {
messageProps.className = message?.bg?.split('hover')[0];
messageProps.titleclass = message?.bg?.split(messageProps.className)[1] + ' cursor-pointer';
}
const regenerateMessage = () => {
if (!isSubmitting && !isCreatedByUser) {
regenerate(message);
}
};
const copyToClipboard = (setIsCopied: React.Dispatch<React.SetStateAction<boolean>>) => {
setIsCopied(true);
copy(text ?? '');
setTimeout(() => {
setIsCopied(false);
}, 3000);
};
const clickSearchResult = async () => {
if (!searchResult) {
return;
}
if (!message) {
return;
}
const response = await getConversationQuery.refetch({
queryKey: [message?.conversationId],
});
console.log('getConversationQuery response.data:', response.data);
if (response.data) {
switchToConversation(response.data);
}
};
return (
<>
<div {...messageProps} onWheel={handleScroll} onTouchMove={handleScroll}>
<div className="relative m-auto flex gap-4 p-4 text-base md:max-w-2xl md:gap-4 md:py-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
<div className="relative flex h-[40px] w-[40px] flex-col items-end text-right text-xs md:text-sm">
{typeof icon === 'string' && /[^\\x00-\\x7F]+/.test(icon as string) ? (
<span className=" direction-rtl w-40 overflow-x-scroll">{icon}</span>
) : (
icon
)}
<div className="sibling-switch invisible absolute left-0 top-2 -ml-4 flex -translate-x-full items-center justify-center gap-1 text-xs group-hover:visible">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
</div>
</div>
<div className="relative flex w-[calc(100%-50px)] flex-col gap-1 md:gap-3 lg:w-[calc(100%-115px)]">
{searchResult && (
<SubRow
classes={messageProps.titleclass + ' rounded'}
subclasses="switch-result pl-2 pb-2"
onClick={clickSearchResult}
>
<strong>{`${message?.title} | ${message?.sender}`}</strong>
</SubRow>
)}
<div className="flex flex-grow flex-col gap-3">
{/* Legacy Plugins */}
{message?.plugin && <Plugin plugin={message?.plugin} />}
<MessageContent
ask={ask}
edit={edit}
isLast={isLast}
text={text ?? ''}
message={message}
enterEdit={enterEdit}
error={!!(error && !searchResult)}
isSubmitting={isSubmitting}
unfinished={unfinished ?? false}
isCreatedByUser={isCreatedByUser ?? true}
siblingIdx={siblingIdx ?? 0}
setSiblingIdx={
setSiblingIdx ??
(() => {
return;
})
}
/>
</div>
<HoverButtons
isEditing={edit}
isSubmitting={isSubmitting}
message={message}
conversation={conversation ?? null}
enterEdit={enterEdit}
regenerate={() => regenerateMessage()}
handleContinue={handleContinue}
copyToClipboard={copyToClipboard}
/>
<SubRow subclasses="switch-container">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
</SubRow>
</div>
</div>
</div>
<MultiMessage
messageId={messageId}
conversation={conversation}
messagesTree={children ?? []}
scrollToBottom={scrollToBottom}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
/>
</>
);
}

View file

@ -1,74 +0,0 @@
import { useEffect } from 'react';
import { useRecoilState } from 'recoil';
import type { TMessageProps } from '~/common';
// eslint-disable-next-line import/no-cycle
import Message from './Message';
import store from '~/store';
export default function MultiMessage({
messageId,
conversation,
messagesTree,
scrollToBottom,
currentEditId,
setCurrentEditId,
isSearchView,
}: TMessageProps) {
const [siblingIdx, setSiblingIdx] = useRecoilState(store.messagesSiblingIdxFamily(messageId));
const setSiblingIdxRev = (value: number) => {
setSiblingIdx((messagesTree?.length ?? 0) - value - 1);
};
useEffect(() => {
// reset siblingIdx when the tree changes, mostly when a new message is submitting.
setSiblingIdx(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messagesTree?.length]);
// if (!messageList?.length) return null;
if (!(messagesTree && messagesTree?.length)) {
return null;
}
if (siblingIdx >= messagesTree?.length) {
setSiblingIdx(0);
return null;
}
const message = messagesTree[messagesTree.length - siblingIdx - 1];
if (isSearchView) {
return (
<>
{messagesTree
? messagesTree.map((message) => (
<Message
key={message.messageId}
conversation={conversation}
message={message}
scrollToBottom={scrollToBottom}
currentEditId={currentEditId}
setCurrentEditId={null}
siblingIdx={1}
siblingCount={1}
setSiblingIdx={null}
/>
))
: null}
</>
);
}
return (
<Message
key={message.messageId}
conversation={conversation}
message={message}
scrollToBottom={scrollToBottom}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
siblingIdx={messagesTree.length - siblingIdx - 1}
siblingCount={messagesTree.length}
setSiblingIdx={setSiblingIdxRev}
/>
);
}

View file

@ -1,71 +0,0 @@
import type { TMessageProps } from '~/common';
type TSiblingSwitchProps = Pick<TMessageProps, 'siblingIdx' | 'siblingCount' | 'setSiblingIdx'>;
export default function SiblingSwitch({
siblingIdx,
siblingCount,
setSiblingIdx,
}: TSiblingSwitchProps) {
if (siblingIdx === undefined) {
return null;
} else if (siblingCount === undefined) {
return null;
}
const previous = () => {
setSiblingIdx && setSiblingIdx(siblingIdx - 1);
};
const next = () => {
setSiblingIdx && setSiblingIdx(siblingIdx + 1);
};
return siblingCount > 1 ? (
<>
<button
className="disabled:text-gray-300 dark:text-white dark:disabled:text-gray-400"
onClick={previous}
disabled={siblingIdx == 0}
>
<svg
stroke="currentColor"
fill="none"
strokeWidth="1.5"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-3 w-3"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<span className="flex-shrink-0 flex-grow">
{siblingIdx + 1} / {siblingCount}
</span>
<button
className="disabled:text-gray-300 dark:text-white dark:disabled:text-gray-400"
onClick={next}
disabled={siblingIdx == siblingCount - 1}
>
<svg
stroke="currentColor"
fill="none"
strokeWidth="1.5"
viewBox="0 0 24 24"
strokeLinecap="round"
strokeLinejoin="round"
className="h-3 w-3"
height="1em"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</>
) : null;
}

View file

@ -1,29 +1,36 @@
import { useNavigate } from 'react-router-dom';
import { EModelEndpoint } from 'librechat-data-provider';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
import { useLocalize, useNewConvo, useLocalStorage } from '~/hooks';
import { getEndpointField, getIconEndpoint, getIconKey } from '~/utils';
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { useLocalize, useNewConvo } from '~/hooks';
import { NewChatIcon } from '~/components/svg';
import { getEndpointField } from '~/utils';
import store from '~/store';
export default function NewChat({
index = 0,
toggleNav,
subHeaders,
}: {
index?: number;
toggleNav: () => void;
subHeaders?: React.ReactNode;
}) {
const { newConversation: newConvo } = useNewConvo();
/** Note: this component needs an explicit index passed if using more than one */
const { newConversation: newConvo } = useNewConvo(index);
const navigate = useNavigate();
const localize = useLocalize();
const { data: endpointsConfig } = useGetEndpointsQuery();
const [convo] = useLocalStorage('lastConversationSetup', { endpoint: EModelEndpoint.openAI });
const { endpoint } = convo;
const { conversation } = store.useCreateConversationAtom(index);
let { endpoint = '' } = conversation ?? {};
const iconURL = conversation?.iconURL ?? '';
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
const endpointType = getEndpointField(endpointsConfig, endpoint, 'type');
const iconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
const iconKey = endpointType ? 'unknown' : endpoint ?? 'unknown';
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
const iconKey = getIconKey({ endpoint, endpointsConfig, endpointType, endpointIconURL });
const Icon = icons[iconKey];
const clickHandler = (event: React.MouseEvent<HTMLAnchorElement>) => {
@ -47,17 +54,22 @@ export default function NewChat({
className="group flex h-10 items-center gap-2 rounded-lg px-2 font-medium hover:bg-gray-200 dark:hover:bg-gray-700"
>
<div className="h-7 w-7 flex-shrink-0">
<div className="shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black dark:bg-white">
{endpoint &&
Icon &&
Icon({
size: 41,
context: 'nav',
className: 'h-2/3 w-2/3',
endpoint: endpoint,
iconURL: iconURL,
})}
</div>
{iconURL && iconURL.includes('http') ? (
<ConvoIconURL preset={conversation} endpointIconURL={iconURL} context="nav" />
) : (
<div className="shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black dark:bg-white">
{endpoint &&
Icon &&
Icon({
size: 41,
context: 'nav',
className: 'h-2/3 w-2/3',
endpoint,
endpointType,
iconURL: endpointIconURL,
})}
</div>
)}
</div>
<div className="text-token-text-primary grow overflow-hidden text-ellipsis whitespace-nowrap text-sm">
{localize('com_ui_new_chat')}

View file

@ -1,13 +1,16 @@
import { Search, X } from 'lucide-react';
import { Dialog } from '@headlessui/react';
import { useState, useEffect } from 'react';
import {
useAvailablePluginsQuery,
useUpdateUserPluginsMutation,
} from 'librechat-data-provider/react-query';
import type { TError, TPluginAction } from 'librechat-data-provider';
import { useState, useEffect, useCallback } from 'react';
import { useAvailablePluginsQuery } from 'librechat-data-provider/react-query';
import type { TError, TPlugin, TPluginAction } from 'librechat-data-provider';
import type { TPluginStoreDialogProps } from '~/common/types';
import { useLocalize, usePluginDialogHelpers, useSetIndexOptions, useAuthContext } from '~/hooks';
import {
usePluginDialogHelpers,
useSetIndexOptions,
usePluginInstall,
useAuthContext,
useLocalize,
} from '~/hooks';
import PluginPagination from './PluginPagination';
import PluginStoreItem from './PluginStoreItem';
import PluginAuthForm from './PluginAuthForm';
@ -16,7 +19,6 @@ function PluginStoreDialog({ isOpen, setIsOpen }: TPluginStoreDialogProps) {
const localize = useLocalize();
const { user } = useAuthContext();
const { data: availablePlugins } = useAvailablePluginsQuery();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { setTools } = useSetIndexOptions();
const [userPlugins, setUserPlugins] = useState<string[]>([]);
@ -44,50 +46,49 @@ function PluginStoreDialog({ isOpen, setIsOpen }: TPluginStoreDialogProps) {
setSelectedPlugin,
} = usePluginDialogHelpers();
const handleInstallError = (error: TError) => {
setError(true);
if (error.response?.data?.message) {
setErrorMessage(error.response?.data?.message);
}
setTimeout(() => {
setError(false);
setErrorMessage('');
}, 5000);
};
const handleInstallError = useCallback(
(error: TError) => {
setError(true);
if (error.response?.data?.message) {
setErrorMessage(error.response?.data?.message);
}
setTimeout(() => {
setError(false);
setErrorMessage('');
}, 5000);
},
[setError, setErrorMessage],
);
const handleInstall = (pluginAction: TPluginAction) => {
updateUserPlugins.mutate(pluginAction, {
onError: (error: unknown) => {
handleInstallError(error as TError);
},
});
const { installPlugin, uninstallPlugin } = usePluginInstall({
onInstallError: handleInstallError,
onUninstallError: handleInstallError,
onUninstallSuccess: (_data, variables) => {
setTools(variables.pluginKey, true);
},
});
const handleInstall = (pluginAction: TPluginAction, plugin?: TPlugin) => {
if (!plugin) {
return;
}
installPlugin(pluginAction, plugin);
setShowPluginAuthForm(false);
};
const onPluginUninstall = (plugin: string) => {
updateUserPlugins.mutate(
{ pluginKey: plugin, action: 'uninstall', auth: null },
{
onError: (error: unknown) => {
handleInstallError(error as TError);
},
onSuccess: () => {
setTools(plugin, true);
},
},
);
};
const onPluginInstall = (pluginKey: string) => {
const getAvailablePluginFromKey = availablePlugins?.find((p) => p.pluginKey === pluginKey);
setSelectedPlugin(getAvailablePluginFromKey);
const plugin = availablePlugins?.find((p) => p.pluginKey === pluginKey);
if (!plugin) {
return;
}
setSelectedPlugin(plugin);
const { authConfig, authenticated } = getAvailablePluginFromKey ?? {};
const { authConfig, authenticated } = plugin ?? {};
if (authConfig && authConfig.length > 0 && !authenticated) {
setShowPluginAuthForm(true);
} else {
handleInstall({ pluginKey, action: 'install', auth: null });
handleInstall({ pluginKey, action: 'install', auth: null }, plugin);
}
};
@ -107,10 +108,17 @@ function PluginStoreDialog({ isOpen, setIsOpen }: TPluginStoreDialogProps) {
setSearchChanged(false);
}
}
// Disabled due to state setters erroneously being flagged as dependencies
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [availablePlugins, itemsPerPage, user, searchValue, filteredPlugins, searchChanged]);
}, [
availablePlugins,
itemsPerPage,
user,
searchValue,
filteredPlugins,
searchChanged,
setMaxPage,
setCurrentPage,
setSearchChanged,
]);
return (
<Dialog
@ -165,7 +173,7 @@ function PluginStoreDialog({ isOpen, setIsOpen }: TPluginStoreDialogProps) {
<div className="p-4 sm:p-6 sm:pt-4">
<PluginAuthForm
plugin={selectedPlugin}
onSubmit={(installActionData: TPluginAction) => handleInstall(installActionData)}
onSubmit={(action: TPluginAction) => handleInstall(action, selectedPlugin)}
/>
</div>
)}
@ -197,7 +205,7 @@ function PluginStoreDialog({ isOpen, setIsOpen }: TPluginStoreDialogProps) {
plugin={plugin}
isInstalled={userPlugins.includes(plugin.pluginKey)}
onInstall={() => onPluginInstall(plugin.pluginKey)}
onUninstall={() => onPluginUninstall(plugin.pluginKey)}
onUninstall={() => uninstallPlugin(plugin.pluginKey)}
/>
))}
</div>

View file

@ -1,6 +1,6 @@
import { useEffect, useMemo } from 'react';
import { Combobox } from '~/components/ui';
import { EModelEndpoint, defaultOrderQuery } from 'librechat-data-provider';
import { EModelEndpoint, defaultOrderQuery, LocalStorageKeys } from 'librechat-data-provider';
import type { SwitcherProps } from '~/common';
import { useSetIndexOptions, useSelectAssistant, useLocalize } from '~/hooks';
import { useChatContext, useAssistantsMapContext } from '~/Providers';
@ -25,7 +25,9 @@ export default function AssistantSwitcher({ isCollapsed }: SwitcherProps) {
useEffect(() => {
if (!selectedAssistant && assistants && assistants.length && assistantMap) {
const assistant_id =
localStorage.getItem(`assistant_id__${index}`) ?? assistants[0]?.id ?? '';
localStorage.getItem(`${LocalStorageKeys.ASST_ID_PREFIX}${index}`) ??
assistants[0]?.id ??
'';
const assistant = assistantMap?.[assistant_id];
if (!assistant) {

View file

@ -1,13 +1,17 @@
import throttle from 'lodash/throttle';
import { EModelEndpoint, getConfigDefaults } from 'librechat-data-provider';
import { useState, useRef, useCallback, useEffect, useMemo, memo } from 'react';
import { useGetEndpointsQuery, useUserKeyQuery } from 'librechat-data-provider/react-query';
import {
useGetEndpointsQuery,
useGetStartupConfig,
useUserKeyQuery,
} from 'librechat-data-provider/react-query';
import type { ImperativePanelHandle } from 'react-resizable-panels';
import { EModelEndpoint, type TEndpointsConfig } from 'librechat-data-provider';
import type { TEndpointsConfig } from 'librechat-data-provider';
import { ResizableHandleAlt, ResizablePanel, ResizablePanelGroup } from '~/components/ui/Resizable';
import { TooltipProvider, Tooltip } from '~/components/ui/Tooltip';
import useSideNavLinks from '~/hooks/Nav/useSideNavLinks';
import { useMediaQuery, useLocalStorage } from '~/hooks';
import { Separator } from '~/components/ui/Separator';
import NavToggle from '~/components/Nav/NavToggle';
import { useChatContext } from '~/Providers';
import Switcher from './Switcher';
@ -23,6 +27,7 @@ interface SidePanelProps {
}
const defaultMinSize = 20;
const defaultInterface = getConfigDefaults().interface;
const SidePanel = ({
defaultLayout = [97, 3],
@ -38,6 +43,12 @@ const SidePanel = ({
const [fullCollapse, setFullCollapse] = useState(fullPanelCollapse);
const [collapsedSize, setCollapsedSize] = useState(navCollapsedSize);
const { data: endpointsConfig = {} as TEndpointsConfig } = useGetEndpointsQuery();
const { data: startupConfig } = useGetStartupConfig();
const interfaceConfig = useMemo(
() => startupConfig?.interface ?? defaultInterface,
[startupConfig],
);
const isSmallScreen = useMediaQuery('(max-width: 767px)');
const { conversation } = useChatContext();
const { endpoint } = conversation ?? {};
@ -69,7 +80,7 @@ const SidePanel = ({
panelRef.current?.collapse();
}, []);
const Links = useSideNavLinks({ hidePanel, assistants, keyProvided, endpoint });
const Links = useSideNavLinks({ hidePanel, assistants, keyProvided, endpoint, interfaceConfig });
// eslint-disable-next-line react-hooks/exhaustive-deps
const throttledSaveLayout = useCallback(
@ -181,19 +192,20 @@ const SidePanel = ({
: 'opacity-100',
)}
>
<div
className={cn(
'sticky left-0 right-0 top-0 z-[100] flex h-[52px] flex-wrap items-center justify-center bg-white dark:bg-gray-850',
isCollapsed ? 'h-[52px]' : 'px-2',
)}
>
<Switcher
isCollapsed={isCollapsed}
endpointKeyProvided={keyProvided}
endpoint={endpoint}
/>
<Separator className="bg-gray-100/50 dark:bg-gray-600" />
</div>
{interfaceConfig.modelSelect && (
<div
className={cn(
'sticky left-0 right-0 top-0 z-[100] flex h-[52px] flex-wrap items-center justify-center bg-white dark:bg-gray-850',
isCollapsed ? 'h-[52px]' : 'px-2',
)}
>
<Switcher
isCollapsed={isCollapsed}
endpointKeyProvided={keyProvided}
endpoint={endpoint}
/>
</div>
)}
<Nav
resize={panelRef.current?.resize}
isCollapsed={isCollapsed}

View file

@ -1,14 +1,25 @@
import { EModelEndpoint } from 'librechat-data-provider';
import type { SwitcherProps } from '~/common';
import { Separator } from '~/components/ui/Separator';
import AssistantSwitcher from './AssistantSwitcher';
import ModelSwitcher from './ModelSwitcher';
export default function Switcher(props: SwitcherProps) {
if (props.endpoint === EModelEndpoint.assistants && props.endpointKeyProvided) {
return <AssistantSwitcher {...props} />;
return (
<>
<AssistantSwitcher {...props} />
<Separator className="bg-gray-100/50 dark:bg-gray-600" />
</>
);
} else if (props.endpoint === EModelEndpoint.assistants) {
return null;
}
return <ModelSwitcher {...props} />;
return (
<>
<ModelSwitcher {...props} />
<Separator className="bg-gray-100/50 dark:bg-gray-600" />
</>
);
}

View file

@ -16,6 +16,8 @@ export type TMultiSelectDropDownProps = {
showAbove?: boolean;
showLabel?: boolean;
containerClassName?: string;
optionsClassName?: string;
labelClassName?: string;
isSelected: (value: string) => boolean;
className?: string;
searchPlaceholder?: string;
@ -31,6 +33,8 @@ function MultiSelectDropDown({
showAbove = false,
showLabel = true,
containerClassName,
optionsClassName = '',
labelClassName = '',
isSelected,
className,
searchPlaceholder,
@ -72,7 +76,7 @@ function MultiSelectDropDown({
<>
<Listbox.Button
className={cn(
'relative flex w-full cursor-default flex-col rounded-md border border-black/10 bg-white py-2 pl-3 pr-10 text-left focus:outline-none focus:ring-0 focus:ring-offset-0 dark:border-white/20 dark:bg-gray-800 sm:text-sm',
'relative flex w-full cursor-default flex-col rounded-md border border-black/10 bg-white py-2 pl-3 pr-10 text-left focus:outline-none focus:ring-0 focus:ring-offset-0 dark:border-gray-600 dark:border-white/20 dark:bg-gray-800 sm:text-sm',
className ?? '',
)}
id={excludeIds[0]}
@ -82,7 +86,7 @@ function MultiSelectDropDown({
{' '}
{showLabel && (
<Listbox.Label
className="block text-xs text-gray-700 dark:text-gray-500"
className={cn('block text-xs text-gray-700 dark:text-gray-500', labelClassName)}
id={excludeIds[1]}
data-headlessui-state=""
>
@ -153,6 +157,7 @@ function MultiSelectDropDown({
ref={menuRef}
className={cn(
'absolute z-50 mt-2 max-h-60 w-full overflow-auto rounded bg-white text-base text-xs ring-1 ring-black/10 focus:outline-none dark:bg-gray-800 dark:ring-white/20 dark:last:border-0 md:w-[100%]',
optionsClassName,
)}
>
{searchRender}

View file

@ -1,4 +1,3 @@
import React, { useMemo, useState } from 'react';
import { Wrench } from 'lucide-react';
import { Root, Trigger, Content, Portal } from '@radix-ui/react-popover';
import type { TPlugin } from 'librechat-data-provider';
@ -115,7 +114,7 @@ function MultiSelectPop({
side="bottom"
align="center"
className={cn(
'mt-2 max-h-60 min-w-full overflow-hidden overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white',
'mt-2 max-h-[52vh] min-w-full overflow-hidden overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-700 dark:text-white',
hasSearchRender && 'relative',
)}
>

View file

@ -1,3 +1,4 @@
import { LocalStorageKeys } from 'librechat-data-provider';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import type { UseMutationResult } from '@tanstack/react-query';
import type t from 'librechat-data-provider';
@ -264,10 +265,10 @@ export const useLogoutUserMutation = (
onMutate: (...args) => {
setDefaultPreset(null);
queryClient.removeQueries();
localStorage.removeItem('lastConversationSetup');
localStorage.removeItem('lastSelectedModel');
localStorage.removeItem('lastSelectedTools');
localStorage.removeItem('filesToDelete');
localStorage.removeItem(LocalStorageKeys.LAST_CONVO_SETUP);
localStorage.removeItem(LocalStorageKeys.LAST_MODEL);
localStorage.removeItem(LocalStorageKeys.LAST_TOOLS);
localStorage.removeItem(LocalStorageKeys.FILES_TO_DELETE);
// localStorage.removeItem('lastAssistant');
options?.onMutate?.(...args);
},

View file

@ -1,9 +1,9 @@
import { useCallback } from 'react';
import { EModelEndpoint, defaultOrderQuery } from 'librechat-data-provider';
import type { TConversation, TPreset } from 'librechat-data-provider';
import useDefaultConvo from '~/hooks/Conversations/useDefaultConvo';
import { useListAssistantsQuery } from '~/data-provider';
import { useChatContext } from '~/Providers/ChatContext';
import useDefaultConvo from '~/hooks/useDefaultConvo';
import { mapAssistants } from '~/utils';
export default function useSelectAssistant() {

View file

@ -1 +1 @@
export { default as useConfigOverride } from './useConfigOverride';
export { default as useAppStartup } from './useAppStartup';

View file

@ -0,0 +1,101 @@
import { useEffect } from 'react';
import { useRecoilState, useSetRecoilState } from 'recoil';
import { LocalStorageKeys } from 'librechat-data-provider';
import { useAvailablePluginsQuery } from 'librechat-data-provider/react-query';
import type { TStartupConfig, TPlugin, TUser } from 'librechat-data-provider';
import { data as modelSpecs } from '~/components/Chat/Menus/Models/fakeData';
import { mapPlugins, selectPlugins, processPlugins } from '~/utils';
import useConfigOverride from './useConfigOverride';
import store from '~/store';
const pluginStore: TPlugin = {
name: 'Plugin store',
pluginKey: 'pluginStore',
isButton: true,
description: '',
icon: '',
authConfig: [],
authenticated: false,
};
export default function useAppStartup({
startupConfig,
user,
}: {
startupConfig?: TStartupConfig;
user?: TUser;
}) {
useConfigOverride();
const setAvailableTools = useSetRecoilState(store.availableTools);
const [defaultPreset, setDefaultPreset] = useRecoilState(store.defaultPreset);
const { data: allPlugins } = useAvailablePluginsQuery({
enabled: !!user?.plugins,
select: selectPlugins,
});
/** Set the app title */
useEffect(() => {
if (startupConfig?.appTitle) {
document.title = startupConfig.appTitle;
localStorage.setItem(LocalStorageKeys.APP_TITLE, startupConfig.appTitle);
}
}, [startupConfig]);
/** Set the default spec's preset as default */
useEffect(() => {
if (defaultPreset && defaultPreset.spec) {
return;
}
if (!modelSpecs || !modelSpecs.length) {
return;
}
const defaultSpec = modelSpecs.find((spec) => spec.default);
if (!defaultSpec) {
return;
}
setDefaultPreset({
...defaultSpec.preset,
iconURL: defaultSpec.iconURL,
spec: defaultSpec.name,
});
}, [defaultPreset, setDefaultPreset]);
/** Set the available Plugins */
useEffect(() => {
if (!user) {
return;
}
if (!allPlugins) {
return;
}
if (!user.plugins || user.plugins.length === 0) {
setAvailableTools({ pluginStore });
return;
}
const tools = [...user.plugins]
.map((el) => allPlugins.map[el])
.filter((el): el is TPlugin => el !== undefined);
/* Filter Last Selected Tools */
const localStorageItem = localStorage.getItem(LocalStorageKeys.LAST_TOOLS);
if (!localStorageItem) {
return setAvailableTools({ pluginStore, ...mapPlugins(tools) });
}
const lastSelectedTools = processPlugins(JSON.parse(localStorageItem) ?? [], allPlugins.map);
const filteredTools = lastSelectedTools
.filter((tool: TPlugin) =>
tools.some((existingTool) => existingTool.pluginKey === tool.pluginKey),
)
.filter((tool: TPlugin) => !!tool);
localStorage.setItem(LocalStorageKeys.LAST_TOOLS, JSON.stringify(filteredTools));
setAvailableTools({ pluginStore, ...mapPlugins(tools) });
}, [allPlugins, user, setAvailableTools]);
}

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

@ -10,7 +10,7 @@ import type {
TEndpointsConfig,
} from 'librechat-data-provider';
import { buildDefaultConvo, getDefaultEndpoint, getEndpointField } from '~/utils';
import useOriginNavigate from './useOriginNavigate';
import useOriginNavigate from '../useOriginNavigate';
import store from '~/store';
const useConversation = () => {

View file

@ -3,17 +3,14 @@ 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 useSetStorage from './useSetStorage';
import useOriginNavigate from '../useOriginNavigate';
import store from '~/store';
const useNavigateToConvo = (index = 0) => {
const setStorage = useSetStorage();
const queryClient = useQueryClient();
const navigate = useOriginNavigate();
const { setConversation } = store.useCreateConversationAtom(index);
const setSubmission = useSetRecoilState(store.submissionByIndex(index));
// const setConversation = useSetRecoilState(store.conversationByIndex(index));
const resetLatestMessage = useResetRecoilState(store.latestMessageFamily(index));
const navigateToConvo = (conversation: TConversation, _resetLatestMessage = true) => {
@ -50,7 +47,6 @@ const useNavigateToConvo = (index = 0) => {
models,
});
}
setStorage(convo);
setConversation(convo);
navigate(convo?.conversationId);
};

View file

@ -1,11 +1,15 @@
import { TPreset } from 'librechat-data-provider';
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 usePresetOptions: TUsePresetOptions = (_preset) => {
const usePresetIndexOptions: TUsePresetOptions = (_preset) => {
const setShowPluginStoreDialog = useSetRecoilState(store.showPluginStoreDialog);
const availableTools = useRecoilValue(store.availableTools);
const { preset, setPreset } = useChatContext();
if (!_preset) {
@ -99,9 +103,52 @@ const usePresetOptions: TUsePresetOptions = (_preset) => {
);
};
const checkPluginSelection: (value: string) => boolean = () => false;
const setTools: (newValue: string) => void = () => {
return;
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 {
@ -116,4 +163,4 @@ const usePresetOptions: TUsePresetOptions = (_preset) => {
};
};
export default usePresetOptions;
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

@ -1,15 +1,8 @@
import { useRecoilValue, useSetRecoilState } from 'recoil';
import {
TPreset,
TPlugin,
tConvoUpdateSchema,
EModelEndpoint,
TConversation,
} from 'librechat-data-provider';
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 useLocalStorage from './useLocalStorage';
import store from '~/store';
type TUseSetOptions = (preset?: TPreset | boolean | null) => TSetOptionsPayload;
@ -18,11 +11,6 @@ const useSetIndexOptions: TUseSetOptions = (preset = false) => {
const setShowPluginStoreDialog = useSetRecoilState(store.showPluginStoreDialog);
const availableTools = useRecoilValue(store.availableTools);
const { conversation, setConversation } = useChatContext();
const [lastBingSettings, setLastBingSettings] = useLocalStorage('lastBingSettings', {});
const [lastModel, setLastModel] = useLocalStorage('lastSelectedModel', {
primaryModel: '',
secondaryModel: '',
});
const result = usePresetIndexOptions(preset);
@ -31,16 +19,10 @@ const useSetIndexOptions: TUseSetOptions = (preset = false) => {
}
const setOption: TSetOption = (param) => (newValue) => {
const { endpoint } = conversation ?? {};
const update = {};
update[param] = newValue;
if (param === 'model' && endpoint) {
const lastModelUpdate = { ...lastModel, [endpoint]: newValue };
setLastModel(lastModelUpdate);
} else if (param === 'jailbreak' && endpoint) {
setLastBingSettings({ ...lastBingSettings, jailbreak: newValue });
} else if (param === 'presetOverride') {
if (param === 'presetOverride') {
const currentOverride = conversation?.presetOverride || {};
update['presetOverride'] = {
...currentOverride,
@ -116,7 +98,14 @@ const useSetIndexOptions: TUseSetOptions = (preset = false) => {
if (!conversation?.tools) {
return false;
}
return conversation.tools.find((el) => el.pluginKey === value) ? true : false;
return conversation.tools.find((el) => {
if (typeof el === 'string') {
return el === value;
}
return el.pluginKey === value;
})
? true
: false;
}
const setAgentOption: TSetOption = (param) => (newValue) => {
@ -124,12 +113,7 @@ const useSetIndexOptions: TUseSetOptions = (preset = false) => {
const convo = JSON.parse(editableConvo);
const { agentOptions } = convo;
agentOptions[param] = newValue;
console.log('agentOptions', agentOptions, param, newValue);
if (param === 'model' && typeof newValue === 'string') {
const lastModelUpdate = { ...lastModel, [EModelEndpoint.gptPlugins]: newValue };
lastModelUpdate.secondaryModel = newValue;
setLastModel(lastModelUpdate);
}
setConversation(
(prevState) =>
tConvoUpdateSchema.parse({
@ -146,17 +130,23 @@ const useSetIndexOptions: TUseSetOptions = (preset = false) => {
}
const update = {};
const current = conversation?.tools || [];
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[availableTools.findIndex((el: TPlugin) => el.pluginKey === newValue)];
const tool = availableTools[newValue];
if (isSelected || remove) {
update['tools'] = current.filter((el) => el.pluginKey !== newValue);
} else {
update['tools'] = [...current, tool];
}
localStorage.setItem('lastSelectedTools', JSON.stringify(update['tools']));
setConversation(
(prevState) =>
tConvoUpdateSchema.parse({

View file

@ -1,5 +1,7 @@
import { LocalStorageKeys } from 'librechat-data-provider';
export default function useSetFilesToDelete() {
const setFilesToDelete = (files: Record<string, unknown>) =>
localStorage.setItem('filesToDelete', JSON.stringify(files));
localStorage.setItem(LocalStorageKeys.FILES_TO_DELETE, JSON.stringify(files));
return setFilesToDelete;
}

View file

@ -1,16 +1,11 @@
import copy from 'copy-to-clipboard';
import { useEffect, useRef, useCallback } from 'react';
import { EModelEndpoint, ContentTypes } from 'librechat-data-provider';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import type { TMessage } from 'librechat-data-provider';
import type { TMessageProps } from '~/common';
import { useChatContext, useAssistantsMapContext } from '~/Providers';
import Icon from '~/components/Endpoints/Icon';
import { getEndpointField } from '~/utils';
export default function useMessageHelpers(props: TMessageProps) {
const latestText = useRef<string | number>('');
const { data: endpointsConfig } = useGetEndpointsQuery();
const { message, currentEditId, setCurrentEditId } = props;
const {
@ -62,18 +57,6 @@ export default function useMessageHelpers(props: TMessageProps) {
const assistant =
conversation?.endpoint === EModelEndpoint.assistants && assistantMap?.[message?.model ?? ''];
const iconEndpoint = message?.endpoint ?? conversation?.endpoint;
const icon = Icon({
...conversation,
...(message as TMessage),
iconURL: !assistant
? getEndpointField(endpointsConfig, iconEndpoint, 'iconURL')
: (assistant?.metadata?.avatar as string | undefined) ?? '',
model: message?.model ?? conversation?.model,
assistantName: assistant ? (assistant.name as string | undefined) : '',
size: 28.8,
});
const regenerateMessage = () => {
if ((isSubmitting && isCreatedByUser) || !message) {
return;
@ -105,7 +88,6 @@ export default function useMessageHelpers(props: TMessageProps) {
return {
ask,
icon,
edit,
isLast,
assistant,

View file

@ -4,7 +4,7 @@ import {
// Settings2,
} from 'lucide-react';
import { EModelEndpoint } from 'librechat-data-provider';
import type { TConfig } from 'librechat-data-provider';
import type { TConfig, TInterfaceConfig } from 'librechat-data-provider';
import type { NavLink } from '~/common';
import PanelSwitch from '~/components/SidePanel/Builder/PanelSwitch';
// import Parameters from '~/components/SidePanel/Parameters/Panel';
@ -16,11 +16,13 @@ export default function useSideNavLinks({
assistants,
keyProvided,
endpoint,
interfaceConfig,
}: {
hidePanel: () => void;
assistants?: TConfig | null;
keyProvided: boolean;
endpoint?: EModelEndpoint | null;
interfaceConfig: Partial<TInterfaceConfig>;
}) {
const Links = useMemo(() => {
const links: NavLink[] = [];
@ -37,7 +39,8 @@ export default function useSideNavLinks({
endpoint === EModelEndpoint.assistants &&
assistants &&
assistants.disableBuilder !== true &&
keyProvided
keyProvided &&
interfaceConfig.parameters
) {
links.push({
title: 'com_sidepanel_assistant_builder',
@ -65,7 +68,7 @@ export default function useSideNavLinks({
});
return links;
}, [assistants, keyProvided, hidePanel, endpoint]);
}, [assistants, keyProvided, hidePanel, endpoint, interfaceConfig.parameters]);
return Links;
}

View file

@ -1 +1,2 @@
export { default as usePluginInstall } from './usePluginInstall';
export { default as usePluginDialogHelpers } from './usePluginDialogHelpers';

View file

@ -0,0 +1,77 @@
// hooks/Plugins/usePluginInstall.ts
import { useCallback } from 'react';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type {
TError,
TUser,
TUpdateUserPlugins,
TPlugin,
TPluginAction,
} from 'librechat-data-provider';
import { useSetRecoilState } from 'recoil';
import store from '~/store';
interface PluginStoreHandlers {
onInstallError?: (error: TError) => void;
onUninstallError?: (error: TError) => void;
onInstallSuccess?: (data: TUser, variables: TUpdateUserPlugins, context: unknown) => void;
onUninstallSuccess?: (data: TUser, variables: TUpdateUserPlugins, context: unknown) => void;
}
export default function usePluginInstall(handlers: PluginStoreHandlers = {}) {
const setAvailableTools = useSetRecoilState(store.availableTools);
const { onInstallError, onInstallSuccess, onUninstallError, onUninstallSuccess } = handlers;
const updateUserPlugins = useUpdateUserPluginsMutation();
const installPlugin = useCallback(
(pluginAction: TPluginAction, plugin: TPlugin) => {
updateUserPlugins.mutate(pluginAction, {
onError: (error: unknown) => {
if (onInstallError) {
onInstallError(error as TError);
}
},
onSuccess: (...rest) => {
setAvailableTools((prev) => {
return { ...prev, [plugin.pluginKey]: plugin };
});
if (onInstallSuccess) {
onInstallSuccess(...rest);
}
},
});
},
[updateUserPlugins, onInstallError, onInstallSuccess, setAvailableTools],
);
const uninstallPlugin = useCallback(
(plugin: string) => {
updateUserPlugins.mutate(
{ pluginKey: plugin, action: 'uninstall', auth: null },
{
onError: (error: unknown) => {
if (onUninstallError) {
onUninstallError(error as TError);
}
},
onSuccess: (...rest) => {
setAvailableTools((prev) => {
const newAvailableTools = { ...prev };
delete newAvailableTools[plugin];
return newAvailableTools;
});
if (onUninstallSuccess) {
onUninstallSuccess(...rest);
}
},
},
);
},
[updateUserPlugins, onUninstallError, onUninstallSuccess, setAvailableTools],
);
return {
installPlugin,
uninstallPlugin,
};
}

View file

@ -7,12 +7,13 @@ import {
/* @ts-ignore */
SSE,
QueryKeys,
EndpointURLs,
Constants,
EndpointURLs,
createPayload,
tPresetSchema,
tMessageSchema,
EModelEndpoint,
LocalStorageKeys,
tConvoUpdateSchema,
removeNullishValues,
} from 'librechat-data-provider';
@ -34,7 +35,6 @@ import { useGenTitleMutation } from '~/data-provider';
import useContentHandler from './useContentHandler';
import { useAuthContext } from '../AuthContext';
import useChatHelpers from '../useChatHelpers';
import useSetStorage from '../useSetStorage';
import store from '~/store';
type TResData = {
@ -59,7 +59,6 @@ type TSyncData = {
};
export default function useSSE(submission: TSubmission | null, index = 0) {
const setStorage = useSetStorage();
const queryClient = useQueryClient();
const genTitle = useGenTitleMutation();
@ -165,13 +164,12 @@ export default function useSSE(submission: TSubmission | null, index = 0) {
...convoUpdate,
};
setStorage(update);
return update;
});
setIsSubmitting(false);
},
[setMessages, setConversation, setStorage, genTitle, queryClient, setIsSubmitting],
[setMessages, setConversation, genTitle, queryClient, setIsSubmitting],
);
const syncHandler = useCallback(
@ -208,7 +206,6 @@ export default function useSSE(submission: TSubmission | null, index = 0) {
messages: [requestMessage.messageId, responseMessage.messageId],
}) as TConversation;
setStorage(update);
return update;
});
@ -227,7 +224,7 @@ export default function useSSE(submission: TSubmission | null, index = 0) {
resetLatestMessage();
},
[setMessages, setConversation, setStorage, queryClient, setShowStopButton, resetLatestMessage],
[setMessages, setConversation, queryClient, setShowStopButton, resetLatestMessage],
);
const createdHandler = useCallback(
@ -273,7 +270,6 @@ export default function useSSE(submission: TSubmission | null, index = 0) {
title,
}) as TConversation;
setStorage(update);
return update;
});
@ -289,7 +285,7 @@ export default function useSSE(submission: TSubmission | null, index = 0) {
});
resetLatestMessage();
},
[setMessages, setConversation, setStorage, queryClient, resetLatestMessage],
[setMessages, setConversation, queryClient, resetLatestMessage],
);
const finalHandler = useCallback(
@ -336,21 +332,12 @@ export default function useSSE(submission: TSubmission | null, index = 0) {
update.model = prevState.model;
}
setStorage(update);
return update;
});
setIsSubmitting(false);
},
[
setMessages,
setConversation,
setStorage,
genTitle,
queryClient,
setIsSubmitting,
setShowStopButton,
],
[genTitle, queryClient, setMessages, setConversation, setIsSubmitting, setShowStopButton],
);
const errorHandler = useCallback(
@ -430,8 +417,9 @@ export default function useSSE(submission: TSubmission | null, index = 0) {
async (conversationId = '', submission: TSubmission) => {
let runAbortKey = '';
try {
const conversation = (JSON.parse(localStorage.getItem('lastConversationSetup') ?? '') ??
{}) as TConversation;
const conversation = (JSON.parse(
localStorage.getItem(LocalStorageKeys.LAST_CONVO_SETUP) ?? '',
) ?? {}) as TConversation;
const { conversationId, messages } = conversation;
runAbortKey = `${conversationId}:${messages?.[messages.length - 1]}`;
} catch (error) {

View file

@ -17,20 +17,11 @@ export { default as useTimeout } from './useTimeout';
export { default as useNewConvo } from './useNewConvo';
export { default as useLocalize } from './useLocalize';
export { default as useMediaQuery } from './useMediaQuery';
export { default as useSetOptions } from './useSetOptions';
export { default as useSetStorage } from './useSetStorage';
export { default as useChatHelpers } from './useChatHelpers';
export { default as useGenerations } from './useGenerations';
export { default as useScrollToRef } from './useScrollToRef';
export { default as useLocalStorage } from './useLocalStorage';
export { default as useConversation } from './useConversation';
export { default as useDefaultConvo } from './useDefaultConvo';
export { default as useServerStream } from './useServerStream';
export { default as useConversations } from './useConversations';
export { default as useDelayedRender } from './useDelayedRender';
export { default as useOnClickOutside } from './useOnClickOutside';
export { default as useMessageHandler } from './useMessageHandler';
export { default as useOriginNavigate } from './useOriginNavigate';
export { default as useNavigateToConvo } from './useNavigateToConvo';
export { default as useSetIndexOptions } from './useSetIndexOptions';
export { default as useGenerationsByLatest } from './useGenerationsByLatest';

View file

@ -211,6 +211,7 @@ export default function useChatHelpers(index = 0, paramId: string | undefined) {
unfinished: false,
isCreatedByUser: false,
isEdited: isEditOrContinue,
iconURL: convo.iconURL,
error: false,
};

Some files were not shown because too many files have changed in this diff Show more