mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
📂 feat: RAG Improvements (#2169)
* feat: new vector file processing strategy * chore: remove unused client files * chore: remove more unused client files * chore: remove more unused client files and move used to new dir * chore(DataIcon): add className * WIP: Model Endpoint Settings Update, draft additional context settings * feat: improve parsing for augmented prompt, add full context option * chore: remove volume mounting from rag.yml as no longer necessary
This commit is contained in:
parent
f427ad792a
commit
45a95acec2
40 changed files with 715 additions and 2046 deletions
|
@ -1,4 +1,15 @@
|
|||
const axios = require('axios');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
|
||||
const footer = `Use the context as your learned knowledge to better answer the user.
|
||||
|
||||
In your response, remember to follow these guidelines:
|
||||
- If you don't know the answer, simply say that you don't know.
|
||||
- If you are unsure how to answer, ask for clarification.
|
||||
- Avoid mentioning that you obtained the information from the context.
|
||||
|
||||
Answer appropriately in the user's language.
|
||||
`;
|
||||
|
||||
function createContextHandlers(req, userMessageContent) {
|
||||
if (!process.env.RAG_API_URL) {
|
||||
|
@ -9,25 +20,37 @@ function createContextHandlers(req, userMessageContent) {
|
|||
const processedFiles = [];
|
||||
const processedIds = new Set();
|
||||
const jwtToken = req.headers.authorization.split(' ')[1];
|
||||
const useFullContext = isEnabled(process.env.RAG_USE_FULL_CONTEXT);
|
||||
|
||||
const query = async (file) => {
|
||||
if (useFullContext) {
|
||||
return axios.get(`${process.env.RAG_API_URL}/documents/${file.file_id}/context`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwtToken}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return axios.post(
|
||||
`${process.env.RAG_API_URL}/query`,
|
||||
{
|
||||
file_id: file.file_id,
|
||||
query: userMessageContent,
|
||||
k: 4,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwtToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const processFile = async (file) => {
|
||||
if (file.embedded && !processedIds.has(file.file_id)) {
|
||||
try {
|
||||
const promise = axios.post(
|
||||
`${process.env.RAG_API_URL}/query`,
|
||||
{
|
||||
file_id: file.file_id,
|
||||
query: userMessageContent,
|
||||
k: 4,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwtToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const promise = query(file);
|
||||
queryPromises.push(promise);
|
||||
processedFiles.push(file);
|
||||
processedIds.add(file.file_id);
|
||||
|
@ -43,67 +66,83 @@ function createContextHandlers(req, userMessageContent) {
|
|||
return '';
|
||||
}
|
||||
|
||||
const oneFile = processedFiles.length === 1;
|
||||
const header = `The user has attached ${oneFile ? 'a' : processedFiles.length} file${
|
||||
!oneFile ? 's' : ''
|
||||
} to the conversation:`;
|
||||
|
||||
const files = `${
|
||||
oneFile
|
||||
? ''
|
||||
: `
|
||||
<files>`
|
||||
}${processedFiles
|
||||
.map(
|
||||
(file) => `
|
||||
<file>
|
||||
<filename>${file.filename}</filename>
|
||||
<type>${file.type}</type>
|
||||
</file>`,
|
||||
)
|
||||
.join('')}${
|
||||
oneFile
|
||||
? ''
|
||||
: `
|
||||
</files>`
|
||||
}`;
|
||||
|
||||
const resolvedQueries = await Promise.all(queryPromises);
|
||||
|
||||
const context = resolvedQueries
|
||||
.map((queryResult, index) => {
|
||||
const file = processedFiles[index];
|
||||
const contextItems = queryResult.data
|
||||
let contextItems = queryResult.data;
|
||||
|
||||
const generateContext = (currentContext) =>
|
||||
`
|
||||
<file>
|
||||
<filename>${file.filename}</filename>
|
||||
<context>${currentContext}
|
||||
</context>
|
||||
</file>`;
|
||||
|
||||
if (useFullContext) {
|
||||
return generateContext(`\n${contextItems}`);
|
||||
}
|
||||
|
||||
contextItems = queryResult.data
|
||||
.map((item) => {
|
||||
const pageContent = item[0].page_content;
|
||||
return `
|
||||
<contextItem>
|
||||
<![CDATA[${pageContent}]]>
|
||||
</contextItem>
|
||||
`;
|
||||
<![CDATA[${pageContent?.trim()}]]>
|
||||
</contextItem>`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
return `
|
||||
<file>
|
||||
<filename>${file.filename}</filename>
|
||||
<context>
|
||||
${contextItems}
|
||||
</context>
|
||||
</file>
|
||||
`;
|
||||
return generateContext(contextItems);
|
||||
})
|
||||
.join('');
|
||||
|
||||
const template = `The user has attached ${
|
||||
processedFiles.length === 1 ? 'a' : processedFiles.length
|
||||
} file${processedFiles.length !== 1 ? 's' : ''} to the conversation:
|
||||
if (useFullContext) {
|
||||
const prompt = `${header}
|
||||
${context}
|
||||
${footer}`;
|
||||
|
||||
<files>
|
||||
${processedFiles
|
||||
.map(
|
||||
(file) => `
|
||||
<file>
|
||||
<filename>${file.filename}</filename>
|
||||
<type>${file.type}</type>
|
||||
</file>
|
||||
`,
|
||||
)
|
||||
.join('')}
|
||||
</files>
|
||||
return prompt;
|
||||
}
|
||||
|
||||
const prompt = `${header}
|
||||
${files}
|
||||
|
||||
A semantic search was executed with the user's message as the query, retrieving the following context inside <context></context> XML tags.
|
||||
|
||||
<context>
|
||||
${context}
|
||||
<context>${context}
|
||||
</context>
|
||||
|
||||
Use the context as your learned knowledge to better answer the user.
|
||||
${footer}`;
|
||||
|
||||
In your response, remember to follow these guidelines:
|
||||
- If you don't know the answer, simply say that you don't know.
|
||||
- If you are unsure how to answer, ask for clarification.
|
||||
- Avoid mentioning that you obtained the information from the context.
|
||||
|
||||
Answer appropriately in the user's language.
|
||||
`;
|
||||
|
||||
return template;
|
||||
return prompt;
|
||||
} catch (error) {
|
||||
console.error('Error creating context:', error);
|
||||
throw error; // Re-throw the error to propagate it to the caller
|
||||
|
|
96
api/server/services/Files/VectorDB/crud.js
Normal file
96
api/server/services/Files/VectorDB/crud.js
Normal file
|
@ -0,0 +1,96 @@
|
|||
const fs = require('fs');
|
||||
const axios = require('axios');
|
||||
const FormData = require('form-data');
|
||||
const { FileSources } = require('librechat-data-provider');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
/**
|
||||
* Deletes a file from the vector database. This function takes a file object, constructs the full path, and
|
||||
* verifies the path's validity before deleting the file. If the path is invalid, an error is thrown.
|
||||
*
|
||||
* @param {Express.Request} req - The request object from Express. It should have an `app.locals.paths` object with
|
||||
* a `publicPath` property.
|
||||
* @param {MongoFile} file - The file object to be deleted. It should have a `filepath` property that is
|
||||
* a string representing the path of the file relative to the publicPath.
|
||||
*
|
||||
* @returns {Promise<void>}
|
||||
* A promise that resolves when the file has been successfully deleted, or throws an error if the
|
||||
* file path is invalid or if there is an error in deletion.
|
||||
*/
|
||||
const deleteVectors = async (req, file) => {
|
||||
if (file.embedded && process.env.RAG_API_URL) {
|
||||
const jwtToken = req.headers.authorization.split(' ')[1];
|
||||
axios.delete(`${process.env.RAG_API_URL}/documents`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwtToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
accept: 'application/json',
|
||||
},
|
||||
data: [file.file_id],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Uploads a file to the configured Vector database
|
||||
*
|
||||
* @param {Object} params - The params object.
|
||||
* @param {Object} params.req - The request object from Express. It should have a `user` property with an `id`
|
||||
* representing the user, and an `app.locals.paths` object with an `uploads` path.
|
||||
* @param {Express.Multer.File} params.file - The file object, which is part of the request. The file object should
|
||||
* have a `path` property that points to the location of the uploaded file.
|
||||
* @param {string} params.file_id - The file ID.
|
||||
*
|
||||
* @returns {Promise<{ filepath: string, bytes: number }>}
|
||||
* A promise that resolves to an object containing:
|
||||
* - filepath: The path where the file is saved.
|
||||
* - bytes: The size of the file in bytes.
|
||||
*/
|
||||
async function uploadVectors({ req, file, file_id }) {
|
||||
if (!process.env.RAG_API_URL) {
|
||||
throw new Error('RAG_API_URL not defined');
|
||||
}
|
||||
|
||||
try {
|
||||
const jwtToken = req.headers.authorization.split(' ')[1];
|
||||
const formData = new FormData();
|
||||
formData.append('file_id', file_id);
|
||||
formData.append('file', fs.createReadStream(file.path));
|
||||
|
||||
const formHeaders = formData.getHeaders(); // Automatically sets the correct Content-Type
|
||||
|
||||
const response = await axios.post(`${process.env.RAG_API_URL}/embed`, formData, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwtToken}`,
|
||||
accept: 'application/json',
|
||||
...formHeaders,
|
||||
},
|
||||
});
|
||||
|
||||
const responseData = response.data;
|
||||
logger.debug('Response from embedding file', responseData);
|
||||
|
||||
if (responseData.known_type === false) {
|
||||
throw new Error(`File embedding failed. The filetype ${file.mimetype} is not supported`);
|
||||
}
|
||||
|
||||
if (!responseData.status) {
|
||||
throw new Error('File embedding failed.');
|
||||
}
|
||||
|
||||
return {
|
||||
bytes: file.size,
|
||||
filename: file.originalname,
|
||||
filepath: FileSources.vectordb,
|
||||
embedded: Boolean(responseData.known_type),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('Error embedding file', error);
|
||||
throw new Error(error.message || 'An error occurred during file upload.');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
deleteVectors,
|
||||
uploadVectors,
|
||||
};
|
5
api/server/services/Files/VectorDB/index.js
Normal file
5
api/server/services/Files/VectorDB/index.js
Normal file
|
@ -0,0 +1,5 @@
|
|||
const crud = require('./crud');
|
||||
|
||||
module.exports = {
|
||||
...crud,
|
||||
};
|
|
@ -1,6 +1,5 @@
|
|||
const path = require('path');
|
||||
const { v4 } = require('uuid');
|
||||
const axios = require('axios');
|
||||
const mime = require('mime/lite');
|
||||
const {
|
||||
isUUID,
|
||||
|
@ -265,50 +264,22 @@ const uploadImageBuffer = async ({ req, context }) => {
|
|||
*/
|
||||
const processFileUpload = async ({ req, res, file, metadata }) => {
|
||||
const isAssistantUpload = metadata.endpoint === EModelEndpoint.assistants;
|
||||
const source = isAssistantUpload ? FileSources.openai : req.app.locals.fileStrategy;
|
||||
const source = isAssistantUpload ? FileSources.openai : FileSources.vectordb;
|
||||
const { handleFileUpload } = getStrategyFunctions(source);
|
||||
const { file_id, temp_file_id } = metadata;
|
||||
|
||||
let embedded = false;
|
||||
if (process.env.RAG_API_URL) {
|
||||
try {
|
||||
const jwtToken = req.headers.authorization.split(' ')[1];
|
||||
const filepath = `./uploads/temp/${file.path.split('uploads/temp/')[1]}`;
|
||||
const response = await axios.post(
|
||||
`${process.env.RAG_API_URL}/embed`,
|
||||
{
|
||||
filename: file.originalname,
|
||||
file_content_type: file.mimetype,
|
||||
filepath,
|
||||
file_id,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${jwtToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (response.status === 200) {
|
||||
embedded = true;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error embedding file', error);
|
||||
throw new Error(error);
|
||||
}
|
||||
} else if (!isAssistantUpload) {
|
||||
logger.error('RAG_API_URL not set, cannot support process file upload');
|
||||
throw new Error('RAG_API_URL not set, cannot support process file upload');
|
||||
}
|
||||
|
||||
/** @type {OpenAI | undefined} */
|
||||
let openai;
|
||||
if (source === FileSources.openai) {
|
||||
({ openai } = await initializeClient({ req }));
|
||||
}
|
||||
|
||||
const { id, bytes, filename, filepath } = await handleFileUpload({ req, file, file_id, openai });
|
||||
const { id, bytes, filename, filepath, embedded } = await handleFileUpload({
|
||||
req,
|
||||
file,
|
||||
file_id,
|
||||
openai,
|
||||
});
|
||||
|
||||
if (isAssistantUpload && !metadata.message_file) {
|
||||
await openai.beta.assistants.files.create(metadata.assistant_id, {
|
||||
|
|
|
@ -5,22 +5,20 @@ const {
|
|||
saveURLToFirebase,
|
||||
deleteFirebaseFile,
|
||||
saveBufferToFirebase,
|
||||
uploadFileToFirebase,
|
||||
uploadImageToFirebase,
|
||||
processFirebaseAvatar,
|
||||
} = require('./Firebase');
|
||||
const {
|
||||
// saveLocalFile,
|
||||
getLocalFileURL,
|
||||
saveFileFromURL,
|
||||
saveLocalBuffer,
|
||||
deleteLocalFile,
|
||||
uploadLocalFile,
|
||||
uploadLocalImage,
|
||||
prepareImagesLocal,
|
||||
processLocalAvatar,
|
||||
} = require('./Local');
|
||||
const { uploadOpenAIFile, deleteOpenAIFile } = require('./OpenAI');
|
||||
const { uploadVectors, deleteVectors } = require('./VectorDB');
|
||||
|
||||
/**
|
||||
* Firebase Storage Strategy Functions
|
||||
|
@ -28,13 +26,14 @@ const { uploadOpenAIFile, deleteOpenAIFile } = require('./OpenAI');
|
|||
* */
|
||||
const firebaseStrategy = () => ({
|
||||
// saveFile:
|
||||
/** @type {typeof uploadVectors | null} */
|
||||
handleFileUpload: null,
|
||||
saveURL: saveURLToFirebase,
|
||||
getFileURL: getFirebaseURL,
|
||||
deleteFile: deleteFirebaseFile,
|
||||
saveBuffer: saveBufferToFirebase,
|
||||
prepareImagePayload: prepareImageURL,
|
||||
processAvatar: processFirebaseAvatar,
|
||||
handleFileUpload: uploadFileToFirebase,
|
||||
handleImageUpload: uploadImageToFirebase,
|
||||
});
|
||||
|
||||
|
@ -43,17 +42,38 @@ const firebaseStrategy = () => ({
|
|||
*
|
||||
* */
|
||||
const localStrategy = () => ({
|
||||
// saveFile: saveLocalFile,
|
||||
/** @type {typeof uploadVectors | null} */
|
||||
handleFileUpload: null,
|
||||
saveURL: saveFileFromURL,
|
||||
getFileURL: getLocalFileURL,
|
||||
saveBuffer: saveLocalBuffer,
|
||||
deleteFile: deleteLocalFile,
|
||||
processAvatar: processLocalAvatar,
|
||||
handleFileUpload: uploadLocalFile,
|
||||
handleImageUpload: uploadLocalImage,
|
||||
prepareImagePayload: prepareImagesLocal,
|
||||
});
|
||||
|
||||
/**
|
||||
* VectorDB Storage Strategy Functions
|
||||
*
|
||||
* */
|
||||
const vectorStrategy = () => ({
|
||||
/** @type {typeof saveFileFromURL | null} */
|
||||
saveURL: null,
|
||||
/** @type {typeof getLocalFileURL | null} */
|
||||
getFileURL: null,
|
||||
/** @type {typeof saveLocalBuffer | null} */
|
||||
saveBuffer: null,
|
||||
/** @type {typeof processLocalAvatar | null} */
|
||||
processAvatar: null,
|
||||
/** @type {typeof uploadLocalImage | null} */
|
||||
handleImageUpload: null,
|
||||
/** @type {typeof prepareImagesLocal | null} */
|
||||
prepareImagePayload: null,
|
||||
handleFileUpload: uploadVectors,
|
||||
deleteFile: deleteVectors,
|
||||
});
|
||||
|
||||
/**
|
||||
* OpenAI Strategy Functions
|
||||
*
|
||||
|
@ -84,6 +104,8 @@ const getStrategyFunctions = (fileSource) => {
|
|||
return localStrategy();
|
||||
} else if (fileSource === FileSources.openai) {
|
||||
return openAIStrategy();
|
||||
} else if (fileSource === FileSources.vectordb) {
|
||||
return vectorStrategy();
|
||||
} else {
|
||||
throw new Error('Invalid file source');
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ 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 { EndpointSettings, SaveAsPresetDialog } from '~/components/Endpoints';
|
||||
import { EndpointSettings, SaveAsPresetDialog, AlternativeSettings } from '~/components/Endpoints';
|
||||
import { ModelSelect } from '~/components/Input/ModelSelect';
|
||||
import { PluginStoreDialog } from '~/components';
|
||||
import OptionsPopover from './OptionsPopover';
|
||||
|
@ -15,7 +15,7 @@ import { Button } from '~/components/ui';
|
|||
import { cn, cardStyle } from '~/utils/';
|
||||
import store from '~/store';
|
||||
|
||||
export default function OptionsBar() {
|
||||
export default function HeaderOptions() {
|
||||
const [saveAsDialogShow, setSaveAsDialogShow] = useState<boolean>(false);
|
||||
const [showPluginStoreDialog, setShowPluginStoreDialog] = useRecoilState(
|
||||
store.showPluginStoreDialog,
|
||||
|
@ -102,6 +102,7 @@ export default function OptionsBar() {
|
|||
setOption={setOption}
|
||||
isMultiChat={true}
|
||||
/>
|
||||
<AlternativeSettings conversation={conversation} setOption={setOption} />
|
||||
</div>
|
||||
</OptionsPopover>
|
||||
<SaveAsPresetDialog
|
||||
|
|
|
@ -1,165 +0,0 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { tPresetUpdateSchema, EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TPreset } from 'librechat-data-provider';
|
||||
import { PluginStoreDialog } from '~/components';
|
||||
import {
|
||||
EndpointSettings,
|
||||
SaveAsPresetDialog,
|
||||
EndpointOptionsPopover,
|
||||
} from '~/components/Endpoints';
|
||||
import { ModelSelect } from '~/components/Input/ModelSelect';
|
||||
import GenerationButtons from './GenerationButtons';
|
||||
import PopoverButtons from './PopoverButtons';
|
||||
import { useSetIndexOptions } from '~/hooks';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { Button } from '~/components/ui';
|
||||
import { cn, cardStyle } from '~/utils/';
|
||||
import store from '~/store';
|
||||
|
||||
export default function OptionsBar({ messagesTree }) {
|
||||
const [opacityClass, setOpacityClass] = useState('full-opacity');
|
||||
const [saveAsDialogShow, setSaveAsDialogShow] = useState<boolean>(false);
|
||||
const [showPluginStoreDialog, setShowPluginStoreDialog] = useRecoilState(
|
||||
store.showPluginStoreDialog,
|
||||
);
|
||||
|
||||
const { showPopover, conversation, latestMessage, setShowPopover, setShowBingToneSetting } =
|
||||
useChatContext();
|
||||
const { setOption } = useSetIndexOptions();
|
||||
|
||||
const { endpoint, conversationId, jailbreak } = conversation ?? {};
|
||||
|
||||
const altConditions: { [key: string]: boolean } = {
|
||||
bingAI: !!(latestMessage && conversation?.jailbreak && endpoint === 'bingAI'),
|
||||
};
|
||||
|
||||
const altSettings: { [key: string]: () => void } = {
|
||||
bingAI: () => setShowBingToneSetting((prev) => !prev),
|
||||
};
|
||||
|
||||
const noSettings = useMemo<{ [key: string]: boolean }>(
|
||||
() => ({
|
||||
[EModelEndpoint.chatGPTBrowser]: true,
|
||||
[EModelEndpoint.bingAI]: jailbreak ? false : conversationId !== 'new',
|
||||
}),
|
||||
[jailbreak, conversationId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (showPopover) {
|
||||
return;
|
||||
} else if (messagesTree && messagesTree.length >= 1) {
|
||||
setOpacityClass('show');
|
||||
} else {
|
||||
setOpacityClass('full-opacity');
|
||||
}
|
||||
}, [messagesTree, showPopover]);
|
||||
|
||||
useEffect(() => {
|
||||
if (endpoint && noSettings[endpoint]) {
|
||||
setShowPopover(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [endpoint, noSettings]);
|
||||
|
||||
const saveAsPreset = () => {
|
||||
setSaveAsDialogShow(true);
|
||||
};
|
||||
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const triggerAdvancedMode = altConditions[endpoint]
|
||||
? altSettings[endpoint]
|
||||
: () => setShowPopover((prev) => !prev);
|
||||
return (
|
||||
<div className="absolute left-0 right-0 mx-auto mb-2 last:mb-2 md:mx-4 md:last:mb-6 lg:mx-auto lg:max-w-2xl xl:max-w-3xl">
|
||||
<GenerationButtons
|
||||
endpoint={endpoint}
|
||||
showPopover={showPopover}
|
||||
opacityClass={opacityClass}
|
||||
/>
|
||||
<span className="flex w-full flex-col items-center justify-center gap-0 md:order-none md:m-auto md:gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'options-bar z-[61] flex w-full flex-wrap items-center justify-center gap-2',
|
||||
showPopover ? '' : opacityClass,
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (showPopover) {
|
||||
return;
|
||||
}
|
||||
setOpacityClass('full-opacity');
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (showPopover) {
|
||||
return;
|
||||
}
|
||||
if (!messagesTree || messagesTree.length === 0) {
|
||||
return;
|
||||
}
|
||||
setOpacityClass('show');
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (showPopover) {
|
||||
return;
|
||||
}
|
||||
setOpacityClass('full-opacity');
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (showPopover) {
|
||||
return;
|
||||
}
|
||||
if (!messagesTree || messagesTree.length === 0) {
|
||||
return;
|
||||
}
|
||||
setOpacityClass('show');
|
||||
}}
|
||||
>
|
||||
<ModelSelect conversation={conversation} setOption={setOption} isMultiChat={true} />
|
||||
{!noSettings[endpoint] && (
|
||||
<Button
|
||||
id="advanced-mode-button"
|
||||
customId="advanced-mode-button"
|
||||
type="button"
|
||||
className={cn(
|
||||
cardStyle,
|
||||
'min-w-4 z-50 flex h-[40px] flex-none items-center justify-center px-3 focus:ring-0 focus:ring-offset-0',
|
||||
)}
|
||||
onClick={triggerAdvancedMode}
|
||||
>
|
||||
<Settings2 id="advanced-settings" className="w-4 text-gray-600 dark:text-white" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<EndpointOptionsPopover
|
||||
visible={showPopover}
|
||||
saveAsPreset={saveAsPreset}
|
||||
closePopover={() => setShowPopover(false)}
|
||||
PopoverButtons={<PopoverButtons />}
|
||||
>
|
||||
<div className="px-4 py-4">
|
||||
<EndpointSettings
|
||||
conversation={conversation}
|
||||
setOption={setOption}
|
||||
isMultiChat={true}
|
||||
/>
|
||||
</div>
|
||||
</EndpointOptionsPopover>
|
||||
<SaveAsPresetDialog
|
||||
open={saveAsDialogShow}
|
||||
onOpenChange={setSaveAsDialogShow}
|
||||
preset={
|
||||
tPresetUpdateSchema.parse({
|
||||
...conversation,
|
||||
}) as TPreset
|
||||
}
|
||||
/>
|
||||
<PluginStoreDialog isOpen={showPluginStoreDialog} setIsOpen={setShowPluginStoreDialog} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -63,7 +63,7 @@ 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 justify-start rounded-md bg-transparent px-2 py-1 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-600"
|
||||
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]" />
|
||||
|
|
|
@ -1,24 +1,33 @@
|
|||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { EModelEndpoint, SettingsViews } from 'librechat-data-provider';
|
||||
import type { ReactNode } from 'react';
|
||||
import { MessagesSquared, GPTIcon } from '~/components/svg';
|
||||
import { MessagesSquared, GPTIcon, AssistantIcon, DataIcon } from '~/components/svg';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { Button } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils/';
|
||||
import store from '~/store';
|
||||
|
||||
type TPopoverButton = {
|
||||
label: string;
|
||||
buttonClass: string;
|
||||
handler: () => void;
|
||||
type?: 'alternative';
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
export default function PopoverButtons({
|
||||
buttonClass,
|
||||
iconClass = '',
|
||||
endpoint: _overrideEndpoint,
|
||||
endpointType: overrideEndpointType,
|
||||
model: overrideModel,
|
||||
}: {
|
||||
buttonClass?: string;
|
||||
iconClass?: string;
|
||||
endpoint?: EModelEndpoint | string;
|
||||
endpointType?: EModelEndpoint | string;
|
||||
model?: string | null;
|
||||
}) {
|
||||
const {
|
||||
conversation,
|
||||
|
@ -28,9 +37,13 @@ export default function PopoverButtons({
|
|||
setShowAgentSettings,
|
||||
} = useChatContext();
|
||||
const localize = useLocalize();
|
||||
const [settingsView, setSettingsView] = useRecoilState(store.currentSettingsView);
|
||||
|
||||
const { model: _model, endpoint: _endpoint, endpointType } = conversation ?? {};
|
||||
const overrideEndpoint = overrideEndpointType ?? _overrideEndpoint;
|
||||
const endpoint = overrideEndpoint ?? endpointType ?? _endpoint;
|
||||
const model = overrideModel ?? _model;
|
||||
|
||||
const { model, endpoint: _endpoint, endpointType } = conversation ?? {};
|
||||
const endpoint = endpointType ?? _endpoint;
|
||||
const isGenerativeModel = model?.toLowerCase()?.includes('gemini');
|
||||
const isChatModel = !isGenerativeModel && model?.toLowerCase()?.includes('chat');
|
||||
const isTextModel = !isGenerativeModel && !isChatModel && /code|text/.test(model ?? '');
|
||||
|
@ -38,10 +51,12 @@ export default function PopoverButtons({
|
|||
const { showExamples } = optionSettings;
|
||||
const showExamplesButton = !isGenerativeModel && !isTextModel && isChatModel;
|
||||
|
||||
const triggerExamples = () =>
|
||||
const triggerExamples = () => {
|
||||
setSettingsView(SettingsViews.default);
|
||||
setOptionSettings((prev) => ({ ...prev, showExamples: !prev.showExamples }));
|
||||
};
|
||||
|
||||
const buttons: { [key: string]: TPopoverButton[] } = {
|
||||
const endpointSpecificbuttons: { [key: string]: TPopoverButton[] } = {
|
||||
[EModelEndpoint.google]: [
|
||||
{
|
||||
label: localize(showExamples ? 'com_hide_examples' : 'com_show_examples'),
|
||||
|
@ -56,14 +71,16 @@ export default function PopoverButtons({
|
|||
showAgentSettings ? 'com_show_completion_settings' : 'com_show_agent_settings',
|
||||
),
|
||||
buttonClass: '',
|
||||
handler: () => setShowAgentSettings((prev) => !prev),
|
||||
handler: () => {
|
||||
setSettingsView(SettingsViews.default);
|
||||
setShowAgentSettings((prev) => !prev);
|
||||
},
|
||||
icon: <GPTIcon className={cn('mr-1 w-[14px]', iconClass)} size={24} />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const endpointButtons = buttons[endpoint ?? ''];
|
||||
if (!endpointButtons) {
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
@ -71,23 +88,71 @@ export default function PopoverButtons({
|
|||
return null;
|
||||
}
|
||||
|
||||
const additionalButtons: { [key: string]: TPopoverButton[] } = {
|
||||
[SettingsViews.default]: [
|
||||
{
|
||||
label: 'Context Settings',
|
||||
buttonClass: '',
|
||||
type: 'alternative',
|
||||
handler: () => setSettingsView(SettingsViews.advanced),
|
||||
icon: <DataIcon className={cn('mr-1 h-6 w-[14px]', iconClass)} />,
|
||||
},
|
||||
],
|
||||
[SettingsViews.advanced]: [
|
||||
{
|
||||
label: 'Model Settings',
|
||||
buttonClass: '',
|
||||
type: 'alternative',
|
||||
handler: () => setSettingsView(SettingsViews.default),
|
||||
icon: <AssistantIcon className={cn('mr-1 h-6 w-[14px]', iconClass)} />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const endpointButtons = endpointSpecificbuttons[endpoint] ?? [];
|
||||
|
||||
const disabled = true;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{endpointButtons.map((button, index) => (
|
||||
<Button
|
||||
key={`button-${index}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
button.buttonClass,
|
||||
'ml-1 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 focus:ring-offset-0 dark:bg-transparent dark:text-white dark:hover:bg-gray-600 dark:hover:text-white dark:focus:outline-none dark:focus:ring-offset-0',
|
||||
buttonClass ?? '',
|
||||
)}
|
||||
onClick={button.handler}
|
||||
>
|
||||
{button.icon}
|
||||
{button.label}
|
||||
</Button>
|
||||
))}
|
||||
<div className="flex w-full justify-between">
|
||||
<div className="flex items-center justify-start">
|
||||
{endpointButtons.map((button, index) => (
|
||||
<Button
|
||||
key={`button-${index}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
button.buttonClass,
|
||||
'border-2 border-gray-300/50 focus:ring-1 focus:ring-green-500/90 dark:border-gray-500/50 dark:focus:ring-green-500',
|
||||
'ml-1 h-full bg-transparent px-2 py-1 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-600 dark:hover:text-white',
|
||||
buttonClass ?? '',
|
||||
)}
|
||||
onClick={button.handler}
|
||||
>
|
||||
{button.icon}
|
||||
{button.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{disabled ? null : (
|
||||
<div className="flex w-[150px] items-center justify-end">
|
||||
{additionalButtons[settingsView].map((button, index) => (
|
||||
<Button
|
||||
key={`button-${index}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
button.buttonClass,
|
||||
'flex justify-center border-2 border-gray-300/50 focus:ring-1 focus:ring-green-500/90 dark:border-gray-500/50 dark:focus:ring-green-500',
|
||||
'h-full w-full bg-transparent px-2 py-1 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-600 dark:hover:text-white',
|
||||
buttonClass ?? '',
|
||||
)}
|
||||
onClick={button.handler}
|
||||
>
|
||||
{button.icon}
|
||||
{button.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -30,7 +30,7 @@ const EditPresetDialog = ({
|
|||
select: mapEndpoints,
|
||||
});
|
||||
|
||||
const { endpoint } = preset || {};
|
||||
const { endpoint, endpointType, model } = preset || {};
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
@ -81,7 +81,7 @@ const EditPresetDialog = ({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-start justify-start gap-4 sm:col-span-1">
|
||||
<div className="col-span-2 flex items-start justify-between gap-4 sm:col-span-4">
|
||||
<div className="flex w-full flex-col">
|
||||
<Label
|
||||
htmlFor="endpoint"
|
||||
|
@ -92,6 +92,9 @@ const EditPresetDialog = ({
|
|||
<PopoverButtons
|
||||
buttonClass="ml-0 w-full border border-gray-100 dark:border-gray-600 dark:bg-gray-700 dark:hover:bg-gray-600 p-2 h-[40px] justify-center mt-0"
|
||||
iconClass="hidden lg:block w-4 "
|
||||
endpoint={endpoint}
|
||||
endpointType={endpointType}
|
||||
model={model}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -5,7 +5,7 @@ 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 FileUpload from '~/components/Input/EndpointMenu/FileUpload';
|
||||
import FileUpload from '~/components/Chat/Input/Files/FileUpload';
|
||||
import { PinIcon, EditIcon, TrashIcon } from '~/components/svg';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import { getPresetTitle, getEndpointField } from '~/utils';
|
||||
|
|
24
client/src/components/Endpoints/AlternativeSettings.tsx
Normal file
24
client/src/components/Endpoints/AlternativeSettings.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { SettingsViews } from 'librechat-data-provider';
|
||||
import type { TSettingsProps } from '~/common';
|
||||
import { Advanced } from './Settings';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
export default function AlternativeSettings({
|
||||
conversation,
|
||||
setOption,
|
||||
isPreset = false,
|
||||
className = '',
|
||||
}: TSettingsProps & { isMultiChat?: boolean }) {
|
||||
const currentSettingsView = useRecoilValue(store.currentSettingsView);
|
||||
if (!conversation?.endpoint || currentSettingsView === SettingsViews.default) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('hide-scrollbar h-[500px] overflow-y-auto md:mb-2 md:h-[350px]', className)}>
|
||||
<Advanced conversation={conversation} setOption={setOption} isPreset={isPreset} />
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,145 +0,0 @@
|
|||
import axios from 'axios';
|
||||
import { useEffect } from 'react';
|
||||
import filenamify from 'filenamify';
|
||||
import exportFromJSON from 'export-from-json';
|
||||
import { useSetRecoilState, useRecoilState } from 'recoil';
|
||||
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
|
||||
import type { TEditPresetProps } from '~/common';
|
||||
import { useSetOptions, useLocalize } from '~/hooks';
|
||||
import { Input, Label, Dropdown, Dialog, DialogClose, DialogButton } from '~/components/';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import PopoverButtons from './PopoverButtons';
|
||||
import EndpointSettings from './EndpointSettings';
|
||||
import { cn, defaultTextProps, removeFocusOutlines, cleanupPreset, mapEndpoints } from '~/utils/';
|
||||
import store from '~/store';
|
||||
|
||||
const EditPresetDialog = ({ open, onOpenChange, preset: _preset, title }: TEditPresetProps) => {
|
||||
const [preset, setPreset] = useRecoilState(store.preset);
|
||||
const setPresets = useSetRecoilState(store.presets);
|
||||
const { data: availableEndpoints = [] } = useGetEndpointsQuery({
|
||||
select: mapEndpoints,
|
||||
});
|
||||
const { setOption } = useSetOptions(_preset);
|
||||
const localize = useLocalize();
|
||||
|
||||
const submitPreset = () => {
|
||||
if (!preset) {
|
||||
return;
|
||||
}
|
||||
axios({
|
||||
method: 'post',
|
||||
url: '/api/presets',
|
||||
data: cleanupPreset({ preset }),
|
||||
withCredentials: true,
|
||||
}).then((res) => {
|
||||
setPresets(res?.data);
|
||||
});
|
||||
};
|
||||
|
||||
const exportPreset = () => {
|
||||
if (!preset) {
|
||||
return;
|
||||
}
|
||||
const fileName = filenamify(preset?.title || 'preset');
|
||||
exportFromJSON({
|
||||
data: cleanupPreset({ preset }),
|
||||
fileName,
|
||||
exportType: exportFromJSON.types.json,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPreset(_preset);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const { endpoint } = preset || {};
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTemplate
|
||||
title={`${title || localize('com_ui_edit') + ' ' + localize('com_endpoint_preset')} - ${
|
||||
preset?.title
|
||||
}`}
|
||||
className="h-full max-w-full overflow-y-auto pb-4 sm:w-[680px] sm:pb-0 md:h-[720px] md:w-[750px] md:overflow-y-hidden lg:w-[950px] xl:h-[720px]"
|
||||
main={
|
||||
<div className="flex w-full flex-col items-center gap-2 md:h-[530px]">
|
||||
<div className="grid w-full grid-cols-5 gap-6">
|
||||
<div className="col-span-4 flex items-start justify-start gap-4">
|
||||
<div className="flex w-full flex-col">
|
||||
<Label htmlFor="preset-name" className="mb-1 text-left text-sm font-medium">
|
||||
{localize('com_endpoint_preset_name')}
|
||||
</Label>
|
||||
<Input
|
||||
id="preset-name"
|
||||
value={preset?.title || ''}
|
||||
onChange={(e) => setOption('title')(e.target.value || '')}
|
||||
placeholder={localize('com_endpoint_set_custom_name')}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
'flex h-10 max-h-10 w-full resize-none px-3 py-2',
|
||||
removeFocusOutlines,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-col">
|
||||
<Label htmlFor="endpoint" className="mb-1 text-left text-sm font-medium">
|
||||
{localize('com_endpoint')}
|
||||
</Label>
|
||||
<Dropdown
|
||||
value={endpoint || ''}
|
||||
onChange={(value) => setOption('endpoint')(value)}
|
||||
options={availableEndpoints}
|
||||
className={cn()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 flex items-start justify-start gap-4 sm:col-span-1">
|
||||
<div className="flex w-full flex-col">
|
||||
<Label
|
||||
htmlFor="endpoint"
|
||||
className="mb-1 hidden text-left text-sm font-medium sm:block"
|
||||
>
|
||||
{'ㅤ'}
|
||||
</Label>
|
||||
<PopoverButtons
|
||||
endpoint={endpoint}
|
||||
buttonClass="ml-0 w-full dark:bg-gray-700 dark:hover:bg-gray-800 p-2 h-[40px] justify-center mt-0"
|
||||
iconClass="hidden lg:block w-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="my-4 w-full border-t border-gray-300 dark:border-gray-500" />
|
||||
<div className="w-full p-0">
|
||||
<EndpointSettings
|
||||
conversation={preset}
|
||||
setOption={setOption}
|
||||
isPreset={true}
|
||||
className="h-full md:mb-4 md:h-[440px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
buttons={
|
||||
<div className="mb-6 md:mb-2">
|
||||
<DialogButton onClick={exportPreset} className="dark:hover:gray-400 border-gray-700">
|
||||
{localize('com_endpoint_export')}
|
||||
</DialogButton>
|
||||
<DialogClose
|
||||
onClick={submitPreset}
|
||||
className="dark:hover:gray-400 ml-2 border-gray-700 bg-green-600 text-white hover:bg-green-700 dark:hover:bg-green-800"
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</DialogClose>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditPresetDialog;
|
|
@ -1,113 +0,0 @@
|
|||
import exportFromJSON from 'export-from-json';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { tPresetSchema } from 'librechat-data-provider';
|
||||
import type { TSetOption, TEditPresetProps } from '~/common';
|
||||
import { Dialog, DialogButton } from '~/components/ui';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import SaveAsPresetDialog from './SaveAsPresetDialog';
|
||||
import EndpointSettings from './EndpointSettings';
|
||||
import PopoverButtons from './PopoverButtons';
|
||||
import { cleanupPreset } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
// A preset dialog to show readonly preset values.
|
||||
const EndpointOptionsDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
preset: _preset,
|
||||
title,
|
||||
}: TEditPresetProps) => {
|
||||
const [preset, setPreset] = useRecoilState(store.preset);
|
||||
const [saveAsDialogShow, setSaveAsDialogShow] = useState(false);
|
||||
const localize = useLocalize();
|
||||
|
||||
const setOption: TSetOption = (param) => (newValue) => {
|
||||
const update = {};
|
||||
update[param] = newValue;
|
||||
setPreset((prevState) =>
|
||||
tPresetSchema.parse({
|
||||
...prevState,
|
||||
...update,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const saveAsPreset = () => {
|
||||
setSaveAsDialogShow(true);
|
||||
};
|
||||
|
||||
const exportPreset = () => {
|
||||
if (!preset) {
|
||||
return;
|
||||
}
|
||||
exportFromJSON({
|
||||
data: cleanupPreset({ preset }),
|
||||
fileName: `${preset?.title}.json`,
|
||||
exportType: exportFromJSON.types.json,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setPreset(_preset);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const { endpoint } = preset ?? {};
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!preset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTemplate
|
||||
title={`${title || localize('com_endpoint_save_convo_as_preset')}`}
|
||||
className="h-full max-w-full overflow-y-auto pb-4 sm:w-[680px] sm:pb-0 md:h-[680px] md:w-[750px] md:overflow-y-hidden lg:w-[950px]"
|
||||
// headerClassName="sm:p-2 h-16"
|
||||
main={
|
||||
<div className="flex w-full flex-col items-center gap-2 md:h-[530px]">
|
||||
<div className="w-full p-0">
|
||||
<PopoverButtons
|
||||
endpoint={endpoint}
|
||||
buttonClass="ml-0 mb-4 col-span-2 dark:bg-gray-700 dark:hover:bg-gray-800 p-2"
|
||||
/>
|
||||
<EndpointSettings
|
||||
conversation={preset}
|
||||
setOption={setOption}
|
||||
isPreset={true}
|
||||
className="h-full md:mb-0 md:h-[490px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
buttons={
|
||||
<div className="mb-6 md:mb-2">
|
||||
<DialogButton onClick={exportPreset} className="dark:hover:gray-400 border-gray-700">
|
||||
{localize('com_endpoint_export')}
|
||||
</DialogButton>
|
||||
<DialogButton
|
||||
onClick={saveAsPreset}
|
||||
className="dark:hover:gray-400 ml-2 border-gray-700 bg-green-600 text-white hover:bg-green-700 dark:hover:bg-green-800"
|
||||
>
|
||||
{localize('com_endpoint_save_as_preset')}
|
||||
</DialogButton>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
<SaveAsPresetDialog
|
||||
open={saveAsDialogShow}
|
||||
onOpenChange={setSaveAsDialogShow}
|
||||
preset={preset}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EndpointOptionsDialog;
|
|
@ -1,4 +1,5 @@
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { SettingsViews } from 'librechat-data-provider';
|
||||
import type { TSettingsProps } from '~/common';
|
||||
import { getSettings } from './Settings';
|
||||
import { cn } from '~/utils';
|
||||
|
@ -12,7 +13,8 @@ export default function Settings({
|
|||
isMultiChat = false,
|
||||
}: TSettingsProps & { isMultiChat?: boolean }) {
|
||||
const modelsConfig = useRecoilValue(store.modelsConfig);
|
||||
if (!conversation?.endpoint) {
|
||||
const currentSettingsView = useRecoilValue(store.currentSettingsView);
|
||||
if (!conversation?.endpoint || currentSettingsView !== SettingsViews.default) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,80 +0,0 @@
|
|||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { MessagesSquared, GPTIcon } from '~/components/svg';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { Button } from '~/components';
|
||||
import { cn } from '~/utils/';
|
||||
import store from '~/store';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
type TPopoverButton = {
|
||||
label: string;
|
||||
buttonClass: string;
|
||||
handler: () => void;
|
||||
icon: React.ReactNode;
|
||||
};
|
||||
|
||||
export default function PopoverButtons({
|
||||
endpoint,
|
||||
buttonClass,
|
||||
iconClass = '',
|
||||
}: {
|
||||
endpoint: EModelEndpoint | string;
|
||||
buttonClass?: string;
|
||||
iconClass?: string;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const [optionSettings, setOptionSettings] = useRecoilState(store.optionSettings);
|
||||
const [showAgentSettings, setShowAgentSettings] = useRecoilState(store.showAgentSettings);
|
||||
const { showExamples, isCodeChat } = optionSettings;
|
||||
const triggerExamples = () =>
|
||||
setOptionSettings((prev) => ({ ...prev, showExamples: !prev.showExamples }));
|
||||
|
||||
const buttons: { [key: string]: TPopoverButton[] } = {
|
||||
google: [
|
||||
{
|
||||
label:
|
||||
(showExamples ? localize('com_endpoint_hide') : localize('com_endpoint_show')) +
|
||||
localize('com_endpoint_examples'),
|
||||
buttonClass: isCodeChat ? 'disabled' : '',
|
||||
handler: triggerExamples,
|
||||
icon: <MessagesSquared className={cn('mr-1 w-[14px]', iconClass)} />,
|
||||
},
|
||||
],
|
||||
gptPlugins: [
|
||||
{
|
||||
label: localize(
|
||||
'com_endpoint_show_what_settings',
|
||||
showAgentSettings ? localize('com_endpoint_completion') : localize('com_endpoint_agent'),
|
||||
),
|
||||
buttonClass: '',
|
||||
handler: () => setShowAgentSettings((prev) => !prev),
|
||||
icon: <GPTIcon className={cn('mr-1 w-[14px]', iconClass)} size={24} />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const endpointButtons = buttons[endpoint];
|
||||
if (!endpointButtons) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{endpointButtons.map((button, index) => (
|
||||
<Button
|
||||
key={`${endpoint}-button-${index}`}
|
||||
type="button"
|
||||
className={cn(
|
||||
button.buttonClass,
|
||||
'ml-1 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 focus:ring-offset-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',
|
||||
buttonClass ?? '',
|
||||
)}
|
||||
onClick={button.handler}
|
||||
>
|
||||
{button.icon}
|
||||
{button.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
333
client/src/components/Endpoints/Settings/Advanced.tsx
Normal file
333
client/src/components/Endpoints/Settings/Advanced.tsx
Normal file
|
@ -0,0 +1,333 @@
|
|||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { ImageDetail, imageDetailNumeric, imageDetailValue } from 'librechat-data-provider';
|
||||
import {
|
||||
Input,
|
||||
Label,
|
||||
Switch,
|
||||
Slider,
|
||||
HoverCard,
|
||||
InputNumber,
|
||||
HoverCardTrigger,
|
||||
} from '~/components/ui';
|
||||
import { cn, defaultTextProps, optionText, removeFocusOutlines } from '~/utils/';
|
||||
import { useLocalize, useDebouncedInput } from '~/hooks';
|
||||
import type { TModelSelectProps } from '~/common';
|
||||
import OptionHover from './OptionHover';
|
||||
import { ESide } from '~/common';
|
||||
|
||||
export default function Settings({
|
||||
conversation,
|
||||
setOption,
|
||||
readonly,
|
||||
}: Omit<TModelSelectProps, 'models'>) {
|
||||
const localize = useLocalize();
|
||||
const {
|
||||
endpoint,
|
||||
endpointType,
|
||||
chatGptLabel,
|
||||
promptPrefix,
|
||||
temperature,
|
||||
top_p: topP,
|
||||
frequency_penalty: freqP,
|
||||
presence_penalty: presP,
|
||||
resendFiles,
|
||||
imageDetail,
|
||||
} = conversation ?? {};
|
||||
const [setChatGptLabel, chatGptLabelValue] = useDebouncedInput({
|
||||
setOption,
|
||||
optionKey: 'chatGptLabel',
|
||||
initialValue: chatGptLabel,
|
||||
});
|
||||
const [setPromptPrefix, promptPrefixValue] = useDebouncedInput({
|
||||
setOption,
|
||||
optionKey: 'promptPrefix',
|
||||
initialValue: promptPrefix,
|
||||
});
|
||||
const [setTemperature, temperatureValue] = useDebouncedInput({
|
||||
setOption,
|
||||
optionKey: 'temperature',
|
||||
initialValue: temperature,
|
||||
});
|
||||
const [setTopP, topPValue] = useDebouncedInput({
|
||||
setOption,
|
||||
optionKey: 'top_p',
|
||||
initialValue: topP,
|
||||
});
|
||||
const [setFreqP, freqPValue] = useDebouncedInput({
|
||||
setOption,
|
||||
optionKey: 'frequency_penalty',
|
||||
initialValue: freqP,
|
||||
});
|
||||
const [setPresP, presPValue] = useDebouncedInput({
|
||||
setOption,
|
||||
optionKey: 'presence_penalty',
|
||||
initialValue: presP,
|
||||
});
|
||||
|
||||
if (!conversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const setResendFiles = setOption('resendFiles');
|
||||
const setImageDetail = setOption('imageDetail');
|
||||
|
||||
const optionEndpoint = endpointType ?? endpoint;
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-5 gap-6">
|
||||
<div className="col-span-5 flex flex-col items-center justify-start gap-6 sm:col-span-3">
|
||||
<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_blank')})</small>
|
||||
</Label>
|
||||
<Input
|
||||
id="chatGptLabel"
|
||||
disabled={readonly}
|
||||
value={(chatGptLabelValue as string) || ''}
|
||||
onChange={setChatGptLabel}
|
||||
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',
|
||||
removeFocusOutlines,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<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_blank')})</small>
|
||||
</Label>
|
||||
<TextareaAutosize
|
||||
id="promptPrefix"
|
||||
disabled={readonly}
|
||||
value={(promptPrefixValue as string) || ''}
|
||||
onChange={setPromptPrefix}
|
||||
placeholder={localize('com_endpoint_openai_prompt_prefix_placeholder')}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
'flex max-h-[138px] min-h-[100px] w-full resize-none px-3 py-2 ',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-5 flex flex-col items-center justify-start gap-6 px-3 sm:col-span-2">
|
||||
<HoverCard openDelay={300}>
|
||||
<HoverCardTrigger className="grid w-full items-center gap-2">
|
||||
<div className="flex justify-between">
|
||||
<Label htmlFor="temp-int" className="text-left text-sm font-medium">
|
||||
{localize('com_endpoint_temperature')}{' '}
|
||||
<small className="opacity-40">
|
||||
({localize('com_endpoint_default_with_num', '1')})
|
||||
</small>
|
||||
</Label>
|
||||
<InputNumber
|
||||
id="temp-int"
|
||||
disabled={readonly}
|
||||
value={temperatureValue as number}
|
||||
onChange={setTemperature}
|
||||
max={2}
|
||||
min={0}
|
||||
step={0.01}
|
||||
controls={false}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
cn(
|
||||
optionText,
|
||||
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200',
|
||||
),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
disabled={readonly}
|
||||
value={[(temperatureValue as number) ?? 1]}
|
||||
onValueChange={(value) => setTemperature(value[0])}
|
||||
doubleClickHandler={() => setTemperature(1)}
|
||||
max={2}
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={optionEndpoint ?? ''} type="temp" side={ESide.Left} />
|
||||
</HoverCard>
|
||||
<HoverCard openDelay={300}>
|
||||
<HoverCardTrigger className="grid w-full items-center gap-2">
|
||||
<div className="flex justify-between">
|
||||
<Label htmlFor="top-p-int" className="text-left text-sm font-medium">
|
||||
{localize('com_endpoint_top_p')}{' '}
|
||||
<small className="opacity-40">({localize('com_endpoint_default')}: 1)</small>
|
||||
</Label>
|
||||
<InputNumber
|
||||
id="top-p-int"
|
||||
disabled={readonly}
|
||||
value={topPValue as number}
|
||||
onChange={(value) => setTopP(Number(value))}
|
||||
max={1}
|
||||
min={0}
|
||||
step={0.01}
|
||||
controls={false}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
cn(
|
||||
optionText,
|
||||
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200',
|
||||
),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
disabled={readonly}
|
||||
value={[(topPValue as number) ?? 1]}
|
||||
onValueChange={(value) => setTopP(value[0])}
|
||||
doubleClickHandler={() => setTopP(1)}
|
||||
max={1}
|
||||
min={0}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={optionEndpoint ?? ''} type="topp" side={ESide.Left} />
|
||||
</HoverCard>
|
||||
|
||||
<HoverCard openDelay={300}>
|
||||
<HoverCardTrigger className="grid w-full items-center gap-2">
|
||||
<div className="flex justify-between">
|
||||
<Label htmlFor="freq-penalty-int" className="text-left text-sm font-medium">
|
||||
{localize('com_endpoint_frequency_penalty')}{' '}
|
||||
<small className="opacity-40">({localize('com_endpoint_default')}: 0)</small>
|
||||
</Label>
|
||||
<InputNumber
|
||||
id="freq-penalty-int"
|
||||
disabled={readonly}
|
||||
value={freqPValue as number}
|
||||
onChange={(value) => setFreqP(Number(value))}
|
||||
max={2}
|
||||
min={-2}
|
||||
step={0.01}
|
||||
controls={false}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
cn(
|
||||
optionText,
|
||||
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200',
|
||||
),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
disabled={readonly}
|
||||
value={[(freqPValue as number) ?? 0]}
|
||||
onValueChange={(value) => setFreqP(value[0])}
|
||||
doubleClickHandler={() => setFreqP(0)}
|
||||
max={2}
|
||||
min={-2}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={optionEndpoint ?? ''} type="freq" side={ESide.Left} />
|
||||
</HoverCard>
|
||||
|
||||
<HoverCard openDelay={300}>
|
||||
<HoverCardTrigger className="grid w-full items-center gap-2">
|
||||
<div className="flex justify-between">
|
||||
<Label htmlFor="pres-penalty-int" className="text-left text-sm font-medium">
|
||||
{localize('com_endpoint_presence_penalty')}{' '}
|
||||
<small className="opacity-40">({localize('com_endpoint_default')}: 0)</small>
|
||||
</Label>
|
||||
<InputNumber
|
||||
id="pres-penalty-int"
|
||||
disabled={readonly}
|
||||
value={presPValue as number}
|
||||
onChange={(value) => setPresP(Number(value))}
|
||||
max={2}
|
||||
min={-2}
|
||||
step={0.01}
|
||||
controls={false}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
cn(
|
||||
optionText,
|
||||
'reset-rc-number-input reset-rc-number-input-text-right h-auto w-12 border-0 group-hover/temp:border-gray-200',
|
||||
),
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<Slider
|
||||
disabled={readonly}
|
||||
value={[(presPValue as number) ?? 0]}
|
||||
onValueChange={(value) => setPresP(value[0])}
|
||||
doubleClickHandler={() => setPresP(0)}
|
||||
max={2}
|
||||
min={-2}
|
||||
step={0.01}
|
||||
className="flex h-4 w-full"
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<OptionHover endpoint={optionEndpoint ?? ''} type="pres" side={ESide.Left} />
|
||||
</HoverCard>
|
||||
<div className="w-full">
|
||||
<div className="mb-2 flex w-full justify-between gap-2">
|
||||
<label
|
||||
htmlFor="resend-files"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
|
||||
>
|
||||
<small>{localize('com_endpoint_plug_resend_files')}</small>
|
||||
</label>
|
||||
<label
|
||||
htmlFor="image-detail-value"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 dark:text-gray-50"
|
||||
>
|
||||
<small>{localize('com_endpoint_plug_image_detail')}</small>
|
||||
</label>
|
||||
<Input
|
||||
id="image-detail-value"
|
||||
disabled={true}
|
||||
value={imageDetail ?? ImageDetail.auto}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
optionText,
|
||||
'flex rounded-md bg-transparent py-2 text-xs focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 dark:border-gray-700',
|
||||
'pointer-events-none max-h-5 w-12 border-0 group-hover/temp:border-gray-200',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full justify-between gap-2">
|
||||
<HoverCard openDelay={500}>
|
||||
<HoverCardTrigger>
|
||||
<Switch
|
||||
id="resend-files"
|
||||
checked={resendFiles ?? true}
|
||||
onCheckedChange={(checked: boolean) => setResendFiles(checked)}
|
||||
disabled={readonly}
|
||||
className="flex"
|
||||
/>
|
||||
<OptionHover endpoint={optionEndpoint ?? ''} type="resend" side={ESide.Bottom} />
|
||||
</HoverCardTrigger>
|
||||
</HoverCard>
|
||||
<HoverCard openDelay={500}>
|
||||
<HoverCardTrigger className="flex w-[52%] md:w-[125px]">
|
||||
<Slider
|
||||
id="image-detail-slider"
|
||||
disabled={readonly}
|
||||
value={[
|
||||
imageDetailNumeric[imageDetail ?? ''] ?? imageDetailNumeric[ImageDetail.auto],
|
||||
]}
|
||||
onValueChange={(value) => setImageDetail(imageDetailValue[value[0]])}
|
||||
doubleClickHandler={() => setImageDetail(ImageDetail.auto)}
|
||||
max={2}
|
||||
min={0}
|
||||
step={1}
|
||||
/>
|
||||
<OptionHover endpoint={optionEndpoint ?? ''} type="detail" side={ESide.Bottom} />
|
||||
</HoverCardTrigger>
|
||||
</HoverCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,3 +1,4 @@
|
|||
export { default as Advanced } from './Advanced';
|
||||
export { default as AssistantsSettings } from './Assistants';
|
||||
export { default as OpenAISettings } from './OpenAI';
|
||||
export { default as BingAISettings } from './BingAI';
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
export { default as Icon } from './Icon';
|
||||
export { default as MinimalIcon } from './MinimalIcon';
|
||||
export { default as PopoverButtons } from './PopoverButtons';
|
||||
export { default as EndpointSettings } from './EndpointSettings';
|
||||
export { default as SaveAsPresetDialog } from './SaveAsPresetDialog';
|
||||
export { default as EndpointOptionsDialog } from './EndpointOptionsDialog';
|
||||
export { default as AlternativeSettings } from './AlternativeSettings';
|
||||
export { default as EndpointOptionsPopover } from './EndpointOptionsPopover';
|
||||
|
|
|
@ -1,80 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { alternateName } from 'librechat-data-provider';
|
||||
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
|
||||
import { DropdownMenuRadioItem } from '~/components';
|
||||
import { SetKeyDialog } from '../SetKeyDialog';
|
||||
import { cn, getEndpointField } from '~/utils';
|
||||
import { Icon } from '~/components/Endpoints';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
export default function ModelItem({
|
||||
endpoint,
|
||||
value,
|
||||
isSelected,
|
||||
}: {
|
||||
endpoint: string;
|
||||
value: string;
|
||||
isSelected: boolean;
|
||||
}) {
|
||||
const [isDialogOpen, setDialogOpen] = useState(false);
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
|
||||
const icon = Icon({
|
||||
size: 20,
|
||||
endpoint,
|
||||
error: false,
|
||||
className: 'mr-2',
|
||||
message: false,
|
||||
isCreatedByUser: false,
|
||||
});
|
||||
|
||||
const userProvidesKey: boolean | null | undefined = getEndpointField(
|
||||
endpointsConfig,
|
||||
endpoint,
|
||||
'userProvide',
|
||||
);
|
||||
const localize = useLocalize();
|
||||
|
||||
// regular model
|
||||
return (
|
||||
<>
|
||||
<DropdownMenuRadioItem
|
||||
value={value}
|
||||
className={cn(
|
||||
'group dark:font-semibold dark:text-gray-200 dark:hover:bg-gray-800',
|
||||
isSelected ? 'active bg-gray-50 dark:bg-gray-800' : '',
|
||||
)}
|
||||
id={endpoint}
|
||||
data-testid={`endpoint-item-${endpoint}`}
|
||||
>
|
||||
{icon}
|
||||
{alternateName[endpoint] || endpoint}
|
||||
{endpoint === 'gptPlugins' && (
|
||||
<span className="py-0.25 ml-1 rounded bg-blue-200 px-1 text-[10px] font-semibold text-[#4559A4]">
|
||||
Beta
|
||||
</span>
|
||||
)}
|
||||
<div className="flex w-4 flex-1" />
|
||||
{userProvidesKey ? (
|
||||
<button
|
||||
className={cn(
|
||||
'invisible m-0 mr-1 flex-initial rounded-md p-0 text-xs font-medium text-gray-400 hover:text-gray-700 group-hover:visible dark:font-normal dark:text-gray-400 dark:hover:text-gray-200',
|
||||
isSelected ? 'visible text-gray-700 dark:text-gray-200' : '',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Settings className="mr-1 inline-block w-[16px] items-center stroke-1" />
|
||||
{localize('com_endpoint_config_key')}
|
||||
</button>
|
||||
) : null}
|
||||
</DropdownMenuRadioItem>
|
||||
{userProvidesKey && (
|
||||
<SetKeyDialog open={isDialogOpen} onOpenChange={setDialogOpen} endpoint={endpoint} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
import EndpointItem from './EndpointItem';
|
||||
|
||||
interface EndpointItemsProps {
|
||||
endpoints: string[];
|
||||
onSelect: (endpoint: string) => void;
|
||||
selectedEndpoint: string;
|
||||
}
|
||||
|
||||
export default function EndpointItems({ endpoints, selectedEndpoint }: EndpointItemsProps) {
|
||||
return (
|
||||
<>
|
||||
{endpoints.map((endpoint) => (
|
||||
<EndpointItem
|
||||
isSelected={selectedEndpoint === endpoint}
|
||||
key={endpoint}
|
||||
value={endpoint}
|
||||
endpoint={endpoint}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,275 +0,0 @@
|
|||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { useRecoilState } from 'recoil';
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
useDeletePresetMutation,
|
||||
useCreatePresetMutation,
|
||||
useGetEndpointsQuery,
|
||||
} from 'librechat-data-provider/react-query';
|
||||
import { Icon } from '~/components/Endpoints';
|
||||
import EndpointItems from './EndpointItems';
|
||||
import PresetItems from './PresetItems';
|
||||
import FileUpload from './FileUpload';
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
TooltipProvider,
|
||||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from '~/components/ui/';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import { cn, cleanupPreset, mapEndpoints } from '~/utils';
|
||||
import { useLocalize, useLocalStorage, useConversation, useDefaultConvo } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
export default function NewConversationMenu() {
|
||||
const localize = useLocalize();
|
||||
const getDefaultConversation = useDefaultConvo();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [showPresets, setShowPresets] = useState(true);
|
||||
const [showEndpoints, setShowEndpoints] = useState(true);
|
||||
const [conversation, setConversation] = useRecoilState(store.conversation) ?? {};
|
||||
const [messages, setMessages] = useRecoilState(store.messages);
|
||||
|
||||
const { data: availableEndpoints = [] } = useGetEndpointsQuery({
|
||||
select: mapEndpoints,
|
||||
});
|
||||
|
||||
const [presets, setPresets] = useRecoilState(store.presets);
|
||||
const modularEndpoints = new Set(['gptPlugins', 'anthropic', 'google', 'openAI']);
|
||||
|
||||
const { endpoint } = conversation;
|
||||
const { newConversation } = useConversation();
|
||||
|
||||
const deletePresetsMutation = useDeletePresetMutation();
|
||||
const createPresetMutation = useCreatePresetMutation();
|
||||
|
||||
const importPreset = (jsonData) => {
|
||||
createPresetMutation.mutate(
|
||||
{ ...jsonData },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
setPresets(data);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error uploading the preset:', error);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const onFileSelected = (jsonData) => {
|
||||
const jsonPreset = { ...cleanupPreset({ preset: jsonData }), presetId: null };
|
||||
importPreset(jsonPreset);
|
||||
};
|
||||
|
||||
// save states to localStorage
|
||||
const [newUser, setNewUser] = useLocalStorage('newUser', true);
|
||||
const [lastModel, setLastModel] = useLocalStorage('lastSelectedModel', {});
|
||||
const setLastConvo = useLocalStorage('lastConversationSetup', {})[1];
|
||||
const [lastBingSettings, setLastBingSettings] = useLocalStorage('lastBingSettings', {});
|
||||
useEffect(() => {
|
||||
if (endpoint && endpoint !== 'bingAI') {
|
||||
const lastModelUpdate = { ...lastModel, [endpoint]: conversation?.model };
|
||||
if (endpoint === 'gptPlugins') {
|
||||
lastModelUpdate.secondaryModel = conversation.agentOptions.model;
|
||||
}
|
||||
setLastModel(lastModelUpdate);
|
||||
} else if (endpoint === 'bingAI') {
|
||||
const { jailbreak, toneStyle } = conversation;
|
||||
setLastBingSettings({ ...lastBingSettings, jailbreak, toneStyle });
|
||||
}
|
||||
|
||||
setLastConvo(conversation);
|
||||
}, [conversation]);
|
||||
|
||||
// set the current model
|
||||
const onSelectEndpoint = (newEndpoint) => {
|
||||
setMenuOpen(false);
|
||||
if (!newEndpoint) {
|
||||
return;
|
||||
} else {
|
||||
newConversation(null, { endpoint: newEndpoint });
|
||||
}
|
||||
};
|
||||
|
||||
// set the current model
|
||||
const isModular = modularEndpoints.has(endpoint);
|
||||
const onSelectPreset = (newPreset) => {
|
||||
setMenuOpen(false);
|
||||
if (!newPreset) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
isModular &&
|
||||
modularEndpoints.has(newPreset?.endpoint) &&
|
||||
endpoint === newPreset?.endpoint
|
||||
) {
|
||||
const currentConvo = getDefaultConversation({
|
||||
conversation,
|
||||
preset: newPreset,
|
||||
});
|
||||
|
||||
setConversation(currentConvo);
|
||||
setMessages(messages);
|
||||
return;
|
||||
}
|
||||
|
||||
newConversation({}, newPreset);
|
||||
};
|
||||
|
||||
const clearAllPresets = () => {
|
||||
deletePresetsMutation.mutate({ arg: {} });
|
||||
};
|
||||
|
||||
const onDeletePreset = (preset) => {
|
||||
deletePresetsMutation.mutate({ arg: preset });
|
||||
};
|
||||
|
||||
const icon = Icon({
|
||||
size: 32,
|
||||
...conversation,
|
||||
error: false,
|
||||
button: true,
|
||||
});
|
||||
|
||||
const onOpenChange = (open) => {
|
||||
setMenuOpen(open);
|
||||
if (newUser) {
|
||||
setNewUser(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<Dialog className="z-[100]">
|
||||
<DropdownMenu open={menuOpen} onOpenChange={onOpenChange}>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
id="new-conversation-menu"
|
||||
data-testid="new-conversation-menu"
|
||||
variant="outline"
|
||||
className={
|
||||
'group relative mb-[-12px] ml-1 mt-[-8px] items-center rounded-md border-0 p-1 outline-none focus:ring-0 focus:ring-offset-0 dark:data-[state=open]:bg-opacity-50 md:left-1 md:ml-0 md:ml-[-12px] md:pl-1'
|
||||
}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent forceMount={newUser} sideOffset={5}>
|
||||
{localize('com_endpoint_open_menu')}
|
||||
</TooltipContent>
|
||||
<DropdownMenuContent
|
||||
className="z-[100] w-[375px] dark:bg-gray-800 md:w-96"
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
side="top"
|
||||
>
|
||||
<DropdownMenuLabel
|
||||
className="cursor-pointer dark:text-gray-300"
|
||||
onClick={() => setShowEndpoints((prev) => !prev)}
|
||||
>
|
||||
{showEndpoints ? localize('com_endpoint_hide') : localize('com_endpoint_show')}{' '}
|
||||
{localize('com_endpoint')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
value={endpoint}
|
||||
onValueChange={onSelectEndpoint}
|
||||
className="flex flex-col gap-1 overflow-y-auto"
|
||||
>
|
||||
{showEndpoints &&
|
||||
(availableEndpoints.length ? (
|
||||
<EndpointItems
|
||||
selectedEndpoint={endpoint}
|
||||
endpoints={availableEndpoints}
|
||||
onSelect={onSelectEndpoint}
|
||||
/>
|
||||
) : (
|
||||
<DropdownMenuLabel className="dark:text-gray-300">
|
||||
{localize('com_endpoint_not_available')}
|
||||
</DropdownMenuLabel>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
|
||||
<div className="mt-2 w-full" />
|
||||
|
||||
<DropdownMenuLabel className="flex items-center dark:text-gray-300">
|
||||
<span
|
||||
className="mr-auto cursor-pointer "
|
||||
onClick={() => setShowPresets((prev) => !prev)}
|
||||
>
|
||||
{showPresets ? localize('com_endpoint_hide') : localize('com_endpoint_show')}{' '}
|
||||
{localize('com_endpoint_presets')}
|
||||
</span>
|
||||
<FileUpload onFileSelected={onFileSelected} />
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<label
|
||||
htmlFor="file-upload"
|
||||
className="mr-1 flex h-[32px] h-auto cursor-pointer items-center rounded bg-transparent px-2 py-1 text-xs font-medium font-normal text-gray-600 transition-colors hover:bg-gray-100 hover:text-red-700 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-green-500"
|
||||
>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="mr-1 flex w-[22px] items-center"
|
||||
>
|
||||
<path d="M9.293 0H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V4.707A1 1 0 0 0 13.707 4L10 .293A1 1 0 0 0 9.293 0M9.5 3.5v-2l3 3h-2a1 1 0 0 1-1-1M6.854 7.146 8 8.293l1.146-1.147a.5.5 0 1 1 .708.708L8.707 9l1.147 1.146a.5.5 0 0 1-.708.708L8 9.707l-1.146 1.147a.5.5 0 0 1-.708-.708L7.293 9 6.146 7.854a.5.5 0 1 1 .708-.708"></path>
|
||||
</svg>
|
||||
{localize('com_ui_clear')} {localize('com_ui_all')}
|
||||
</label>
|
||||
</DialogTrigger>
|
||||
<DialogTemplate
|
||||
title={`${localize('com_ui_clear')} ${localize('com_endpoint_presets')}`}
|
||||
description={localize('com_endpoint_presets_clear_warning')}
|
||||
selection={{
|
||||
selectHandler: clearAllPresets,
|
||||
selectClasses: 'bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white',
|
||||
selectText: localize('com_ui_clear'),
|
||||
}}
|
||||
className="max-w-[500px]"
|
||||
/>
|
||||
</Dialog>
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuRadioGroup
|
||||
onValueChange={onSelectPreset}
|
||||
className={cn(
|
||||
'overflow-y-auto overflow-x-hidden',
|
||||
showEndpoints ? 'max-h-[210px]' : 'max-h-[315px]',
|
||||
)}
|
||||
>
|
||||
{showPresets &&
|
||||
(presets.length ? (
|
||||
<PresetItems
|
||||
presets={presets}
|
||||
onSelect={onSelectPreset}
|
||||
onDeletePreset={onDeletePreset}
|
||||
/>
|
||||
) : (
|
||||
<DropdownMenuLabel className="dark:text-gray-300">
|
||||
{localize('com_endpoint_no_presets')}
|
||||
</DropdownMenuLabel>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</Dialog>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
|
@ -1,101 +0,0 @@
|
|||
import type { TPresetItemProps } from '~/common';
|
||||
import type { TPreset } from 'librechat-data-provider';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { DropdownMenuRadioItem, EditIcon, TrashIcon } from '~/components';
|
||||
import { Icon } from '~/components/Endpoints';
|
||||
|
||||
export default function PresetItem({
|
||||
preset = {} as TPreset,
|
||||
value,
|
||||
onChangePreset,
|
||||
onDeletePreset,
|
||||
}: TPresetItemProps) {
|
||||
const { endpoint } = preset;
|
||||
|
||||
const icon = Icon({
|
||||
size: 20,
|
||||
endpoint: preset?.endpoint,
|
||||
model: preset?.model,
|
||||
error: false,
|
||||
className: 'mr-2',
|
||||
isCreatedByUser: false,
|
||||
});
|
||||
|
||||
const getPresetTitle = () => {
|
||||
let _title = `${endpoint}`;
|
||||
const { chatGptLabel, modelLabel, model, jailbreak, toneStyle } = preset;
|
||||
|
||||
if (endpoint === EModelEndpoint.azureOpenAI || endpoint === EModelEndpoint.openAI) {
|
||||
if (model) {
|
||||
_title += `: ${model}`;
|
||||
}
|
||||
if (chatGptLabel) {
|
||||
_title += ` as ${chatGptLabel}`;
|
||||
}
|
||||
} else if (endpoint === EModelEndpoint.google) {
|
||||
if (model) {
|
||||
_title += `: ${model}`;
|
||||
}
|
||||
if (modelLabel) {
|
||||
_title += ` as ${modelLabel}`;
|
||||
}
|
||||
} else if (endpoint === EModelEndpoint.bingAI) {
|
||||
if (toneStyle) {
|
||||
_title += `: ${toneStyle}`;
|
||||
}
|
||||
if (jailbreak) {
|
||||
_title += ' as Sydney';
|
||||
}
|
||||
} else if (endpoint === EModelEndpoint.chatGPTBrowser) {
|
||||
if (model) {
|
||||
_title += `: ${model}`;
|
||||
}
|
||||
} else if (endpoint === EModelEndpoint.gptPlugins) {
|
||||
if (model) {
|
||||
_title += `: ${model}`;
|
||||
}
|
||||
} else if (endpoint === null) {
|
||||
null;
|
||||
} else {
|
||||
null;
|
||||
}
|
||||
return _title;
|
||||
};
|
||||
|
||||
// regular model
|
||||
return (
|
||||
<DropdownMenuRadioItem
|
||||
/* @ts-ignore, value can be an object as well */
|
||||
value={value}
|
||||
className="group flex h-10 max-h-[44px] flex-row justify-between dark:font-semibold dark:text-gray-200 dark:hover:bg-gray-800 sm:h-auto"
|
||||
>
|
||||
<div className="flex items-center justify-start">
|
||||
{icon}
|
||||
<small className="text-[11px]">{preset?.title}</small>
|
||||
<small className="invisible ml-1 flex w-0 flex-shrink text-[10px] sm:visible sm:w-auto">
|
||||
({getPresetTitle()})
|
||||
</small>
|
||||
</div>
|
||||
<div className="flex h-full items-center justify-end">
|
||||
<button
|
||||
className="m-0 mr-1 h-full rounded-md px-4 text-gray-400 hover:text-gray-700 dark:bg-gray-700 dark:text-gray-400 dark:hover:text-gray-200 sm:invisible sm:p-2 sm:group-hover:visible"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onChangePreset(preset);
|
||||
}}
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
<button
|
||||
className="m-0 h-full rounded-md px-4 text-gray-400 hover:text-gray-700 dark:bg-gray-700 dark:text-gray-400 dark:hover:text-gray-200 sm:invisible sm:p-2 sm:group-hover:visible"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onDeletePreset(preset);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
</DropdownMenuRadioItem>
|
||||
);
|
||||
}
|
|
@ -1,20 +0,0 @@
|
|||
import React from 'react';
|
||||
import PresetItem from './PresetItem';
|
||||
import type { TPreset } from 'librechat-data-provider';
|
||||
|
||||
export default function PresetItems({ presets, onSelect, onChangePreset, onDeletePreset }) {
|
||||
return (
|
||||
<>
|
||||
{presets.map((preset: TPreset) => (
|
||||
<PresetItem
|
||||
key={preset?.presetId ?? Math.random()}
|
||||
value={preset}
|
||||
onSelect={onSelect}
|
||||
onChangePreset={onChangePreset}
|
||||
onDeletePreset={onDeletePreset}
|
||||
preset={preset}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
export { default as EndpointMenu } from './EndpointMenu';
|
|
@ -1,155 +0,0 @@
|
|||
import { Settings2 } from 'lucide-react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useRecoilValue, useRecoilState, useSetRecoilState } from 'recoil';
|
||||
import { tPresetSchema, EModelEndpoint } from 'librechat-data-provider';
|
||||
import { PluginStoreDialog } from '~/components';
|
||||
import {
|
||||
PopoverButtons,
|
||||
EndpointSettings,
|
||||
SaveAsPresetDialog,
|
||||
EndpointOptionsPopover,
|
||||
} from '~/components/Endpoints';
|
||||
import { Button } from '~/components/ui';
|
||||
import { cn, cardStyle } from '~/utils/';
|
||||
import { useSetOptions } from '~/hooks';
|
||||
import { ModelSelect } from './ModelSelect';
|
||||
import { GenerationButtons } from './Generations';
|
||||
import store from '~/store';
|
||||
|
||||
export default function OptionsBar() {
|
||||
const conversation = useRecoilValue(store.conversation);
|
||||
const messagesTree = useRecoilValue(store.messagesTree);
|
||||
const latestMessage = useRecoilValue(store.latestMessage);
|
||||
const setShowBingToneSetting = useSetRecoilState(store.showBingToneSetting);
|
||||
const [showPluginStoreDialog, setShowPluginStoreDialog] = useRecoilState(
|
||||
store.showPluginStoreDialog,
|
||||
);
|
||||
const [saveAsDialogShow, setSaveAsDialogShow] = useState<boolean>(false);
|
||||
const [showPopover, setShowPopover] = useRecoilState(store.showPopover);
|
||||
const [opacityClass, setOpacityClass] = useState('full-opacity');
|
||||
const { setOption } = useSetOptions();
|
||||
|
||||
const { endpoint, conversationId, jailbreak } = conversation ?? {};
|
||||
|
||||
const altConditions: { [key: string]: boolean } = {
|
||||
bingAI: !!(latestMessage && conversation?.jailbreak && endpoint === 'bingAI'),
|
||||
};
|
||||
|
||||
const altSettings: { [key: string]: () => void } = {
|
||||
bingAI: () => setShowBingToneSetting((prev) => !prev),
|
||||
};
|
||||
|
||||
const noSettings = useMemo<{ [key: string]: boolean }>(
|
||||
() => ({
|
||||
[EModelEndpoint.chatGPTBrowser]: true,
|
||||
[EModelEndpoint.bingAI]: jailbreak ? false : conversationId !== 'new',
|
||||
}),
|
||||
[jailbreak, conversationId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (showPopover) {
|
||||
return;
|
||||
} else if (messagesTree && messagesTree.length >= 1) {
|
||||
setOpacityClass('show');
|
||||
} else {
|
||||
setOpacityClass('full-opacity');
|
||||
}
|
||||
}, [messagesTree, showPopover]);
|
||||
|
||||
useEffect(() => {
|
||||
if (endpoint && noSettings[endpoint]) {
|
||||
setShowPopover(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [endpoint, noSettings]);
|
||||
|
||||
const saveAsPreset = () => {
|
||||
setSaveAsDialogShow(true);
|
||||
};
|
||||
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const triggerAdvancedMode = altConditions[endpoint]
|
||||
? altSettings[endpoint]
|
||||
: () => setShowPopover((prev) => !prev);
|
||||
return (
|
||||
<div className="relative py-2 last:mb-2 md:mx-4 md:mb-[-16px] md:py-4 md:pt-2 md:last:mb-6 lg:mx-auto lg:mb-[-32px] lg:max-w-2xl lg:pt-6 xl:max-w-3xl">
|
||||
<GenerationButtons
|
||||
endpoint={endpoint}
|
||||
showPopover={showPopover}
|
||||
opacityClass={opacityClass}
|
||||
/>
|
||||
<span className="flex w-full flex-col items-center justify-center gap-0 md:order-none md:m-auto md:gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'options-bar z-[61] flex w-full flex-wrap items-center justify-center gap-2',
|
||||
showPopover ? '' : opacityClass,
|
||||
)}
|
||||
onMouseEnter={() => {
|
||||
if (showPopover) {
|
||||
return;
|
||||
}
|
||||
setOpacityClass('full-opacity');
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (showPopover) {
|
||||
return;
|
||||
}
|
||||
if (!messagesTree || messagesTree.length === 0) {
|
||||
return;
|
||||
}
|
||||
setOpacityClass('show');
|
||||
}}
|
||||
onFocus={() => {
|
||||
if (showPopover) {
|
||||
return;
|
||||
}
|
||||
setOpacityClass('full-opacity');
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (showPopover) {
|
||||
return;
|
||||
}
|
||||
if (!messagesTree || messagesTree.length === 0) {
|
||||
return;
|
||||
}
|
||||
setOpacityClass('show');
|
||||
}}
|
||||
>
|
||||
<ModelSelect conversation={conversation} setOption={setOption} />
|
||||
{!noSettings[endpoint] && (
|
||||
<Button
|
||||
type="button"
|
||||
className={cn(
|
||||
cardStyle,
|
||||
'min-w-4 z-50 flex h-[40px] flex-none items-center justify-center px-3 focus:ring-0 focus:ring-offset-0',
|
||||
)}
|
||||
onClick={triggerAdvancedMode}
|
||||
>
|
||||
<Settings2 className="w-4 text-gray-600 dark:text-white" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<EndpointOptionsPopover
|
||||
visible={showPopover}
|
||||
saveAsPreset={saveAsPreset}
|
||||
closePopover={() => setShowPopover(false)}
|
||||
PopoverButtons={<PopoverButtons endpoint={endpoint} />}
|
||||
>
|
||||
<div className="px-4 py-4">
|
||||
<EndpointSettings conversation={conversation} setOption={setOption} />
|
||||
</div>
|
||||
</EndpointOptionsPopover>
|
||||
<SaveAsPresetDialog
|
||||
open={saveAsDialogShow}
|
||||
onOpenChange={setSaveAsDialogShow}
|
||||
preset={tPresetSchema.parse({ ...conversation })}
|
||||
/>
|
||||
<PluginStoreDialog isOpen={showPluginStoreDialog} setIsOpen={setShowPluginStoreDialog} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -2,7 +2,7 @@ import React from 'react';
|
|||
import { object, string } from 'zod';
|
||||
import { AuthKeys } from 'librechat-data-provider';
|
||||
import type { TConfigProps } from '~/common';
|
||||
import FileUpload from '~/components/Input/EndpointMenu/FileUpload';
|
||||
import FileUpload from '~/components/Chat/Input/Files/FileUpload';
|
||||
import { useLocalize, useMultipleKeys } from '~/hooks';
|
||||
import InputWithLabel from './InputWithLabel';
|
||||
import { Label } from '~/components/ui';
|
||||
|
|
|
@ -1,137 +0,0 @@
|
|||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { StopGeneratingIcon } from '~/components';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { SetKeyDialog } from './SetKeyDialog';
|
||||
import { useUserKey, useLocalize, useMediaQuery } from '~/hooks';
|
||||
import { SendMessageIcon } from '~/components/svg';
|
||||
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui/';
|
||||
|
||||
export default function SubmitButton({
|
||||
conversation,
|
||||
submitMessage,
|
||||
handleStopGenerating,
|
||||
disabled,
|
||||
isSubmitting,
|
||||
userProvidesKey,
|
||||
hasText,
|
||||
}) {
|
||||
const { endpoint } = conversation;
|
||||
const [isDialogOpen, setDialogOpen] = useState(false);
|
||||
const { checkExpiry } = useUserKey(endpoint);
|
||||
const [isKeyProvided, setKeyProvided] = useState(userProvidesKey ? checkExpiry() : true);
|
||||
const isKeyActive = checkExpiry();
|
||||
const localize = useLocalize();
|
||||
const dots = ['·', '··', '···'];
|
||||
const [dotIndex, setDotIndex] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setDotIndex((prevDotIndex) => (prevDotIndex + 1) % dots.length);
|
||||
}, 500);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [dots.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userProvidesKey) {
|
||||
setKeyProvided(isKeyActive);
|
||||
} else {
|
||||
setKeyProvided(true);
|
||||
}
|
||||
}, [checkExpiry, endpoint, userProvidesKey, isKeyActive]);
|
||||
|
||||
const clickHandler = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
submitMessage();
|
||||
},
|
||||
[submitMessage],
|
||||
);
|
||||
|
||||
const [isSquareGreen, setIsSquareGreen] = useState(false);
|
||||
|
||||
const setKey = useCallback(() => {
|
||||
setDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
const iconContainerClass = `m-1 mr-0 rounded-md pb-[5px] pl-[6px] pr-[4px] pt-[5px] ${
|
||||
hasText ? (isSquareGreen ? 'bg-green-500' : '') : ''
|
||||
} group-hover:bg-19C37D group-disabled:hover:bg-transparent dark:${
|
||||
hasText ? (isSquareGreen ? 'bg-green-500' : '') : ''
|
||||
} dark:group-hover:text-gray-400 dark:group-disabled:hover:bg-transparent`;
|
||||
|
||||
useEffect(() => {
|
||||
setIsSquareGreen(hasText);
|
||||
}, [hasText]);
|
||||
|
||||
if (isSubmitting && isSmallScreen) {
|
||||
return (
|
||||
<button onClick={handleStopGenerating} type="button">
|
||||
<div className="m-1 mr-0 rounded-md p-2 pb-[10px] pt-[10px] group-hover:bg-gray-200 group-disabled:hover:bg-transparent dark:group-hover:bg-gray-800 dark:group-hover:text-gray-400 dark:group-disabled:hover:bg-transparent">
|
||||
<StopGeneratingIcon />
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
} else if (isSubmitting) {
|
||||
return (
|
||||
<div className="relative flex h-full">
|
||||
<div
|
||||
className="absolute text-2xl"
|
||||
style={{ top: '50%', transform: 'translateY(-20%) translateX(-33px)' }}
|
||||
>
|
||||
{dots[dotIndex]}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (!isKeyProvided) {
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={setKey}
|
||||
type="button"
|
||||
className="group absolute bottom-0 right-0 z-[101] flex h-[100%] w-auto items-center justify-center bg-transparent pr-1 text-gray-500"
|
||||
>
|
||||
<div className="flex items-center justify-center rounded-md text-xs group-hover:bg-gray-200 group-disabled:hover:bg-transparent dark:group-hover:bg-gray-800 dark:group-hover:text-gray-400 dark:group-disabled:hover:bg-transparent">
|
||||
<div className="m-0 mr-0 flex items-center justify-center rounded-md p-2 sm:p-2">
|
||||
<Settings className="mr-1 inline-block h-auto w-[18px]" />
|
||||
{localize('com_endpoint_config_key_name_placeholder')}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{userProvidesKey && (
|
||||
<SetKeyDialog open={isDialogOpen} onOpenChange={setDialogOpen} endpoint={endpoint} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<TooltipProvider delayDuration={250}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={clickHandler}
|
||||
disabled={disabled}
|
||||
data-testid="submit-button"
|
||||
className="group absolute bottom-0 right-0 z-[101] flex h-[100%] w-[50px] items-center justify-center bg-transparent p-1 text-gray-500"
|
||||
>
|
||||
<div className={iconContainerClass}>
|
||||
{hasText ? (
|
||||
<div className="bg-19C37D flex h-[24px] w-[24px] items-center justify-center rounded-full text-white">
|
||||
<SendMessageIcon />
|
||||
</div>
|
||||
) : (
|
||||
<SendMessageIcon />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" sideOffset={-5}>
|
||||
{localize('com_nav_send_message')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,212 +0,0 @@
|
|||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { useRecoilValue, useRecoilState, useSetRecoilState } from 'recoil';
|
||||
import React, { useEffect, useContext, useRef, useState, useCallback } from 'react';
|
||||
|
||||
import { EndpointMenu } from './EndpointMenu';
|
||||
import SubmitButton from './SubmitButton';
|
||||
import OptionsBar from './OptionsBar';
|
||||
import Footer from './Footer';
|
||||
|
||||
import { useMessageHandler, ThemeContext } from '~/hooks';
|
||||
import { cn, getEndpointField } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
interface TextChatProps {
|
||||
isSearchView?: boolean;
|
||||
}
|
||||
|
||||
export default function TextChat({ isSearchView = false }: TextChatProps) {
|
||||
const { ask, isSubmitting, handleStopGenerating, latestMessage, endpointsConfig } =
|
||||
useMessageHandler();
|
||||
const conversation = useRecoilValue(store.conversation);
|
||||
const setShowBingToneSetting = useSetRecoilState(store.showBingToneSetting);
|
||||
const [text, setText] = useRecoilState(store.text);
|
||||
const { theme } = useContext(ThemeContext);
|
||||
const isComposing = useRef(false);
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [hasText, setHasText] = useState(false);
|
||||
|
||||
// TODO: do we need this?
|
||||
const disabled = false;
|
||||
|
||||
const isNotAppendable = (latestMessage?.unfinished && !isSubmitting) || latestMessage?.error;
|
||||
const { conversationId, jailbreak } = conversation || {};
|
||||
|
||||
// auto focus to input, when entering a conversation.
|
||||
useEffect(() => {
|
||||
if (!conversationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prevents Settings from not showing on a new conversation, also prevents showing toneStyle change without jailbreak
|
||||
if (conversationId === 'new' || !jailbreak) {
|
||||
setShowBingToneSetting(false);
|
||||
}
|
||||
|
||||
if (conversationId !== 'search') {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
// setShowBingToneSetting is a recoil setter, so it doesn't need to be in the dependency array
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [conversationId, jailbreak]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [isSubmitting]);
|
||||
|
||||
const submitMessage = () => {
|
||||
ask({ text });
|
||||
setText('');
|
||||
setHasText(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && isSubmitting) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isComposing?.current) {
|
||||
submitMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.keyCode === 8 && e.currentTarget.value.trim() === '') {
|
||||
setText(e.currentTarget.value);
|
||||
}
|
||||
|
||||
if (e.key === 'Enter' && e.shiftKey) {
|
||||
return console.log('Enter + Shift');
|
||||
}
|
||||
|
||||
if (isSubmitting) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCompositionStart = () => {
|
||||
isComposing.current = true;
|
||||
};
|
||||
|
||||
const handleCompositionEnd = () => {
|
||||
isComposing.current = false;
|
||||
};
|
||||
|
||||
const changeHandler = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const { value } = e.target;
|
||||
|
||||
setText(value);
|
||||
updateHasText(value);
|
||||
};
|
||||
|
||||
const updateHasText = useCallback(
|
||||
(text: string) => {
|
||||
setHasText(!!text.trim() || !!latestMessage?.error);
|
||||
},
|
||||
[setHasText, latestMessage],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
updateHasText(text);
|
||||
}, [text, latestMessage, updateHasText]);
|
||||
|
||||
const getPlaceholderText = () => {
|
||||
if (isSearchView) {
|
||||
return 'Click a message title to open its conversation.';
|
||||
}
|
||||
|
||||
if (disabled) {
|
||||
return 'Choose another model or customize GPT again';
|
||||
}
|
||||
|
||||
if (isNotAppendable) {
|
||||
return 'Edit your message or Regenerate.';
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
if (isSearchView) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
let isDark = theme === 'dark';
|
||||
|
||||
if (theme === 'system') {
|
||||
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="no-gradient-sm fixed bottom-0 left-0 w-full pt-6 sm:bg-gradient-to-b md:absolute md:w-[calc(100%-.5rem)]"
|
||||
style={{
|
||||
background: `linear-gradient(to bottom,
|
||||
${isDark ? 'rgba(23, 23, 23, 0)' : 'rgba(255, 255, 255, 0)'},
|
||||
${isDark ? 'rgba(23, 23, 23, 0.08)' : 'rgba(255, 255, 255, 0.08)'},
|
||||
${isDark ? 'rgba(23, 23, 23, 0.38)' : 'rgba(255, 255, 255, 0.38)'},
|
||||
${isDark ? 'rgba(23, 23, 23, 1)' : 'rgba(255, 255, 255, 1)'},
|
||||
${isDark ? '#171717' : '#ffffff'})`,
|
||||
}}
|
||||
>
|
||||
<OptionsBar />
|
||||
<div className="input-panel md:bg-vert-light-gradient dark:md:bg-vert-dark-gradient relative w-full border-t bg-white py-2 dark:border-white/20 dark:bg-gray-800 md:border-t-0 md:border-transparent md:bg-transparent md:dark:border-transparent md:dark:bg-transparent">
|
||||
<form className="stretch z-[60] mx-2 flex flex-row gap-3 last:mb-2 md:mx-4 md:pt-2 md:last:mb-6 lg:mx-auto lg:max-w-3xl lg:pt-6">
|
||||
<div className="relative flex h-full flex-1 md:flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex flex-grow flex-row rounded-xl border border-black/10 py-[10px] md:py-4 md:pl-4',
|
||||
'shadow-[0_0_15px_rgba(0,0,0,0.10)] dark:shadow-[0_0_15px_rgba(0,0,0,0.10)]',
|
||||
'dark:border-gray-800/50 dark:text-white',
|
||||
disabled ? 'bg-gray-200 dark:bg-gray-800' : 'bg-white dark:bg-gray-700',
|
||||
)}
|
||||
>
|
||||
<EndpointMenu />
|
||||
<TextareaAutosize
|
||||
// set test id for e2e testing
|
||||
data-testid="text-input"
|
||||
tabIndex={0}
|
||||
autoFocus
|
||||
ref={inputRef}
|
||||
// style={{maxHeight: '200px', height: '24px', overflowY: 'hidden'}}
|
||||
rows={1}
|
||||
value={disabled || isNotAppendable ? '' : text}
|
||||
onKeyUp={handleKeyUp}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={changeHandler}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
placeholder={getPlaceholderText()}
|
||||
disabled={disabled || isNotAppendable}
|
||||
className="m-0 flex h-auto max-h-52 flex-1 resize-none overflow-auto border-0 bg-transparent p-0 pl-2 pr-12 leading-6 placeholder:text-sm placeholder:text-gray-600 focus:outline-none focus:ring-0 focus-visible:ring-0 dark:bg-transparent dark:placeholder:text-gray-500 md:pl-2"
|
||||
/>
|
||||
<SubmitButton
|
||||
conversation={conversation}
|
||||
submitMessage={submitMessage}
|
||||
handleStopGenerating={handleStopGenerating}
|
||||
disabled={disabled || isNotAppendable}
|
||||
isSubmitting={isSubmitting}
|
||||
userProvidesKey={
|
||||
conversation?.endpoint
|
||||
? getEndpointField(endpointsConfig, conversation.endpoint, 'userProvide')
|
||||
: undefined
|
||||
}
|
||||
hasText={hasText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<Footer />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,113 +0,0 @@
|
|||
import { useState } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { EModelEndpoint, alternateName } from 'librechat-data-provider';
|
||||
import type { TPreset } from 'librechat-data-provider';
|
||||
import EndpointOptionsDialog from '../Endpoints/EndpointOptionsDialog';
|
||||
import { Plugin } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
import store from '~/store';
|
||||
|
||||
const MessageHeader = ({ isSearchView = false }) => {
|
||||
const [saveAsDialogShow, setSaveAsDialogShow] = useState(false);
|
||||
const conversation = useRecoilValue(store.conversation);
|
||||
const searchQuery = useRecoilValue(store.searchQuery);
|
||||
const localize = useLocalize();
|
||||
|
||||
if (!conversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { endpoint, model } = conversation;
|
||||
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isNotClickable = endpoint === EModelEndpoint.chatGPTBrowser;
|
||||
|
||||
const plugins = (
|
||||
<>
|
||||
<Plugin /> <span className="px-1">•</span>
|
||||
{/* <span className="py-0.25 ml-1 rounded bg-blue-200 px-1 text-[10px] font-semibold uppercase text-[#4559A4]">
|
||||
beta
|
||||
</span>
|
||||
<span className="px-1">•</span> */}
|
||||
{localize('com_ui_model')}: {model}
|
||||
</>
|
||||
);
|
||||
|
||||
const getConversationTitle = () => {
|
||||
if (isSearchView) {
|
||||
return `Search: ${searchQuery}`;
|
||||
} else {
|
||||
let _title = `${alternateName[endpoint] ?? endpoint}`;
|
||||
|
||||
if (endpoint === EModelEndpoint.azureOpenAI || endpoint === EModelEndpoint.openAI) {
|
||||
const { chatGptLabel } = conversation;
|
||||
if (model) {
|
||||
_title += `: ${model}`;
|
||||
}
|
||||
if (chatGptLabel) {
|
||||
_title += ` as ${chatGptLabel}`;
|
||||
}
|
||||
} else if (endpoint === EModelEndpoint.google) {
|
||||
_title = 'PaLM';
|
||||
const { modelLabel, model } = conversation;
|
||||
if (model) {
|
||||
_title += `: ${model}`;
|
||||
}
|
||||
if (modelLabel) {
|
||||
_title += ` as ${modelLabel}`;
|
||||
}
|
||||
} else if (endpoint === EModelEndpoint.bingAI) {
|
||||
const { jailbreak, toneStyle } = conversation;
|
||||
if (toneStyle) {
|
||||
_title += `: ${toneStyle}`;
|
||||
}
|
||||
if (jailbreak) {
|
||||
_title += ' as Sydney';
|
||||
}
|
||||
} else if (endpoint === EModelEndpoint.chatGPTBrowser) {
|
||||
if (model) {
|
||||
_title += `: ${model}`;
|
||||
}
|
||||
} else if (endpoint === EModelEndpoint.gptPlugins) {
|
||||
return plugins;
|
||||
} else if (endpoint === EModelEndpoint.anthropic) {
|
||||
_title = 'Claude';
|
||||
} else if (endpoint === null) {
|
||||
null;
|
||||
} else {
|
||||
null;
|
||||
}
|
||||
return _title;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-h-[60px] w-full flex-wrap items-center justify-between gap-3 border-b border-black/10 bg-white text-sm text-gray-500 transition-all hover:bg-gray-50 dark:border-gray-800/50 dark:bg-gray-800 dark:hover:bg-gray-700',
|
||||
isNotClickable ? '' : 'cursor-pointer',
|
||||
'sticky top-0 z-10',
|
||||
)}
|
||||
onClick={() => (isNotClickable ? null : setSaveAsDialogShow(true))}
|
||||
>
|
||||
<div className="flex flex-1 flex-grow items-center justify-center gap-1 p-1 text-gray-600 dark:text-gray-200 sm:p-0">
|
||||
{getConversationTitle()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EndpointOptionsDialog
|
||||
open={saveAsDialogShow}
|
||||
onOpenChange={setSaveAsDialogShow}
|
||||
preset={conversation as TPreset}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageHeader;
|
|
@ -1,124 +0,0 @@
|
|||
import { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
|
||||
import ScrollToBottom from './ScrollToBottom';
|
||||
import MessageHeader from './MessageHeader';
|
||||
import MultiMessage from './MultiMessage';
|
||||
import { Spinner } from '~/components';
|
||||
import { useScreenshot, useScrollToRef } from '~/hooks';
|
||||
|
||||
import store from '~/store';
|
||||
|
||||
export default function Messages({ isSearchView = false }) {
|
||||
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const scrollableRef = useRef<HTMLDivElement | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const messagesTree = useRecoilValue(store.messagesTree);
|
||||
const showPopover = useRecoilValue(store.showPopover);
|
||||
const setAbortScroll = useSetRecoilState(store.abortScroll);
|
||||
const searchResultMessagesTree = useRecoilValue(store.searchResultMessagesTree);
|
||||
|
||||
const _messagesTree = isSearchView ? searchResultMessagesTree : messagesTree;
|
||||
|
||||
const conversation = useRecoilValue(store.conversation);
|
||||
const { conversationId } = conversation ?? {};
|
||||
|
||||
const { screenshotTargetRef } = useScreenshot();
|
||||
|
||||
const checkIfAtBottom = useCallback(() => {
|
||||
if (!scrollableRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollableRef.current;
|
||||
const diff = Math.abs(scrollHeight - scrollTop);
|
||||
const percent = Math.abs(clientHeight - diff) / clientHeight;
|
||||
const hasScrollbar = scrollHeight > clientHeight && percent >= 0.15;
|
||||
setShowScrollButton(hasScrollbar);
|
||||
}, [scrollableRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
checkIfAtBottom();
|
||||
}, 650);
|
||||
|
||||
// Add a listener on the window object
|
||||
window.addEventListener('scroll', checkIfAtBottom);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
window.removeEventListener('scroll', checkIfAtBottom);
|
||||
};
|
||||
}, [_messagesTree, checkIfAtBottom]);
|
||||
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const debouncedHandleScroll = () => {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(checkIfAtBottom, 100);
|
||||
};
|
||||
|
||||
const scrollCallback = () => setShowScrollButton(false);
|
||||
const { scrollToRef: scrollToBottom, handleSmoothToRef } = useScrollToRef({
|
||||
targetRef: messagesEndRef,
|
||||
callback: scrollCallback,
|
||||
smoothCallback: () => {
|
||||
scrollCallback();
|
||||
setAbortScroll(false);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex-1 overflow-y-auto pt-0"
|
||||
ref={scrollableRef}
|
||||
onScroll={debouncedHandleScroll}
|
||||
>
|
||||
<div className="dark:gpt-dark-gray mb-32 h-auto md:mb-48" ref={screenshotTargetRef}>
|
||||
<div className="dark:gpt-dark-gray flex h-auto flex-col items-center text-sm">
|
||||
<MessageHeader isSearchView={isSearchView} />
|
||||
{_messagesTree === null ? (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : _messagesTree?.length == 0 && isSearchView ? (
|
||||
<div className="flex w-full items-center justify-center gap-1 bg-gray-50 p-3 text-sm text-gray-500 dark:border-gray-800/50 dark:bg-gray-800 dark:text-gray-300">
|
||||
Nothing found
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<MultiMessage
|
||||
key={conversationId} // avoid internal state mixture
|
||||
messageId={conversationId ?? null}
|
||||
conversation={conversation}
|
||||
messagesTree={_messagesTree}
|
||||
scrollToBottom={scrollToBottom}
|
||||
currentEditId={currentEditId ?? null}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
isSearchView={isSearchView}
|
||||
/>
|
||||
<CSSTransition
|
||||
in={showScrollButton}
|
||||
timeout={400}
|
||||
classNames="scroll-down"
|
||||
unmountOnExit={false}
|
||||
// appear
|
||||
>
|
||||
{() =>
|
||||
showScrollButton &&
|
||||
!showPopover && <ScrollToBottom scrollHandler={handleSmoothToRef} />
|
||||
}
|
||||
</CSSTransition>
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className="dark:gpt-dark-gray group h-0 w-full flex-shrink-0 dark:border-gray-800/50"
|
||||
ref={messagesEndRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
export default function DataIcon() {
|
||||
export default function DataIcon({ className = 'icon-sm' }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
|
@ -6,7 +6,7 @@ export default function DataIcon() {
|
|||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-sm"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
|
|
|
@ -1,153 +0,0 @@
|
|||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
|
||||
import {
|
||||
useGetStartupConfig,
|
||||
useGetMessagesByConvoId,
|
||||
useGetConversationByIdMutation,
|
||||
} from 'librechat-data-provider/react-query';
|
||||
|
||||
import Landing from '~/components/ui/Landing';
|
||||
import Messages from '~/components/Messages/Messages';
|
||||
import TextChat from '~/components/Input/TextChat';
|
||||
|
||||
import { useAuthContext, useConversation } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
export default function Chat() {
|
||||
const { isAuthenticated } = useAuthContext();
|
||||
const [shouldNavigate, setShouldNavigate] = useState(true);
|
||||
const searchQuery = useRecoilValue(store.searchQuery);
|
||||
const [conversation, setConversation] = useRecoilState(store.conversation);
|
||||
const setMessages = useSetRecoilState(store.messages);
|
||||
const messagesTree = useRecoilValue(store.messagesTree);
|
||||
const isSubmitting = useRecoilValue(store.isSubmitting);
|
||||
const { newConversation } = useConversation();
|
||||
const { conversationId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
|
||||
//disabled by default, we only enable it when messagesTree is null
|
||||
const messagesQuery = useGetMessagesByConvoId(conversationId ?? '', { enabled: !messagesTree });
|
||||
const getConversationMutation = useGetConversationByIdMutation(conversationId ?? '');
|
||||
const { data: config } = useGetStartupConfig();
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
if (!isAuthenticated) {
|
||||
navigate('/login', { replace: true });
|
||||
}
|
||||
}, 300);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [isAuthenticated, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSubmitting && !shouldNavigate) {
|
||||
setShouldNavigate(true);
|
||||
}
|
||||
}, [shouldNavigate, isSubmitting]);
|
||||
|
||||
// when conversation changed or conversationId (in url) changed
|
||||
useEffect(() => {
|
||||
// No current conversation and conversationId is 'new'
|
||||
if (conversation === null && conversationId === 'new') {
|
||||
newConversation();
|
||||
setShouldNavigate(true);
|
||||
}
|
||||
// No current conversation and conversationId exists
|
||||
else if (conversation === null && conversationId) {
|
||||
getConversationMutation.mutate(conversationId, {
|
||||
onSuccess: (data) => {
|
||||
console.log('Conversation fetched successfully');
|
||||
setConversation(data);
|
||||
setShouldNavigate(true);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to fetch the conversation');
|
||||
console.error(error);
|
||||
navigate('/c/new');
|
||||
newConversation();
|
||||
setShouldNavigate(true);
|
||||
},
|
||||
});
|
||||
setMessages(null);
|
||||
}
|
||||
// No current conversation and no conversationId
|
||||
else if (conversation === null) {
|
||||
navigate('/c/new');
|
||||
setShouldNavigate(true);
|
||||
}
|
||||
// Current conversationId is 'search'
|
||||
else if (conversation?.conversationId === 'search') {
|
||||
navigate(`/search/${searchQuery}`);
|
||||
setShouldNavigate(true);
|
||||
}
|
||||
// Conversation change and isSubmitting
|
||||
else if (conversation?.conversationId !== conversationId && isSubmitting) {
|
||||
setShouldNavigate(false);
|
||||
}
|
||||
// conversationId (in url) should always follow conversation?.conversationId, unless conversation is null
|
||||
// messagesTree is null when user navigates, but not on page refresh, so we need to navigate in this case
|
||||
else if (conversation?.conversationId !== conversationId && !messagesTree) {
|
||||
if (shouldNavigate) {
|
||||
navigate(`/chat/${conversation?.conversationId}`);
|
||||
} else {
|
||||
setShouldNavigate(true);
|
||||
}
|
||||
}
|
||||
document.title = conversation?.title || config?.appTitle || 'Chat';
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [conversation, conversationId, config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesTree === null && conversation?.conversationId) {
|
||||
messagesQuery.refetch({ queryKey: [conversation?.conversationId] });
|
||||
}
|
||||
}, [conversation?.conversationId, messagesQuery, messagesTree]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesQuery.data) {
|
||||
setMessages(messagesQuery.data);
|
||||
} else if (messagesQuery.isError) {
|
||||
console.error('failed to fetch the messages');
|
||||
console.error(messagesQuery.error);
|
||||
setMessages(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messagesQuery.data, messagesQuery.isError, setMessages]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// if not a conversation
|
||||
if (conversation?.conversationId === 'search') {
|
||||
return null;
|
||||
}
|
||||
// if conversationId not match
|
||||
if (conversation?.conversationId !== conversationId && !conversation) {
|
||||
return null;
|
||||
}
|
||||
// if conversationId is null
|
||||
if (!conversationId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (conversationId && !messagesTree) {
|
||||
return (
|
||||
<>
|
||||
<Messages />
|
||||
<TextChat />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{conversationId === 'new' && !messagesTree?.length ? <Landing /> : <Messages />}
|
||||
<TextChat />
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -1,9 +1,7 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useRecoilState, useRecoilValue } from 'recoil';
|
||||
|
||||
import Messages from '~/components/Messages/Messages';
|
||||
import TextChat from '~/components/Input/TextChat';
|
||||
// import TextChat from '~/components/Input/TextChat';
|
||||
|
||||
import { useConversation } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
@ -53,8 +51,8 @@ export default function Search() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Messages isSearchView={true} />
|
||||
<TextChat isSearchView={true} />
|
||||
{/* <Messages isSearchView={true} /> */}
|
||||
{/* <TextChat isSearchView={true} /> */}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import { createBrowserRouter, Navigate, Outlet } from 'react-router-dom';
|
||||
import Root from './Root';
|
||||
import Chat from './Chat';
|
||||
import ChatRoute from './ChatRoute';
|
||||
import Search from './Search';
|
||||
// import Search from './Search';
|
||||
import {
|
||||
Login,
|
||||
Registration,
|
||||
|
@ -51,14 +50,10 @@ export const router = createBrowserRouter([
|
|||
path: 'c/:conversationId?',
|
||||
element: <ChatRoute />,
|
||||
},
|
||||
{
|
||||
path: 'chat/:conversationId?',
|
||||
element: <Chat />,
|
||||
},
|
||||
{
|
||||
path: 'search/:query?',
|
||||
element: <Search />,
|
||||
},
|
||||
// {
|
||||
// path: 'search/:query?',
|
||||
// element: <Search />,
|
||||
// },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import { atom } from 'recoil';
|
||||
import { SettingsViews } from 'librechat-data-provider';
|
||||
import type { TOptionSettings } from '~/common';
|
||||
|
||||
const abortScroll = atom<boolean>({
|
||||
|
@ -26,6 +27,11 @@ const showAgentSettings = atom<boolean>({
|
|||
default: false,
|
||||
});
|
||||
|
||||
const currentSettingsView = atom<SettingsViews>({
|
||||
key: 'currentSettingsView',
|
||||
default: SettingsViews.default,
|
||||
});
|
||||
|
||||
const showBingToneSetting = atom<boolean>({
|
||||
key: 'showBingToneSetting',
|
||||
default: false,
|
||||
|
@ -137,6 +143,7 @@ export default {
|
|||
optionSettings,
|
||||
showPluginStoreDialog,
|
||||
showAgentSettings,
|
||||
currentSettingsView,
|
||||
showBingToneSetting,
|
||||
showPopover,
|
||||
autoScroll,
|
||||
|
|
|
@ -18,6 +18,11 @@ export const defaultRetrievalModels = [
|
|||
'gpt-4-1106',
|
||||
];
|
||||
|
||||
export enum SettingsViews {
|
||||
default = 'default',
|
||||
advanced = 'advanced',
|
||||
}
|
||||
|
||||
export const fileSourceSchema = z.nativeEnum(FileSources);
|
||||
|
||||
export const modelConfigSchema = z
|
||||
|
|
|
@ -3,6 +3,7 @@ export enum FileSources {
|
|||
firebase = 'firebase',
|
||||
openai = 'openai',
|
||||
s3 = 's3',
|
||||
vectordb = 'vectordb',
|
||||
}
|
||||
|
||||
export enum FileContext {
|
||||
|
|
2
rag.yml
2
rag.yml
|
@ -22,8 +22,6 @@ services:
|
|||
- POSTGRES_PASSWORD=mypassword
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
- ./uploads/temp:/app/uploads/temp
|
||||
depends_on:
|
||||
- vectordb
|
||||
env_file:
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue