mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-21 21:50:49 +02:00
WIP: Update UI to match Official Style; Vision and Assistants 👷🏽 (#1190)
* wip: initial client side code * wip: initial api code * refactor: export query keys from own module, export assistant hooks * refactor(SelectDropDown): more customization via props * feat: create Assistant and render real Assistants * refactor: major refactor of UI components to allow multi-chat, working alongside CreationPanel * refactor: move assistant routes to own directory * fix(CreationHeader): state issue with assistant select * refactor: style changes for form, fix setSiblingIdx from useChatHelpers to use latestMessageParentId, fix render issue with ChatView and change location * feat: parseCompactConvo: begin refactor of slimmer JSON payloads between client/api * refactor(endpoints): add assistant endpoint, also use EModelEndpoint as much as possible * refactor(useGetConversationsQuery): use object to access query data easily * fix(MultiMessage): react warning of bad state set, making use of effect during render (instead of useEffect) * fix(useNewConvo): use correct atom key (index instead of convoId) for reset latestMessageFamily * refactor: make routing navigation/conversation change simpler * chore: add removeNullishValues for smaller payloads, remove unused fields, setup frontend pinging of assistant endpoint * WIP: initial complete assistant run handling * fix: CreationPanel form correctly setting internal state * refactor(api/assistants/chat): revise functions to working run handling strategy * refactor(UI): initial major refactor of ChatForm and options * feat: textarea hook * refactor: useAuthRedirect hook and change directory name * feat: add ChatRoute (/c/), make optionsBar absolute and change on textarea height, add temp header * feat: match new toggle Nav open button to ChatGPT's * feat: add OpenAI custom classnames * feat: useOriginNavigate * feat: messages loading view * fix: conversation navigation and effects * refactor: make toggle change nav opacity * WIP: new endpoint menu * feat: NewEndpointsMenu complete * fix: ensure set key dialog shows on endpoint change, and new conversation resets messages * WIP: textarea styling fix, add temp footer, create basic file handling component * feat: image file handling (UI) * feat: PopOver and ModelSelect in Header, remove GenButtons * feat: drop file handling * refactor: bug fixes use SSE at route level add opts to useOriginNavigate delay render of unfinishedMessage to avoid flickering pass params (convoId) to chatHelpers to set messages query data based on param when the route is new (fixes can't continue convo on /new/) style(MessagesView): matches height to official fix(SSE): pass paramId and invalidate convos style(Message): make bg uniform * refactor(useSSE): setStorage within setConversation updates * feat: conversationKeysAtom, allConversationsSelector, update convos query data on created message (if new), correctly handle convo deletion (individual) * feat: add popover select dropdowns to allow options in header while allowing horizontal scroll for mobile * style(pluginsSelect): styling changes * refactor(NewEndpointsMenu): make UI components modular * feat: Presets complete * fix: preset editing, make by index * fix: conversations not setting on inital navigation, fix getMessages() based on query param * fix: changing preset no longer resets latestMessage * feat: useOnClickOutside for OptionsPopover and fix bug that causes selection of preset when deleting * fix: revert /chat/ switchToConvo, also use NewDeleteButton in Convo * fix: Popover correctly closes on close Popover button using custom condition for useOnClickOutside * style: new message and nav styling * style: hover/sibling buttons and preset menu scrolling * feat: new convo header button * style(Textarea): minor style changes to textarea buttons * feat: stop/continue generating and hide hoverbuttons when submitting * feat: compact AI Provider schemas to make json payloads and db saves smaller * style: styling changes for consistency on chat route * fix: created usePresetIndexOptions to prevent bugs between /c/ and /chat/ routes when editing presets, removed redundant code from the new dialog * chore: make /chat/ route default for now since we still lack full image support
This commit is contained in:
parent
adbeb46399
commit
bac1fb67d2
171 changed files with 8380 additions and 468 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -40,7 +40,7 @@ meili_data/
|
|||
api/node_modules/
|
||||
client/node_modules/
|
||||
bower_components/
|
||||
types/
|
||||
*.d.ts
|
||||
|
||||
# Floobits
|
||||
.floo
|
||||
|
|
|
@ -18,36 +18,36 @@ const convoSchema = mongoose.Schema(
|
|||
user: {
|
||||
type: String,
|
||||
index: true,
|
||||
default: null,
|
||||
// default: null,
|
||||
},
|
||||
messages: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Message' }],
|
||||
// google only
|
||||
examples: [{ type: mongoose.Schema.Types.Mixed }],
|
||||
agentOptions: {
|
||||
type: mongoose.Schema.Types.Mixed,
|
||||
default: null,
|
||||
// default: null,
|
||||
},
|
||||
...conversationPreset,
|
||||
// for bingAI only
|
||||
bingConversationId: {
|
||||
type: String,
|
||||
default: null,
|
||||
// default: null,
|
||||
},
|
||||
jailbreakConversationId: {
|
||||
type: String,
|
||||
default: null,
|
||||
// default: null,
|
||||
},
|
||||
conversationSignature: {
|
||||
type: String,
|
||||
default: null,
|
||||
// default: null,
|
||||
},
|
||||
clientId: {
|
||||
type: String,
|
||||
default: null,
|
||||
// default: null,
|
||||
},
|
||||
invocationId: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
// default: 1,
|
||||
},
|
||||
},
|
||||
{ timestamps: true },
|
||||
|
|
|
@ -8,147 +8,147 @@ const conversationPreset = {
|
|||
// for azureOpenAI, openAI, chatGPTBrowser only
|
||||
model: {
|
||||
type: String,
|
||||
default: null,
|
||||
// default: null,
|
||||
required: false,
|
||||
},
|
||||
// for azureOpenAI, openAI only
|
||||
chatGptLabel: {
|
||||
type: String,
|
||||
default: null,
|
||||
// default: null,
|
||||
required: false,
|
||||
},
|
||||
// for google only
|
||||
modelLabel: {
|
||||
type: String,
|
||||
default: null,
|
||||
// default: null,
|
||||
required: false,
|
||||
},
|
||||
promptPrefix: {
|
||||
type: String,
|
||||
default: null,
|
||||
// default: null,
|
||||
required: false,
|
||||
},
|
||||
temperature: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
// default: 1,
|
||||
required: false,
|
||||
},
|
||||
top_p: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
// default: 1,
|
||||
required: false,
|
||||
},
|
||||
// for google only
|
||||
topP: {
|
||||
type: Number,
|
||||
default: 0.95,
|
||||
// default: 0.95,
|
||||
required: false,
|
||||
},
|
||||
topK: {
|
||||
type: Number,
|
||||
default: 40,
|
||||
// default: 40,
|
||||
required: false,
|
||||
},
|
||||
maxOutputTokens: {
|
||||
type: Number,
|
||||
default: 1024,
|
||||
// default: 1024,
|
||||
required: false,
|
||||
},
|
||||
presence_penalty: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
// default: 0,
|
||||
required: false,
|
||||
},
|
||||
frequency_penalty: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
// default: 0,
|
||||
required: false,
|
||||
},
|
||||
// for bingai only
|
||||
jailbreak: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
// default: false,
|
||||
},
|
||||
context: {
|
||||
type: String,
|
||||
default: null,
|
||||
// default: null,
|
||||
},
|
||||
systemMessage: {
|
||||
type: String,
|
||||
default: null,
|
||||
// default: null,
|
||||
},
|
||||
toneStyle: {
|
||||
type: String,
|
||||
default: null,
|
||||
// default: null,
|
||||
},
|
||||
};
|
||||
|
||||
const agentOptions = {
|
||||
model: {
|
||||
type: String,
|
||||
default: null,
|
||||
// default: null,
|
||||
required: false,
|
||||
},
|
||||
// for azureOpenAI, openAI only
|
||||
chatGptLabel: {
|
||||
type: String,
|
||||
default: null,
|
||||
// default: null,
|
||||
required: false,
|
||||
},
|
||||
// for google only
|
||||
modelLabel: {
|
||||
type: String,
|
||||
default: null,
|
||||
// default: null,
|
||||
required: false,
|
||||
},
|
||||
promptPrefix: {
|
||||
type: String,
|
||||
default: null,
|
||||
// default: null,
|
||||
required: false,
|
||||
},
|
||||
temperature: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
// default: 1,
|
||||
required: false,
|
||||
},
|
||||
top_p: {
|
||||
type: Number,
|
||||
default: 1,
|
||||
// default: 1,
|
||||
required: false,
|
||||
},
|
||||
// for google only
|
||||
topP: {
|
||||
type: Number,
|
||||
default: 0.95,
|
||||
// default: 0.95,
|
||||
required: false,
|
||||
},
|
||||
topK: {
|
||||
type: Number,
|
||||
default: 40,
|
||||
// default: 40,
|
||||
required: false,
|
||||
},
|
||||
maxOutputTokens: {
|
||||
type: Number,
|
||||
default: 1024,
|
||||
// default: 1024,
|
||||
required: false,
|
||||
},
|
||||
presence_penalty: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
// default: 0,
|
||||
required: false,
|
||||
},
|
||||
frequency_penalty: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
// default: 0,
|
||||
required: false,
|
||||
},
|
||||
context: {
|
||||
type: String,
|
||||
default: null,
|
||||
// default: null,
|
||||
},
|
||||
systemMessage: {
|
||||
type: String,
|
||||
default: null,
|
||||
// default: null,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
const { EModelEndpoint } = require('../routes/endpoints/schemas');
|
||||
const { availableTools } = require('../../app/clients/tools');
|
||||
const { addOpenAPISpecs } = require('../../app/clients/tools/util/addOpenAPISpecs');
|
||||
const {
|
||||
|
@ -7,6 +8,7 @@ const {
|
|||
userProvidedOpenAI,
|
||||
palmKey,
|
||||
openAI,
|
||||
assistant,
|
||||
azureOpenAI,
|
||||
bingAI,
|
||||
chatGPTBrowser,
|
||||
|
@ -53,7 +55,16 @@ async function endpointController(req, res) {
|
|||
: false;
|
||||
|
||||
res.send(
|
||||
JSON.stringify({ azureOpenAI, openAI, google, bingAI, chatGPTBrowser, gptPlugins, anthropic }),
|
||||
JSON.stringify({
|
||||
[EModelEndpoint.openAI]: openAI,
|
||||
[EModelEndpoint.assistant]: assistant,
|
||||
[EModelEndpoint.azureOpenAI]: azureOpenAI,
|
||||
[EModelEndpoint.google]: google,
|
||||
[EModelEndpoint.bingAI]: bingAI,
|
||||
[EModelEndpoint.chatGPTBrowser]: chatGPTBrowser,
|
||||
[EModelEndpoint.gptPlugins]: gptPlugins,
|
||||
[EModelEndpoint.anthropic]: anthropic,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
const { EModelEndpoint } = require('../routes/endpoints/schemas');
|
||||
const {
|
||||
getOpenAIModels,
|
||||
getChatGPTBrowserModels,
|
||||
|
@ -6,17 +7,28 @@ const {
|
|||
|
||||
const { useAzurePlugins } = require('../services/EndpointService').config;
|
||||
|
||||
const fitlerAssistantModels = (str) => {
|
||||
return /gpt-4|gpt-3\\.5/i.test(str) && !/vision|instruct/i.test(str);
|
||||
};
|
||||
|
||||
async function modelController(req, res) {
|
||||
const google = ['chat-bison', 'text-bison', 'codechat-bison'];
|
||||
const openAI = await getOpenAIModels();
|
||||
const azureOpenAI = await getOpenAIModels({ azure: true });
|
||||
const gptPlugins = await getOpenAIModels({ azure: useAzurePlugins, plugins: true });
|
||||
const bingAI = ['BingAI', 'Sydney'];
|
||||
const chatGPTBrowser = getChatGPTBrowserModels();
|
||||
const anthropic = getAnthropicModels();
|
||||
|
||||
res.send(
|
||||
JSON.stringify({ azureOpenAI, openAI, google, bingAI, chatGPTBrowser, gptPlugins, anthropic }),
|
||||
JSON.stringify({
|
||||
[EModelEndpoint.openAI]: openAI,
|
||||
[EModelEndpoint.azureOpenAI]: azureOpenAI,
|
||||
[EModelEndpoint.assistant]: openAI.filter(fitlerAssistantModels),
|
||||
[EModelEndpoint.google]: ['chat-bison', 'text-bison', 'codechat-bison'],
|
||||
[EModelEndpoint.bingAI]: ['BingAI', 'Sydney'],
|
||||
[EModelEndpoint.chatGPTBrowser]: chatGPTBrowser,
|
||||
[EModelEndpoint.gptPlugins]: gptPlugins,
|
||||
[EModelEndpoint.anthropic]: anthropic,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -64,6 +64,7 @@ const startServer = async () => {
|
|||
app.use('/api/models', routes.models);
|
||||
app.use('/api/plugins', routes.plugins);
|
||||
app.use('/api/config', routes.config);
|
||||
app.use('/api/assistants', routes.assistants);
|
||||
|
||||
// Static files
|
||||
app.get('/*', function (req, res) {
|
||||
|
|
|
@ -15,6 +15,7 @@ const {
|
|||
messageUserLimiter,
|
||||
} = require('../../middleware');
|
||||
const { isEnabled } = require('../../utils');
|
||||
const { EModelEndpoint } = require('../endpoints/schemas');
|
||||
|
||||
const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
|
||||
|
||||
|
@ -34,11 +35,11 @@ if (isEnabled(LIMIT_MESSAGE_USER)) {
|
|||
router.use(messageUserLimiter);
|
||||
}
|
||||
|
||||
router.use(['/azureOpenAI', '/openAI'], openAI);
|
||||
router.use('/google', google);
|
||||
router.use('/bingAI', bingAI);
|
||||
router.use('/chatGPTBrowser', askChatGPTBrowser);
|
||||
router.use('/gptPlugins', gptPlugins);
|
||||
router.use('/anthropic', anthropic);
|
||||
router.use([`/${EModelEndpoint.azureOpenAI}`, `/${EModelEndpoint.openAI}`], openAI);
|
||||
router.use(`/${EModelEndpoint.google}`, google);
|
||||
router.use(`/${EModelEndpoint.bingAI}`, bingAI);
|
||||
router.use(`/${EModelEndpoint.chatGPTBrowser}`, askChatGPTBrowser);
|
||||
router.use(`/${EModelEndpoint.gptPlugins}`, gptPlugins);
|
||||
router.use(`/${EModelEndpoint.anthropic}`, anthropic);
|
||||
|
||||
module.exports = router;
|
||||
|
|
98
api/server/routes/assistants/assistants.js
Normal file
98
api/server/routes/assistants/assistants.js
Normal file
|
@ -0,0 +1,98 @@
|
|||
const OpenAI = require('openai');
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* Create an assistant.
|
||||
* @route POST /assistants
|
||||
* @param {AssistantCreateParams} req.body - The assistant creation parameters.
|
||||
* @returns {Assistant} 201 - success response - application/json
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const openai = new OpenAI(process.env.OPENAI_API_KEY);
|
||||
const assistantData = req.body;
|
||||
const assistant = await openai.beta.assistants.create(assistantData);
|
||||
console.log(assistant);
|
||||
res.status(201).json(assistant);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Retrieves an assistant.
|
||||
* @route GET /assistants/:id
|
||||
* @param {string} req.params.id - Assistant identifier.
|
||||
* @returns {Assistant} 200 - success response - application/json
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const openai = new OpenAI(process.env.OPENAI_API_KEY);
|
||||
const assistant_id = req.params.id;
|
||||
const assistant = await openai.beta.assistants.retrieve(assistant_id);
|
||||
res.json(assistant);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Modifies an assistant.
|
||||
* @route PATCH /assistants/:id
|
||||
* @param {string} req.params.id - Assistant identifier.
|
||||
* @param {AssistantUpdateParams} req.body - The assistant update parameters.
|
||||
* @returns {Assistant} 200 - success response - application/json
|
||||
*/
|
||||
router.patch('/:id', async (req, res) => {
|
||||
try {
|
||||
const openai = new OpenAI(process.env.OPENAI_API_KEY);
|
||||
const assistant_id = req.params.id;
|
||||
const updateData = req.body;
|
||||
const updatedAssistant = await openai.beta.assistants.update(assistant_id, updateData);
|
||||
res.json(updatedAssistant);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Deletes an assistant.
|
||||
* @route DELETE /assistants/:id
|
||||
* @param {string} req.params.id - Assistant identifier.
|
||||
* @returns {Assistant} 200 - success response - application/json
|
||||
*/
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const openai = new OpenAI(process.env.OPENAI_API_KEY);
|
||||
const assistant_id = req.params.id;
|
||||
const deletionStatus = await openai.beta.assistants.del(assistant_id);
|
||||
res.json(deletionStatus);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns a list of assistants.
|
||||
* @route GET /assistants
|
||||
* @param {AssistantListParams} req.query - The assistant list parameters for pagination and sorting.
|
||||
* @returns {Array<Assistant>} 200 - success response - application/json
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const openai = new OpenAI(process.env.OPENAI_API_KEY);
|
||||
const { limit, order, after, before } = req.query;
|
||||
const assistants = await openai.beta.assistants.list({
|
||||
limit,
|
||||
order,
|
||||
after,
|
||||
before,
|
||||
});
|
||||
res.json(assistants);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
108
api/server/routes/assistants/chat.js
Normal file
108
api/server/routes/assistants/chat.js
Normal file
|
@ -0,0 +1,108 @@
|
|||
const crypto = require('crypto');
|
||||
const OpenAI = require('openai');
|
||||
const { sendMessage } = require('../../utils');
|
||||
const { initThread, createRun, handleRun } = require('../../services/AssistantService');
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
setHeaders,
|
||||
// handleAbort,
|
||||
// handleAbortError,
|
||||
// validateEndpoint,
|
||||
// buildEndpointOption,
|
||||
// createAbortController,
|
||||
} = require('../../middleware');
|
||||
|
||||
// const thread = {
|
||||
// id: 'thread_LexzJUVugYFqfslS7c7iL3Zo',
|
||||
// "thread_nZoiCbPauU60LqY1Q0ME1elg"
|
||||
// };
|
||||
|
||||
/**
|
||||
* Chat with an assistant.
|
||||
*/
|
||||
router.post('/', setHeaders, async (req, res) => {
|
||||
try {
|
||||
console.log(req.body);
|
||||
// test message:
|
||||
// How many polls of 500 ms intervals are there in 18 seconds?
|
||||
|
||||
const { assistant_id, messages, text: userMessage, messageId } = req.body;
|
||||
const conversationId = req.body.conversationId || crypto.randomUUID();
|
||||
// let thread_id = req.body.thread_id ?? 'thread_nZoiCbPauU60LqY1Q0ME1elg'; // for testing
|
||||
let thread_id = req.body.thread_id;
|
||||
|
||||
if (!assistant_id) {
|
||||
throw new Error('Missing assistant_id');
|
||||
}
|
||||
|
||||
const openai = new OpenAI(process.env.OPENAI_API_KEY);
|
||||
console.log(messages);
|
||||
|
||||
const initThreadBody = {
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: userMessage,
|
||||
metadata: {
|
||||
messageId,
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
conversationId,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await initThread({ openai, body: initThreadBody, thread_id });
|
||||
// const { messages: _messages } = result;
|
||||
thread_id = result.thread_id;
|
||||
|
||||
/* NOTE:
|
||||
* By default, a Run will use the model and tools configuration specified in Assistant object,
|
||||
* but you can override most of these when creating the Run for added flexibility:
|
||||
*/
|
||||
const run = await createRun({
|
||||
openai,
|
||||
thread_id,
|
||||
body: { assistant_id, model: 'gpt-3.5-turbo-1106' },
|
||||
});
|
||||
const response = await handleRun({ openai, thread_id, run_id: run.id });
|
||||
|
||||
// TODO: parse responses, save to db, send to user
|
||||
|
||||
sendMessage(res, {
|
||||
title: 'New Chat',
|
||||
final: true,
|
||||
conversation: {
|
||||
conversationId: 'fake-convo-id',
|
||||
title: 'New Chat',
|
||||
},
|
||||
requestMessage: {
|
||||
messageId: 'fake-user-message-id',
|
||||
parentMessageId: '00000000-0000-0000-0000-000000000000',
|
||||
conversationId: 'fake-convo-id',
|
||||
sender: 'User',
|
||||
text: req.body.text,
|
||||
isCreatedByUser: true,
|
||||
},
|
||||
responseMessage: {
|
||||
messageId: 'fake-response-id',
|
||||
conversationId: 'fake-convo-id',
|
||||
parentMessageId: 'fake-user-message-id',
|
||||
isCreatedByUser: false,
|
||||
isEdited: false,
|
||||
model: 'gpt-3.5-turbo-1106',
|
||||
sender: 'Assistant',
|
||||
text: response.choices[0].text,
|
||||
},
|
||||
});
|
||||
res.end();
|
||||
} catch (error) {
|
||||
// res.status(500).json({ error: error.message });
|
||||
console.error(error);
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
22
api/server/routes/assistants/index.js
Normal file
22
api/server/routes/assistants/index.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const {
|
||||
uaParser,
|
||||
checkBan,
|
||||
requireJwtAuth,
|
||||
// concurrentLimiter,
|
||||
// messageIpLimiter,
|
||||
// messageUserLimiter,
|
||||
} = require('../../middleware');
|
||||
|
||||
const assistants = require('./assistants');
|
||||
const chat = require('./chat');
|
||||
|
||||
router.use(requireJwtAuth);
|
||||
router.use(checkBan);
|
||||
router.use(uaParser);
|
||||
|
||||
router.use('/', assistants);
|
||||
router.use('/chat', chat);
|
||||
|
||||
module.exports = router;
|
|
@ -8,6 +8,7 @@ const EModelEndpoint = {
|
|||
google: 'google',
|
||||
gptPlugins: 'gptPlugins',
|
||||
anthropic: 'anthropic',
|
||||
assistant: 'assistant',
|
||||
};
|
||||
|
||||
const eModelEndpointSchema = z.nativeEnum(EModelEndpoint);
|
||||
|
@ -263,14 +264,33 @@ const gptPluginsSchema = tConversationSchema
|
|||
},
|
||||
}));
|
||||
|
||||
const assistantSchema = tConversationSchema
|
||||
.pick({
|
||||
model: true,
|
||||
assistant_id: true,
|
||||
thread_id: true,
|
||||
})
|
||||
.transform((obj) => {
|
||||
const newObj = { ...obj };
|
||||
Object.keys(newObj).forEach((key) => {
|
||||
const value = newObj[key];
|
||||
if (value === undefined || value === null) {
|
||||
delete newObj[key];
|
||||
}
|
||||
});
|
||||
return newObj;
|
||||
})
|
||||
.catch(() => ({}));
|
||||
|
||||
const endpointSchemas = {
|
||||
openAI: openAISchema,
|
||||
azureOpenAI: openAISchema,
|
||||
google: googleSchema,
|
||||
bingAI: bingAISchema,
|
||||
anthropic: anthropicSchema,
|
||||
chatGPTBrowser: chatGPTBrowserSchema,
|
||||
gptPlugins: gptPluginsSchema,
|
||||
[EModelEndpoint.openAI]: openAISchema,
|
||||
[EModelEndpoint.assistant]: assistantSchema,
|
||||
[EModelEndpoint.azureOpenAI]: openAISchema,
|
||||
[EModelEndpoint.google]: googleSchema,
|
||||
[EModelEndpoint.bingAI]: bingAISchema,
|
||||
[EModelEndpoint.anthropic]: anthropicSchema,
|
||||
[EModelEndpoint.chatGPTBrowser]: chatGPTBrowserSchema,
|
||||
[EModelEndpoint.gptPlugins]: gptPluginsSchema,
|
||||
};
|
||||
|
||||
function getFirstDefinedValue(possibleValues) {
|
||||
|
@ -303,19 +323,26 @@ const parseConvo = (endpoint, conversation, possibleValues) => {
|
|||
const getResponseSender = (endpointOption) => {
|
||||
const { endpoint, chatGptLabel, modelLabel, jailbreak } = endpointOption;
|
||||
|
||||
if (['openAI', 'azureOpenAI', 'gptPlugins', 'chatGPTBrowser'].includes(endpoint)) {
|
||||
if (
|
||||
[
|
||||
EModelEndpoint.openAI,
|
||||
EModelEndpoint.azureOpenAI,
|
||||
EModelEndpoint.gptPlugins,
|
||||
EModelEndpoint.chatGPTBrowser,
|
||||
].includes(endpoint)
|
||||
) {
|
||||
return chatGptLabel ?? 'ChatGPT';
|
||||
}
|
||||
|
||||
if (endpoint === 'bingAI') {
|
||||
if (endpoint === EModelEndpoint.bingAI) {
|
||||
return jailbreak ? 'Sydney' : 'BingAI';
|
||||
}
|
||||
|
||||
if (endpoint === 'anthropic') {
|
||||
if (endpoint === EModelEndpoint.anthropic) {
|
||||
return modelLabel ?? 'Anthropic';
|
||||
}
|
||||
|
||||
if (endpoint === 'google') {
|
||||
if (endpoint === EModelEndpoint.google) {
|
||||
return modelLabel ?? 'PaLM2';
|
||||
}
|
||||
|
||||
|
@ -325,4 +352,5 @@ const getResponseSender = (endpointOption) => {
|
|||
module.exports = {
|
||||
parseConvo,
|
||||
getResponseSender,
|
||||
EModelEndpoint,
|
||||
};
|
||||
|
|
|
@ -15,6 +15,7 @@ const models = require('./models');
|
|||
const plugins = require('./plugins');
|
||||
const user = require('./user');
|
||||
const config = require('./config');
|
||||
const assistants = require('./assistants');
|
||||
|
||||
module.exports = {
|
||||
search,
|
||||
|
@ -34,4 +35,5 @@ module.exports = {
|
|||
models,
|
||||
plugins,
|
||||
config,
|
||||
assistants,
|
||||
};
|
||||
|
|
53
api/server/routes/types/assistants.js
Normal file
53
api/server/routes/types/assistants.js
Normal file
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* Enum for the possible tools that can be enabled on an assistant.
|
||||
* @readonly
|
||||
* @enum {string}
|
||||
*/
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const Tools = {
|
||||
code_interpreter: 'code_interpreter',
|
||||
retrieval: 'retrieval',
|
||||
function: 'function',
|
||||
};
|
||||
|
||||
/**
|
||||
* Represents a tool with its type.
|
||||
* @typedef {Object} Tool
|
||||
* @property {Tools} toolName - The name of the tool and its corresponding type from the Tools enum.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Assistant
|
||||
* @property {string} id - The identifier, which can be referenced in API endpoints.
|
||||
* @property {number} created_at - The Unix timestamp (in seconds) for when the assistant was created.
|
||||
* @property {string|null} description - The maximum length is 512 characters.
|
||||
* @property {Array<string>} file_ids - A list of file IDs attached to this assistant.
|
||||
* @property {string|null} instructions - The system instructions that the assistant uses. The maximum length is 32768 characters.
|
||||
* @property {Object|null} metadata - Set of 16 key-value pairs that can be attached to an object.
|
||||
* @property {string} model - ID of the model to use.
|
||||
* @property {string|null} name - The name of the assistant. The maximum length is 256 characters.
|
||||
* @property {string} object - The object type, which is always 'assistant'.
|
||||
* @property {Tool[]} tools - A list of tools enabled on the assistant.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AssistantCreateParams
|
||||
* @property {string} model - ID of the model to use.
|
||||
* @property {string|null} [description] - The description of the assistant.
|
||||
* @property {Array<string>} [file_ids] - A list of file IDs attached to this assistant.
|
||||
* @property {string|null} [instructions] - The system instructions that the assistant uses.
|
||||
* @property {Object|null} [metadata] - Set of 16 key-value pairs that can be attached to an object.
|
||||
* @property {string|null} [name] - The name of the assistant.
|
||||
* @property {Tool[]} tools - A list of tools enabled on the assistant.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AssistantUpdateParams
|
||||
* // Similar properties to AssistantCreateParams, but all optional
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AssistantListParams
|
||||
* @property {string|null} [before] - A cursor for use in pagination.
|
||||
* @property {'asc'|'desc'} [order] - Sort order by the created_at timestamp of the objects.
|
||||
*/
|
398
api/server/services/AssistantService.js
Normal file
398
api/server/services/AssistantService.js
Normal file
|
@ -0,0 +1,398 @@
|
|||
const RunManager = require('./Runs/RunMananger');
|
||||
|
||||
/**
|
||||
* @typedef {import('openai').OpenAI} OpenAI
|
||||
* @typedef {import('openai').OpenAI.Beta.Threads.ThreadMessage} ThreadMessage
|
||||
* @typedef {import('openai').OpenAI.Beta.Threads.RequiredActionFunctionToolCall} RequiredActionFunctionToolCall
|
||||
* @typedef {import('./Runs/RunManager').RunManager} RunManager
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Thread
|
||||
* @property {string} id - The identifier of the thread.
|
||||
* @property {string} object - The object type, always 'thread'.
|
||||
* @property {number} created_at - The Unix timestamp (in seconds) for when the thread was created.
|
||||
* @property {Object} [metadata] - Optional metadata associated with the thread.
|
||||
* @property {Message[]} [messages] - An array of messages associated with the thread.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Message
|
||||
* @property {string} id - The identifier of the message.
|
||||
* @property {string} object - The object type, always 'thread.message'.
|
||||
* @property {number} created_at - The Unix timestamp (in seconds) for when the message was created.
|
||||
* @property {string} thread_id - The thread ID that this message belongs to.
|
||||
* @property {string} role - The entity that produced the message. One of 'user' or 'assistant'.
|
||||
* @property {Object[]} content - The content of the message in an array of text and/or images.
|
||||
* @property {string} content[].type - The type of content, either 'text' or 'image_file'.
|
||||
* @property {Object} [content[].text] - The text content, present if type is 'text'.
|
||||
* @property {string} content[].text.value - The data that makes up the text.
|
||||
* @property {Object[]} [content[].text.annotations] - Annotations for the text content.
|
||||
* @property {Object} [content[].image_file] - The image file content, present if type is 'image_file'.
|
||||
* @property {string} content[].image_file.file_id - The File ID of the image in the message content.
|
||||
* @property {string[]} [file_ids] - Optional list of File IDs for the message.
|
||||
* @property {string|null} [assistant_id] - If applicable, the ID of the assistant that authored this message.
|
||||
* @property {string|null} [run_id] - If applicable, the ID of the run associated with the authoring of this message.
|
||||
* @property {Object} [metadata] - Optional metadata for the message, a map of key-value pairs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} FunctionTool
|
||||
* @property {string} type - The type of tool, 'function'.
|
||||
* @property {Object} function - The function definition.
|
||||
* @property {string} function.description - A description of what the function does.
|
||||
* @property {string} function.name - The name of the function to be called.
|
||||
* @property {Object} function.parameters - The parameters the function accepts, described as a JSON Schema object.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Tool
|
||||
* @property {string} type - The type of tool, can be 'code_interpreter', 'retrieval', or 'function'.
|
||||
* @property {FunctionTool} [function] - The function tool, present if type is 'function'.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Run
|
||||
* @property {string} id - The identifier of the run.
|
||||
* @property {string} object - The object type, always 'thread.run'.
|
||||
* @property {number} created_at - The Unix timestamp (in seconds) for when the run was created.
|
||||
* @property {string} thread_id - The ID of the thread that was executed on as a part of this run.
|
||||
* @property {string} assistant_id - The ID of the assistant used for execution of this run.
|
||||
* @property {string} status - The status of the run (e.g., 'queued', 'completed').
|
||||
* @property {Object} [required_action] - Details on the action required to continue the run.
|
||||
* @property {string} required_action.type - The type of required action, always 'submit_tool_outputs'.
|
||||
* @property {Object} required_action.submit_tool_outputs - Details on the tool outputs needed for the run to continue.
|
||||
* @property {Object[]} required_action.submit_tool_outputs.tool_calls - A list of the relevant tool calls.
|
||||
* @property {string} required_action.submit_tool_outputs.tool_calls[].id - The ID of the tool call.
|
||||
* @property {string} required_action.submit_tool_outputs.tool_calls[].type - The type of tool call the output is required for, always 'function'.
|
||||
* @property {Object} required_action.submit_tool_outputs.tool_calls[].function - The function definition.
|
||||
* @property {string} required_action.submit_tool_outputs.tool_calls[].function.name - The name of the function.
|
||||
* @property {string} required_action.submit_tool_outputs.tool_calls[].function.arguments - The arguments that the model expects you to pass to the function.
|
||||
* @property {Object} [last_error] - The last error associated with this run.
|
||||
* @property {string} last_error.code - One of 'server_error' or 'rate_limit_exceeded'.
|
||||
* @property {string} last_error.message - A human-readable description of the error.
|
||||
* @property {number} [expires_at] - The Unix timestamp (in seconds) for when the run will expire.
|
||||
* @property {number} [started_at] - The Unix timestamp (in seconds) for when the run was started.
|
||||
* @property {number} [cancelled_at] - The Unix timestamp (in seconds) for when the run was cancelled.
|
||||
* @property {number} [failed_at] - The Unix timestamp (in seconds) for when the run failed.
|
||||
* @property {number} [completed_at] - The Unix timestamp (in seconds) for when the run was completed.
|
||||
* @property {string} [model] - The model that the assistant used for this run.
|
||||
* @property {string} [instructions] - The instructions that the assistant used for this run.
|
||||
* @property {Tool[]} [tools] - The list of tools used for this run.
|
||||
* @property {string[]} [file_ids] - The list of File IDs used for this run.
|
||||
* @property {Object} [metadata] - Metadata associated with this run.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RunStep
|
||||
* @property {string} id - The identifier of the run step.
|
||||
* @property {string} object - The object type, always 'thread.run.step'.
|
||||
* @property {number} created_at - The Unix timestamp (in seconds) for when the run step was created.
|
||||
* @property {string} assistant_id - The ID of the assistant associated with the run step.
|
||||
* @property {string} thread_id - The ID of the thread that was run.
|
||||
* @property {string} run_id - The ID of the run that this run step is a part of.
|
||||
* @property {string} type - The type of run step, either 'message_creation' or 'tool_calls'.
|
||||
* @property {string} status - The status of the run step, can be 'in_progress', 'cancelled', 'failed', 'completed', or 'expired'.
|
||||
* @property {Object} step_details - The details of the run step.
|
||||
* @property {Object} [last_error] - The last error associated with this run step.
|
||||
* @property {string} last_error.code - One of 'server_error' or 'rate_limit_exceeded'.
|
||||
* @property {string} last_error.message - A human-readable description of the error.
|
||||
* @property {number} [expired_at] - The Unix timestamp (in seconds) for when the run step expired.
|
||||
* @property {number} [cancelled_at] - The Unix timestamp (in seconds) for when the run step was cancelled.
|
||||
* @property {number} [failed_at] - The Unix timestamp (in seconds) for when the run step failed.
|
||||
* @property {number} [completed_at] - The Unix timestamp (in seconds) for when the run step completed.
|
||||
* @property {Object} [metadata] - Metadata associated with this run step, a map of up to 16 key-value pairs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} StepMessage
|
||||
* @property {Message} message - The complete message object created by the step.
|
||||
* @property {string} id - The identifier of the run step.
|
||||
* @property {string} object - The object type, always 'thread.run.step'.
|
||||
* @property {number} created_at - The Unix timestamp (in seconds) for when the run step was created.
|
||||
* @property {string} assistant_id - The ID of the assistant associated with the run step.
|
||||
* @property {string} thread_id - The ID of the thread that was run.
|
||||
* @property {string} run_id - The ID of the run that this run step is a part of.
|
||||
* @property {string} type - The type of run step, either 'message_creation' or 'tool_calls'.
|
||||
* @property {string} status - The status of the run step, can be 'in_progress', 'cancelled', 'failed', 'completed', or 'expired'.
|
||||
* @property {Object} step_details - The details of the run step.
|
||||
* @property {Object} [last_error] - The last error associated with this run step.
|
||||
* @property {string} last_error.code - One of 'server_error' or 'rate_limit_exceeded'.
|
||||
* @property {string} last_error.message - A human-readable description of the error.
|
||||
* @property {number} [expired_at] - The Unix timestamp (in seconds) for when the run step expired.
|
||||
* @property {number} [cancelled_at] - The Unix timestamp (in seconds) for when the run step was cancelled.
|
||||
* @property {number} [failed_at] - The Unix timestamp (in seconds) for when the run step failed.
|
||||
* @property {number} [completed_at] - The Unix timestamp (in seconds) for when the run step completed.
|
||||
* @property {Object} [metadata] - Metadata associated with this run step, a map of up to 16 key-value pairs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Initializes a new thread or adds messages to an existing thread.
|
||||
*
|
||||
* @param {Object} params - The parameters for initializing a thread.
|
||||
* @param {OpenAI} params.openai - The OpenAI client instance.
|
||||
* @param {Object} params.body - The body of the request.
|
||||
* @param {Message[]} params.body.messages - A list of messages to start the thread with.
|
||||
* @param {Object} [params.body.metadata] - Optional metadata for the thread.
|
||||
* @param {string} [params.thread_id] - Optional existing thread ID. If provided, a message will be added to this thread.
|
||||
* @return {Promise<Thread>} A promise that resolves to the newly created thread object or the updated thread object.
|
||||
*/
|
||||
async function initThread({ openai, body, thread_id: _thread_id }) {
|
||||
let thread = {};
|
||||
const messages = [];
|
||||
if (_thread_id) {
|
||||
const message = await openai.beta.threads.messages.create(_thread_id, body.messages[0]);
|
||||
messages.push(message);
|
||||
} else {
|
||||
thread = await openai.beta.threads.create(body);
|
||||
}
|
||||
|
||||
const thread_id = _thread_id ?? thread.id;
|
||||
return { messages, thread_id, ...thread };
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a run on a thread using the OpenAI API.
|
||||
*
|
||||
* @param {Object} params - The parameters for creating a run.
|
||||
* @param {OpenAI} params.openai - The OpenAI client instance.
|
||||
* @param {string} params.thread_id - The ID of the thread to run.
|
||||
* @param {Object} params.body - The body of the request to create a run.
|
||||
* @param {string} params.body.assistant_id - The ID of the assistant to use for this run.
|
||||
* @param {string} [params.body.model] - Optional. The ID of the model to be used for this run.
|
||||
* @param {string} [params.body.instructions] - Optional. Override the default system message of the assistant.
|
||||
* @param {Object[]} [params.body.tools] - Optional. Override the tools the assistant can use for this run.
|
||||
* @param {string[]} [params.body.file_ids] - Optional. List of File IDs the assistant can use for this run.
|
||||
* @param {Object} [params.body.metadata] - Optional. Metadata for the run.
|
||||
* @return {Promise<Run>} A promise that resolves to the created run object.
|
||||
*/
|
||||
async function createRun({ openai, thread_id, body }) {
|
||||
const run = await openai.beta.threads.runs.create(thread_id, body);
|
||||
return run;
|
||||
}
|
||||
|
||||
// /**
|
||||
// * Retrieves all steps of a run.
|
||||
// *
|
||||
// * @param {Object} params - The parameters for the retrieveRunSteps function.
|
||||
// * @param {OpenAI} params.openai - The OpenAI client instance.
|
||||
// * @param {string} params.thread_id - The ID of the thread associated with the run.
|
||||
// * @param {string} params.run_id - The ID of the run to retrieve steps for.
|
||||
// * @return {Promise<RunStep[]>} A promise that resolves to an array of RunStep objects.
|
||||
// */
|
||||
// async function retrieveRunSteps({ openai, thread_id, run_id }) {
|
||||
// const runSteps = await openai.beta.threads.runs.steps.list(thread_id, run_id);
|
||||
// return runSteps;
|
||||
// }
|
||||
|
||||
/**
|
||||
* Delays the execution for a specified number of milliseconds.
|
||||
*
|
||||
* @param {number} ms - The number of milliseconds to delay.
|
||||
* @return {Promise<void>} A promise that resolves after the specified delay.
|
||||
*/
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for a run to complete by repeatedly checking its status. It uses a RunManager instance to fetch and manage run steps based on the run status.
|
||||
*
|
||||
* @param {Object} params - The parameters for the waitForRun function.
|
||||
* @param {OpenAI} params.openai - The OpenAI client instance.
|
||||
* @param {string} params.run_id - The ID of the run to wait for.
|
||||
* @param {string} params.thread_id - The ID of the thread associated with the run.
|
||||
* @param {RunManager} params.runManager - The RunManager instance to manage run steps.
|
||||
* @param {number} params.pollIntervalMs - The interval for polling the run status, default is 500 milliseconds.
|
||||
* @return {Promise<Run>} A promise that resolves to the last fetched run object.
|
||||
*/
|
||||
async function waitForRun({ openai, run_id, thread_id, runManager, pollIntervalMs = 500 }) {
|
||||
const timeout = 18000; // 18 seconds
|
||||
let timeElapsed = 0;
|
||||
let run;
|
||||
|
||||
// this runManager will be passed in from the caller
|
||||
// const runManager = new RunManager({
|
||||
// 'in_progress': (step) => { /* ... */ },
|
||||
// 'queued': (step) => { /* ... */ },
|
||||
// });
|
||||
|
||||
while (timeElapsed < timeout) {
|
||||
run = await openai.beta.threads.runs.retrieve(thread_id, run_id);
|
||||
console.log(`Run status: ${run.status}`);
|
||||
|
||||
if (!['in_progress', 'queued'].includes(run.status)) {
|
||||
await runManager.fetchRunSteps({
|
||||
openai,
|
||||
thread_id: thread_id,
|
||||
run_id: run_id,
|
||||
runStatus: run.status,
|
||||
final: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
// may use in future
|
||||
// await runManager.fetchRunSteps({
|
||||
// openai,
|
||||
// thread_id: thread_id,
|
||||
// run_id: run_id,
|
||||
// runStatus: run.status,
|
||||
// });
|
||||
|
||||
await sleep(pollIntervalMs);
|
||||
timeElapsed += pollIntervalMs;
|
||||
}
|
||||
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} AgentAction
|
||||
* @property {string} tool - The name of the tool used.
|
||||
* @property {string} toolInput - The input provided to the tool.
|
||||
* @property {string} log - A log or message associated with the action.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} AgentFinish
|
||||
* @property {Record<string, any>} returnValues - The return values of the agent's execution.
|
||||
* @property {string} log - A log or message associated with the finish.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {AgentFinish & { run_id: string; thread_id: string; }} OpenAIAssistantFinish
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {AgentAction & { toolCallId: string; run_id: string; thread_id: string; }} OpenAIAssistantAction
|
||||
*/
|
||||
|
||||
/**
|
||||
* Retrieves the response from an OpenAI run.
|
||||
*
|
||||
* @param {Object} params - The parameters for getting the response.
|
||||
* @param {OpenAI} params.openai - The OpenAI client instance.
|
||||
* @param {string} params.run_id - The ID of the run to get the response for.
|
||||
* @param {string} params.thread_id - The ID of the thread associated with the run.
|
||||
* @return {Promise<OpenAIAssistantFinish | OpenAIAssistantAction[] | Message[] | RequiredActionFunctionToolCall[]>}
|
||||
*/
|
||||
async function getResponse({ openai, run_id, thread_id }) {
|
||||
const run = await waitForRun({ openai, run_id, thread_id, pollIntervalMs: 500 });
|
||||
|
||||
if (run.status === 'completed') {
|
||||
const messages = await openai.beta.threads.messages.list(thread_id, {
|
||||
order: 'asc',
|
||||
});
|
||||
const newMessages = messages.data.filter((msg) => msg.run_id === run_id);
|
||||
|
||||
return newMessages;
|
||||
} else if (run.status === 'requires_action') {
|
||||
const actions = [];
|
||||
run.required_action?.submit_tool_outputs.tool_calls.forEach((item) => {
|
||||
const functionCall = item.function;
|
||||
const args = JSON.parse(functionCall.arguments);
|
||||
actions.push({
|
||||
tool: functionCall.name,
|
||||
toolInput: args,
|
||||
toolCallId: item.id,
|
||||
log: '',
|
||||
run_id,
|
||||
thread_id,
|
||||
});
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
const runInfo = JSON.stringify(run, null, 2);
|
||||
throw new Error(`Unexpected run status ${run.status}.\nFull run info:\n\n${runInfo}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes a RunManager with handlers, then invokes waitForRun to monitor and manage an OpenAI run.
|
||||
*
|
||||
* @param {Object} params - The parameters for managing and monitoring the run.
|
||||
* @param {OpenAI} params.openai - The OpenAI client instance.
|
||||
* @param {string} params.run_id - The ID of the run to manage and monitor.
|
||||
* @param {string} params.thread_id - The ID of the thread associated with the run.
|
||||
* @return {Promise<Object>} A promise that resolves to an object containing the run and managed steps.
|
||||
*/
|
||||
async function handleRun({ openai, run_id, thread_id }) {
|
||||
let steps;
|
||||
let messages;
|
||||
const runManager = new RunManager({
|
||||
// 'in_progress': async ({ step, final, isLast }) => {
|
||||
// // Define logic for handling steps with 'in_progress' status
|
||||
// },
|
||||
// 'queued': async ({ step, final, isLast }) => {
|
||||
// // Define logic for handling steps with 'queued' status
|
||||
// },
|
||||
final: async ({ step, runStatus, stepsByStatus }) => {
|
||||
console.log(`Final step for ${run_id} with status ${runStatus}`);
|
||||
console.dir(step, { depth: null });
|
||||
|
||||
const promises = [];
|
||||
promises.push(
|
||||
openai.beta.threads.messages.list(thread_id, {
|
||||
order: 'asc',
|
||||
}),
|
||||
);
|
||||
|
||||
const finalSteps = stepsByStatus[runStatus];
|
||||
|
||||
// loop across all statuses, may use in the future
|
||||
// for (const [_status, stepsPromises] of Object.entries(stepsByStatus)) {
|
||||
// promises.push(...stepsPromises);
|
||||
// }
|
||||
for (const stepPromise of finalSteps) {
|
||||
promises.push(stepPromise);
|
||||
}
|
||||
|
||||
const resolved = await Promise.all(promises);
|
||||
const res = resolved.shift();
|
||||
messages = res.data.filter((msg) => msg.run_id === run_id);
|
||||
resolved.push(step);
|
||||
steps = resolved;
|
||||
},
|
||||
});
|
||||
|
||||
const run = await waitForRun({ openai, run_id, thread_id, runManager, pollIntervalMs: 500 });
|
||||
|
||||
return { run, steps, messages };
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps messages to their corresponding steps. Steps with message creation will be paired with their messages,
|
||||
* while steps without message creation will be returned as is.
|
||||
*
|
||||
* @param {RunStep[]} steps - An array of steps from the run.
|
||||
* @param {Message[]} messages - An array of message objects.
|
||||
* @returns {(StepMessage | RunStep)[]} An array where each element is either a step with its corresponding message (StepMessage) or a step without a message (RunStep).
|
||||
*/
|
||||
function mapMessagesToSteps(steps, messages) {
|
||||
// Create a map of messages indexed by their IDs for efficient lookup
|
||||
const messageMap = messages.reduce((acc, msg) => {
|
||||
acc[msg.id] = msg;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Map each step to its corresponding message, or return the step as is if no message ID is present
|
||||
return steps.map((step) => {
|
||||
const messageId = step.step_details?.message_creation?.message_id;
|
||||
|
||||
if (messageId && messageMap[messageId]) {
|
||||
return { step, message: messageMap[messageId] };
|
||||
}
|
||||
return step;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initThread,
|
||||
createRun,
|
||||
waitForRun,
|
||||
getResponse,
|
||||
handleRun,
|
||||
mapMessagesToSteps,
|
||||
};
|
|
@ -1,3 +1,5 @@
|
|||
const { EModelEndpoint } = require('../routes/endpoints/schemas');
|
||||
|
||||
const {
|
||||
OPENAI_API_KEY: openAIApiKey,
|
||||
AZURE_API_KEY: azureOpenAIApiKey,
|
||||
|
@ -18,12 +20,6 @@ function isUserProvided(key) {
|
|||
return key ? { userProvide: key === 'user_provided' } : false;
|
||||
}
|
||||
|
||||
const openAI = isUserProvided(openAIApiKey);
|
||||
const azureOpenAI = isUserProvided(azureOpenAIApiKey);
|
||||
const bingAI = isUserProvided(bingToken);
|
||||
const chatGPTBrowser = isUserProvided(chatGPTToken);
|
||||
const anthropic = isUserProvided(anthropicApiKey);
|
||||
|
||||
module.exports = {
|
||||
config: {
|
||||
openAIApiKey,
|
||||
|
@ -31,10 +27,11 @@ module.exports = {
|
|||
useAzurePlugins,
|
||||
userProvidedOpenAI,
|
||||
palmKey,
|
||||
openAI,
|
||||
azureOpenAI,
|
||||
chatGPTBrowser,
|
||||
anthropic,
|
||||
bingAI,
|
||||
[EModelEndpoint.openAI]: isUserProvided(openAIApiKey),
|
||||
[EModelEndpoint.assistant]: isUserProvided(openAIApiKey),
|
||||
[EModelEndpoint.azureOpenAI]: isUserProvided(azureOpenAIApiKey),
|
||||
[EModelEndpoint.chatGPTBrowser]: isUserProvided(chatGPTToken),
|
||||
[EModelEndpoint.anthropic]: isUserProvided(anthropicApiKey),
|
||||
[EModelEndpoint.bingAI]: isUserProvided(bingToken),
|
||||
},
|
||||
};
|
||||
|
|
93
api/server/services/Runs/RunMananger.js
Normal file
93
api/server/services/Runs/RunMananger.js
Normal file
|
@ -0,0 +1,93 @@
|
|||
/**
|
||||
* @typedef {import('openai').OpenAI} OpenAI
|
||||
* @typedef {import('../AssistantService').RunStep} RunStep
|
||||
* @callback StepHandler
|
||||
* @param {RunStep} step - A single run step to be processed.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} RunManager
|
||||
* Manages the retrieval and processing of run steps based on run status.
|
||||
* @property {Set<string>} seenSteps - A set of IDs for steps that have already been seen.
|
||||
* @property {Object.<string, Promise<RunStep[]>>} stepsByStatus - Steps organized by run status.
|
||||
* @property {Object.<string, StepHandler>} handlers - Handlers for different run statuses.
|
||||
* @property {Object.<string, Promise>} lastStepPromiseByStatus - Last processed step's promise by run status.
|
||||
* @property {Function} fetchRunSteps - Fetches run steps based on run status.
|
||||
* @property {Function} handleStep - Handles a run step based on its status.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Manages the retrieval and processing of run steps based on run status.
|
||||
*/
|
||||
class RunManager {
|
||||
/**
|
||||
* Initializes the RunManager instance.
|
||||
* @param {Object.<string, StepHandler>} handlers - An object containing handler functions for different run statuses.
|
||||
*/
|
||||
constructor(handlers = {}) {
|
||||
this.seenSteps = new Set();
|
||||
this.stepsByStatus = {};
|
||||
this.handlers = handlers;
|
||||
this.lastStepPromiseByStatus = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches run steps once and filters out already seen steps.
|
||||
* @param {Object} params - The parameters for fetching run steps.
|
||||
* @param {OpenAI} params.openai - The OpenAI client instance.
|
||||
* @param {string} params.thread_id - The ID of the thread associated with the run.
|
||||
* @param {string} params.run_id - The ID of the run to retrieve steps for.
|
||||
* @param {string} params.runStatus - The status of the run.
|
||||
* @param {boolean} [params.final] - The end of the run polling loop, due to `requires_action`, `cancelling`, `cancelled`, `failed`, `completed`, or `expired` statuses.
|
||||
*/
|
||||
async fetchRunSteps({ openai, thread_id, run_id, runStatus, final = false }) {
|
||||
// const { data: steps, first_id, last_id, has_more } = await openai.beta.threads.runs.steps.list(thread_id, run_id);
|
||||
const { data: _steps } = await openai.beta.threads.runs.steps.list(thread_id, run_id);
|
||||
const steps = _steps.sort((a, b) => a.created_at - b.created_at);
|
||||
for (const [i, step] of steps.entries()) {
|
||||
if (this.seenSteps.has(step.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const isLast = i === steps.length - 1;
|
||||
this.seenSteps.add(step.id);
|
||||
this.stepsByStatus[runStatus] = this.stepsByStatus[runStatus] || [];
|
||||
|
||||
const currentStepPromise = (async () => {
|
||||
await (this.lastStepPromiseByStatus[runStatus] || Promise.resolve());
|
||||
return this.handleStep({ step, runStatus, final, isLast });
|
||||
})();
|
||||
|
||||
if (final && isLast) {
|
||||
return await currentStepPromise;
|
||||
}
|
||||
|
||||
this.lastStepPromiseByStatus[runStatus] = currentStepPromise;
|
||||
this.stepsByStatus[runStatus].push(currentStepPromise);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a run step based on its status.
|
||||
* @param {Object} params - The parameters for handling a run step.
|
||||
* @param {RunStep} params.step - The run step to handle.
|
||||
* @param {string} params.runStatus - The status of the run step.
|
||||
* @param {string} params.final - The final run status (no further polling will occur)
|
||||
* @param {boolean} params.isLast - Whether the current step is the last step of the list.
|
||||
*/
|
||||
async handleStep({ step, runStatus, final, isLast }) {
|
||||
if (this.handlers[runStatus]) {
|
||||
return this.handlers[runStatus]({ step, final, isLast });
|
||||
}
|
||||
|
||||
if (final && isLast && this.handlers['final']) {
|
||||
return await this.handlers['final']({ step, runStatus, stepsByStatus: this.stepsByStatus });
|
||||
}
|
||||
|
||||
console.log(`Default handler for ${step.id} with status \`${runStatus}\``);
|
||||
console.dir({ step, runStatus, final, isLast }, { depth: null });
|
||||
return step;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = RunManager;
|
|
@ -34,6 +34,8 @@
|
|||
"@radix-ui/react-hover-card": "^1.0.5",
|
||||
"@radix-ui/react-icons": "^1.3.0",
|
||||
"@radix-ui/react-label": "^2.0.0",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"@radix-ui/react-slider": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.0.3",
|
||||
"@radix-ui/react-tabs": "^1.0.3",
|
||||
|
@ -55,6 +57,8 @@
|
|||
"lucide-react": "^0.220.0",
|
||||
"rc-input-number": "^7.4.2",
|
||||
"react": "^18.2.0",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hook-form": "^7.43.9",
|
||||
"react-markdown": "^8.0.6",
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import { RecoilRoot } from 'recoil';
|
||||
import * as RadixToast from '@radix-ui/react-toast';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { RouterProvider } from 'react-router-dom';
|
||||
import * as RadixToast from '@radix-ui/react-toast';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
|
||||
import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query';
|
||||
import { ScreenshotProvider, ThemeProvider, useApiErrorBoundary } from './hooks';
|
||||
import { ToastProvider } from './Providers';
|
||||
import { ToastProvider, AssistantsProvider } from './Providers';
|
||||
import Toast from './components/ui/Toast';
|
||||
import { router } from './routes';
|
||||
|
||||
|
@ -27,10 +29,14 @@ const App = () => {
|
|||
<ThemeProvider>
|
||||
<RadixToast.Provider>
|
||||
<ToastProvider>
|
||||
<RouterProvider router={router} />
|
||||
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
|
||||
<Toast />
|
||||
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[60] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />
|
||||
<AssistantsProvider>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<RouterProvider router={router} />
|
||||
<ReactQueryDevtools initialIsOpen={false} position="top-right" />
|
||||
<Toast />
|
||||
<RadixToast.Viewport className="pointer-events-none fixed inset-0 z-[60] mx-auto my-2 flex max-w-[560px] flex-col items-stretch justify-start md:pb-5" />
|
||||
</DndProvider>
|
||||
</AssistantsProvider>
|
||||
</ToastProvider>
|
||||
</RadixToast.Provider>
|
||||
</ThemeProvider>
|
||||
|
|
29
client/src/Providers/AssistantsContext.tsx
Normal file
29
client/src/Providers/AssistantsContext.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
import type { UseFormReturn } from 'react-hook-form';
|
||||
import type { CreationForm } from '~/common';
|
||||
import useCreationForm from './useCreationForm';
|
||||
|
||||
// type AssistantsContextType = {
|
||||
// // open: boolean;
|
||||
// // setOpen: Dispatch<SetStateAction<boolean>>;
|
||||
// form: UseFormReturn<CreationForm>;
|
||||
// };
|
||||
type AssistantsContextType = UseFormReturn<CreationForm>;
|
||||
|
||||
export const AssistantsContext = createContext<AssistantsContextType>({} as AssistantsContextType);
|
||||
|
||||
export function useAssistantsContext() {
|
||||
const context = useContext(AssistantsContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error('useAssistantsContext must be used within an AssistantsProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
export default function AssistantsProvider({ children }) {
|
||||
const hookValues = useCreationForm();
|
||||
|
||||
return <AssistantsContext.Provider value={hookValues}>{children}</AssistantsContext.Provider>;
|
||||
}
|
6
client/src/Providers/ChatContext.tsx
Normal file
6
client/src/Providers/ChatContext.tsx
Normal file
|
@ -0,0 +1,6 @@
|
|||
import { createContext, useContext } from 'react';
|
||||
import useChatHelpers from '~/hooks/useChatHelpers';
|
||||
type TChatContext = ReturnType<typeof useChatHelpers>;
|
||||
|
||||
export const ChatContext = createContext<TChatContext>({} as TChatContext);
|
||||
export const useChatContext = () => useContext(ChatContext);
|
|
@ -1,2 +1,5 @@
|
|||
export { default as ToastProvider } from './ToastContext';
|
||||
export { default as AssistantsProvider } from './AssistantsContext';
|
||||
export * from './ChatContext';
|
||||
export * from './ToastContext';
|
||||
export * from './AssistantsContext';
|
||||
|
|
19
client/src/Providers/useCreationForm.ts
Normal file
19
client/src/Providers/useCreationForm.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
// import { useState } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import type { CreationForm } from '~/common';
|
||||
|
||||
export default function useViewPromptForm() {
|
||||
return useForm<CreationForm>({
|
||||
defaultValues: {
|
||||
assistant: '',
|
||||
id: '',
|
||||
name: '',
|
||||
description: '',
|
||||
instructions: '',
|
||||
model: 'gpt-3.5-turbo-1106',
|
||||
function: false,
|
||||
code_interpreter: false,
|
||||
retrieval: false,
|
||||
},
|
||||
});
|
||||
}
|
19
client/src/common/assistants-types.ts
Normal file
19
client/src/common/assistants-types.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
import type { Option } from './types';
|
||||
import type { Assistant } from 'librechat-data-provider';
|
||||
|
||||
export type TAssistantOption = string | (Option & Assistant);
|
||||
|
||||
export type Actions = {
|
||||
function: boolean;
|
||||
code_interpreter: boolean;
|
||||
retrieval: boolean;
|
||||
};
|
||||
|
||||
export type CreationForm = {
|
||||
assistant: TAssistantOption;
|
||||
id: string;
|
||||
name: string | null;
|
||||
description: string | null;
|
||||
instructions: string | null;
|
||||
model: string;
|
||||
} & Actions;
|
|
@ -1 +1,2 @@
|
|||
export * from './types';
|
||||
export * from './assistants-types';
|
||||
|
|
|
@ -6,6 +6,7 @@ import type {
|
|||
TLoginUser,
|
||||
TUser,
|
||||
} from 'librechat-data-provider';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
|
||||
export type TSetOption = (param: number | string) => (newValue: number | string | boolean) => void;
|
||||
export type TSetExample = (
|
||||
|
@ -14,6 +15,22 @@ export type TSetExample = (
|
|||
newValue: number | string | boolean | null,
|
||||
) => void;
|
||||
|
||||
export const alternateName = {
|
||||
[EModelEndpoint.openAI]: 'OpenAI',
|
||||
[EModelEndpoint.assistant]: 'Assistants',
|
||||
[EModelEndpoint.azureOpenAI]: 'Azure OpenAI',
|
||||
[EModelEndpoint.bingAI]: 'Bing',
|
||||
[EModelEndpoint.chatGPTBrowser]: 'ChatGPT',
|
||||
[EModelEndpoint.gptPlugins]: 'Plugins',
|
||||
[EModelEndpoint.google]: 'PaLM',
|
||||
[EModelEndpoint.anthropic]: 'Anthropic',
|
||||
};
|
||||
|
||||
export const supportsFiles = {
|
||||
[EModelEndpoint.openAI]: true,
|
||||
[EModelEndpoint.assistant]: true,
|
||||
};
|
||||
|
||||
export enum ESide {
|
||||
Top = 'top',
|
||||
Right = 'right',
|
||||
|
@ -47,6 +64,8 @@ export type TSettingsProps = TBaseSettingsProps & {
|
|||
|
||||
export type TModels = {
|
||||
models: string[];
|
||||
showAbove?: boolean;
|
||||
popover?: boolean;
|
||||
};
|
||||
|
||||
export type TModelSelectProps = TSettingsProps & TModels;
|
||||
|
@ -64,7 +83,7 @@ export type TSetOptionsPayload = {
|
|||
addExample: () => void;
|
||||
removeExample: () => void;
|
||||
setAgentOption: TSetOption;
|
||||
getConversation: () => TConversation | TPreset | null;
|
||||
// getConversation: () => TConversation | TPreset | null;
|
||||
checkPluginSelection: (value: string) => boolean;
|
||||
setTools: (newValue: string) => void;
|
||||
};
|
||||
|
@ -201,3 +220,23 @@ export type IconProps = Pick<TMessage, 'isCreatedByUser' | 'model' | 'error'> &
|
|||
className?: string;
|
||||
endpoint?: string | null;
|
||||
};
|
||||
|
||||
export type Option = Record<string, unknown> & {
|
||||
label?: string;
|
||||
value: string | number | null;
|
||||
};
|
||||
|
||||
export type TOptionSettings = {
|
||||
showExamples?: boolean;
|
||||
isCodeChat?: boolean;
|
||||
};
|
||||
|
||||
export interface ExtendedFile {
|
||||
file: File;
|
||||
width?: number;
|
||||
height?: number;
|
||||
preview: string;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
export type ContextType = { navVisible: boolean; setNavVisible: (visible: boolean) => void };
|
||||
|
|
72
client/src/components/Chat/ChatView.tsx
Normal file
72
client/src/components/Chat/ChatView.tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
import { memo } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useGetMessagesByConvoId } from 'librechat-data-provider';
|
||||
import { useChatHelpers, useDragHelpers, useSSE } from '~/hooks';
|
||||
// import GenerationButtons from './Input/GenerationButtons';
|
||||
import DragDropOverlay from './Input/Files/DragDropOverlay';
|
||||
import MessagesView from './Messages/MessagesView';
|
||||
// import OptionsBar from './Input/OptionsBar';
|
||||
import { ChatContext } from '~/Providers';
|
||||
import ChatForm from './Input/ChatForm';
|
||||
import { Spinner } from '~/components';
|
||||
import { buildTree } from '~/utils';
|
||||
import Landing from './Landing';
|
||||
import Header from './Header';
|
||||
import Footer from './Footer';
|
||||
import store from '~/store';
|
||||
|
||||
function ChatView({
|
||||
// messagesTree,
|
||||
// isLoading,
|
||||
index = 0,
|
||||
}: {
|
||||
// messagesTree?: TMessage[] | null;
|
||||
// isLoading: boolean;
|
||||
index?: number;
|
||||
}) {
|
||||
const { conversationId } = useParams();
|
||||
const submissionAtIndex = useRecoilValue(store.submissionByIndex(0));
|
||||
useSSE(submissionAtIndex);
|
||||
|
||||
const { data: messagesTree = null, isLoading } = useGetMessagesByConvoId(conversationId ?? '', {
|
||||
select: (data) => {
|
||||
const dataTree = buildTree(data, false);
|
||||
return dataTree?.length === 0 ? null : dataTree ?? null;
|
||||
},
|
||||
});
|
||||
const chatHelpers = useChatHelpers(index, conversationId);
|
||||
const { isOver, canDrop, drop } = useDragHelpers(chatHelpers.setFiles);
|
||||
const isActive = canDrop && isOver;
|
||||
return (
|
||||
<ChatContext.Provider value={chatHelpers}>
|
||||
<div
|
||||
ref={drop}
|
||||
className="relative flex w-full grow overflow-hidden bg-white dark:bg-gray-800"
|
||||
>
|
||||
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden bg-white pt-0 dark:bg-gray-800">
|
||||
<div className="flex h-full flex-col" role="presentation" tabIndex={0}>
|
||||
{isLoading && conversationId !== 'new' ? (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Spinner className="dark:text-white" />
|
||||
</div>
|
||||
) : messagesTree && messagesTree.length !== 0 ? (
|
||||
<MessagesView messagesTree={messagesTree} Header={<Header />} />
|
||||
) : (
|
||||
<Landing Header={<Header />} />
|
||||
)}
|
||||
{/* <OptionsBar messagesTree={messagesTree} /> */}
|
||||
{/* <GenerationButtons endpoint={chatHelpers.conversation.endpoint ?? ''} /> */}
|
||||
<div className="gizmo:border-t-0 gizmo:pl-0 gizmo:md:pl-0 w-full border-t pt-2 dark:border-white/20 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-2 md:pt-0 md:dark:border-transparent">
|
||||
<ChatForm index={index} />
|
||||
<Footer />
|
||||
</div>
|
||||
{isActive && <DragDropOverlay />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ChatContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ChatView);
|
113
client/src/components/Chat/CreationHeader.tsx
Normal file
113
client/src/components/Chat/CreationHeader.tsx
Normal file
|
@ -0,0 +1,113 @@
|
|||
// import { useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { useListAssistantsQuery } from 'librechat-data-provider';
|
||||
import type { Assistant } from 'librechat-data-provider';
|
||||
import type { UseFormReset, UseFormSetValue } from 'react-hook-form';
|
||||
import type { CreationForm, Actions, Option } from '~/common';
|
||||
import SelectDropDown from '~/components/ui/SelectDropDown';
|
||||
import { cn } from '~/utils/';
|
||||
|
||||
const keys = new Set(['name', 'id', 'description', 'instructions', 'model']);
|
||||
|
||||
type TAssistantOption = string | (Option & Assistant);
|
||||
|
||||
export default function CreationHeader({
|
||||
reset,
|
||||
value,
|
||||
onChange,
|
||||
setValue,
|
||||
}: {
|
||||
reset: UseFormReset<CreationForm>;
|
||||
value: TAssistantOption;
|
||||
onChange: (value: TAssistantOption) => void;
|
||||
setValue: UseFormSetValue<CreationForm>;
|
||||
}) {
|
||||
const assistants = useListAssistantsQuery(
|
||||
{
|
||||
order: 'asc',
|
||||
},
|
||||
{
|
||||
select: (res) =>
|
||||
res.data.map((assistant) => ({
|
||||
...assistant,
|
||||
label: assistant?.name ?? '',
|
||||
value: assistant.id,
|
||||
})),
|
||||
},
|
||||
);
|
||||
|
||||
const onSelect = (value: string) => {
|
||||
const assistant = assistants.data?.find((assistant) => assistant.id === value);
|
||||
if (!assistant) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
onChange({
|
||||
...assistant,
|
||||
label: assistant?.name ?? '',
|
||||
value: assistant?.id ?? '',
|
||||
});
|
||||
const actions: Actions = {
|
||||
function: false,
|
||||
code_interpreter: false,
|
||||
retrieval: false,
|
||||
};
|
||||
assistant?.tools
|
||||
?.map((tool) => tool.type)
|
||||
.forEach((tool) => {
|
||||
actions[tool] = true;
|
||||
});
|
||||
|
||||
Object.entries(assistant).forEach(([name, value]) => {
|
||||
if (typeof value === 'number') {
|
||||
return;
|
||||
} else if (typeof value === 'object') {
|
||||
return;
|
||||
}
|
||||
if (keys.has(name)) {
|
||||
setValue(name as keyof CreationForm, value);
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(actions).forEach(([name, value]) => setValue(name as keyof Actions, value));
|
||||
};
|
||||
|
||||
return (
|
||||
<SelectDropDown
|
||||
value={!value ? 'Create Assistant' : value}
|
||||
setValue={onSelect}
|
||||
availableValues={
|
||||
assistants.data ?? [
|
||||
{
|
||||
label: 'Loading...',
|
||||
value: '',
|
||||
},
|
||||
]
|
||||
}
|
||||
iconSide="left"
|
||||
showAbove={false}
|
||||
showLabel={false}
|
||||
emptyTitle={true}
|
||||
optionsClass="hover:bg-gray-20/50"
|
||||
optionsListClass="rounded-lg shadow-lg"
|
||||
currentValueClass={cn(
|
||||
'text-md font-semibold text-gray-900 dark:text-gray-100',
|
||||
value === '' ? 'text-gray-500' : '',
|
||||
)}
|
||||
className={cn(
|
||||
'rounded-none',
|
||||
'z-50 flex h-[40px] w-full flex-none items-center justify-center px-4 hover:cursor-pointer hover:border-green-500 focus:border-green-500',
|
||||
)}
|
||||
renderOption={() => (
|
||||
<span className="flex items-center gap-1.5 truncate">
|
||||
<span className="absolute inset-y-0 left-0 flex items-center pl-2 text-gray-800 dark:text-gray-100">
|
||||
<Plus className="w-[16px]" />
|
||||
</span>
|
||||
<span className={cn('ml-4 flex h-6 items-center gap-1 text-gray-800 dark:text-gray-100')}>
|
||||
{'Create Assistant'}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
228
client/src/components/Chat/CreationPanel.tsx
Normal file
228
client/src/components/Chat/CreationPanel.tsx
Normal file
|
@ -0,0 +1,228 @@
|
|||
import { Controller, useWatch } from 'react-hook-form';
|
||||
import type { Tool } from 'librechat-data-provider';
|
||||
import type { CreationForm, Actions } from '~/common';
|
||||
import { useCreateAssistantMutation, Tools, EModelEndpoint } from 'librechat-data-provider';
|
||||
import { Separator } from '~/components/ui/Separator';
|
||||
import { useAssistantsContext } from '~/Providers';
|
||||
import { Switch } from '~/components/ui/Switch';
|
||||
import CreationHeader from './CreationHeader';
|
||||
import { useNewConvo } from '~/hooks';
|
||||
|
||||
export default function CreationPanel({ index = 0 }) {
|
||||
const { switchToConversation } = useNewConvo(index);
|
||||
const create = useCreateAssistantMutation();
|
||||
const { control, handleSubmit, reset, setValue } = useAssistantsContext();
|
||||
|
||||
const onSubmit = (data: CreationForm) => {
|
||||
const tools: Tool[] = [];
|
||||
console.log(data);
|
||||
if (data.function) {
|
||||
tools.push({ type: Tools.function });
|
||||
}
|
||||
if (data.code_interpreter) {
|
||||
tools.push({ type: Tools.code_interpreter });
|
||||
}
|
||||
if (data.retrieval) {
|
||||
tools.push({ type: Tools.retrieval });
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
description,
|
||||
instructions,
|
||||
model,
|
||||
// file_ids,
|
||||
} = data;
|
||||
|
||||
create.mutate({
|
||||
name,
|
||||
description,
|
||||
instructions,
|
||||
model,
|
||||
tools,
|
||||
});
|
||||
};
|
||||
|
||||
const assistant_id = useWatch({ control, name: 'id' });
|
||||
|
||||
// Render function for the Switch component
|
||||
const renderSwitch = (name: keyof Actions) => (
|
||||
<Controller
|
||||
name={name}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className="relative inline-flex h-6 w-11 items-center rounded-full data-[state=checked]:bg-green-500"
|
||||
value={field?.value?.toString()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="h-auto w-1/3 flex-shrink-0 overflow-x-hidden"
|
||||
>
|
||||
<Controller
|
||||
name="assistant"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<CreationHeader
|
||||
reset={reset}
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
setValue={setValue}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<div className="h-auto bg-white px-8 pb-8 pt-6">
|
||||
{/* Name */}
|
||||
<div className="mb-4">
|
||||
<label className="mb-2 block text-xs font-bold text-gray-700" htmlFor="name">
|
||||
Name
|
||||
</label>
|
||||
<Controller
|
||||
name="name"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<input
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
{...{ max: 256 }}
|
||||
className="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 text-sm leading-tight text-gray-700 shadow focus:border-green-500 focus:outline-none focus:ring-0"
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder="Optional: The name of the assistant"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="id"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<p className="h-3 text-xs italic text-gray-600">{field.value ?? ''}</p>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* Description */}
|
||||
<div className="mb-4">
|
||||
<label className="mb-2 block text-xs font-bold text-gray-700" htmlFor="description">
|
||||
Description
|
||||
</label>
|
||||
<Controller
|
||||
name="description"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<input
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
{...{ max: 512 }}
|
||||
className="focus:shadow-outline w-full appearance-none rounded border px-3 py-2 text-sm leading-tight text-gray-700 shadow focus:border-green-500 focus:outline-none focus:ring-0"
|
||||
id="description"
|
||||
type="text"
|
||||
placeholder="Optional: Describe your Assistant here"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Instructions */}
|
||||
<div className="mb-6">
|
||||
<label className="mb-2 block text-xs font-bold text-gray-700" htmlFor="instructions">
|
||||
Instructions
|
||||
</label>
|
||||
<Controller
|
||||
name="instructions"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<textarea
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
{...{ max: 32768 }}
|
||||
className="focus:shadow-outline w-full resize-none appearance-none rounded border px-3 py-2 text-sm leading-tight text-gray-700 shadow focus:border-green-500 focus:outline-none focus:ring-0"
|
||||
id="instructions"
|
||||
placeholder="The system instructions that the assistant uses"
|
||||
rows={3}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div className="mb-6">
|
||||
<label className="mb-2 block text-xs font-bold text-gray-700" htmlFor="model">
|
||||
Model
|
||||
</label>
|
||||
<Controller
|
||||
name="model"
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<select
|
||||
{...field}
|
||||
className="focus:shadow-outline block w-full appearance-none rounded border border-gray-200 bg-white px-4 py-2 pr-8 text-sm leading-tight shadow hover:border-gray-100 focus:border-green-500 focus:outline-none focus:ring-0"
|
||||
id="model"
|
||||
>
|
||||
<option value="gpt-3.5-turbo-1106">gpt-3.5-turbo-1106</option>
|
||||
{/* Additional model options here */}
|
||||
</select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tools */}
|
||||
<div className="mb-6">
|
||||
<label className="mb-2 block text-xs font-bold text-gray-700">Tools</label>
|
||||
<div className="flex flex-col space-y-4">
|
||||
<Separator orientation="horizontal" className="bg-gray-100/50" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-700">Functions</span>
|
||||
{renderSwitch('function')}
|
||||
</div>
|
||||
<Separator orientation="horizontal" className="bg-gray-100/50" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-700">Code Interpreter</span>
|
||||
{renderSwitch('code_interpreter')}
|
||||
</div>
|
||||
<Separator orientation="horizontal" className="bg-gray-100/50" />
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-gray-700">Retrieval</span>
|
||||
{renderSwitch('retrieval')}
|
||||
</div>
|
||||
<Separator orientation="horizontal" className="bg-gray-100/50" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end">
|
||||
{/* Use Button */}
|
||||
<button
|
||||
className="focus:shadow-outline mx-2 rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0"
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
switchToConversation({
|
||||
endpoint: EModelEndpoint.assistant,
|
||||
conversationId: 'new',
|
||||
assistant_id,
|
||||
title: null,
|
||||
createdAt: '',
|
||||
updatedAt: '',
|
||||
});
|
||||
}}
|
||||
>
|
||||
Use
|
||||
</button>
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
className="focus:shadow-outline rounded bg-green-500 px-4 py-2 font-semibold text-white hover:bg-green-400 focus:border-green-500 focus:outline-none focus:ring-0"
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
7
client/src/components/Chat/Footer.tsx
Normal file
7
client/src/components/Chat/Footer.tsx
Normal file
|
@ -0,0 +1,7 @@
|
|||
export default function Footer() {
|
||||
return (
|
||||
<div className="relative px-2 py-2 text-center text-xs text-gray-600 dark:text-gray-300 md:px-[60px]">
|
||||
<span>ChatGPT can make mistakes. Consider checking important information.</span>
|
||||
</div>
|
||||
);
|
||||
}
|
20
client/src/components/Chat/Header.tsx
Normal file
20
client/src/components/Chat/Header.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import { useOutletContext } from 'react-router-dom';
|
||||
import type { ContextType } from '~/common';
|
||||
import { EndpointsMenu, PresetsMenu, NewChat } from './Menus';
|
||||
import HeaderOptions from './Input/HeaderOptions';
|
||||
|
||||
export default function Header() {
|
||||
const { navVisible } = useOutletContext<ContextType>();
|
||||
return (
|
||||
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white/95 p-2 font-semibold dark:bg-gray-800/90 dark:text-white">
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
{!navVisible && <NewChat />}
|
||||
<EndpointsMenu />
|
||||
<HeaderOptions />
|
||||
<PresetsMenu />
|
||||
</div>
|
||||
{/* Empty div for spacing */}
|
||||
<div />
|
||||
</div>
|
||||
);
|
||||
}
|
47
client/src/components/Chat/Input/ChatForm.tsx
Normal file
47
client/src/components/Chat/Input/ChatForm.tsx
Normal file
|
@ -0,0 +1,47 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import AttachFile from './Files/AttachFile';
|
||||
import StopButton from './StopButton';
|
||||
import SendButton from './SendButton';
|
||||
import Images from './Files/Images';
|
||||
import Textarea from './Textarea';
|
||||
import store from '~/store';
|
||||
|
||||
export default function ChatForm({ index = 0 }) {
|
||||
const [text, setText] = useRecoilState(store.textByIndex(index));
|
||||
const { ask, files, setFiles, conversation, isSubmitting, handleStopGenerating } =
|
||||
useChatContext();
|
||||
|
||||
const submitMessage = () => {
|
||||
ask({ text });
|
||||
setText('');
|
||||
};
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
submitMessage();
|
||||
}}
|
||||
className="stretch mx-2 flex flex-row gap-3 last:mb-2 md:mx-4 md:last:mb-6 lg:mx-auto lg:max-w-2xl xl:max-w-3xl"
|
||||
>
|
||||
<div className="relative flex h-full flex-1 items-stretch md:flex-col">
|
||||
<div className="flex w-full items-center">
|
||||
<div className="[&:has(textarea:focus)]:border-token-border-xheavy border-token-border-heavy shadow-xs dark:shadow-xs relative flex w-full flex-grow flex-col overflow-hidden rounded-2xl border border-black/10 bg-white shadow-[0_0_0_2px_rgba(255,255,255,0.95)] dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:shadow-[0_0_0_2px_rgba(52,53,65,0.95)] [&:has(textarea:focus)]:shadow-[0_2px_6px_rgba(0,0,0,.05)]">
|
||||
<Images files={files} setFiles={setFiles} />
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => setText(e.target.value)}
|
||||
setText={setText}
|
||||
submitMessage={submitMessage}
|
||||
endpoint={conversation?.endpoint}
|
||||
/>
|
||||
<AttachFile endpoint={conversation?.endpoint ?? ''} />
|
||||
{isSubmitting ? <StopButton stop={handleStopGenerating} /> : <SendButton text={text} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
24
client/src/components/Chat/Input/Files/AttachFile.tsx
Normal file
24
client/src/components/Chat/Input/Files/AttachFile.tsx
Normal file
|
@ -0,0 +1,24 @@
|
|||
import type { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { AttachmentIcon } from '~/components/svg';
|
||||
import { FileUpload } from '~/components/ui';
|
||||
import { useFileHandling } from '~/hooks';
|
||||
import { supportsFiles } from '~/common';
|
||||
|
||||
export default function AttachFile({ endpoint }: { endpoint: EModelEndpoint | '' }) {
|
||||
const { handleFileChange } = useFileHandling();
|
||||
if (!supportsFiles[endpoint]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-1 left-0 md:left-1">
|
||||
<FileUpload handleFileChange={handleFileChange} className="flex">
|
||||
<button className="btn relative p-0 text-black dark:text-white" aria-label="Attach files">
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
<AttachmentIcon />
|
||||
</div>
|
||||
</button>
|
||||
</FileUpload>
|
||||
</div>
|
||||
);
|
||||
}
|
56
client/src/components/Chat/Input/Files/DragDropOverlay.tsx
Normal file
56
client/src/components/Chat/Input/Files/DragDropOverlay.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
export default function DragDropOverlay() {
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-gray-100 opacity-80 dark:bg-gray-800 dark:text-gray-100">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 132 108"
|
||||
fill="none"
|
||||
width="132"
|
||||
height="108"
|
||||
>
|
||||
<g clipPath="url(#clip0_3605_64419)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M25.2025 29.3514C10.778 33.2165 8.51524 37.1357 11.8281 49.4995L13.4846 55.6814C16.7975 68.0453 20.7166 70.308 35.1411 66.443L43.3837 64.2344C57.8082 60.3694 60.0709 56.4502 56.758 44.0864L55.1016 37.9044C51.7887 25.5406 47.8695 23.2778 33.445 27.1428L29.3237 28.2471L25.2025 29.3514ZM18.1944 42.7244C18.8572 41.5764 20.325 41.1831 21.4729 41.8459L27.3517 45.24C28.4996 45.9027 28.8929 47.3706 28.2301 48.5185L24.836 54.3972C24.1733 55.5451 22.7054 55.9384 21.5575 55.2757C20.4096 54.613 20.0163 53.1451 20.6791 51.9972L22.8732 48.1969L19.0729 46.0028C17.925 45.3401 17.5317 43.8723 18.1944 42.7244ZM29.4091 56.3843C29.066 55.104 29.8258 53.7879 31.1062 53.4449L40.3791 50.9602C41.6594 50.6172 42.9754 51.377 43.3184 52.6573C43.6615 53.9376 42.9017 55.2536 41.6214 55.5967L32.3485 58.0813C31.0682 58.4244 29.7522 57.6646 29.4091 56.3843Z"
|
||||
fill="#AFC1FF"
|
||||
/>
|
||||
</g>
|
||||
<g clipPath="url(#clip1_3605_64419)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M86.8124 13.4036C81.0973 11.8722 78.5673 13.2649 77.0144 19.0603L68.7322 49.97C67.1793 55.7656 68.5935 58.2151 74.4696 59.7895L97.4908 65.958C103.367 67.5326 105.816 66.1184 107.406 60.1848L115.393 30.379C115.536 29.8456 115.217 29.2959 114.681 29.16C113.478 28.8544 112.435 28.6195 111.542 28.4183C106.243 27.2253 106.22 27.2201 109.449 20.7159C109.73 20.1507 109.426 19.4638 108.816 19.3004L86.8124 13.4036ZM87.2582 28.4311C86.234 28.1567 85.1812 28.7645 84.9067 29.7888C84.6323 30.813 85.2401 31.8658 86.2644 32.1403L101.101 36.1158C102.125 36.3902 103.178 35.7824 103.453 34.7581C103.727 33.7339 103.119 32.681 102.095 32.4066L87.2582 28.4311ZM82.9189 37.2074C83.1934 36.1831 84.2462 35.5753 85.2704 35.8497L100.107 39.8252C101.131 40.0996 101.739 41.1524 101.465 42.1767C101.19 43.201 100.137 43.8088 99.1132 43.5343L84.2766 39.5589C83.2523 39.2844 82.6445 38.2316 82.9189 37.2074ZM83.2826 43.2683C82.2584 42.9939 81.2056 43.6017 80.9311 44.626C80.6567 45.6502 81.2645 46.703 82.2888 46.9775L89.7071 48.9652C90.7313 49.2396 91.7841 48.6318 92.0586 47.6076C92.333 46.5833 91.7252 45.5305 90.7009 45.256L83.2826 43.2683Z"
|
||||
fill="#7989FF"
|
||||
/>
|
||||
</g>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M40.4004 71.8426C40.4004 57.2141 44.0575 53.5569 61.1242 53.5569H66.0004H70.8766C87.9432 53.5569 91.6004 57.2141 91.6004 71.8426V79.1569C91.6004 93.7855 87.9432 97.4426 70.8766 97.4426H61.1242C44.0575 97.4426 40.4004 93.7855 40.4004 79.1569V71.8426ZM78.8002 67.4995C78.8002 70.1504 76.6512 72.2995 74.0002 72.2995C71.3492 72.2995 69.2002 70.1504 69.2002 67.4995C69.2002 64.8485 71.3492 62.6995 74.0002 62.6995C76.6512 62.6995 78.8002 64.8485 78.8002 67.4995ZM60.7204 70.8597C60.2672 70.2553 59.5559 69.8997 58.8004 69.8997C58.045 69.8997 57.3337 70.2553 56.8804 70.8597L47.2804 83.6597C46.4851 84.72 46.7 86.2244 47.7604 87.0197C48.8208 87.8149 50.3251 87.6 51.1204 86.5397L58.8004 76.2997L66.4804 86.5397C66.8979 87.0962 67.5363 87.4443 68.2303 87.4936C68.9243 87.5429 69.6055 87.2887 70.0975 86.7967L74.8004 82.0938L79.5034 86.7967C80.4406 87.734 81.9602 87.734 82.8975 86.7967C83.8347 85.8595 83.8347 84.3399 82.8975 83.4026L76.4975 77.0026C75.5602 76.0653 74.0406 76.0653 73.1034 77.0026L68.6601 81.4459L60.7204 70.8597Z"
|
||||
fill="#3C46FF"
|
||||
/>
|
||||
<defs>
|
||||
<clipPath id="clip0_3605_64419">
|
||||
<rect
|
||||
width="56"
|
||||
height="56"
|
||||
fill="white"
|
||||
transform="translate(0 26.9939) rotate(-15)"
|
||||
/>
|
||||
</clipPath>
|
||||
<clipPath id="clip1_3605_64419">
|
||||
<rect
|
||||
width="64"
|
||||
height="64"
|
||||
fill="white"
|
||||
transform="translate(69.5645 0.5) rotate(15)"
|
||||
/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
<h3>Add anything</h3>
|
||||
<h4 className="w-2/3">Drop any file here to add it to the conversation</h4>
|
||||
</div>
|
||||
);
|
||||
}
|
113
client/src/components/Chat/Input/Files/Image.tsx
Normal file
113
client/src/components/Chat/Input/Files/Image.tsx
Normal file
|
@ -0,0 +1,113 @@
|
|||
type styleProps = {
|
||||
backgroundImage?: string;
|
||||
backgroundSize?: string;
|
||||
backgroundPosition?: string;
|
||||
backgroundRepeat?: string;
|
||||
};
|
||||
|
||||
const Image = ({
|
||||
imageBase64,
|
||||
url,
|
||||
onDelete,
|
||||
progress = 1,
|
||||
}: {
|
||||
imageBase64?: string;
|
||||
url?: string;
|
||||
onDelete: () => void;
|
||||
progress: number; // between 0 and 1
|
||||
}) => {
|
||||
let style: styleProps = {
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
};
|
||||
if (imageBase64) {
|
||||
style = {
|
||||
...style,
|
||||
backgroundImage: `url(${imageBase64})`,
|
||||
};
|
||||
} else if (url) {
|
||||
style = {
|
||||
...style,
|
||||
backgroundImage: `url(${url})`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!style.backgroundImage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const radius = 55; // Radius of the SVG circle
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
|
||||
// Calculate the offset based on the loading progress
|
||||
const offset = circumference - progress * circumference;
|
||||
const circleCSSProperties = {
|
||||
transition: 'stroke-dashoffset 0.3s linear',
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative inline-block text-sm text-black/70 dark:text-white/90">
|
||||
<div className="relative overflow-hidden rounded-xl border border-gray-200 dark:border-gray-600">
|
||||
<div className="h-14 w-14">
|
||||
<button
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded="false"
|
||||
className="h-full w-full"
|
||||
style={style}
|
||||
/>
|
||||
{progress < 1 && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/5 text-white">
|
||||
<svg width="120" height="120" viewBox="0 0 120 120" className="h-6 w-6">
|
||||
<circle
|
||||
className="origin-[50%_50%] -rotate-90 stroke-gray-400"
|
||||
strokeWidth="10"
|
||||
fill="transparent"
|
||||
r="55"
|
||||
cx="60"
|
||||
cy="60"
|
||||
/>
|
||||
{/* <circle className="origin-[50%_50%] -rotate-90 transition-[stroke-dashoffset]" stroke="currentColor" strokeWidth="10" strokeDashoffset="311.01767270538954" strokeDasharray="345.57519189487726 345.57519189487726" fill="transparent" r="55" cx="60" cy="60"/>*/}
|
||||
<circle
|
||||
className="origin-[50%_50%] -rotate-90 transition-[stroke-dashoffset]"
|
||||
stroke="currentColor"
|
||||
strokeWidth="10"
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={offset}
|
||||
fill="transparent"
|
||||
r="55"
|
||||
cx="60"
|
||||
cy="60"
|
||||
style={circleCSSProperties}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="absolute right-1 top-1 -translate-y-1/2 translate-x-1/2 rounded-full border border-white bg-gray-500 p-0.5 text-white transition-colors hover:bg-black hover:opacity-100 group-hover:opacity-100 md:opacity-0"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<span>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="icon-sm"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Image;
|
29
client/src/components/Chat/Input/Files/Images.tsx
Normal file
29
client/src/components/Chat/Input/Files/Images.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
import Image from './Image';
|
||||
import { ExtendedFile } from '~/common';
|
||||
|
||||
export default function Images({
|
||||
files,
|
||||
setFiles,
|
||||
}: {
|
||||
files: ExtendedFile[];
|
||||
setFiles: React.Dispatch<React.SetStateAction<ExtendedFile[]>>;
|
||||
}) {
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-2 mt-2 flex flex-wrap gap-2 px-2.5 md:pl-0 md:pr-4">
|
||||
{files.map((file: ExtendedFile, index: number) => {
|
||||
const handleDelete = () => {
|
||||
setFiles((currentFiles) =>
|
||||
currentFiles.filter((_file) => file.preview !== _file.preview),
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Image key={index} url={file.preview} onDelete={handleDelete} progress={file.progress} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
86
client/src/components/Chat/Input/GenerationButtons.tsx
Normal file
86
client/src/components/Chat/Input/GenerationButtons.tsx
Normal file
|
@ -0,0 +1,86 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { useMediaQuery, useGenerationsByLatest } from '~/hooks';
|
||||
import Regenerate from '~/components/Input/Generations/Regenerate';
|
||||
import Continue from '~/components/Input/Generations/Continue';
|
||||
import Stop from '~/components/Input/Generations/Stop';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type GenerationButtonsProps = {
|
||||
endpoint: string;
|
||||
showPopover?: boolean;
|
||||
opacityClass?: string;
|
||||
};
|
||||
|
||||
export default function GenerationButtons({
|
||||
endpoint,
|
||||
showPopover = false,
|
||||
opacityClass = 'full-opacity',
|
||||
}: GenerationButtonsProps) {
|
||||
const {
|
||||
getMessages,
|
||||
isSubmitting,
|
||||
latestMessage,
|
||||
handleContinue,
|
||||
handleRegenerate,
|
||||
handleStopGenerating,
|
||||
} = useChatContext();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 768px)');
|
||||
const { continueSupported, regenerateEnabled } = useGenerationsByLatest({
|
||||
endpoint,
|
||||
message: latestMessage as TMessage,
|
||||
isSubmitting,
|
||||
latestMessage,
|
||||
});
|
||||
|
||||
const [userStopped, setUserStopped] = useState(false);
|
||||
const messages = getMessages();
|
||||
|
||||
const handleStop = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
setUserStopped(true);
|
||||
handleStopGenerating(e);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
let timer: NodeJS.Timeout;
|
||||
|
||||
if (userStopped) {
|
||||
timer = setTimeout(() => {
|
||||
setUserStopped(false);
|
||||
}, 200);
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [userStopped]);
|
||||
|
||||
if (isSmallScreen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let button: React.ReactNode = null;
|
||||
|
||||
if (isSubmitting) {
|
||||
button = <Stop onClick={handleStop} />;
|
||||
} else if (userStopped || continueSupported) {
|
||||
button = <Continue onClick={handleContinue} />;
|
||||
} else if (messages && messages.length > 0 && regenerateEnabled) {
|
||||
button = <Regenerate onClick={handleRegenerate} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute bottom-0 right-0 z-[62]">
|
||||
<div className="grow" />
|
||||
<div className="flex items-center md:items-end">
|
||||
<div
|
||||
className={cn('option-buttons', showPopover ? '' : opacityClass)}
|
||||
data-projection-id="173"
|
||||
>
|
||||
{button}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
120
client/src/components/Chat/Input/HeaderOptions.tsx
Normal file
120
client/src/components/Chat/Input/HeaderOptions.tsx
Normal file
|
@ -0,0 +1,120 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { Root, Anchor } from '@radix-ui/react-popover';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { tPresetSchema, EModelEndpoint } from 'librechat-data-provider';
|
||||
import { EndpointSettings, SaveAsPresetDialog } from '~/components/Endpoints';
|
||||
import { ModelSelect } from '~/components/Input/ModelSelect';
|
||||
import { PluginStoreDialog } from '~/components';
|
||||
import OptionsPopover from './OptionsPopover';
|
||||
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() {
|
||||
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 (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 (
|
||||
<Root
|
||||
open={showPopover}
|
||||
// onOpenChange={} // called when the open state of the popover changes.
|
||||
>
|
||||
<Anchor>
|
||||
<div className="my-auto lg:max-w-2xl xl:max-w-3xl">
|
||||
<span className="flex w-full flex-col items-center justify-center gap-0 md:order-none md:m-auto md:gap-2">
|
||||
<div className="z-[61] flex w-full items-center justify-center gap-2">
|
||||
<ModelSelect
|
||||
conversation={conversation}
|
||||
setOption={setOption}
|
||||
isMultiChat={true}
|
||||
showAbove={false}
|
||||
/>
|
||||
{!noSettings[endpoint] && (
|
||||
<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',
|
||||
'hover:bg-gray-50 radix-state-open:bg-gray-50 dark:hover:bg-black/10 dark:radix-state-open:bg-black/20',
|
||||
)}
|
||||
onClick={triggerAdvancedMode}
|
||||
>
|
||||
<Settings2 className="w-4 text-gray-600 dark:text-white" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<OptionsPopover
|
||||
visible={showPopover}
|
||||
saveAsPreset={saveAsPreset}
|
||||
closePopover={() => setShowPopover(false)}
|
||||
PopoverButtons={<PopoverButtons endpoint={endpoint} />}
|
||||
>
|
||||
<div className="px-4 py-4">
|
||||
<EndpointSettings
|
||||
conversation={conversation}
|
||||
setOption={setOption}
|
||||
isMultiChat={true}
|
||||
/>
|
||||
</div>
|
||||
</OptionsPopover>
|
||||
<SaveAsPresetDialog
|
||||
open={saveAsDialogShow}
|
||||
onOpenChange={setSaveAsDialogShow}
|
||||
preset={tPresetSchema.parse({ ...conversation })}
|
||||
/>
|
||||
<PluginStoreDialog
|
||||
isOpen={showPluginStoreDialog}
|
||||
setIsOpen={setShowPluginStoreDialog}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
</Anchor>
|
||||
</Root>
|
||||
);
|
||||
}
|
173
client/src/components/Chat/Input/OptionsBar.tsx
Normal file
173
client/src/components/Chat/Input/OptionsBar.tsx
Normal file
|
@ -0,0 +1,173 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { Settings2 } from 'lucide-react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { tPresetSchema, EModelEndpoint } 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,
|
||||
textareaHeight,
|
||||
} = 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"
|
||||
style={{
|
||||
// TODO: option to hide footer and handle this
|
||||
// bottom: `${80 + (textareaHeight - 56)}px`, // without footer
|
||||
bottom: `${85 + (textareaHeight - 56)}px`, // with footer
|
||||
}}
|
||||
>
|
||||
<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 endpoint={endpoint} />}
|
||||
>
|
||||
<div className="px-4 py-4">
|
||||
<EndpointSettings
|
||||
conversation={conversation}
|
||||
setOption={setOption}
|
||||
isMultiChat={true}
|
||||
/>
|
||||
</div>
|
||||
</EndpointOptionsPopover>
|
||||
<SaveAsPresetDialog
|
||||
open={saveAsDialogShow}
|
||||
onOpenChange={setSaveAsDialogShow}
|
||||
preset={tPresetSchema.parse({ ...conversation })}
|
||||
/>
|
||||
<PluginStoreDialog isOpen={showPluginStoreDialog} setIsOpen={setShowPluginStoreDialog} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
83
client/src/components/Chat/Input/OptionsPopover.tsx
Normal file
83
client/src/components/Chat/Input/OptionsPopover.tsx
Normal file
|
@ -0,0 +1,83 @@
|
|||
import { useRef } from 'react';
|
||||
import { Save } from 'lucide-react';
|
||||
import { Portal, Content } from '@radix-ui/react-popover';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLocalize, useOnClickOutside } from '~/hooks';
|
||||
import { cn, removeFocusOutlines } from '~/utils';
|
||||
import { CrossIcon } from '~/components/svg';
|
||||
import { Button } from '~/components/ui';
|
||||
|
||||
type TOptionsPopoverProps = {
|
||||
children: ReactNode;
|
||||
visible: boolean;
|
||||
saveAsPreset: () => void;
|
||||
closePopover: () => void;
|
||||
PopoverButtons: ReactNode;
|
||||
};
|
||||
|
||||
export default function OptionsPopover({
|
||||
children,
|
||||
// endpoint,
|
||||
visible,
|
||||
saveAsPreset,
|
||||
closePopover,
|
||||
PopoverButtons,
|
||||
}: TOptionsPopoverProps) {
|
||||
const popoverRef = useRef(null);
|
||||
useOnClickOutside(
|
||||
popoverRef,
|
||||
() => closePopover(),
|
||||
['dialog-template-content', 'shadcn-button', 'advanced-settings'],
|
||||
(target) => {
|
||||
const tagName = (target as Element)?.tagName;
|
||||
return tagName === 'path' || tagName === 'svg' || tagName === 'circle';
|
||||
},
|
||||
);
|
||||
|
||||
const localize = useLocalize();
|
||||
const cardStyle =
|
||||
'shadow-xl rounded-md min-w-[75px] font-normal bg-white border-black/10 border dark:bg-gray-700 text-black dark:text-white';
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<Content sideOffset={8} align="start" ref={popoverRef} asChild>
|
||||
<div className="z-0 flex w-full flex-col items-center md:px-4">
|
||||
<div
|
||||
className={cn(
|
||||
cardStyle,
|
||||
'dark:bg-gray-900',
|
||||
'border-d-0 flex w-full flex-col overflow-hidden rounded-none border-s-0 border-t bg-white px-0 pb-[10px] dark:border-white/10 md:rounded-md md:border lg:w-[736px]',
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center bg-slate-100 px-2 py-2 dark:bg-gray-800/60">
|
||||
<Button
|
||||
type="button"
|
||||
className="h-auto justify-start bg-transparent px-2 py-1 text-xs font-medium font-normal text-black hover:bg-slate-200 hover:text-black focus:ring-0 dark:bg-transparent dark:text-white dark:hover:bg-gray-700 dark:hover:text-white dark:focus:outline-none dark:focus:ring-offset-0"
|
||||
onClick={saveAsPreset}
|
||||
>
|
||||
<Save className="mr-1 w-[14px]" />
|
||||
{localize('com_endpoint_save_as_preset')}
|
||||
</Button>
|
||||
{PopoverButtons}
|
||||
<Button
|
||||
type="button"
|
||||
className={cn(
|
||||
'ml-auto h-auto bg-transparent px-3 py-2 text-xs font-medium font-normal text-black hover:bg-slate-200 hover:text-black dark:bg-transparent dark:text-white dark:hover:bg-gray-700 dark:hover:text-white',
|
||||
removeFocusOutlines,
|
||||
)}
|
||||
onClick={closePopover}
|
||||
>
|
||||
<CrossIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Content>
|
||||
</Portal>
|
||||
);
|
||||
}
|
74
client/src/components/Chat/Input/PopoverButtons.tsx
Normal file
74
client/src/components/Chat/Input/PopoverButtons.tsx
Normal file
|
@ -0,0 +1,74 @@
|
|||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { ReactNode } from 'react';
|
||||
import { MessagesSquared, GPTIcon } from '~/components/svg';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { Button } from '~/components';
|
||||
import { cn } from '~/utils/';
|
||||
|
||||
type TPopoverButton = {
|
||||
label: string;
|
||||
buttonClass: string;
|
||||
handler: () => void;
|
||||
icon: ReactNode;
|
||||
};
|
||||
|
||||
export default function PopoverButtons({
|
||||
endpoint,
|
||||
buttonClass,
|
||||
iconClass = '',
|
||||
}: {
|
||||
endpoint: EModelEndpoint;
|
||||
buttonClass?: string;
|
||||
iconClass?: string;
|
||||
}) {
|
||||
const { optionSettings, setOptionSettings, showAgentSettings, setShowAgentSettings } =
|
||||
useChatContext();
|
||||
|
||||
const { showExamples, isCodeChat } = optionSettings;
|
||||
const triggerExamples = () =>
|
||||
setOptionSettings((prev) => ({ ...prev, showExamples: !prev.showExamples }));
|
||||
|
||||
const buttons: { [key: string]: TPopoverButton[] } = {
|
||||
[EModelEndpoint.google]: [
|
||||
{
|
||||
label: (showExamples ? 'Hide' : 'Show') + ' Examples',
|
||||
buttonClass: isCodeChat ? 'disabled' : '',
|
||||
handler: triggerExamples,
|
||||
icon: <MessagesSquared className={cn('mr-1 w-[14px]', iconClass)} />,
|
||||
},
|
||||
],
|
||||
[EModelEndpoint.gptPlugins]: [
|
||||
{
|
||||
label: `Show ${showAgentSettings ? 'Completion' : 'Agent'} Settings`,
|
||||
buttonClass: '',
|
||||
handler: () => setShowAgentSettings((prev) => !prev),
|
||||
icon: <GPTIcon className={cn('mr-1 mt-[2px] w-[14px]', iconClass)} size={14} />,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
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-slate-200 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>
|
||||
);
|
||||
}
|
16
client/src/components/Chat/Input/SendButton.tsx
Normal file
16
client/src/components/Chat/Input/SendButton.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { SendIcon } from '~/components/svg';
|
||||
|
||||
export default function SendButton({ text }) {
|
||||
return (
|
||||
<button
|
||||
disabled={!text}
|
||||
className="enabled:bg-brand-purple absolute bottom-2.5 right-1.5 rounded-lg rounded-md border border-black p-0.5 p-1 text-white transition-colors enabled:bg-black disabled:bg-black disabled:text-gray-400 disabled:opacity-10 dark:border-white dark:bg-white dark:disabled:bg-white md:bottom-3 md:right-3 md:p-[2px]"
|
||||
data-testid="send-button"
|
||||
type="submit"
|
||||
>
|
||||
<span className="" data-state="closed">
|
||||
<SendIcon size={24} />
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
30
client/src/components/Chat/Input/StopButton.tsx
Normal file
30
client/src/components/Chat/Input/StopButton.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
export default function StopButton({ stop }) {
|
||||
return (
|
||||
<div className="absolute bottom-0 right-2 top-0 p-1 md:right-3 md:p-2">
|
||||
<div className="flex h-full">
|
||||
<div className="flex h-full flex-row items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="border-gizmo-gray-950 rounded-full border-2 p-1 dark:border-gray-200"
|
||||
aria-label="Stop generating"
|
||||
onClick={stop}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
className="text-gizmo-gray-950 h-2 w-2 dark:text-gray-200"
|
||||
height="16"
|
||||
width="16"
|
||||
>
|
||||
<path
|
||||
d="M0 2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v12a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2z"
|
||||
strokeWidth="0"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
42
client/src/components/Chat/Input/Textarea.tsx
Normal file
42
client/src/components/Chat/Input/Textarea.tsx
Normal file
|
@ -0,0 +1,42 @@
|
|||
import TextareaAutosize from 'react-textarea-autosize';
|
||||
import { supportsFiles } from '~/common';
|
||||
import { useTextarea } from '~/hooks';
|
||||
|
||||
export default function Textarea({ value, onChange, setText, submitMessage, endpoint }) {
|
||||
const {
|
||||
inputRef,
|
||||
handleKeyDown,
|
||||
handleKeyUp,
|
||||
handleCompositionStart,
|
||||
handleCompositionEnd,
|
||||
onHeightChange,
|
||||
placeholder,
|
||||
} = useTextarea({ setText, submitMessage });
|
||||
|
||||
const className = supportsFiles[endpoint]
|
||||
? 'm-0 w-full resize-none border-0 bg-transparent py-3.5 pr-10 focus:ring-0 focus-visible:ring-0 dark:bg-transparent placeholder-black/50 dark:placeholder-white/50 pl-10 md:py-3.5 md:pr-12 md:pl-[55px]'
|
||||
: 'm-0 w-full resize-none border-0 bg-transparent py-[10px] pr-10 focus:ring-0 focus-visible:ring-0 dark:bg-transparent md:py-4 md:pr-12 gizmo:md:py-3.5 gizmo:placeholder-black/50 gizmo:dark:placeholder-white/50 pl-3 md:pl-4';
|
||||
|
||||
return (
|
||||
<TextareaAutosize
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onKeyUp={handleKeyUp}
|
||||
onKeyDown={handleKeyDown}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onHeightChange={onHeightChange}
|
||||
id="prompt-textarea"
|
||||
tabIndex={0}
|
||||
data-testid="text-input"
|
||||
// style={{ maxHeight: '200px', height: '52px', overflowY: 'hidden' }}
|
||||
rows={1}
|
||||
placeholder={placeholder}
|
||||
// className="m-0 w-full resize-none border-0 bg-transparent py-[10px] pr-10 focus:ring-0 focus-visible:ring-0 dark:bg-transparent md:py-4 md:pr-12 gizmo:md:py-3.5 gizmo:placeholder-black/50 gizmo:dark:placeholder-white/50 pl-12 gizmo:pl-10 md:pl-[46px] gizmo:md:pl-[55px]"
|
||||
// className="gizmo:md:py-3.5 gizmo:placeholder-black/50 gizmo:dark:placeholder-white/50 gizmo:pl-10 gizmo:md:pl-[55px] m-0 h-auto max-h-52 w-full resize-none overflow-y-hidden border-0 bg-transparent py-[10px] pl-12 pr-10 focus:ring-0 focus-visible:ring-0 dark:bg-transparent md:py-4 md:pl-[46px] md:pr-12"
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
32
client/src/components/Chat/Landing.tsx
Normal file
32
client/src/components/Chat/Landing.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import type { ReactNode } from 'react';
|
||||
export default function Landing({ Header }: { Header?: ReactNode }) {
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
<div className="absolute left-0 right-0">{Header && Header}</div>
|
||||
<div className="flex h-full flex-col items-center justify-center">
|
||||
<div className="mb-3 h-[72px] w-[72px]">
|
||||
<div className="gizmo-shadow-stroke relative flex h-full items-center justify-center rounded-full bg-white text-black">
|
||||
<svg
|
||||
width="41"
|
||||
height="41"
|
||||
viewBox="0 0 41 41"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-2/3 w-2/3"
|
||||
role="img"
|
||||
>
|
||||
<text x="-9999" y="-9999">
|
||||
ChatGPT
|
||||
</text>
|
||||
<path
|
||||
d="M37.5324 16.8707C37.9808 15.5241 38.1363 14.0974 37.9886 12.6859C37.8409 11.2744 37.3934 9.91076 36.676 8.68622C35.6126 6.83404 33.9882 5.3676 32.0373 4.4985C30.0864 3.62941 27.9098 3.40259 25.8215 3.85078C24.8796 2.7893 23.7219 1.94125 22.4257 1.36341C21.1295 0.785575 19.7249 0.491269 18.3058 0.500197C16.1708 0.495044 14.0893 1.16803 12.3614 2.42214C10.6335 3.67624 9.34853 5.44666 8.6917 7.47815C7.30085 7.76286 5.98686 8.3414 4.8377 9.17505C3.68854 10.0087 2.73073 11.0782 2.02839 12.312C0.956464 14.1591 0.498905 16.2988 0.721698 18.4228C0.944492 20.5467 1.83612 22.5449 3.268 24.1293C2.81966 25.4759 2.66413 26.9026 2.81182 28.3141C2.95951 29.7256 3.40701 31.0892 4.12437 32.3138C5.18791 34.1659 6.8123 35.6322 8.76321 36.5013C10.7141 37.3704 12.8907 37.5973 14.9789 37.1492C15.9208 38.2107 17.0786 39.0587 18.3747 39.6366C19.6709 40.2144 21.0755 40.5087 22.4946 40.4998C24.6307 40.5054 26.7133 39.8321 28.4418 38.5772C30.1704 37.3223 31.4556 35.5506 32.1119 33.5179C33.5027 33.2332 34.8167 32.6547 35.9659 31.821C37.115 30.9874 38.0728 29.9178 38.7752 28.684C39.8458 26.8371 40.3023 24.6979 40.0789 22.5748C39.8556 20.4517 38.9639 18.4544 37.5324 16.8707ZM22.4978 37.8849C20.7443 37.8874 19.0459 37.2733 17.6994 36.1501C17.7601 36.117 17.8666 36.0586 17.936 36.0161L25.9004 31.4156C26.1003 31.3019 26.2663 31.137 26.3813 30.9378C26.4964 30.7386 26.5563 30.5124 26.5549 30.2825V19.0542L29.9213 20.998C29.9389 21.0068 29.9541 21.0198 29.9656 21.0359C29.977 21.052 29.9842 21.0707 29.9867 21.0902V30.3889C29.9842 32.375 29.1946 34.2791 27.7909 35.6841C26.3872 37.0892 24.4838 37.8806 22.4978 37.8849ZM6.39227 31.0064C5.51397 29.4888 5.19742 27.7107 5.49804 25.9832C5.55718 26.0187 5.66048 26.0818 5.73461 26.1244L13.699 30.7248C13.8975 30.8408 14.1233 30.902 14.3532 30.902C14.583 30.902 14.8088 30.8408 15.0073 30.7248L24.731 25.1103V28.9979C24.7321 29.0177 24.7283 29.0376 24.7199 29.0556C24.7115 29.0736 24.6988 29.0893 24.6829 29.1012L16.6317 33.7497C14.9096 34.7416 12.8643 35.0097 10.9447 34.4954C9.02506 33.9811 7.38785 32.7263 6.39227 31.0064ZM4.29707 13.6194C5.17156 12.0998 6.55279 10.9364 8.19885 10.3327C8.19885 10.4013 8.19491 10.5228 8.19491 10.6071V19.808C8.19351 20.0378 8.25334 20.2638 8.36823 20.4629C8.48312 20.6619 8.64893 20.8267 8.84863 20.9404L18.5723 26.5542L15.206 28.4979C15.1894 28.5089 15.1703 28.5155 15.1505 28.5173C15.1307 28.5191 15.1107 28.516 15.0924 28.5082L7.04046 23.8557C5.32135 22.8601 4.06716 21.2235 3.55289 19.3046C3.03862 17.3858 3.30624 15.3413 4.29707 13.6194ZM31.955 20.0556L22.2312 14.4411L25.5976 12.4981C25.6142 12.4872 25.6333 12.4805 25.6531 12.4787C25.6729 12.4769 25.6928 12.4801 25.7111 12.4879L33.7631 17.1364C34.9967 17.849 36.0017 18.8982 36.6606 20.1613C37.3194 21.4244 37.6047 22.849 37.4832 24.2684C37.3617 25.6878 36.8382 27.0432 35.9743 28.1759C35.1103 29.3086 33.9415 30.1717 32.6047 30.6641C32.6047 30.5947 32.6047 30.4733 32.6047 30.3889V21.188C32.6066 20.9586 32.5474 20.7328 32.4332 20.5338C32.319 20.3348 32.154 20.1698 31.955 20.0556ZM35.3055 15.0128C35.2464 14.9765 35.1431 14.9142 35.069 14.8717L27.1045 10.2712C26.906 10.1554 26.6803 10.0943 26.4504 10.0943C26.2206 10.0943 25.9948 10.1554 25.7963 10.2712L16.0726 15.8858V11.9982C16.0715 11.9783 16.0753 11.9585 16.0837 11.9405C16.0921 11.9225 16.1048 11.9068 16.1207 11.8949L24.1719 7.25025C25.4053 6.53903 26.8158 6.19376 28.2383 6.25482C29.6608 6.31589 31.0364 6.78077 32.2044 7.59508C33.3723 8.40939 34.2842 9.53945 34.8334 10.8531C35.3826 12.1667 35.5464 13.6095 35.3055 15.0128ZM14.2424 21.9419L10.8752 19.9981C10.8576 19.9893 10.8423 19.9763 10.8309 19.9602C10.8195 19.9441 10.8122 19.9254 10.8098 19.9058V10.6071C10.8107 9.18295 11.2173 7.78848 11.9819 6.58696C12.7466 5.38544 13.8377 4.42659 15.1275 3.82264C16.4173 3.21869 17.8524 2.99464 19.2649 3.1767C20.6775 3.35876 22.0089 3.93941 23.1034 4.85067C23.0427 4.88379 22.937 4.94215 22.8668 4.98473L14.9024 9.58517C14.7025 9.69878 14.5366 9.86356 14.4215 10.0626C14.3065 10.2616 14.2466 10.4877 14.2479 10.7175L14.2424 21.9419ZM16.071 17.9991L20.4018 15.4978L24.7325 17.9975V22.9985L20.4018 25.4983L16.071 22.9985V17.9991Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-5 text-2xl font-medium dark:text-white">How can I help you today?</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
43
client/src/components/Chat/Menus/Endpoints/Icons.tsx
Normal file
43
client/src/components/Chat/Menus/Endpoints/Icons.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import {
|
||||
MinimalPlugin,
|
||||
GPTIcon,
|
||||
AnthropicIcon,
|
||||
AzureMinimalIcon,
|
||||
BingAIMinimalIcon,
|
||||
PaLMinimalIcon,
|
||||
LightningIcon,
|
||||
} from '~/components/svg';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export const icons = {
|
||||
[EModelEndpoint.azureOpenAI]: AzureMinimalIcon,
|
||||
[EModelEndpoint.openAI]: GPTIcon,
|
||||
[EModelEndpoint.gptPlugins]: MinimalPlugin,
|
||||
[EModelEndpoint.anthropic]: AnthropicIcon,
|
||||
[EModelEndpoint.chatGPTBrowser]: LightningIcon,
|
||||
[EModelEndpoint.google]: PaLMinimalIcon,
|
||||
[EModelEndpoint.bingAI]: BingAIMinimalIcon,
|
||||
[EModelEndpoint.assistant]: ({ className = '' }) => (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={cn('icon-md shrink-0', className)}
|
||||
>
|
||||
<path
|
||||
d="M19.3975 1.35498C19.3746 1.15293 19.2037 1.00021 19.0004 1C18.7971 0.999793 18.6259 1.15217 18.6026 1.35417C18.4798 2.41894 18.1627 3.15692 17.6598 3.65983C17.1569 4.16274 16.4189 4.47983 15.3542 4.60264C15.1522 4.62593 14.9998 4.79707 15 5.00041C15.0002 5.20375 15.1529 5.37457 15.355 5.39746C16.4019 5.51605 17.1562 5.83304 17.6716 6.33906C18.1845 6.84269 18.5078 7.57998 18.6016 8.63539C18.6199 8.84195 18.7931 9.00023 19.0005 9C19.2078 8.99977 19.3806 8.84109 19.3985 8.6345C19.4883 7.59673 19.8114 6.84328 20.3273 6.32735C20.8433 5.81142 21.5967 5.48834 22.6345 5.39851C22.8411 5.38063 22.9998 5.20782 23 5.00045C23.0002 4.79308 22.842 4.61992 22.6354 4.60157C21.58 4.50782 20.8427 4.18447 20.3391 3.67157C19.833 3.15623 19.516 2.40192 19.3975 1.35498Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M11 3C11.4833 3 11.8974 3.34562 11.9839 3.82111C12.4637 6.46043 13.279 8.23983 14.5196 9.48039C15.7602 10.721 17.5396 11.5363 20.1789 12.0161C20.6544 12.1026 21 12.5167 21 13C21 13.4833 20.6544 13.8974 20.1789 13.9839C17.5396 14.4637 15.7602 15.279 14.5196 16.5196C13.279 17.7602 12.4637 19.5396 11.9839 22.1789C11.8974 22.6544 11.4833 23 11 23C10.5167 23 10.1026 22.6544 10.0161 22.1789C9.53625 19.5396 8.72096 17.7602 7.48039 16.5196C6.23983 15.279 4.46043 14.4637 1.82111 13.9839C1.34562 13.8974 1 13.4833 1 13C1 12.5167 1.34562 12.1026 1.82111 12.0161C4.46043 11.5363 6.23983 10.721 7.48039 9.48039C8.72096 8.23983 9.53625 6.46043 10.0161 3.82111C10.1026 3.34562 10.5167 3 11 3ZM5.66618 13C6.9247 13.5226 7.99788 14.2087 8.89461 15.1054C9.79134 16.0021 10.4774 17.0753 11 18.3338C11.5226 17.0753 12.2087 16.0021 13.1054 15.1054C14.0021 14.2087 15.0753 13.5226 16.3338 13C15.0753 12.4774 14.0021 11.7913 13.1054 10.8946C12.2087 9.99788 11.5226 8.9247 11 7.66618C10.4774 8.9247 9.79134 9.99788 8.89461 10.8946C7.99788 11.7913 6.9247 12.4774 5.66618 13Z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
),
|
||||
unknown: GPTIcon,
|
||||
};
|
139
client/src/components/Chat/Menus/Endpoints/MenuItem.tsx
Normal file
139
client/src/components/Chat/Menus/Endpoints/MenuItem.tsx
Normal file
|
@ -0,0 +1,139 @@
|
|||
import { useState } from 'react';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { FC } from 'react';
|
||||
import { useLocalize, useUserKey, useNewConvo, useOriginNavigate } from '~/hooks';
|
||||
import { SetKeyDialog } from '~/components/Input/SetKeyDialog';
|
||||
import { icons } from './Icons';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type MenuItemProps = {
|
||||
title: string;
|
||||
value: EModelEndpoint;
|
||||
selected: boolean;
|
||||
description?: string;
|
||||
userProvidesKey: boolean;
|
||||
// iconPath: string;
|
||||
// hoverContent?: string;
|
||||
};
|
||||
|
||||
const MenuItem: FC<MenuItemProps> = ({
|
||||
title,
|
||||
value: endpoint,
|
||||
description,
|
||||
selected,
|
||||
userProvidesKey,
|
||||
...rest
|
||||
}) => {
|
||||
const Icon = icons[endpoint] ?? icons.unknown;
|
||||
const [isDialogOpen, setDialogOpen] = useState(false);
|
||||
const { getExpiry } = useUserKey(endpoint);
|
||||
const { newConversation } = useNewConvo();
|
||||
const navigate = useOriginNavigate();
|
||||
const localize = useLocalize();
|
||||
const expiryTime = getExpiry();
|
||||
|
||||
const onSelectEndpoint = (newEndpoint: EModelEndpoint) => {
|
||||
if (!newEndpoint) {
|
||||
return;
|
||||
} else {
|
||||
if (!expiryTime) {
|
||||
setDialogOpen(true);
|
||||
}
|
||||
newConversation({ template: { endpoint: newEndpoint } });
|
||||
navigate('new');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="menuitem"
|
||||
className="group m-1.5 flex cursor-pointer gap-2 rounded px-5 py-2.5 !pr-3 text-sm !opacity-100 hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5"
|
||||
tabIndex={-1}
|
||||
{...rest}
|
||||
onClick={() => onSelectEndpoint(endpoint)}
|
||||
>
|
||||
<div className="flex grow items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
{<Icon size={18} className="icon-md shrink-0 dark:text-white" />}
|
||||
{/* <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="icon-md shrink-0">
|
||||
<path d="M19.3975 1.35498C19.3746 1.15293 19.2037 1.00021 19.0004 1C18.7971 0.999793 18.6259 1.15217 18.6026 1.35417C18.4798 2.41894 18.1627 3.15692 17.6598 3.65983C17.1569 4.16274 16.4189 4.47983 15.3542 4.60264C15.1522 4.62593 14.9998 4.79707 15 5.00041C15.0002 5.20375 15.1529 5.37457 15.355 5.39746C16.4019 5.51605 17.1562 5.83304 17.6716 6.33906C18.1845 6.84269 18.5078 7.57998 18.6016 8.63539C18.6199 8.84195 18.7931 9.00023 19.0005 9C19.2078 8.99977 19.3806 8.84109 19.3985 8.6345C19.4883 7.59673 19.8114 6.84328 20.3273 6.32735C20.8433 5.81142 21.5967 5.48834 22.6345 5.39851C22.8411 5.38063 22.9998 5.20782 23 5.00045C23.0002 4.79308 22.842 4.61992 22.6354 4.60157C21.58 4.50782 20.8427 4.18447 20.3391 3.67157C19.833 3.15623 19.516 2.40192 19.3975 1.35498Z" fill="currentColor"/>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M11 3C11.4833 3 11.8974 3.34562 11.9839 3.82111C12.4637 6.46043 13.279 8.23983 14.5196 9.48039C15.7602 10.721 17.5396 11.5363 20.1789 12.0161C20.6544 12.1026 21 12.5167 21 13C21 13.4833 20.6544 13.8974 20.1789 13.9839C17.5396 14.4637 15.7602 15.279 14.5196 16.5196C13.279 17.7602 12.4637 19.5396 11.9839 22.1789C11.8974 22.6544 11.4833 23 11 23C10.5167 23 10.1026 22.6544 10.0161 22.1789C9.53625 19.5396 8.72096 17.7602 7.48039 16.5196C6.23983 15.279 4.46043 14.4637 1.82111 13.9839C1.34562 13.8974 1 13.4833 1 13C1 12.5167 1.34562 12.1026 1.82111 12.0161C4.46043 11.5363 6.23983 10.721 7.48039 9.48039C8.72096 8.23983 9.53625 6.46043 10.0161 3.82111C10.1026 3.34562 10.5167 3 11 3ZM5.66618 13C6.9247 13.5226 7.99788 14.2087 8.89461 15.1054C9.79134 16.0021 10.4774 17.0753 11 18.3338C11.5226 17.0753 12.2087 16.0021 13.1054 15.1054C14.0021 14.2087 15.0753 13.5226 16.3338 13C15.0753 12.4774 14.0021 11.7913 13.1054 10.8946C12.2087 9.99788 11.5226 8.9247 11 7.66618C10.4774 8.9247 9.79134 9.99788 8.89461 10.8946C7.99788 11.7913 6.9247 12.4774 5.66618 13Z" fill="currentColor"/>
|
||||
</svg> */}
|
||||
<div>
|
||||
{title}
|
||||
<div className="text-token-text-tertiary">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{userProvidesKey ? (
|
||||
<div className="text-token-text-primary" key={`set-key-${endpoint}`}>
|
||||
<button
|
||||
className={cn(
|
||||
'invisible flex gap-x-1 group-hover:visible',
|
||||
selected ? 'visible' : '',
|
||||
expiryTime ? 'w-full rounded-lg p-2 hover:bg-gray-900' : '',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<div className={cn('invisible group-hover:visible', expiryTime ? 'text-xs' : '')}>
|
||||
{localize('com_endpoint_config_key')}
|
||||
</div>
|
||||
<Settings className={cn(expiryTime ? 'icon-sm' : 'icon-md stroke-1')} />
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{selected && (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-md block group-hover:hidden"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{(!userProvidesKey || expiryTime) && (
|
||||
<div className="text-token-text-primary hidden gap-x-1 group-hover:flex ">
|
||||
<div className="">New Chat</div>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-md"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M16.7929 2.79289C18.0118 1.57394 19.9882 1.57394 21.2071 2.79289C22.4261 4.01184 22.4261 5.98815 21.2071 7.20711L12.7071 15.7071C12.5196 15.8946 12.2652 16 12 16H9C8.44772 16 8 15.5523 8 15V12C8 11.7348 8.10536 11.4804 8.29289 11.2929L16.7929 2.79289ZM19.7929 4.20711C19.355 3.7692 18.645 3.7692 18.2071 4.2071L10 12.4142V14H11.5858L19.7929 5.79289C20.2308 5.35499 20.2308 4.64501 19.7929 4.20711ZM6 5C5.44772 5 5 5.44771 5 6V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V14C19 13.4477 19.4477 13 20 13C20.5523 13 21 13.4477 21 14V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V6C3 4.34314 4.34315 3 6 3H10C10.5523 3 11 3.44771 11 4C11 4.55228 10.5523 5 10 5H6Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{userProvidesKey && (
|
||||
<SetKeyDialog open={isDialogOpen} onOpenChange={setDialogOpen} endpoint={endpoint} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuItem;
|
41
client/src/components/Chat/Menus/Endpoints/MenuItems.tsx
Normal file
41
client/src/components/Chat/Menus/Endpoints/MenuItems.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import type { FC } from 'react';
|
||||
import { EModelEndpoint, useGetEndpointsQuery } from 'librechat-data-provider';
|
||||
import MenuSeparator from '../UI/MenuSeparator';
|
||||
import { alternateName } from '~/common';
|
||||
import MenuItem from './MenuItem';
|
||||
|
||||
const EndpointItems: FC<{
|
||||
endpoints: EModelEndpoint[];
|
||||
selected: EModelEndpoint | '';
|
||||
}> = ({ endpoints, selected }) => {
|
||||
const { data: endpointsConfig } = useGetEndpointsQuery();
|
||||
return (
|
||||
<>
|
||||
{endpoints &&
|
||||
endpoints.map((endpoint, i) => {
|
||||
if (!endpoint) {
|
||||
return null;
|
||||
} else if (!endpointsConfig?.[endpoint]) {
|
||||
return null;
|
||||
}
|
||||
const userProvidesKey = endpointsConfig?.[endpoint]?.userProvide;
|
||||
return (
|
||||
<div key={`endpoint-${endpoint}`}>
|
||||
<MenuItem
|
||||
key={`endpoint-item-${endpoint}`}
|
||||
title={alternateName[endpoint] || endpoint}
|
||||
value={endpoint}
|
||||
selected={selected === endpoint}
|
||||
data-testid={`endpoint-item-${endpoint}`}
|
||||
userProvidesKey={!!userProvidesKey}
|
||||
// description="With DALL·E, browsing and analysis"
|
||||
/>
|
||||
{i !== endpoints.length - 1 && <MenuSeparator />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default EndpointItems;
|
44
client/src/components/Chat/Menus/EndpointsMenu.tsx
Normal file
44
client/src/components/Chat/Menus/EndpointsMenu.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { Content, Portal, Root } from '@radix-ui/react-popover';
|
||||
import { useGetEndpointsQuery } from 'librechat-data-provider';
|
||||
import type { FC } from 'react';
|
||||
import EndpointItems from './Endpoints/MenuItems';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import TitleButton from './UI/TitleButton';
|
||||
import { alternateName } from '~/common';
|
||||
import { mapEndpoints } from '~/utils';
|
||||
|
||||
const EndpointsMenu: FC = () => {
|
||||
const { data: endpoints = [] } = useGetEndpointsQuery({
|
||||
select: mapEndpoints,
|
||||
});
|
||||
|
||||
const { conversation } = useChatContext();
|
||||
const selected = conversation?.endpoint ?? '';
|
||||
return (
|
||||
<Root>
|
||||
<TitleButton primaryText={alternateName[selected] + ' '} />
|
||||
<Portal>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: '0px',
|
||||
top: '0px',
|
||||
transform: 'translate3d(268px, 50px, 0px)',
|
||||
minWidth: 'max-content',
|
||||
zIndex: 'auto',
|
||||
}}
|
||||
>
|
||||
<Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="mt-2 min-w-[340px] overflow-hidden rounded-lg border border-gray-100 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900 dark:text-white"
|
||||
>
|
||||
<EndpointItems endpoints={endpoints} selected={selected} />
|
||||
</Content>
|
||||
</div>
|
||||
</Portal>
|
||||
</Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default EndpointsMenu;
|
30
client/src/components/Chat/Menus/NewChat.tsx
Normal file
30
client/src/components/Chat/Menus/NewChat.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import { useChatContext } from '~/Providers';
|
||||
|
||||
export default function Header() {
|
||||
const { newConversation } = useChatContext();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className=" btn btn-neutral btn-small border-token-border-medium relative ml-2 flex h-9 w-9 items-center justify-center whitespace-nowrap rounded-lg rounded-lg border focus:ring-0 focus:ring-offset-0"
|
||||
onClick={() => newConversation()}
|
||||
>
|
||||
<div className="flex w-full items-center justify-center gap-2">
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="text-black dark:text-white"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M16.7929 2.79289C18.0118 1.57394 19.9882 1.57394 21.2071 2.79289C22.4261 4.01184 22.4261 5.98815 21.2071 7.20711L12.7071 15.7071C12.5196 15.8946 12.2652 16 12 16H9C8.44772 16 8 15.5523 8 15V12C8 11.7348 8.10536 11.4804 8.29289 11.2929L16.7929 2.79289ZM19.7929 4.20711C19.355 3.7692 18.645 3.7692 18.2071 4.2071L10 12.4142V14H11.5858L19.7929 5.79289C20.2308 5.35499 20.2308 4.64501 19.7929 4.20711ZM6 5C5.44772 5 5 5.44771 5 6V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V14C19 13.4477 19.4477 13 20 13C20.5523 13 21 13.4477 21 14V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V6C3 4.34314 4.34315 3 6 3H10C10.5523 3 11 3.44771 11 4C11 4.55228 10.5523 5 10 5H6Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
146
client/src/components/Chat/Menus/Presets/EditPresetDialog.tsx
Normal file
146
client/src/components/Chat/Menus/Presets/EditPresetDialog.tsx
Normal file
|
@ -0,0 +1,146 @@
|
|||
import axios from 'axios';
|
||||
import filenamify from 'filenamify';
|
||||
import { useSetRecoilState } from 'recoil';
|
||||
import exportFromJSON from 'export-from-json';
|
||||
import { useGetEndpointsQuery } from 'librechat-data-provider';
|
||||
import type { TEditPresetProps } from '~/common';
|
||||
import { cn, defaultTextProps, removeFocusOutlines, cleanupPreset, mapEndpoints } from '~/utils';
|
||||
import { Input, Label, Dropdown, Dialog, DialogClose, DialogButton } from '~/components/';
|
||||
import PopoverButtons from '~/components/Endpoints/PopoverButtons';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import { useSetIndexOptions, useLocalize } from '~/hooks';
|
||||
import { EndpointSettings } from '~/components/Endpoints';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import store from '~/store';
|
||||
|
||||
const EditPresetDialog = ({ open, onOpenChange, title }: Omit<TEditPresetProps, 'preset'>) => {
|
||||
const { preset } = useChatContext();
|
||||
|
||||
// TODO: use React Query for presets data
|
||||
const setPresets = useSetRecoilState(store.presets);
|
||||
const { data: availableEndpoints = [] } = useGetEndpointsQuery({
|
||||
select: mapEndpoints,
|
||||
});
|
||||
const { setOption } = useSetIndexOptions(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,
|
||||
});
|
||||
};
|
||||
|
||||
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={setOption('endpoint')}
|
||||
options={availableEndpoints}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
'flex h-10 max-h-10 w-full resize-none ',
|
||||
removeFocusOutlines,
|
||||
)}
|
||||
/>
|
||||
</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_endpoint_save')}
|
||||
</DialogClose>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditPresetDialog;
|
115
client/src/components/Chat/Menus/Presets/PresetItems.tsx
Normal file
115
client/src/components/Chat/Menus/Presets/PresetItems.tsx
Normal file
|
@ -0,0 +1,115 @@
|
|||
import type { FC } from 'react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Close } from '@radix-ui/react-popover';
|
||||
import type { TPreset } from 'librechat-data-provider';
|
||||
import FileUpload from '~/components/Input/EndpointMenu/FileUpload';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import { Dialog, DialogTrigger } from '~/components/ui/';
|
||||
import { EditIcon, TrashIcon } from '~/components/svg';
|
||||
import { MenuSeparator, MenuItem } from '../UI';
|
||||
import { icons } from '../Endpoints/Icons';
|
||||
import { getPresetTitle } from '~/utils';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const PresetItems: FC<{
|
||||
presets: TPreset[];
|
||||
onSelectPreset: (preset: TPreset) => void;
|
||||
onChangePreset: (preset: TPreset) => void;
|
||||
onDeletePreset: (preset: TPreset) => void;
|
||||
clearAllPresets: () => void;
|
||||
onFileSelected: (jsonData: Record<string, unknown>) => void;
|
||||
}> = ({
|
||||
presets,
|
||||
onSelectPreset,
|
||||
onChangePreset,
|
||||
onDeletePreset,
|
||||
clearAllPresets,
|
||||
onFileSelected,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
role="menuitem"
|
||||
className="pointer-none group m-1.5 flex h-8 min-w-[170px] gap-2 rounded px-5 py-2.5 !pr-3 text-sm !opacity-100 hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5 md:min-w-[240px]"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="flex h-full grow items-center justify-end gap-2">
|
||||
<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-slate-200 hover:text-red-700 dark:bg-transparent dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-green-500"
|
||||
>
|
||||
<Trash2 className="mr-1 flex w-[22px] items-center stroke-1" />
|
||||
{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]"
|
||||
/>
|
||||
<FileUpload onFileSelected={onFileSelected} />
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
{presets &&
|
||||
presets.map((preset, i) => {
|
||||
if (!preset) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Close asChild key={`preset-${preset.presetId}`}>
|
||||
<div key={`preset-${preset.presetId}`}>
|
||||
<MenuItem
|
||||
key={`preset-item-${preset.presetId}`}
|
||||
className="w-[380px] md:min-w-[240px]"
|
||||
textClassName="text-xs max-w-[180px] md:max-w-[250px]"
|
||||
title={getPresetTitle(preset)}
|
||||
disableHover={true}
|
||||
onClick={() => onSelectPreset(preset)}
|
||||
icon={icons[preset.endpoint ?? 'unknown']({ className: 'icon-md mr-1 ' })}
|
||||
// value={preset.presetId}
|
||||
selected={false}
|
||||
data-testid={`preset-item-${preset}`}
|
||||
// description="With DALL·E, browsing and analysis"
|
||||
>
|
||||
<div className="flex h-full items-center justify-end gap-1">
|
||||
<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();
|
||||
e.stopPropagation();
|
||||
onDeletePreset(preset);
|
||||
}}
|
||||
>
|
||||
<TrashIcon />
|
||||
</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();
|
||||
onChangePreset(preset);
|
||||
}}
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
</div>
|
||||
</MenuItem>
|
||||
{i !== presets.length - 1 && <MenuSeparator />}
|
||||
</div>
|
||||
</Close>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PresetItems;
|
2
client/src/components/Chat/Menus/Presets/index.ts
Normal file
2
client/src/components/Chat/Menus/Presets/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export { default as EditPresetDialog } from './EditPresetDialog';
|
||||
export { default as PresetItems } from './PresetItems';
|
130
client/src/components/Chat/Menus/PresetsMenu.tsx
Normal file
130
client/src/components/Chat/Menus/PresetsMenu.tsx
Normal file
|
@ -0,0 +1,130 @@
|
|||
import type { FC } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import {
|
||||
modularEndpoints,
|
||||
useDeletePresetMutation,
|
||||
useCreatePresetMutation,
|
||||
} from 'librechat-data-provider';
|
||||
import type { TPreset } from 'librechat-data-provider';
|
||||
import { Content, Portal, Root } from '@radix-ui/react-popover';
|
||||
import { useLocalize, useDefaultConvo, useNavigateToConvo } from '~/hooks';
|
||||
import { EditPresetDialog, PresetItems } from './Presets';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import TitleButton from './UI/TitleButton';
|
||||
import { cleanupPreset } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const PresetsMenu: FC = () => {
|
||||
const localize = useLocalize();
|
||||
const { conversation, newConversation, setPreset } = useChatContext();
|
||||
const { navigateToConvo } = useNavigateToConvo();
|
||||
const getDefaultConversation = useDefaultConvo();
|
||||
|
||||
const [presetModalVisible, setPresetModalVisible] = useState(false);
|
||||
// TODO: rely on react query for presets data
|
||||
const [presets, setPresets] = useRecoilState(store.presets);
|
||||
|
||||
const deletePresetsMutation = useDeletePresetMutation();
|
||||
const createPresetMutation = useCreatePresetMutation();
|
||||
|
||||
const { endpoint } = conversation ?? {};
|
||||
|
||||
const importPreset = (jsonPreset: TPreset) => {
|
||||
createPresetMutation.mutate(
|
||||
{ ...jsonPreset },
|
||||
{
|
||||
onSuccess: (data) => {
|
||||
setPresets(data);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Error uploading the preset:', error);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
const onFileSelected = (jsonData: Record<string, unknown>) => {
|
||||
const jsonPreset = { ...cleanupPreset({ preset: jsonData }), presetId: null };
|
||||
importPreset(jsonPreset);
|
||||
};
|
||||
const onSelectPreset = (newPreset: TPreset) => {
|
||||
if (!newPreset) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
modularEndpoints.has(endpoint ?? '') &&
|
||||
modularEndpoints.has(newPreset?.endpoint ?? '') &&
|
||||
endpoint === newPreset?.endpoint
|
||||
) {
|
||||
const currentConvo = getDefaultConversation({
|
||||
conversation: conversation ?? {},
|
||||
preset: newPreset,
|
||||
});
|
||||
|
||||
/* We don't reset the latest message, only when changing settings mid-converstion */
|
||||
navigateToConvo(currentConvo, false);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('preset', newPreset, endpoint);
|
||||
newConversation({ preset: newPreset });
|
||||
};
|
||||
|
||||
const onChangePreset = (preset: TPreset) => {
|
||||
setPreset(preset);
|
||||
setPresetModalVisible(true);
|
||||
};
|
||||
|
||||
const clearAllPresets = () => {
|
||||
deletePresetsMutation.mutate({ arg: {} });
|
||||
};
|
||||
|
||||
const onDeletePreset = (preset: TPreset) => {
|
||||
deletePresetsMutation.mutate({ arg: preset });
|
||||
};
|
||||
|
||||
return (
|
||||
<Root>
|
||||
<TitleButton primaryText={'Presets'} />
|
||||
<Portal>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: '0px',
|
||||
top: '0px',
|
||||
transform: 'translate3d(268px, 50px, 0px)',
|
||||
minWidth: 'max-content',
|
||||
zIndex: 'auto',
|
||||
}}
|
||||
>
|
||||
<Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
className="mt-2 max-h-[495px] max-w-[370px] overflow-x-hidden rounded-lg border border-gray-100 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900 dark:text-white md:min-w-[400px]"
|
||||
>
|
||||
{presets.length ? (
|
||||
<PresetItems
|
||||
presets={presets}
|
||||
onSelectPreset={onSelectPreset}
|
||||
onChangePreset={onChangePreset}
|
||||
onDeletePreset={onDeletePreset}
|
||||
clearAllPresets={clearAllPresets}
|
||||
onFileSelected={onFileSelected}
|
||||
/>
|
||||
) : (
|
||||
<div className="dark:text-gray-300">{localize('com_endpoint_no_presets')}</div>
|
||||
)}
|
||||
</Content>
|
||||
</div>
|
||||
</Portal>
|
||||
<EditPresetDialog
|
||||
open={presetModalVisible}
|
||||
onOpenChange={setPresetModalVisible}
|
||||
// preset={preset as TPreset}
|
||||
/>
|
||||
</Root>
|
||||
);
|
||||
};
|
||||
|
||||
export default PresetsMenu;
|
122
client/src/components/Chat/Menus/UI/MenuItem.tsx
Normal file
122
client/src/components/Chat/Menus/UI/MenuItem.tsx
Normal file
|
@ -0,0 +1,122 @@
|
|||
import type { FC } from 'react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type MenuItemProps = {
|
||||
title: string;
|
||||
value?: string;
|
||||
selected: boolean;
|
||||
description?: string;
|
||||
onClick?: () => void;
|
||||
hoverCondition?: boolean;
|
||||
hoverContent?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
className?: string;
|
||||
textClassName?: string;
|
||||
disableHover?: boolean;
|
||||
// hoverContent?: string;
|
||||
};
|
||||
|
||||
const MenuItem: FC<MenuItemProps> = ({
|
||||
title,
|
||||
// value,
|
||||
description,
|
||||
selected,
|
||||
// hoverCondition = true,
|
||||
// hoverContent,
|
||||
icon,
|
||||
className = '',
|
||||
textClassName = '',
|
||||
disableHover = false,
|
||||
children,
|
||||
onClick,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
role="menuitem"
|
||||
className={cn(
|
||||
'group m-1.5 flex cursor-pointer gap-2 rounded px-5 py-2.5 !pr-3 text-sm !opacity-100 hover:bg-black/5 focus:ring-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-white/5 md:min-w-[240px]',
|
||||
className ?? '',
|
||||
)}
|
||||
tabIndex={-1}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex grow items-center justify-between gap-2">
|
||||
<div>
|
||||
<div className={cn('flex items-center gap-1 ')}>
|
||||
{icon && icon}
|
||||
{/* <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className="icon-md shrink-0">
|
||||
<path d="M19.3975 1.35498C19.3746 1.15293 19.2037 1.00021 19.0004 1C18.7971 0.999793 18.6259 1.15217 18.6026 1.35417C18.4798 2.41894 18.1627 3.15692 17.6598 3.65983C17.1569 4.16274 16.4189 4.47983 15.3542 4.60264C15.1522 4.62593 14.9998 4.79707 15 5.00041C15.0002 5.20375 15.1529 5.37457 15.355 5.39746C16.4019 5.51605 17.1562 5.83304 17.6716 6.33906C18.1845 6.84269 18.5078 7.57998 18.6016 8.63539C18.6199 8.84195 18.7931 9.00023 19.0005 9C19.2078 8.99977 19.3806 8.84109 19.3985 8.6345C19.4883 7.59673 19.8114 6.84328 20.3273 6.32735C20.8433 5.81142 21.5967 5.48834 22.6345 5.39851C22.8411 5.38063 22.9998 5.20782 23 5.00045C23.0002 4.79308 22.842 4.61992 22.6354 4.60157C21.58 4.50782 20.8427 4.18447 20.3391 3.67157C19.833 3.15623 19.516 2.40192 19.3975 1.35498Z" fill="currentColor"/>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M11 3C11.4833 3 11.8974 3.34562 11.9839 3.82111C12.4637 6.46043 13.279 8.23983 14.5196 9.48039C15.7602 10.721 17.5396 11.5363 20.1789 12.0161C20.6544 12.1026 21 12.5167 21 13C21 13.4833 20.6544 13.8974 20.1789 13.9839C17.5396 14.4637 15.7602 15.279 14.5196 16.5196C13.279 17.7602 12.4637 19.5396 11.9839 22.1789C11.8974 22.6544 11.4833 23 11 23C10.5167 23 10.1026 22.6544 10.0161 22.1789C9.53625 19.5396 8.72096 17.7602 7.48039 16.5196C6.23983 15.279 4.46043 14.4637 1.82111 13.9839C1.34562 13.8974 1 13.4833 1 13C1 12.5167 1.34562 12.1026 1.82111 12.0161C4.46043 11.5363 6.23983 10.721 7.48039 9.48039C8.72096 8.23983 9.53625 6.46043 10.0161 3.82111C10.1026 3.34562 10.5167 3 11 3ZM5.66618 13C6.9247 13.5226 7.99788 14.2087 8.89461 15.1054C9.79134 16.0021 10.4774 17.0753 11 18.3338C11.5226 17.0753 12.2087 16.0021 13.1054 15.1054C14.0021 14.2087 15.0753 13.5226 16.3338 13C15.0753 12.4774 14.0021 11.7913 13.1054 10.8946C12.2087 9.99788 11.5226 8.9247 11 7.66618C10.4774 8.9247 9.79134 9.99788 8.89461 10.8946C7.99788 11.7913 6.9247 12.4774 5.66618 13Z" fill="currentColor"/>
|
||||
</svg> */}
|
||||
<div className={cn('truncate', textClassName)}>
|
||||
{title}
|
||||
<div className="text-token-text-tertiary">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{children}
|
||||
{selected && (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-md block "
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{!selected && !disableHover && (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="icon-md block hidden gap-x-1 group-hover:flex "
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 12C2 6.47715 6.47715 2 12 2C17.5228 2 22 6.47715 22 12C22 17.5228 17.5228 22 12 22C6.47715 22 2 17.5228 2 12ZM16.0755 7.93219C16.5272 8.25003 16.6356 8.87383 16.3178 9.32549L11.5678 16.0755C11.3931 16.3237 11.1152 16.4792 10.8123 16.4981C10.5093 16.517 10.2142 16.3973 10.0101 16.1727L7.51006 13.4227C7.13855 13.014 7.16867 12.3816 7.57733 12.0101C7.98598 11.6386 8.61843 11.6687 8.98994 12.0773L10.6504 13.9039L14.6822 8.17451C15 7.72284 15.6238 7.61436 16.0755 7.93219Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
{/* {(hoverCondition && hoverContent) && (
|
||||
hoverContent
|
||||
// <div className="text-token-text-primary hidden gap-x-1 group-hover:flex ">
|
||||
// <div className="">New Chat</div>
|
||||
// <svg
|
||||
// width="24"
|
||||
// height="24"
|
||||
// viewBox="0 0 24 24"
|
||||
// fill="none"
|
||||
// xmlns="http://www.w3.org/2000/svg"
|
||||
// className="icon-md"
|
||||
// >
|
||||
// <path
|
||||
// fillRule="evenodd"
|
||||
// clipRule="evenodd"
|
||||
// d="M16.7929 2.79289C18.0118 1.57394 19.9882 1.57394 21.2071 2.79289C22.4261 4.01184 22.4261 5.98815 21.2071 7.20711L12.7071 15.7071C12.5196 15.8946 12.2652 16 12 16H9C8.44772 16 8 15.5523 8 15V12C8 11.7348 8.10536 11.4804 8.29289 11.2929L16.7929 2.79289ZM19.7929 4.20711C19.355 3.7692 18.645 3.7692 18.2071 4.2071L10 12.4142V14H11.5858L19.7929 5.79289C20.2308 5.35499 20.2308 4.64501 19.7929 4.20711ZM6 5C5.44772 5 5 5.44771 5 6V18C5 18.5523 5.44772 19 6 19H18C18.5523 19 19 18.5523 19 18V14C19 13.4477 19.4477 13 20 13C20.5523 13 21 13.4477 21 14V18C21 19.6569 19.6569 21 18 21H6C4.34315 21 3 19.6569 3 18V6C3 4.34314 4.34315 3 6 3H10C10.5523 3 11 3.44771 11 4C11 4.55228 10.5523 5 10 5H6Z"
|
||||
// fill="currentColor"
|
||||
// />
|
||||
// </svg>
|
||||
// </div>
|
||||
)
|
||||
} */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MenuItem;
|
11
client/src/components/Chat/Menus/UI/MenuSeparator.tsx
Normal file
11
client/src/components/Chat/Menus/UI/MenuSeparator.tsx
Normal file
|
@ -0,0 +1,11 @@
|
|||
import type { FC } from 'react';
|
||||
|
||||
const MenuSeparator: FC = () => (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
className="my-1.5 border-b bg-gray-100 dark:border-gray-700"
|
||||
/>
|
||||
);
|
||||
|
||||
export default MenuSeparator;
|
32
client/src/components/Chat/Menus/UI/TitleButton.tsx
Normal file
32
client/src/components/Chat/Menus/UI/TitleButton.tsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { Trigger } from '@radix-ui/react-popover';
|
||||
|
||||
export default function TitleButton({ primaryText = '', secondaryText = '' }) {
|
||||
return (
|
||||
<Trigger asChild>
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-1 rounded-xl px-3 py-2 text-lg font-medium hover:bg-gray-50 radix-state-open:bg-gray-50 dark:hover:bg-black/10 dark:radix-state-open:bg-black/20"
|
||||
// type="button"
|
||||
>
|
||||
<div>
|
||||
{primaryText}{' '}
|
||||
{!!secondaryText && <span className="text-token-text-secondary">{secondaryText}</span>}
|
||||
</div>
|
||||
<svg
|
||||
width="16"
|
||||
height="17"
|
||||
viewBox="0 0 16 17"
|
||||
fill="none"
|
||||
className="text-token-text-tertiary"
|
||||
>
|
||||
<path
|
||||
d="M11.3346 7.83203L8.00131 11.1654L4.66797 7.83203"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</Trigger>
|
||||
);
|
||||
}
|
3
client/src/components/Chat/Menus/UI/index.ts
Normal file
3
client/src/components/Chat/Menus/UI/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { default as MenuItem } from './MenuItem';
|
||||
export { default as MenuSeparator } from './MenuSeparator';
|
||||
export { default as TitleButton } from './TitleButton';
|
3
client/src/components/Chat/Menus/index.ts
Normal file
3
client/src/components/Chat/Menus/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { default as EndpointsMenu } from './EndpointsMenu';
|
||||
export { default as PresetsMenu } from './PresetsMenu';
|
||||
export { default as NewChat } from './NewChat';
|
|
@ -0,0 +1,8 @@
|
|||
// Container Component
|
||||
const Container = ({ children }: { children: React.ReactNode }) => (
|
||||
<div className="text-message peer flex min-h-[20px] flex-col items-start gap-3 overflow-x-auto break-words peer-[.text-message]:mt-5">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Container;
|
117
client/src/components/Chat/Messages/Content/EditMessage.tsx
Normal file
117
client/src/components/Chat/Messages/Content/EditMessage.tsx
Normal file
|
@ -0,0 +1,117 @@
|
|||
import { useRef } from 'react';
|
||||
import { useUpdateMessageMutation } from 'librechat-data-provider';
|
||||
import Container from '~/components/Messages/Content/Container';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import type { TEditProps } from '~/common';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const EditMessage = ({
|
||||
text,
|
||||
message,
|
||||
isSubmitting,
|
||||
ask,
|
||||
enterEdit,
|
||||
siblingIdx,
|
||||
setSiblingIdx,
|
||||
}: TEditProps) => {
|
||||
const { getMessages, setMessages, conversation } = useChatContext();
|
||||
|
||||
const textEditor = useRef<HTMLDivElement | null>(null);
|
||||
const { conversationId, parentMessageId, messageId } = message;
|
||||
const updateMessageMutation = useUpdateMessageMutation(conversationId ?? '');
|
||||
const localize = useLocalize();
|
||||
|
||||
const resubmitMessage = () => {
|
||||
const text = textEditor?.current?.innerText ?? '';
|
||||
if (message.isCreatedByUser) {
|
||||
ask({
|
||||
text,
|
||||
parentMessageId,
|
||||
conversationId,
|
||||
});
|
||||
|
||||
setSiblingIdx((siblingIdx ?? 0) - 1);
|
||||
} else {
|
||||
const messages = getMessages();
|
||||
const parentMessage = messages?.find((msg) => msg.messageId === parentMessageId);
|
||||
|
||||
if (!parentMessage) {
|
||||
return;
|
||||
}
|
||||
ask(
|
||||
{ ...parentMessage },
|
||||
{
|
||||
editedText: text,
|
||||
editedMessageId: messageId,
|
||||
isRegenerate: true,
|
||||
isEdited: true,
|
||||
},
|
||||
);
|
||||
|
||||
setSiblingIdx((siblingIdx ?? 0) - 1);
|
||||
}
|
||||
|
||||
enterEdit(true);
|
||||
};
|
||||
|
||||
const updateMessage = () => {
|
||||
const messages = getMessages();
|
||||
if (!messages) {
|
||||
return;
|
||||
}
|
||||
const text = textEditor?.current?.innerText ?? '';
|
||||
updateMessageMutation.mutate({
|
||||
conversationId: conversationId ?? '',
|
||||
model: conversation?.model ?? 'gpt-3.5-turbo',
|
||||
messageId,
|
||||
text,
|
||||
});
|
||||
setMessages(
|
||||
messages.map((msg) =>
|
||||
msg.messageId === messageId
|
||||
? {
|
||||
...msg,
|
||||
text,
|
||||
isEdited: true,
|
||||
}
|
||||
: msg,
|
||||
),
|
||||
);
|
||||
enterEdit(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<div
|
||||
data-testid="message-text-editor"
|
||||
className="markdown prose dark:prose-invert light w-full whitespace-pre-wrap break-words border-none focus:outline-none"
|
||||
contentEditable={true}
|
||||
ref={textEditor}
|
||||
suppressContentEditableWarning={true}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
<div className="mt-2 flex w-full justify-center text-center">
|
||||
<button
|
||||
className="btn btn-primary relative mr-2"
|
||||
disabled={isSubmitting}
|
||||
onClick={resubmitMessage}
|
||||
>
|
||||
{localize('com_ui_save')} {'&'} {localize('com_ui_submit')}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary relative mr-2"
|
||||
disabled={isSubmitting}
|
||||
onClick={updateMessage}
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</button>
|
||||
<button className="btn btn-neutral relative" onClick={() => enterEdit(true)}>
|
||||
{localize('com_ui_cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditMessage;
|
120
client/src/components/Chat/Messages/Content/Markdown.tsx
Normal file
120
client/src/components/Chat/Messages/Content/Markdown.tsx
Normal file
|
@ -0,0 +1,120 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import type { PluggableList } from 'unified';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import supersub from 'remark-supersub';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import remarkMath from 'remark-math';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import { langSubset, validateIframe } from '~/utils';
|
||||
import CodeBlock from '~/components/Messages/Content/CodeBlock';
|
||||
|
||||
type TCodeProps = {
|
||||
inline: boolean;
|
||||
className: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
type TContentProps = {
|
||||
content: string;
|
||||
message: TMessage;
|
||||
showCursor?: boolean;
|
||||
};
|
||||
|
||||
const code = React.memo(({ inline, className, children }: TCodeProps) => {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const lang = match && match[1];
|
||||
|
||||
if (inline) {
|
||||
return <code className={className}>{children}</code>;
|
||||
} else {
|
||||
return <CodeBlock lang={lang || 'text'} codeChildren={children} />;
|
||||
}
|
||||
});
|
||||
|
||||
const p = React.memo(({ children }: { children: React.ReactNode }) => {
|
||||
return <p className="mb-2 whitespace-pre-wrap">{children}</p>;
|
||||
});
|
||||
|
||||
const Markdown = React.memo(({ content, message, showCursor }: TContentProps) => {
|
||||
const [cursor, setCursor] = useState('█');
|
||||
const { isSubmitting, latestMessage } = useChatContext();
|
||||
const isInitializing = content === '<span className="result-streaming">█</span>';
|
||||
|
||||
const { isEdited, messageId } = message ?? {};
|
||||
const isLatestMessage = messageId === latestMessage?.messageId;
|
||||
const currentContent = content?.replace('z-index: 1;', '') ?? '';
|
||||
|
||||
useEffect(() => {
|
||||
let timer1: NodeJS.Timeout, timer2: NodeJS.Timeout;
|
||||
|
||||
if (!showCursor) {
|
||||
setCursor('ㅤ');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSubmitting && isLatestMessage) {
|
||||
timer1 = setInterval(() => {
|
||||
setCursor('ㅤ');
|
||||
timer2 = setTimeout(() => {
|
||||
setCursor('█');
|
||||
}, 200);
|
||||
}, 1000);
|
||||
} else {
|
||||
setCursor('ㅤ');
|
||||
}
|
||||
|
||||
// This is the cleanup function that React will run when the component unmounts
|
||||
return () => {
|
||||
clearInterval(timer1);
|
||||
clearTimeout(timer2);
|
||||
};
|
||||
}, [isSubmitting, isLatestMessage, showCursor]);
|
||||
|
||||
const rehypePlugins: PluggableList = [
|
||||
[rehypeKatex, { output: 'mathml' }],
|
||||
[
|
||||
rehypeHighlight,
|
||||
{
|
||||
detect: true,
|
||||
ignoreMissing: true,
|
||||
subset: langSubset,
|
||||
},
|
||||
],
|
||||
[rehypeRaw],
|
||||
];
|
||||
|
||||
let isValidIframe: string | boolean | null = false;
|
||||
if (!isEdited) {
|
||||
isValidIframe = validateIframe(currentContent);
|
||||
}
|
||||
|
||||
if (isEdited || ((!isInitializing || !isLatestMessage) && !isValidIframe)) {
|
||||
rehypePlugins.pop();
|
||||
}
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
|
||||
rehypePlugins={rehypePlugins}
|
||||
linkTarget="_new"
|
||||
components={
|
||||
{
|
||||
code,
|
||||
p,
|
||||
} as {
|
||||
[nodeType: string]: React.ElementType;
|
||||
}
|
||||
}
|
||||
>
|
||||
{isLatestMessage && isSubmitting && !isInitializing
|
||||
? currentContent + cursor
|
||||
: currentContent}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
});
|
||||
|
||||
export default Markdown;
|
130
client/src/components/Chat/Messages/Content/MessageContent.tsx
Normal file
130
client/src/components/Chat/Messages/Content/MessageContent.tsx
Normal file
|
@ -0,0 +1,130 @@
|
|||
import { Fragment, Suspense } from 'react';
|
||||
import type { TResPlugin } from 'librechat-data-provider';
|
||||
import type { TMessageContent, TText, TDisplayProps } from '~/common';
|
||||
import Plugin from '~/components/Messages/Content/Plugin';
|
||||
import Error from '~/components/Messages/Content/Error';
|
||||
import { DelayedRender } from '~/components/ui';
|
||||
import { useAuthContext } from '~/hooks';
|
||||
import EditMessage from './EditMessage';
|
||||
import Container from './Container';
|
||||
import Markdown from './Markdown';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const ErrorMessage = ({ text }: TText) => {
|
||||
const { logout } = useAuthContext();
|
||||
|
||||
if (text.includes('ban')) {
|
||||
logout();
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<Container>
|
||||
<div className="rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-100">
|
||||
<Error text={text} />
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
// Display Message Component
|
||||
const DisplayMessage = ({ text, isCreatedByUser, message, showCursor }: TDisplayProps) => (
|
||||
<Container>
|
||||
<div
|
||||
className={cn(
|
||||
'markdown prose dark:prose-invert light w-full break-words',
|
||||
isCreatedByUser ? 'whitespace-pre-wrap dark:text-gray-20' : 'dark:text-gray-70',
|
||||
)}
|
||||
>
|
||||
{!isCreatedByUser ? (
|
||||
<Markdown content={text} message={message} showCursor={showCursor} />
|
||||
) : (
|
||||
<>{text}</>
|
||||
)}
|
||||
</div>
|
||||
</Container>
|
||||
);
|
||||
|
||||
// Unfinished Message Component
|
||||
const UnfinishedMessage = () => (
|
||||
<ErrorMessage text="This is an unfinished message. The AI may still be generating a response, it was aborted, or a censor was triggered. Refresh or visit later to see more updates." />
|
||||
);
|
||||
|
||||
// Content Component
|
||||
const MessageContent = ({
|
||||
text,
|
||||
edit,
|
||||
error,
|
||||
unfinished,
|
||||
isSubmitting,
|
||||
isLast,
|
||||
...props
|
||||
}: TMessageContent) => {
|
||||
if (error) {
|
||||
return <ErrorMessage text={text} />;
|
||||
} else if (edit) {
|
||||
return <EditMessage text={text} isSubmitting={isSubmitting} {...props} />;
|
||||
} else {
|
||||
const marker = ':::plugin:::\n';
|
||||
const splitText = text.split(marker);
|
||||
const { message } = props;
|
||||
const { plugins, messageId } = message;
|
||||
const displayedIndices = new Set<number>();
|
||||
// Function to get the next non-empty text index
|
||||
const getNextNonEmptyTextIndex = (currentIndex: number) => {
|
||||
for (let i = currentIndex + 1; i < splitText.length; i++) {
|
||||
// Allow the last index to be last in case it has text
|
||||
// this may need to change if I add back streaming
|
||||
if (i === splitText.length - 1) {
|
||||
return currentIndex;
|
||||
}
|
||||
|
||||
if (splitText[i].trim() !== '' && !displayedIndices.has(i)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return currentIndex; // If no non-empty text is found, return the current index
|
||||
};
|
||||
|
||||
return splitText.map((text, idx) => {
|
||||
let currentText = text.trim();
|
||||
let plugin: TResPlugin | null = null;
|
||||
|
||||
if (plugins) {
|
||||
plugin = plugins[idx];
|
||||
}
|
||||
|
||||
// If the current text is empty, get the next non-empty text index
|
||||
const displayTextIndex = currentText === '' ? getNextNonEmptyTextIndex(idx) : idx;
|
||||
currentText = splitText[displayTextIndex];
|
||||
const isLastIndex = displayTextIndex === splitText.length - 1;
|
||||
const isEmpty = currentText.trim() === '';
|
||||
const showText =
|
||||
(currentText && !isEmpty && !displayedIndices.has(displayTextIndex)) ||
|
||||
(isEmpty && isLastIndex);
|
||||
displayedIndices.add(displayTextIndex);
|
||||
|
||||
return (
|
||||
<Fragment key={idx}>
|
||||
{plugin && <Plugin key={`plugin-${messageId}-${idx}`} plugin={plugin} />}
|
||||
{showText ? (
|
||||
<DisplayMessage
|
||||
key={`display-${messageId}-${idx}`}
|
||||
showCursor={isLastIndex && isLast}
|
||||
text={currentText}
|
||||
{...props}
|
||||
/>
|
||||
) : null}
|
||||
{!isSubmitting && unfinished && (
|
||||
<Suspense>
|
||||
<DelayedRender delay={250}>
|
||||
<UnfinishedMessage key={`unfinished-${messageId}-${idx}`} />
|
||||
</DelayedRender>
|
||||
</Suspense>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export default MessageContent;
|
104
client/src/components/Chat/Messages/HoverButtons.tsx
Normal file
104
client/src/components/Chat/Messages/HoverButtons.tsx
Normal file
|
@ -0,0 +1,104 @@
|
|||
import { useState } from 'react';
|
||||
import type { TConversation, TMessage } from 'librechat-data-provider';
|
||||
import { Clipboard, CheckMark, EditIcon, RegenerateIcon, ContinueIcon } from '~/components/svg';
|
||||
import { useGenerations, useLocalize } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
type THoverButtons = {
|
||||
isEditing: boolean;
|
||||
enterEdit: (cancel?: boolean) => void;
|
||||
copyToClipboard: (setIsCopied: React.Dispatch<React.SetStateAction<boolean>>) => void;
|
||||
conversation: TConversation | null;
|
||||
isSubmitting: boolean;
|
||||
message: TMessage;
|
||||
regenerate: () => void;
|
||||
handleContinue: (e: React.MouseEvent<HTMLButtonElement>) => void;
|
||||
latestMessage: TMessage | null;
|
||||
};
|
||||
|
||||
export default function HoverButtons({
|
||||
isEditing,
|
||||
enterEdit,
|
||||
copyToClipboard,
|
||||
conversation,
|
||||
isSubmitting,
|
||||
message,
|
||||
regenerate,
|
||||
handleContinue,
|
||||
latestMessage,
|
||||
}: THoverButtons) {
|
||||
const localize = useLocalize();
|
||||
const { endpoint } = conversation ?? {};
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const { hideEditButton, regenerateEnabled, continueSupported } = useGenerations({
|
||||
isEditing,
|
||||
isSubmitting,
|
||||
message,
|
||||
endpoint: endpoint ?? '',
|
||||
latestMessage,
|
||||
});
|
||||
if (!conversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { isCreatedByUser } = message;
|
||||
|
||||
const onEdit = () => {
|
||||
if (isEditing) {
|
||||
return enterEdit(true);
|
||||
}
|
||||
enterEdit();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-400 lg:justify-start">
|
||||
<button
|
||||
className={cn(
|
||||
'hover-button rounded-md p-1 pl-0 text-gray-400 hover:text-gray-950 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible md:group-[.final-completion]:visible',
|
||||
isCreatedByUser ? '' : 'active',
|
||||
hideEditButton ? 'opacity-0' : '',
|
||||
isEditing ? 'active bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200' : '',
|
||||
)}
|
||||
onClick={onEdit}
|
||||
type="button"
|
||||
title={localize('com_ui_edit')}
|
||||
disabled={hideEditButton}
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
'hover-button ml-0 flex items-center gap-1.5 rounded-md p-1 pl-0 text-xs hover:text-gray-950 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible md:group-[.final-completion]:visible',
|
||||
isCreatedByUser ? '' : 'active',
|
||||
)}
|
||||
onClick={() => copyToClipboard(setIsCopied)}
|
||||
type="button"
|
||||
title={
|
||||
isCopied ? localize('com_ui_copied_to_clipboard') : localize('com_ui_copy_to_clipboard')
|
||||
}
|
||||
>
|
||||
{isCopied ? <CheckMark /> : <Clipboard />}
|
||||
</button>
|
||||
{regenerateEnabled ? (
|
||||
<button
|
||||
className="hover-button active rounded-md p-1 pl-0 text-gray-400 hover:text-gray-950 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible md:group-[.final-completion]:visible"
|
||||
onClick={regenerate}
|
||||
type="button"
|
||||
title={localize('com_ui_regenerate')}
|
||||
>
|
||||
<RegenerateIcon className="hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
|
||||
</button>
|
||||
) : null}
|
||||
{continueSupported ? (
|
||||
<button
|
||||
className="hover-button active rounded-md p-1 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400/70 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible "
|
||||
onClick={handleContinue}
|
||||
type="button"
|
||||
title={localize('com_ui_continue')}
|
||||
>
|
||||
<ContinueIcon className="h-4 w-4 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
200
client/src/components/Chat/Messages/Message.tsx
Normal file
200
client/src/components/Chat/Messages/Message.tsx
Normal file
|
@ -0,0 +1,200 @@
|
|||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { useEffect } from 'react';
|
||||
import copy from 'copy-to-clipboard';
|
||||
import { Plugin } from '~/components/Messages/Content';
|
||||
import MessageContent from './Content/MessageContent';
|
||||
import { Icon } from '~/components/Endpoints';
|
||||
import SiblingSwitch from './SiblingSwitch';
|
||||
import type { TMessageProps } from '~/common';
|
||||
import { useChatContext } from '~/Providers';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import MultiMessage from './MultiMessage';
|
||||
import HoverButtons from './HoverButtons';
|
||||
import SubRow from './SubRow';
|
||||
// import { cn } from '~/utils';
|
||||
|
||||
export default function Message(props: TMessageProps) {
|
||||
const {
|
||||
message,
|
||||
scrollToBottom,
|
||||
currentEditId,
|
||||
setCurrentEditId,
|
||||
siblingIdx,
|
||||
siblingCount,
|
||||
setSiblingIdx,
|
||||
} = props;
|
||||
|
||||
const {
|
||||
ask,
|
||||
regenerate,
|
||||
autoScroll,
|
||||
abortScroll,
|
||||
isSubmitting,
|
||||
conversation,
|
||||
setAbortScroll,
|
||||
handleContinue,
|
||||
latestMessage,
|
||||
setLatestMessage,
|
||||
} = useChatContext();
|
||||
|
||||
const { conversationId } = conversation ?? {};
|
||||
|
||||
const { text, children, messageId = null, isCreatedByUser, error, unfinished } = message ?? {};
|
||||
|
||||
const isLast = !children?.length;
|
||||
const edit = messageId === currentEditId;
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmitting && scrollToBottom && !abortScroll) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [isSubmitting, text, scrollToBottom, abortScroll]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollToBottom && autoScroll && conversationId !== 'new') {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [autoScroll, conversationId, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!message) {
|
||||
return;
|
||||
} else if (isLast) {
|
||||
setLatestMessage({ ...message });
|
||||
}
|
||||
}, [isLast, message, setLatestMessage]);
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const enterEdit = (cancel?: boolean) =>
|
||||
setCurrentEditId && setCurrentEditId(cancel ? -1 : messageId);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (isSubmitting) {
|
||||
setAbortScroll(true);
|
||||
} else {
|
||||
setAbortScroll(false);
|
||||
}
|
||||
};
|
||||
|
||||
// const commonClasses =
|
||||
// 'w-full border-b text-gray-800 group border-black/10 dark:border-gray-900/50 dark:text-gray-100 dark:border-none';
|
||||
// const uniqueClasses = isCreatedByUser
|
||||
// ? 'bg-white dark:bg-gray-800 dark:text-gray-20'
|
||||
// : 'bg-white dark:bg-gray-800 dark:text-gray-70';
|
||||
|
||||
// const messageProps = {
|
||||
// className: cn(commonClasses, uniqueClasses),
|
||||
// titleclass: '',
|
||||
// };
|
||||
|
||||
const icon = Icon({
|
||||
...conversation,
|
||||
...message,
|
||||
model: message?.model ?? conversation?.model,
|
||||
size: 28.8,
|
||||
});
|
||||
|
||||
const regenerateMessage = () => {
|
||||
if (isSubmitting && isCreatedByUser) {
|
||||
return;
|
||||
}
|
||||
|
||||
regenerate(message);
|
||||
};
|
||||
|
||||
const copyToClipboard = (setIsCopied: React.Dispatch<React.SetStateAction<boolean>>) => {
|
||||
setIsCopied(true);
|
||||
copy(text ?? '');
|
||||
|
||||
setTimeout(() => {
|
||||
setIsCopied(false);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="text-token-text-primary w-full border-0 bg-transparent dark:border-0 dark:bg-transparent"
|
||||
onWheel={handleScroll}
|
||||
onTouchMove={handleScroll}
|
||||
>
|
||||
<div className="m-auto justify-center p-4 py-2 text-base md:gap-6 md:py-6">
|
||||
<div className="final-completion group mx-auto flex flex-1 gap-3 text-base md:max-w-3xl md:gap-6 md:px-5 lg:max-w-[40rem] lg:px-1 xl:max-w-[48rem] xl:px-5">
|
||||
<div className="relative flex flex-shrink-0 flex-col items-end">
|
||||
<div>
|
||||
<div className="pt-0.5">
|
||||
<div className="gizmo-shadow-stroke flex h-6 w-6 items-center justify-center overflow-hidden rounded-full">
|
||||
{typeof icon === 'string' && /[^\\x00-\\x7F]+/.test(icon as string) ? (
|
||||
<span className=" direction-rtl w-40 overflow-x-scroll">{icon}</span>
|
||||
) : (
|
||||
icon
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="agent-turn relative flex w-[calc(100%-50px)] w-full flex-col lg:w-[calc(100%-36px)]">
|
||||
<div className="flex-col gap-1 md:gap-3">
|
||||
<div className="flex max-w-full flex-grow flex-col gap-0">
|
||||
{/* Legacy Plugins */}
|
||||
{message?.plugin && <Plugin plugin={message?.plugin} />}
|
||||
<MessageContent
|
||||
ask={ask}
|
||||
edit={edit}
|
||||
isLast={isLast}
|
||||
text={text ?? ''}
|
||||
message={message}
|
||||
enterEdit={enterEdit}
|
||||
error={!!error}
|
||||
isSubmitting={isSubmitting}
|
||||
unfinished={unfinished ?? false}
|
||||
isCreatedByUser={isCreatedByUser ?? true}
|
||||
siblingIdx={siblingIdx ?? 0}
|
||||
setSiblingIdx={
|
||||
setSiblingIdx ??
|
||||
(() => {
|
||||
return;
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{isLast && isSubmitting ? null : (
|
||||
<SubRow classes="text-xs">
|
||||
<SiblingSwitch
|
||||
siblingIdx={siblingIdx}
|
||||
siblingCount={siblingCount}
|
||||
setSiblingIdx={setSiblingIdx}
|
||||
/>
|
||||
<HoverButtons
|
||||
isEditing={edit}
|
||||
message={message}
|
||||
enterEdit={enterEdit}
|
||||
isSubmitting={isSubmitting}
|
||||
conversation={conversation ?? null}
|
||||
regenerate={() => regenerateMessage()}
|
||||
copyToClipboard={copyToClipboard}
|
||||
handleContinue={handleContinue}
|
||||
latestMessage={latestMessage}
|
||||
/>
|
||||
</SubRow>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<MultiMessage
|
||||
key={messageId}
|
||||
messageId={messageId}
|
||||
conversation={conversation}
|
||||
messagesTree={children ?? []}
|
||||
scrollToBottom={scrollToBottom}
|
||||
currentEditId={currentEditId}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
116
client/src/components/Chat/Messages/MessagesView.tsx
Normal file
116
client/src/components/Chat/Messages/MessagesView.tsx
Normal file
|
@ -0,0 +1,116 @@
|
|||
import { useLayoutEffect, useState, useRef, useCallback } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import ScrollToBottom from '~/components/Messages/ScrollToBottom';
|
||||
import { CSSTransition } from 'react-transition-group';
|
||||
import { useChatContext } from '~/Providers';
|
||||
import MultiMessage from './MultiMessage';
|
||||
import { useScrollToRef } from '~/hooks';
|
||||
|
||||
export default function MessagesView({
|
||||
messagesTree: _messagesTree,
|
||||
Header,
|
||||
}: {
|
||||
messagesTree?: TMessage[] | null;
|
||||
Header?: ReactNode;
|
||||
}) {
|
||||
const scrollableRef = useRef<HTMLDivElement | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
|
||||
const { conversation, showPopover, setAbortScroll } = useChatContext();
|
||||
const { conversationId } = conversation ?? {};
|
||||
|
||||
// TODO: screenshot target ref
|
||||
// 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]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
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 h-full">
|
||||
<div>
|
||||
<div className="flex flex-col pb-9 text-sm dark:bg-transparent">
|
||||
{(_messagesTree && _messagesTree?.length == 0) || _messagesTree === null ? (
|
||||
<div className="flex w-full items-center justify-center gap-1 bg-gray-50 p-3 text-sm text-gray-500 dark:border-gray-900/50 dark:bg-gray-800 dark:text-gray-300">
|
||||
Nothing found
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{Header && Header}
|
||||
<MultiMessage
|
||||
key={conversationId} // avoid internal state mixture
|
||||
messageId={conversationId ?? null}
|
||||
messagesTree={_messagesTree}
|
||||
scrollToBottom={scrollToBottom}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
currentEditId={currentEditId ?? null}
|
||||
/>
|
||||
<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-900/50"
|
||||
ref={messagesEndRef}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
56
client/src/components/Chat/Messages/MultiMessage.tsx
Normal file
56
client/src/components/Chat/Messages/MultiMessage.tsx
Normal file
|
@ -0,0 +1,56 @@
|
|||
import { useEffect } from 'react';
|
||||
import { useRecoilState } from 'recoil';
|
||||
import type { TMessageProps } from '~/common';
|
||||
// eslint-disable-next-line import/no-cycle
|
||||
import Message from './Message';
|
||||
import store from '~/store';
|
||||
|
||||
export default function MultiMessage({
|
||||
// messageId is used recursively here
|
||||
messageId,
|
||||
messagesTree,
|
||||
scrollToBottom,
|
||||
currentEditId,
|
||||
setCurrentEditId,
|
||||
}: TMessageProps) {
|
||||
const [siblingIdx, setSiblingIdx] = useRecoilState(store.messagesSiblingIdxFamily(messageId));
|
||||
|
||||
const setSiblingIdxRev = (value: number) => {
|
||||
setSiblingIdx((messagesTree?.length ?? 0) - value - 1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// reset siblingIdx when the tree changes, mostly when a new message is submitting.
|
||||
setSiblingIdx(0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [messagesTree?.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messagesTree?.length && siblingIdx >= messagesTree?.length) {
|
||||
setSiblingIdx(0);
|
||||
}
|
||||
}, [siblingIdx, messagesTree?.length, setSiblingIdx]);
|
||||
|
||||
if (!(messagesTree && messagesTree?.length)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = messagesTree[messagesTree.length - siblingIdx - 1];
|
||||
|
||||
if (!message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Message
|
||||
key={message.messageId}
|
||||
message={message}
|
||||
scrollToBottom={scrollToBottom}
|
||||
currentEditId={currentEditId}
|
||||
setCurrentEditId={setCurrentEditId}
|
||||
siblingIdx={messagesTree.length - siblingIdx - 1}
|
||||
siblingCount={messagesTree.length}
|
||||
setSiblingIdx={setSiblingIdxRev}
|
||||
/>
|
||||
);
|
||||
}
|
71
client/src/components/Chat/Messages/SiblingSwitch.tsx
Normal file
71
client/src/components/Chat/Messages/SiblingSwitch.tsx
Normal file
|
@ -0,0 +1,71 @@
|
|||
import type { TMessageProps } from '~/common';
|
||||
|
||||
type TSiblingSwitchProps = Pick<TMessageProps, 'siblingIdx' | 'siblingCount' | 'setSiblingIdx'>;
|
||||
|
||||
export default function SiblingSwitch({
|
||||
siblingIdx,
|
||||
siblingCount,
|
||||
setSiblingIdx,
|
||||
}: TSiblingSwitchProps) {
|
||||
if (siblingIdx === undefined) {
|
||||
return null;
|
||||
} else if (siblingCount === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const previous = () => {
|
||||
setSiblingIdx && setSiblingIdx(siblingIdx - 1);
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
setSiblingIdx && setSiblingIdx(siblingIdx + 1);
|
||||
};
|
||||
|
||||
return siblingCount > 1 ? (
|
||||
<div className="visible flex items-center justify-center gap-1 self-center pt-0 text-xs">
|
||||
<button
|
||||
className="disabled:text-gray-300 dark:text-white dark:disabled:text-gray-400"
|
||||
onClick={previous}
|
||||
disabled={siblingIdx == 0}
|
||||
>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-3 w-3"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polyline points="15 18 9 12 15 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<span className="flex-shrink-0 flex-grow tabular-nums">
|
||||
{siblingIdx + 1}/{siblingCount}
|
||||
</span>
|
||||
<button
|
||||
className="disabled:text-gray-300 dark:text-white dark:disabled:text-gray-400"
|
||||
onClick={next}
|
||||
disabled={siblingIdx == siblingCount - 1}
|
||||
>
|
||||
<svg
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
strokeWidth="1.5"
|
||||
viewBox="0 0 24 24"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="h-3 w-3"
|
||||
height="1em"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<polyline points="9 18 15 12 9 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
19
client/src/components/Chat/Messages/SubRow.tsx
Normal file
19
client/src/components/Chat/Messages/SubRow.tsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { cn } from '~/utils';
|
||||
|
||||
type TSubRowProps = {
|
||||
children: React.ReactNode;
|
||||
classes?: string;
|
||||
subclasses?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export default function SubRow({ children, classes = '', onClick }: TSubRowProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn('mt-1 flex justify-start gap-3 empty:hidden lg:flex', classes)}
|
||||
onClick={onClick}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
41
client/src/components/Chat/SingleChatView.tsx
Normal file
41
client/src/components/Chat/SingleChatView.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { memo } from 'react';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import MessagesView from './Messages/MessagesView';
|
||||
import OptionsBar from './Input/OptionsBar';
|
||||
import CreationPanel from './CreationPanel';
|
||||
import { ChatContext } from '~/Providers';
|
||||
import { useChatHelpers } from '~/hooks';
|
||||
import ChatForm from './Input/ChatForm';
|
||||
import Landing from './Landing';
|
||||
import Header from './Header';
|
||||
|
||||
function ChatView({
|
||||
messagesTree,
|
||||
index = 0,
|
||||
}: {
|
||||
messagesTree?: TMessage[] | null;
|
||||
index?: number;
|
||||
}) {
|
||||
return (
|
||||
<ChatContext.Provider value={useChatHelpers(index)}>
|
||||
<div className="relative flex w-full grow overflow-hidden bg-white dark:bg-gray-800">
|
||||
<CreationPanel index={index} />
|
||||
<div className="transition-width relative flex h-full w-full flex-1 flex-col items-stretch overflow-hidden bg-white pt-10 dark:bg-gray-800 md:pt-0">
|
||||
<div className="flex h-full flex-col" role="presentation" tabIndex={0}>
|
||||
{messagesTree && messagesTree.length !== 0 ? (
|
||||
<MessagesView messagesTree={messagesTree} Header={<Header />} />
|
||||
) : (
|
||||
<Landing />
|
||||
)}
|
||||
<OptionsBar messagesTree={messagesTree} />
|
||||
<div className="gizmo:border-t-0 gizmo:pl-0 gizmo:md:pl-0 w-full border-t pt-2 dark:border-white/20 md:w-[calc(100%-.5rem)] md:border-t-0 md:border-transparent md:pl-2 md:pt-0 md:dark:border-transparent">
|
||||
<ChatForm index={index} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ChatContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ChatView);
|
|
@ -111,7 +111,7 @@ export default function Conversation({ conversation, retainView }) {
|
|||
|
||||
if (currentConversation?.conversationId !== conversationId) {
|
||||
aProps.className =
|
||||
'group relative flex cursor-pointer items-center gap-3 break-all rounded-md py-3 px-3 hover:bg-gray-800 hover:pr-4';
|
||||
'group relative flex cursor-pointer items-center gap-3 break-all rounded-md py-3 px-3 hover:bg-gray-900 hover:pr-4';
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -149,7 +149,7 @@ export default function Conversation({ conversation, retainView }) {
|
|||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-y-0 right-0 z-10 w-8 rounded-r-md bg-gradient-to-l from-gray-900 group-hover:from-gray-700/70" />
|
||||
<div className="absolute inset-y-0 right-0 z-10 w-8 rounded-r-md bg-gradient-to-l from-black group-hover:from-gray-900" />
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
|
|
|
@ -1,4 +1,6 @@
|
|||
import Convo from './Convo';
|
||||
import Conversation from './Conversation';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { TConversation } from 'librechat-data-provider';
|
||||
|
||||
export default function Conversations({
|
||||
|
@ -8,13 +10,22 @@ export default function Conversations({
|
|||
conversations: TConversation[];
|
||||
moveToTop: () => void;
|
||||
}) {
|
||||
const location = useLocation();
|
||||
const { pathname } = location;
|
||||
const ConvoItem = pathname.includes('chat') ? Conversation : Convo;
|
||||
|
||||
return (
|
||||
<>
|
||||
{conversations &&
|
||||
conversations.length > 0 &&
|
||||
conversations.map((convo: TConversation) => {
|
||||
conversations.map((convo: TConversation, i) => {
|
||||
return (
|
||||
<Conversation key={convo.conversationId} conversation={convo} retainView={moveToTop} />
|
||||
<ConvoItem
|
||||
key={convo.conversationId}
|
||||
conversation={convo}
|
||||
retainView={moveToTop}
|
||||
i={i}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
|
|
142
client/src/components/Conversations/Convo.tsx
Normal file
142
client/src/components/Conversations/Convo.tsx
Normal file
|
@ -0,0 +1,142 @@
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { useState, useRef } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useUpdateConversationMutation } from 'librechat-data-provider';
|
||||
import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react';
|
||||
import { useConversations, useNavigateToConvo } from '~/hooks';
|
||||
import { MinimalIcon } from '~/components/Endpoints';
|
||||
import { NotificationSeverity } from '~/common';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import DeleteButton from './NewDeleteButton';
|
||||
import RenameButton from './RenameButton';
|
||||
import store from '~/store';
|
||||
|
||||
type KeyEvent = KeyboardEvent<HTMLInputElement>;
|
||||
|
||||
export default function Conversation({ conversation, retainView, i }) {
|
||||
const { conversationId: currentConvoId } = useParams();
|
||||
const activeConvos = useRecoilValue(store.allConversationsSelector);
|
||||
const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? '');
|
||||
const { refreshConversations } = useConversations();
|
||||
const { navigateToConvo } = useNavigateToConvo();
|
||||
const { showToast } = useToastContext();
|
||||
|
||||
const { conversationId, title } = conversation;
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [titleInput, setTitleInput] = useState(title);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
|
||||
const clickHandler = async () => {
|
||||
if (currentConvoId === conversationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// set document title
|
||||
document.title = title;
|
||||
|
||||
// set conversation to the new conversation
|
||||
if (conversation?.endpoint === 'gptPlugins') {
|
||||
const lastSelectedTools = JSON.parse(localStorage.getItem('lastSelectedTools') ?? '') || [];
|
||||
navigateToConvo({ ...conversation, tools: lastSelectedTools });
|
||||
} else {
|
||||
navigateToConvo(conversation);
|
||||
}
|
||||
};
|
||||
|
||||
const renameHandler = (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
setTitleInput(title);
|
||||
setRenaming(true);
|
||||
setTimeout(() => {
|
||||
if (!inputRef.current) {
|
||||
return;
|
||||
}
|
||||
inputRef.current.focus();
|
||||
}, 25);
|
||||
};
|
||||
|
||||
const onRename = (e: MouseEvent<HTMLButtonElement> | FocusEvent<HTMLInputElement> | KeyEvent) => {
|
||||
e.preventDefault();
|
||||
setRenaming(false);
|
||||
if (titleInput === title) {
|
||||
return;
|
||||
}
|
||||
updateConvoMutation.mutate(
|
||||
{ conversationId, title: titleInput },
|
||||
{
|
||||
onSuccess: () => refreshConversations(),
|
||||
onError: () => {
|
||||
setTitleInput(title);
|
||||
showToast({
|
||||
message: 'Failed to rename conversation',
|
||||
severity: NotificationSeverity.ERROR,
|
||||
showIcon: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const icon = MinimalIcon({
|
||||
size: 20,
|
||||
endpoint: conversation.endpoint,
|
||||
model: conversation.model,
|
||||
error: false,
|
||||
className: 'mr-0',
|
||||
isCreatedByUser: false,
|
||||
});
|
||||
|
||||
const handleKeyDown = (e: KeyEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
onRename(e);
|
||||
}
|
||||
};
|
||||
|
||||
const aProps = {
|
||||
className:
|
||||
'animate-flash group relative flex cursor-pointer items-center gap-3 break-all rounded-md bg-gray-900 py-3 px-3 pr-14 hover:bg-gray-900',
|
||||
};
|
||||
|
||||
const activeConvo =
|
||||
currentConvoId === conversationId ||
|
||||
(i === 0 && currentConvoId === 'new' && activeConvos[0] && activeConvos[0] !== 'new');
|
||||
|
||||
if (!activeConvo) {
|
||||
aProps.className =
|
||||
'group relative flex cursor-pointer items-center gap-3 break-all rounded-md py-3 px-3 hover:bg-gray-900 hover:pr-4';
|
||||
}
|
||||
|
||||
return (
|
||||
<a data-testid="convo-item" onClick={() => clickHandler()} {...aProps}>
|
||||
{icon}
|
||||
<div className="relative max-h-5 flex-1 overflow-hidden text-ellipsis break-all">
|
||||
{renaming === true ? (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="m-0 mr-0 w-full border border-blue-500 bg-transparent p-0 text-sm leading-tight outline-none"
|
||||
value={titleInput}
|
||||
onChange={(e) => setTitleInput(e.target.value)}
|
||||
onBlur={onRename}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
) : (
|
||||
title
|
||||
)}
|
||||
</div>
|
||||
{activeConvo ? (
|
||||
<div className="visible absolute right-1 z-10 flex text-gray-400">
|
||||
<RenameButton renaming={renaming} onRename={onRename} renameHandler={renameHandler} />
|
||||
<DeleteButton
|
||||
conversationId={conversationId}
|
||||
retainView={retainView}
|
||||
renaming={renaming}
|
||||
title={title}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute inset-y-0 right-0 z-10 w-8 rounded-r-md bg-gradient-to-l from-black group-hover:from-gray-900" />
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
}
|
|
@ -1,17 +1,15 @@
|
|||
import TrashIcon from '../svg/TrashIcon';
|
||||
import CrossIcon from '../svg/CrossIcon';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useDeleteConversationMutation } from 'librechat-data-provider';
|
||||
import { Dialog, DialogTrigger, Label } from '~/components/ui/';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import { useLocalize, useConversations, useConversation } from '~/hooks';
|
||||
import store from '~/store';
|
||||
import { Dialog, DialogTrigger, Label } from '~/components/ui';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import { TrashIcon, CrossIcon } from '~/components/svg';
|
||||
|
||||
export default function DeleteButton({ conversationId, renaming, retainView, title }) {
|
||||
const localize = useLocalize();
|
||||
const currentConversation = useRecoilValue(store.conversation) || {};
|
||||
const { newConversation } = useConversation();
|
||||
const { refreshConversations } = useConversations();
|
||||
const { conversationId: currentConvoId } = useParams();
|
||||
const deleteConvoMutation = useDeleteConversationMutation(conversationId);
|
||||
|
||||
const confirmDelete = () => {
|
||||
|
@ -19,9 +17,7 @@ export default function DeleteButton({ conversationId, renaming, retainView, tit
|
|||
{ conversationId, source: 'button' },
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (
|
||||
(currentConversation as { conversationId?: string }).conversationId == conversationId
|
||||
) {
|
||||
if (currentConvoId == conversationId) {
|
||||
newConversation();
|
||||
}
|
||||
|
||||
|
|
59
client/src/components/Conversations/NewDeleteButton.tsx
Normal file
59
client/src/components/Conversations/NewDeleteButton.tsx
Normal file
|
@ -0,0 +1,59 @@
|
|||
import { useParams } from 'react-router-dom';
|
||||
import { useDeleteConversationMutation } from 'librechat-data-provider';
|
||||
import { useLocalize, useConversations, useNewConvo } from '~/hooks';
|
||||
import { Dialog, DialogTrigger, Label } from '~/components/ui';
|
||||
import DialogTemplate from '~/components/ui/DialogTemplate';
|
||||
import { TrashIcon, CrossIcon } from '~/components/svg';
|
||||
|
||||
export default function DeleteButton({ conversationId, renaming, retainView, title }) {
|
||||
const localize = useLocalize();
|
||||
// TODO: useNewConvo uses indices so we need to update global index state on every switch to Convo
|
||||
const { newConversation } = useNewConvo();
|
||||
const { refreshConversations } = useConversations();
|
||||
const { conversationId: currentConvoId } = useParams();
|
||||
const deleteConvoMutation = useDeleteConversationMutation(conversationId);
|
||||
|
||||
const confirmDelete = () => {
|
||||
deleteConvoMutation.mutate(
|
||||
{ conversationId, source: 'button' },
|
||||
{
|
||||
onSuccess: () => {
|
||||
if (currentConvoId == conversationId) {
|
||||
newConversation();
|
||||
}
|
||||
|
||||
refreshConversations();
|
||||
retainView();
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<button className="p-1 hover:text-white">{renaming ? <CrossIcon /> : <TrashIcon />}</button>
|
||||
</DialogTrigger>
|
||||
<DialogTemplate
|
||||
title={localize('com_ui_delete_conversation')}
|
||||
className="max-w-[450px]"
|
||||
main={
|
||||
<>
|
||||
<div className="flex w-full flex-col items-center gap-2">
|
||||
<div className="grid w-full items-center gap-2">
|
||||
<Label htmlFor="chatGptLabel" className="text-left text-sm font-medium">
|
||||
{localize('com_ui_delete_conversation_confirm')} <strong>{title}</strong>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
selection={{
|
||||
selectHandler: confirmDelete,
|
||||
selectClasses: 'bg-red-600 hover:bg-red-700 dark:hover:bg-red-800 text-white',
|
||||
selectText: localize('com_ui_delete'),
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
|
@ -1,11 +1,10 @@
|
|||
import React, { ReactElement } from 'react';
|
||||
import RenameIcon from '../svg/RenameIcon';
|
||||
import CheckMark from '../svg/CheckMark';
|
||||
import type { MouseEvent, ReactElement } from 'react';
|
||||
import { RenameIcon, CheckMark } from '~/components/svg';
|
||||
|
||||
interface RenameButtonProps {
|
||||
renaming: boolean;
|
||||
renameHandler: () => void;
|
||||
onRename: () => void;
|
||||
renameHandler: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
onRename: (e: MouseEvent<HTMLButtonElement>) => void;
|
||||
twcss?: string;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,26 +1,28 @@
|
|||
import React from 'react';
|
||||
import { Save } from 'lucide-react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { Button } from '~/components/ui';
|
||||
import { CrossIcon } from '~/components/svg';
|
||||
import PopoverButtons from './PopoverButtons';
|
||||
import type { ReactNode } from 'react';
|
||||
// import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { cn, removeFocusOutlines } from '~/utils';
|
||||
// import PopoverButtons from './PopoverButtons';
|
||||
import { CrossIcon } from '~/components/svg';
|
||||
import { Button } from '~/components/ui';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
type TEndpointOptionsPopoverProps = {
|
||||
children: React.ReactNode;
|
||||
children: ReactNode;
|
||||
visible: boolean;
|
||||
endpoint: EModelEndpoint;
|
||||
// endpoint: EModelEndpoint;
|
||||
saveAsPreset: () => void;
|
||||
closePopover: () => void;
|
||||
PopoverButtons: ReactNode;
|
||||
};
|
||||
|
||||
export default function EndpointOptionsPopover({
|
||||
children,
|
||||
endpoint,
|
||||
// endpoint,
|
||||
visible,
|
||||
saveAsPreset,
|
||||
closePopover,
|
||||
PopoverButtons,
|
||||
}: TEndpointOptionsPopoverProps) {
|
||||
const localize = useLocalize();
|
||||
const cardStyle =
|
||||
|
@ -49,7 +51,7 @@ export default function EndpointOptionsPopover({
|
|||
<Save className="mr-1 w-[14px]" />
|
||||
{localize('com_endpoint_save_as_preset')}
|
||||
</Button>
|
||||
<PopoverButtons endpoint={endpoint} />
|
||||
{PopoverButtons}
|
||||
<Button
|
||||
type="button"
|
||||
className={cn(
|
||||
|
|
|
@ -1,36 +1,25 @@
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { OpenAISettings, BingAISettings, AnthropicSettings } from './Settings';
|
||||
import { GoogleSettings, PluginsSettings } from './Settings/MultiView';
|
||||
import type { TSettingsProps, TModelSelectProps, TBaseSettingsProps, TModels } from '~/common';
|
||||
import type { TSettingsProps } from '~/common';
|
||||
import { getSettings } from './Settings';
|
||||
import { cn } from '~/utils';
|
||||
import store from '~/store';
|
||||
|
||||
const optionComponents: { [key: string]: React.FC<TModelSelectProps> } = {
|
||||
openAI: OpenAISettings,
|
||||
azureOpenAI: OpenAISettings,
|
||||
bingAI: BingAISettings,
|
||||
anthropic: AnthropicSettings,
|
||||
};
|
||||
|
||||
const multiViewComponents: { [key: string]: React.FC<TBaseSettingsProps & TModels> } = {
|
||||
google: GoogleSettings,
|
||||
gptPlugins: PluginsSettings,
|
||||
};
|
||||
|
||||
export default function Settings({
|
||||
conversation,
|
||||
setOption,
|
||||
isPreset = false,
|
||||
className = '',
|
||||
}: TSettingsProps) {
|
||||
isMultiChat = false,
|
||||
}: TSettingsProps & { isMultiChat?: boolean }) {
|
||||
const modelsConfig = useRecoilValue(store.modelsConfig);
|
||||
if (!conversation?.endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { settings, multiViewSettings } = getSettings(isMultiChat);
|
||||
const { endpoint } = conversation;
|
||||
const models = modelsConfig?.[endpoint] ?? [];
|
||||
const OptionComponent = optionComponents[endpoint];
|
||||
const OptionComponent = settings[endpoint];
|
||||
|
||||
if (OptionComponent) {
|
||||
return (
|
||||
|
@ -45,7 +34,7 @@ export default function Settings({
|
|||
);
|
||||
}
|
||||
|
||||
const MultiViewComponent = multiViewComponents[endpoint];
|
||||
const MultiViewComponent = multiViewSettings[endpoint];
|
||||
|
||||
if (!MultiViewComponent) {
|
||||
return null;
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { Plugin, GPTIcon, AnthropicIcon, AzureMinimalIcon } from '~/components/svg';
|
||||
import { useAuthContext } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
import { IconProps } from '~/common';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
const Icon: React.FC<IconProps> = (props) => {
|
||||
const { size = 30, isCreatedByUser, button, model = true, endpoint, error, jailbreak } = props;
|
||||
|
@ -19,7 +19,7 @@ const Icon: React.FC<IconProps> = (props) => {
|
|||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
className={`relative flex items-center justify-center ${props.className ?? ''}`}
|
||||
className={cn('relative flex items-center justify-center', props.className ?? '')}
|
||||
>
|
||||
<img
|
||||
className="rounded-sm"
|
||||
|
@ -33,12 +33,12 @@ const Icon: React.FC<IconProps> = (props) => {
|
|||
);
|
||||
} else {
|
||||
const endpointIcons = {
|
||||
azureOpenAI: {
|
||||
[EModelEndpoint.azureOpenAI]: {
|
||||
icon: <AzureMinimalIcon size={size * 0.5555555555555556} />,
|
||||
bg: 'linear-gradient(0.375turn, #61bde2, #4389d0)',
|
||||
name: 'ChatGPT',
|
||||
},
|
||||
openAI: {
|
||||
[EModelEndpoint.openAI]: {
|
||||
icon: <GPTIcon size={size * 0.5555555555555556} />,
|
||||
bg:
|
||||
typeof model === 'string' && model.toLowerCase().includes('gpt-4')
|
||||
|
@ -46,18 +46,21 @@ const Icon: React.FC<IconProps> = (props) => {
|
|||
: '#19C37D',
|
||||
name: 'ChatGPT',
|
||||
},
|
||||
gptPlugins: {
|
||||
[EModelEndpoint.gptPlugins]: {
|
||||
icon: <Plugin size={size * 0.7} />,
|
||||
bg: `rgba(69, 89, 164, ${button ? 0.75 : 1})`,
|
||||
name: 'Plugins',
|
||||
},
|
||||
google: { icon: <img src="/assets/google-palm.svg" alt="Palm Icon" />, name: 'PaLM2' },
|
||||
anthropic: {
|
||||
[EModelEndpoint.google]: {
|
||||
icon: <img src="/assets/google-palm.svg" alt="Palm Icon" />,
|
||||
name: 'PaLM2',
|
||||
},
|
||||
[EModelEndpoint.anthropic]: {
|
||||
icon: <AnthropicIcon size={size * 0.5555555555555556} />,
|
||||
bg: '#d09a74',
|
||||
name: 'Claude',
|
||||
},
|
||||
bingAI: {
|
||||
[EModelEndpoint.bingAI]: {
|
||||
icon: jailbreak ? (
|
||||
<img src="/assets/bingai-jb.png" alt="Bing Icon" />
|
||||
) : (
|
||||
|
@ -65,7 +68,7 @@ const Icon: React.FC<IconProps> = (props) => {
|
|||
),
|
||||
name: jailbreak ? 'Sydney' : 'BingAI',
|
||||
},
|
||||
chatGPTBrowser: {
|
||||
[EModelEndpoint.chatGPTBrowser]: {
|
||||
icon: <GPTIcon size={size * 0.5555555555555556} />,
|
||||
bg:
|
||||
typeof model === 'string' && model.toLowerCase().includes('gpt-4')
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React from 'react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import {
|
||||
AzureMinimalIcon,
|
||||
OpenAIMinimalIcon,
|
||||
|
@ -21,13 +21,19 @@ const MinimalIcon: React.FC<IconProps> = (props) => {
|
|||
}
|
||||
|
||||
const endpointIcons = {
|
||||
azureOpenAI: { icon: <AzureMinimalIcon />, name: props.chatGptLabel || 'ChatGPT' },
|
||||
openAI: { icon: <OpenAIMinimalIcon />, name: props.chatGptLabel || 'ChatGPT' },
|
||||
gptPlugins: { icon: <PluginMinimalIcon />, name: 'Plugins' },
|
||||
google: { icon: <PaLMinimalIcon />, name: props.modelLabel || 'PaLM2' },
|
||||
anthropic: { icon: <AnthropicMinimalIcon />, name: props.modelLabel || 'Claude' },
|
||||
bingAI: { icon: <BingAIMinimalIcon />, name: 'BingAI' },
|
||||
chatGPTBrowser: { icon: <ChatGPTMinimalIcon />, name: 'ChatGPT' },
|
||||
[EModelEndpoint.azureOpenAI]: {
|
||||
icon: <AzureMinimalIcon />,
|
||||
name: props.chatGptLabel || 'ChatGPT',
|
||||
},
|
||||
[EModelEndpoint.openAI]: { icon: <OpenAIMinimalIcon />, name: props.chatGptLabel || 'ChatGPT' },
|
||||
[EModelEndpoint.gptPlugins]: { icon: <PluginMinimalIcon />, name: 'Plugins' },
|
||||
[EModelEndpoint.google]: { icon: <PaLMinimalIcon />, name: props.modelLabel || 'PaLM2' },
|
||||
[EModelEndpoint.anthropic]: {
|
||||
icon: <AnthropicMinimalIcon />,
|
||||
name: props.modelLabel || 'Claude',
|
||||
},
|
||||
[EModelEndpoint.bingAI]: { icon: <BingAIMinimalIcon />, name: 'BingAI' },
|
||||
[EModelEndpoint.chatGPTBrowser]: { icon: <ChatGPTMinimalIcon />, name: 'ChatGPT' },
|
||||
default: { icon: <OpenAIMinimalIcon />, name: 'UNKNOWN' },
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
import Settings from '../Google';
|
||||
import Examples from '../Examples';
|
||||
import { useSetIndexOptions } from '~/hooks';
|
||||
import { useChatContext } from '~/Providers';
|
||||
|
||||
export default function GoogleView({ conversation, models, isPreset = false }) {
|
||||
const { optionSettings } = useChatContext();
|
||||
const { setOption, setExample, addExample, removeExample } = useSetIndexOptions(
|
||||
isPreset ? conversation : null,
|
||||
);
|
||||
if (!conversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { examples } = conversation;
|
||||
const { showExamples, isCodeChat } = optionSettings;
|
||||
return showExamples && !isCodeChat ? (
|
||||
<Examples
|
||||
examples={examples ?? []}
|
||||
setExample={setExample}
|
||||
addExample={addExample}
|
||||
removeExample={removeExample}
|
||||
/>
|
||||
) : (
|
||||
<Settings conversation={conversation} setOption={setOption} models={models} />
|
||||
);
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
import Settings from '../Plugins';
|
||||
import AgentSettings from '../AgentSettings';
|
||||
import { useSetIndexOptions } from '~/hooks';
|
||||
import { useChatContext } from '~/Providers';
|
||||
|
||||
export default function PluginsView({ conversation, models, isPreset = false }) {
|
||||
const { showAgentSettings } = useChatContext();
|
||||
const { setOption, setAgentOption } = useSetIndexOptions(isPreset ? conversation : null);
|
||||
if (!conversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return showAgentSettings ? (
|
||||
<AgentSettings conversation={conversation} setOption={setAgentOption} models={models} />
|
||||
) : (
|
||||
<Settings conversation={conversation} setOption={setOption} models={models} />
|
||||
);
|
||||
}
|
|
@ -1,2 +1,4 @@
|
|||
export { default as GoogleSettings } from './Google';
|
||||
export { default as PluginsSettings } from './Plugins';
|
||||
export { default as Google } from './Google';
|
||||
export { default as Plugins } from './Plugins';
|
||||
export { default as GoogleSettings } from './GoogleSettings';
|
||||
export { default as PluginSettings } from './PluginSettings';
|
||||
|
|
|
@ -67,6 +67,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
|||
placeholder={localize('com_endpoint_openai_custom_name_placeholder')}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
'dark:bg-gray-700 dark:hover:bg-gray-700/60 dark:focus:bg-gray-700',
|
||||
'flex h-10 max-h-10 w-full resize-none px-3 py-2',
|
||||
removeFocusOutlines,
|
||||
)}
|
||||
|
@ -85,6 +86,7 @@ export default function Settings({ conversation, setOption, models, readonly }:
|
|||
placeholder={localize('com_endpoint_openai_prompt_prefix_placeholder')}
|
||||
className={cn(
|
||||
defaultTextProps,
|
||||
'dark:bg-gray-700 dark:hover:bg-gray-700/60 dark:focus:bg-gray-700',
|
||||
'flex max-h-[300px] min-h-[100px] w-full resize-none px-3 py-2 ',
|
||||
)}
|
||||
/>
|
||||
|
|
|
@ -5,3 +5,4 @@ export { default as PluginsSettings } from './Plugins';
|
|||
export { default as Examples } from './Examples';
|
||||
export { default as AgentSettings } from './AgentSettings';
|
||||
export { default as AnthropicSettings } from './Anthropic';
|
||||
export * from './settings';
|
||||
|
|
36
client/src/components/Endpoints/Settings/settings.ts
Normal file
36
client/src/components/Endpoints/Settings/settings.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import OpenAISettings from './OpenAI';
|
||||
import BingAISettings from './BingAI';
|
||||
import AnthropicSettings from './Anthropic';
|
||||
import { Google, Plugins, GoogleSettings, PluginSettings } from './MultiView';
|
||||
import type { FC } from 'react';
|
||||
import type { TModelSelectProps, TBaseSettingsProps, TModels } from '~/common';
|
||||
|
||||
const settings: { [key: string]: FC<TModelSelectProps> } = {
|
||||
[EModelEndpoint.openAI]: OpenAISettings,
|
||||
[EModelEndpoint.azureOpenAI]: OpenAISettings,
|
||||
[EModelEndpoint.bingAI]: BingAISettings,
|
||||
[EModelEndpoint.anthropic]: AnthropicSettings,
|
||||
};
|
||||
|
||||
const multiViewSettings: { [key: string]: FC<TBaseSettingsProps & TModels> } = {
|
||||
[EModelEndpoint.google]: Google,
|
||||
[EModelEndpoint.gptPlugins]: Plugins,
|
||||
};
|
||||
|
||||
export const getSettings = (isMultiChat = false) => {
|
||||
if (!isMultiChat) {
|
||||
return {
|
||||
settings,
|
||||
multiViewSettings,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
multiViewSettings: {
|
||||
[EModelEndpoint.google]: GoogleSettings,
|
||||
[EModelEndpoint.gptPlugins]: PluginSettings,
|
||||
},
|
||||
};
|
||||
};
|
|
@ -1,5 +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 EditPresetDialog } from './EditPresetDialog';
|
||||
export { default as SaveAsPresetDialog } from './SaveAsPresetDialog';
|
||||
|
|
|
@ -4,9 +4,9 @@ import { Settings } from 'lucide-react';
|
|||
import { DropdownMenuRadioItem } from '~/components';
|
||||
import { Icon } from '~/components/Endpoints';
|
||||
import { SetKeyDialog } from '../SetKeyDialog';
|
||||
import { alternateName } from '~/common';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
import { cn, alternateName } from '~/utils';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function ModelItem({
|
||||
endpoint,
|
||||
|
|
|
@ -37,7 +37,7 @@ export default function NewConversationMenu() {
|
|||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [showPresets, setShowPresets] = useState(true);
|
||||
const [showEndpoints, setShowEndpoints] = useState(true);
|
||||
const [presetModelVisible, setPresetModelVisible] = useState(false);
|
||||
const [presetModalVisible, setPresetModalVisible] = useState(false);
|
||||
const [preset, setPreset] = useState(false);
|
||||
const [conversation, setConversation] = useRecoilState(store.conversation) ?? {};
|
||||
const [messages, setMessages] = useRecoilState(store.messages);
|
||||
|
@ -131,7 +131,7 @@ export default function NewConversationMenu() {
|
|||
};
|
||||
|
||||
const onChangePreset = (preset) => {
|
||||
setPresetModelVisible(true);
|
||||
setPresetModalVisible(true);
|
||||
setPreset(preset);
|
||||
};
|
||||
|
||||
|
@ -269,8 +269,8 @@ export default function NewConversationMenu() {
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<EditPresetDialog
|
||||
open={presetModelVisible}
|
||||
onOpenChange={setPresetModelVisible}
|
||||
open={presetModalVisible}
|
||||
onOpenChange={setPresetModalVisible}
|
||||
preset={preset}
|
||||
/>
|
||||
</Dialog>
|
||||
|
|
|
@ -4,7 +4,7 @@ import { cn } from '~/utils/';
|
|||
import { useLocalize } from '~/hooks';
|
||||
|
||||
type FileUploadProps = {
|
||||
onFileSelected: (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onFileSelected: (jsonData: Record<string, unknown>) => void;
|
||||
className?: string;
|
||||
successText?: string;
|
||||
invalidText?: string;
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
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';
|
||||
|
||||
|
@ -24,32 +25,32 @@ export default function PresetItem({
|
|||
let _title = `${endpoint}`;
|
||||
const { chatGptLabel, modelLabel, model, jailbreak, toneStyle } = preset;
|
||||
|
||||
if (endpoint === 'azureOpenAI' || endpoint === 'openAI') {
|
||||
if (endpoint === EModelEndpoint.azureOpenAI || endpoint === EModelEndpoint.openAI) {
|
||||
if (model) {
|
||||
_title += `: ${model}`;
|
||||
}
|
||||
if (chatGptLabel) {
|
||||
_title += ` as ${chatGptLabel}`;
|
||||
}
|
||||
} else if (endpoint === 'google') {
|
||||
} else if (endpoint === EModelEndpoint.google) {
|
||||
if (model) {
|
||||
_title += `: ${model}`;
|
||||
}
|
||||
if (modelLabel) {
|
||||
_title += ` as ${modelLabel}`;
|
||||
}
|
||||
} else if (endpoint === 'bingAI') {
|
||||
} else if (endpoint === EModelEndpoint.bingAI) {
|
||||
if (toneStyle) {
|
||||
_title += `: ${toneStyle}`;
|
||||
}
|
||||
if (jailbreak) {
|
||||
_title += ' as Sydney';
|
||||
}
|
||||
} else if (endpoint === 'chatGPTBrowser') {
|
||||
} else if (endpoint === EModelEndpoint.chatGPTBrowser) {
|
||||
if (model) {
|
||||
_title += `: ${model}`;
|
||||
}
|
||||
} else if (endpoint === 'gptPlugins') {
|
||||
} else if (endpoint === EModelEndpoint.gptPlugins) {
|
||||
if (model) {
|
||||
_title += `: ${model}`;
|
||||
}
|
||||
|
|
|
@ -1,14 +1,21 @@
|
|||
import { SelectDropDown } from '~/components/ui';
|
||||
import { cn, cardStyle } from '~/utils/';
|
||||
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
|
||||
import type { TModelSelectProps } from '~/common';
|
||||
import { cn, cardStyle } from '~/utils/';
|
||||
|
||||
export default function Anthropic({ conversation, setOption, models }: TModelSelectProps) {
|
||||
export default function Anthropic({
|
||||
conversation,
|
||||
setOption,
|
||||
models,
|
||||
showAbove,
|
||||
popover = false,
|
||||
}: TModelSelectProps) {
|
||||
const Menu = popover ? SelectDropDownPop : SelectDropDown;
|
||||
return (
|
||||
<SelectDropDown
|
||||
<Menu
|
||||
value={conversation?.model ?? ''}
|
||||
setValue={setOption('model')}
|
||||
availableValues={models}
|
||||
showAbove={true}
|
||||
showAbove={showAbove}
|
||||
showLabel={false}
|
||||
className={cn(
|
||||
cardStyle,
|
||||
|
|
|
@ -1,10 +1,17 @@
|
|||
import { useRecoilValue } from 'recoil';
|
||||
import { SelectDropDown, Tabs, TabsList, TabsTrigger } from '~/components/ui';
|
||||
import { SelectDropDown, SelectDropDownPop, Tabs, TabsList, TabsTrigger } from '~/components/ui';
|
||||
import { cn, cardStyle } from '~/utils/';
|
||||
import type { TModelSelectProps } from '~/common';
|
||||
import store from '~/store';
|
||||
|
||||
export default function BingAI({ conversation, setOption, models }: TModelSelectProps) {
|
||||
export default function BingAI({
|
||||
conversation,
|
||||
setOption,
|
||||
models,
|
||||
showAbove,
|
||||
popover = false,
|
||||
}: TModelSelectProps) {
|
||||
// TODO: index family bing tone settings, important for multiview
|
||||
const showBingToneSetting = useRecoilValue(store.showBingToneSetting);
|
||||
if (!conversation) {
|
||||
return null;
|
||||
|
@ -21,16 +28,17 @@ export default function BingAI({ conversation, setOption, models }: TModelSelect
|
|||
'font-medium data-[state=active]:text-white text-xs text-white',
|
||||
);
|
||||
const selectedClass = (val: string) => val + '-tab ' + defaultSelected;
|
||||
const Menu = popover ? SelectDropDownPop : SelectDropDown;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SelectDropDown
|
||||
<Menu
|
||||
title="Mode"
|
||||
value={jailbreak ? 'Sydney' : 'BingAI'}
|
||||
data-testid="bing-select-dropdown"
|
||||
setValue={(value) => setOption('jailbreak')(value === 'Sydney')}
|
||||
availableValues={models}
|
||||
showAbove={true}
|
||||
showAbove={showAbove}
|
||||
showLabel={false}
|
||||
className={cn(
|
||||
cardStyle,
|
||||
|
|
|
@ -1,8 +1,14 @@
|
|||
import { SelectDropDown } from '~/components/ui';
|
||||
import { cn, cardStyle } from '~/utils/';
|
||||
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
|
||||
import type { TModelSelectProps } from '~/common';
|
||||
import { cn, cardStyle } from '~/utils/';
|
||||
|
||||
export default function ChatGPT({ conversation, setOption, models }: TModelSelectProps) {
|
||||
export default function ChatGPT({
|
||||
conversation,
|
||||
setOption,
|
||||
models,
|
||||
showAbove,
|
||||
popover = false,
|
||||
}: TModelSelectProps) {
|
||||
if (!conversation) {
|
||||
return null;
|
||||
}
|
||||
|
@ -10,13 +16,13 @@ export default function ChatGPT({ conversation, setOption, models }: TModelSelec
|
|||
if (conversationId !== 'new') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Menu = popover ? SelectDropDownPop : SelectDropDown;
|
||||
return (
|
||||
<SelectDropDown
|
||||
<Menu
|
||||
value={model ?? ''}
|
||||
setValue={setOption('model')}
|
||||
availableValues={models}
|
||||
showAbove={true}
|
||||
showAbove={showAbove}
|
||||
showLabel={false}
|
||||
className={cn(
|
||||
cardStyle,
|
||||
|
|
|
@ -1,14 +1,21 @@
|
|||
import { SelectDropDown } from '~/components/ui';
|
||||
import { cn, cardStyle } from '~/utils/';
|
||||
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
|
||||
import type { TModelSelectProps } from '~/common';
|
||||
import { cn, cardStyle } from '~/utils/';
|
||||
|
||||
export default function Google({ conversation, setOption, models }: TModelSelectProps) {
|
||||
export default function Google({
|
||||
conversation,
|
||||
setOption,
|
||||
models,
|
||||
showAbove,
|
||||
popover = false,
|
||||
}: TModelSelectProps) {
|
||||
const Menu = popover ? SelectDropDownPop : SelectDropDown;
|
||||
return (
|
||||
<SelectDropDown
|
||||
<Menu
|
||||
value={conversation?.model ?? ''}
|
||||
setValue={setOption('model')}
|
||||
availableValues={models}
|
||||
showAbove={true}
|
||||
showAbove={showAbove}
|
||||
showLabel={false}
|
||||
className={cn(
|
||||
cardStyle,
|
||||
|
|
|
@ -1,13 +1,7 @@
|
|||
import React from 'react';
|
||||
import OpenAI from './OpenAI';
|
||||
import BingAI from './BingAI';
|
||||
import Google from './Google';
|
||||
import Plugins from './Plugins';
|
||||
import ChatGPT from './ChatGPT';
|
||||
import Anthropic from './Anthropic';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import type { TConversation } from 'librechat-data-provider';
|
||||
import type { TSetOption, TModelSelectProps } from '~/common';
|
||||
import type { TSetOption } from '~/common';
|
||||
import { options, multiChatOptions } from './options';
|
||||
import store from '~/store';
|
||||
|
||||
type TGoogleProps = {
|
||||
|
@ -19,31 +13,36 @@ type TSelectProps = {
|
|||
conversation: TConversation | null;
|
||||
setOption: TSetOption;
|
||||
extraProps?: TGoogleProps;
|
||||
isMultiChat?: boolean;
|
||||
showAbove?: boolean;
|
||||
};
|
||||
|
||||
const optionComponents: { [key: string]: React.FC<TModelSelectProps> } = {
|
||||
openAI: OpenAI,
|
||||
azureOpenAI: OpenAI,
|
||||
bingAI: BingAI,
|
||||
google: Google,
|
||||
gptPlugins: Plugins,
|
||||
anthropic: Anthropic,
|
||||
chatGPTBrowser: ChatGPT,
|
||||
};
|
||||
|
||||
export default function ModelSelect({ conversation, setOption }: TSelectProps) {
|
||||
export default function ModelSelect({
|
||||
conversation,
|
||||
setOption,
|
||||
isMultiChat = false,
|
||||
showAbove = true,
|
||||
}: TSelectProps) {
|
||||
const modelsConfig = useRecoilValue(store.modelsConfig);
|
||||
if (!conversation?.endpoint) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { endpoint } = conversation;
|
||||
const OptionComponent = optionComponents[endpoint];
|
||||
const OptionComponent = isMultiChat ? multiChatOptions[endpoint] : options[endpoint];
|
||||
const models = modelsConfig?.[endpoint] ?? [];
|
||||
|
||||
if (!OptionComponent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <OptionComponent conversation={conversation} setOption={setOption} models={models} />;
|
||||
return (
|
||||
<OptionComponent
|
||||
conversation={conversation}
|
||||
setOption={setOption}
|
||||
models={models}
|
||||
showAbove={showAbove}
|
||||
popover={isMultiChat}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,14 +1,21 @@
|
|||
import { SelectDropDown } from '~/components/ui';
|
||||
import { cn, cardStyle } from '~/utils/';
|
||||
import { SelectDropDown, SelectDropDownPop } from '~/components/ui';
|
||||
import type { TModelSelectProps } from '~/common';
|
||||
import { cn, cardStyle } from '~/utils/';
|
||||
|
||||
export default function OpenAI({ conversation, setOption, models }: TModelSelectProps) {
|
||||
export default function OpenAI({
|
||||
conversation,
|
||||
setOption,
|
||||
models,
|
||||
showAbove = true,
|
||||
popover = false,
|
||||
}: TModelSelectProps) {
|
||||
const Menu = popover ? SelectDropDownPop : SelectDropDown;
|
||||
return (
|
||||
<SelectDropDown
|
||||
<Menu
|
||||
value={conversation?.model ?? ''}
|
||||
setValue={setOption('model')}
|
||||
availableValues={models}
|
||||
showAbove={true}
|
||||
showAbove={showAbove}
|
||||
showLabel={false}
|
||||
className={cn(
|
||||
cardStyle,
|
||||
|
|
|
@ -3,7 +3,7 @@ import { useState, useEffect } from 'react';
|
|||
import { ChevronDownIcon } from 'lucide-react';
|
||||
import { useAvailablePluginsQuery, TPlugin } from 'librechat-data-provider';
|
||||
import type { TModelSelectProps } from '~/common';
|
||||
import { SelectDropDown, MultiSelectDropDown, Button } from '~/components/ui';
|
||||
import { SelectDropDown, MultiSelectDropDown, SelectDropDownPop, Button } from '~/components/ui';
|
||||
import { useSetOptions, useAuthContext, useMediaQuery } from '~/hooks';
|
||||
import { cn, cardStyle } from '~/utils/';
|
||||
import store from '~/store';
|
||||
|
@ -18,13 +18,20 @@ const pluginStore: TPlugin = {
|
|||
authenticated: false,
|
||||
};
|
||||
|
||||
export default function Plugins({ conversation, setOption, models }: TModelSelectProps) {
|
||||
export default function Plugins({
|
||||
conversation,
|
||||
setOption,
|
||||
models,
|
||||
showAbove,
|
||||
popover = false,
|
||||
}: TModelSelectProps) {
|
||||
const { data: allPlugins } = useAvailablePluginsQuery();
|
||||
const [visible, setVisibility] = useState<boolean>(true);
|
||||
const [availableTools, setAvailableTools] = useRecoilState(store.availableTools);
|
||||
const { checkPluginSelection, setTools } = useSetOptions();
|
||||
const { user } = useAuthContext();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 640px)');
|
||||
const Menu = popover ? SelectDropDownPop : SelectDropDown;
|
||||
|
||||
useEffect(() => {
|
||||
if (isSmallScreen) {
|
||||
|
@ -87,11 +94,11 @@ export default function Plugins({ conversation, setOption, models }: TModelSelec
|
|||
)}
|
||||
/>
|
||||
</Button>
|
||||
<SelectDropDown
|
||||
<Menu
|
||||
value={conversation.model ?? ''}
|
||||
setValue={setOption('model')}
|
||||
availableValues={models}
|
||||
showAbove={true}
|
||||
showAbove={showAbove}
|
||||
className={cn(cardStyle, 'min-w-60 z-40 flex w-64 sm:w-48', visible ? '' : 'hidden')}
|
||||
/>
|
||||
<MultiSelectDropDown
|
||||
|
@ -100,7 +107,7 @@ export default function Plugins({ conversation, setOption, models }: TModelSelec
|
|||
setSelected={setTools}
|
||||
availableValues={availableTools}
|
||||
optionValueKey="pluginKey"
|
||||
showAbove={true}
|
||||
showAbove={showAbove}
|
||||
className={cn(cardStyle, 'min-w-60 z-50 w-64 sm:w-48', visible ? '' : 'hidden')}
|
||||
/>
|
||||
</>
|
||||
|
|
125
client/src/components/Input/ModelSelect/PluginsByIndex.tsx
Normal file
125
client/src/components/Input/ModelSelect/PluginsByIndex.tsx
Normal file
|
@ -0,0 +1,125 @@
|
|||
import { useRecoilState } from 'recoil';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { ChevronDownIcon } from 'lucide-react';
|
||||
import { useAvailablePluginsQuery, TPlugin } from 'librechat-data-provider';
|
||||
import type { TModelSelectProps } from '~/common';
|
||||
import {
|
||||
SelectDropDown,
|
||||
SelectDropDownPop,
|
||||
MultiSelectDropDown,
|
||||
MultiSelectPop,
|
||||
Button,
|
||||
} from '~/components/ui';
|
||||
import { useSetIndexOptions, useAuthContext, useMediaQuery } from '~/hooks';
|
||||
import { cn, cardStyle } from '~/utils/';
|
||||
import store from '~/store';
|
||||
|
||||
const pluginStore: TPlugin = {
|
||||
name: 'Plugin store',
|
||||
pluginKey: 'pluginStore',
|
||||
isButton: true,
|
||||
description: '',
|
||||
icon: '',
|
||||
authConfig: [],
|
||||
authenticated: false,
|
||||
};
|
||||
|
||||
export default function PluginsByIndex({
|
||||
conversation,
|
||||
setOption,
|
||||
models,
|
||||
showAbove,
|
||||
popover = false,
|
||||
}: TModelSelectProps) {
|
||||
const { data: allPlugins } = useAvailablePluginsQuery();
|
||||
const [visible, setVisibility] = useState<boolean>(true);
|
||||
const [availableTools, setAvailableTools] = useRecoilState(store.availableTools);
|
||||
const { checkPluginSelection, setTools } = useSetIndexOptions();
|
||||
const { user } = useAuthContext();
|
||||
const isSmallScreen = useMediaQuery('(max-width: 640px)');
|
||||
|
||||
useEffect(() => {
|
||||
if (isSmallScreen) {
|
||||
setVisibility(false);
|
||||
}
|
||||
}, [isSmallScreen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!allPlugins) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!user.plugins || user.plugins.length === 0) {
|
||||
setAvailableTools([pluginStore]);
|
||||
return;
|
||||
}
|
||||
|
||||
const tools = [...user.plugins]
|
||||
.map((el) => allPlugins.find((plugin: TPlugin) => plugin.pluginKey === el))
|
||||
.filter((el): el is TPlugin => el !== undefined);
|
||||
|
||||
/* Filter Last Selected Tools */
|
||||
const localStorageItem = localStorage.getItem('lastSelectedTools');
|
||||
if (!localStorageItem) {
|
||||
return setAvailableTools([...tools, pluginStore]);
|
||||
}
|
||||
const lastSelectedTools = JSON.parse(localStorageItem);
|
||||
const filteredTools = lastSelectedTools.filter((tool: TPlugin) =>
|
||||
tools.some((existingTool) => existingTool.pluginKey === tool.pluginKey),
|
||||
);
|
||||
localStorage.setItem('lastSelectedTools', JSON.stringify(filteredTools));
|
||||
|
||||
setAvailableTools([...tools, pluginStore]);
|
||||
// setAvailableTools is a recoil state setter, so it's safe to use it in useEffect
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allPlugins, user]);
|
||||
|
||||
if (!conversation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const Menu = popover ? SelectDropDownPop : SelectDropDown;
|
||||
const PluginsMenu = popover ? MultiSelectPop : MultiSelectDropDown;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
className={cn(
|
||||
cardStyle,
|
||||
'min-w-4 z-40 flex h-[40px] flex-none items-center justify-center px-3 hover:bg-white focus:ring-0 focus:ring-offset-0 dark:hover:bg-gray-700',
|
||||
)}
|
||||
onClick={() => setVisibility((prev) => !prev)}
|
||||
>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
!visible ? '' : 'rotate-180 transform',
|
||||
'w-4 text-gray-600 dark:text-white',
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
<Menu
|
||||
value={conversation.model ?? ''}
|
||||
setValue={setOption('model')}
|
||||
availableValues={models}
|
||||
showAbove={showAbove}
|
||||
showLabel={false}
|
||||
className={cn(cardStyle, 'min-w-60 z-40 flex w-64 sm:w-48 ', visible ? '' : 'hidden')}
|
||||
/>
|
||||
<PluginsMenu
|
||||
value={conversation.tools || []}
|
||||
isSelected={checkPluginSelection}
|
||||
setSelected={setTools}
|
||||
availableValues={availableTools}
|
||||
optionValueKey="pluginKey"
|
||||
showAbove={false}
|
||||
showLabel={false}
|
||||
className={cn(cardStyle, 'min-w-60 z-50 w-64 sm:w-48 ', visible ? '' : 'hidden')}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
26
client/src/components/Input/ModelSelect/options.ts
Normal file
26
client/src/components/Input/ModelSelect/options.ts
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import type { TModelSelectProps } from '~/common';
|
||||
import type { FC } from 'react';
|
||||
|
||||
import OpenAI from './OpenAI';
|
||||
import BingAI from './BingAI';
|
||||
import Google from './Google';
|
||||
import Plugins from './Plugins';
|
||||
import ChatGPT from './ChatGPT';
|
||||
import Anthropic from './Anthropic';
|
||||
import PluginsByIndex from './PluginsByIndex';
|
||||
|
||||
export const options: { [key: string]: FC<TModelSelectProps> } = {
|
||||
[EModelEndpoint.openAI]: OpenAI,
|
||||
[EModelEndpoint.azureOpenAI]: OpenAI,
|
||||
[EModelEndpoint.bingAI]: BingAI,
|
||||
[EModelEndpoint.google]: Google,
|
||||
[EModelEndpoint.gptPlugins]: Plugins,
|
||||
[EModelEndpoint.anthropic]: Anthropic,
|
||||
[EModelEndpoint.chatGPTBrowser]: ChatGPT,
|
||||
};
|
||||
|
||||
export const multiChatOptions = {
|
||||
...options,
|
||||
[EModelEndpoint.gptPlugins]: PluginsByIndex,
|
||||
};
|
|
@ -1,9 +1,10 @@
|
|||
import { Settings2 } from 'lucide-react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useRecoilValue, useRecoilState, useSetRecoilState } from 'recoil';
|
||||
import { tPresetSchema } from 'librechat-data-provider';
|
||||
import { tPresetSchema, EModelEndpoint } from 'librechat-data-provider';
|
||||
import { PluginStoreDialog } from '~/components';
|
||||
import {
|
||||
PopoverButtons,
|
||||
EndpointSettings,
|
||||
SaveAsPresetDialog,
|
||||
EndpointOptionsPopover,
|
||||
|
@ -40,8 +41,8 @@ export default function OptionsBar() {
|
|||
|
||||
const noSettings = useMemo<{ [key: string]: boolean }>(
|
||||
() => ({
|
||||
chatGPTBrowser: true,
|
||||
bingAI: jailbreak ? false : conversationId !== 'new',
|
||||
[EModelEndpoint.chatGPTBrowser]: true,
|
||||
[EModelEndpoint.bingAI]: jailbreak ? false : conversationId !== 'new',
|
||||
}),
|
||||
[jailbreak, conversationId],
|
||||
);
|
||||
|
@ -133,10 +134,10 @@ export default function OptionsBar() {
|
|||
)}
|
||||
</div>
|
||||
<EndpointOptionsPopover
|
||||
endpoint={endpoint}
|
||||
visible={showPopover}
|
||||
saveAsPreset={saveAsPreset}
|
||||
closePopover={() => setShowPopover(false)}
|
||||
PopoverButtons={<PopoverButtons endpoint={endpoint} />}
|
||||
>
|
||||
<div className="px-4 py-4">
|
||||
<EndpointSettings conversation={conversation} setOption={setOption} />
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue