Compare commits

...

20 Commits

Author SHA1 Message Date
Danny Avila
1c5e827ce9 chore: azureAssistants ENDPOINTS exclusion warning 2024-05-19 12:48:36 -04:00
Danny Avila
84f68f9a15 feat: add additional metadata: endpoint, author ID 2024-05-19 12:41:35 -04:00
Danny Avila
fb6e87c36d chore: update comparisons 2024-05-19 12:25:28 -04:00
Danny Avila
bce0584c67 localization updates 2024-05-19 12:24:50 -04:00
Danny Avila
5c15b60601 meilisearch improvements 2024-05-19 12:16:25 -04:00
Danny Avila
e55a89270e chore: update .env.example 2024-05-19 11:35:30 -04:00
Danny Avila
3eabbd572e ci: fix backend tests due to new updates 2024-05-18 15:44:22 -04:00
Danny Avila
6082e26716 fix: merge conflict 2024-05-18 15:29:31 -04:00
Danny Avila
bc46ccdcad 🤖 Assistants V2 Support: Part 2
🎹 fix: Autocompletion Chrome Bug on Action API Key Input

chore: remove `useOriginNavigate`

chore: set correct OpenAI Storage Source

fix: azure file deletions, instantiate clients by source for deletion

update code interpret files info

feat: deleteResourceFileId

chore: increase poll interval as azure easily rate limits

fix: openai file deletions, TODO: evaluate rejected deletion settled promises to determine which to delete from db records

file source icons

update table file filters

chore: file search info and versioning

fix: retrieval update with necessary tool_resources if specified

fix(useMentions): add optional chaining in case listMap value is undefined

fix: force assistant avatar roundedness

fix: azure assistants, check correct flag

chore: bump data-provider
2024-05-18 15:22:48 -04:00
Danny Avila
2bdbff5141 🤖 Assistants V2 Support: Part 1
- Separated Azure Assistants to its own endpoint
- File Search / Vector Store integration is incomplete, but can toggle and use storage from playground
- Code Interpreter resource files can be added but not deleted
- GPT-4o is supported
- Many improvements to the Assistants Endpoint overall

data-provider v2 changes

copy existing route as v1

chore: rename new endpoint to reduce comparison operations and add new azure filesource

api: add azureAssistants part 1

force use of version for assistants/assistantsAzure

chore: switch name back to azureAssistants

refactor type version: string | number

Ensure assistants endpoints have version set

fix: isArchived type issue in ConversationListParams

refactor: update assistants mutations/queries with endpoint/version definitions, update Assistants Map structure

chore:  FilePreview component ExtendedFile type assertion

feat: isAssistantsEndpoint helper

chore: remove unused useGenerations

chore(buildTree): type issue

chore(Advanced): type issue (unused component, maybe in future)

first pass for multi-assistant endpoint rewrite

fix(listAssistants): pass params correctly

feat: list separate assistants by endpoint

fix(useTextarea): access assistantMap correctly

fix: assistant endpoint switching, resetting ID

fix: broken during rewrite, selecting assistant mention

fix: set/invalidate assistants endpoint query data correctly

feat: Fix issue with assistant ID not being reset correctly

getOpenAIClient helper function

feat: add toast for assistant deletion

fix: assistants delete right after create issue for azure

fix: assistant patching

refactor: actions to use getOpenAIClient

refactor: consolidate logic into helpers file

fix: issue where conversation data was not initially available

v1 chat support

refactor(spendTokens): only early return if completionTokens isNaN

fix(OpenAIClient): ensure spendTokens has all necessary params

refactor: route/controller logic

fix(assistants/initializeClient): use defaultHeaders field

fix: sanitize default operation id

chore: bump openai package

first pass v2 action service

feat: retroactive domain parsing for actions added via v1

feat: delete db records of actions/assistants on openai assistant deletion

chore: remove vision tools from v2 assistants

feat: v2 upload and delete assistant vision images

WIP first pass, thread attachments

fix: show assistant vision files (save local/firebase copy)

v2 image continue

fix: annotations

fix: refine annotations

show analyze as error if is no longer submitting before progress reaches 1 and show file_search as retrieval tool

fix: abort run, undefined endpoint issue

refactor: consolidate capabilities logic and anticipate versioning

frontend version 2 changes

fix: query selection and filter

add endpoint to unknown filepath

add file ids to resource, deleting in progress

enable/disable file search

remove version log
2024-05-18 15:22:47 -04:00
Danny Avila
f0e8cca5df 🚀 feat: Shared Links (#2772)
*  feat(types): add necessary types for shared link feature

*  feat: add shared links functions to data service

Added functions for retrieving, creating, updating, and deleting shared links and shared messages.

*  feat: Add useGetSharedMessages hook to fetch shared messages by shareId

Adds a new hook `useGetSharedMessages` which fetches shared messages based on the provided shareId.

*  feat: Add share schema and data access functions to API models

*  feat: Add share endpoint to API

The GET /api/share/${shareId} is exposed to the public, so authentication is not required. Other paths require authentication.

* ♻️ refactor(utils): generalize react-query cache manipulation functions

Introduces generic functions for manipulating react-query cache entries, marking a refinement in how query cache data is managed. It aims to enhance the flexibility and reusability of the cache interaction patterns within our application.

- Replaced specific index names with more generic terms in queries.ts, enhancing consistency across data handling functions.
- Introduced new utility functions in collection.ts for adding, updating, and deleting data entries in an InfiniteData<TCollection>. These utility functions (`addData`, `updateData`, `deleteData`, `findPage`) are designed to be re-usable across different data types and collections.
- Adapted existing conversation utility functions in convos.ts to leverage these new generic utilities.

*  feat(shared-link): add functions to manipulate shared link cache list

implemented new utility functions to handle additions, updates, and deletions in the shared link cache list.

*  feat: Add mutations and queries for shared links

*  feat(shared-link): add `Share` button to conversation list

- Added a share button in each conversation in the conversation list.
- Implemented functionality where clicking the share button triggers a POST request to the API.
- The API checks if a share link was already created for the conversation today; if so, it returns the existing link.
- If no link was created for today, the API will create a new share link and return it.
- Each click on the share button results in a new API request, following the specification similar to ChatGPT's share link feature.

* ♻️ refactor(hooks): generalize useNavScrolling for broader use

- Modified `useNavScrolling` to accept a generic type parameter `TData`, allowing it to be used with different data structures besides `ConversationListResponse`.
- Updated instances in `Nav.tsx` and `ArchivedChatsTable.tsx` to explicitly specify `ConversationListResponse` as the type argument when invoking `useNavScrolling`.

*  feat(settings): add shared links listing table with delete functionality in settings

- Integrated a delete button for each shared link in the table, allowing users to remove links as needed.

* ♻️ refactor(components): separate `EndpointIcon` from `Icon` component for standalone use

* ♻️ refactor: update useGetSharedMessages to return TSharedLink

- Modified the useGetSharedMessages hook to return not only a list of TMessage but also the TSharedLink itself.
- This change was necessary to support displaying the title and date in the Shared Message UI, which requires data from TSharedLink.

*  feat(shared link): add UI for displaying shared conversations without authentication

- Implemented a new UI component to display shared conversations, designed to be accessible without requiring authentication.
- Reused components from the authenticated Messages module where possible. Copied and adapted components that could not be directly reused to fit the non-authenticated context.

* 🔧 chore: Add translations

Translate labels only. Messages remain in English as they are possibly subject to change.

* ♻️ refactor: add icon and tooltip props to EditMenuButton component

* moved icon and popover to arguments so that EditMenuButton can be reused.
* modified so that when a ShareButton is closed, the parent DropdownMenu is also closed.

* ♻️irefactor: added DropdownMenu for Export and Share

* ♻️ refactor: renamed component names more intuitive

* More accurate naming of the dropdown menu.
* When the export button is closed, the parent dropdown menu is also closed.

* 🌍 chore: updated translations

* 🐞 Fix: OpenID Profile Image Download (#2757)

* Add fetch requirement

Fixes - error: [openidStrategy] downloadImage: Error downloading image at URL "https://graph.microsoft.com/v1.0/me/photo/$value": TypeError: response.buffer is not a function

* Update openidStrategy.js

---------

Co-authored-by: Danny Avila <danacordially@gmail.com>

* 🚑 fix(export): Issue exporting Conversation with Assistants (#2769)

* 🚑 fix(export): use content as text if content is present in the message

If the endpoint is assistants, the text of the message goes into content, not message.text.

* refactor(ExportModel): TypeScript, remove unused code

---------

Co-authored-by: Yuichi Ohneda <ohneda@gmail.com>

* 📤style: export button icon (#2752)

* refactor(ShareDialog): logic and styling

* refactor(ExportAndShareMenu): imports order and icon update

* chore: imports

* chore: imports/render logic

* feat: message branching

* refactor: add optional config to useGetStartupConfig

* refactor: disable endpoints query

* chore: fix search view styling gradient in light mode

* style: ShareView gradient styling

* refactor(Share): use select queries

* style: shared link table buttons

* localization and dark text styling

* style: fix clipboard button layout shift app-wide and add localization for copy code

* support assistants message content in shared links, add useCopyToClipboard, add copy buttons to Search Messages and Shared Link Messages

* add localizations

* comparisons

---------

Co-authored-by: Yuichi Ohneda <ohneda@gmail.com>
Co-authored-by: bsu3338 <bsu3338@users.noreply.github.com>
Co-authored-by: Fuegovic <32828263+fuegovic@users.noreply.github.com>
2024-05-17 18:13:32 -04:00
Fuegovic
38ad36c1c5 📤style: export button icon (#2752) 2024-05-17 14:16:02 -04:00
Danny Avila
53fe2f6453 🚑 fix(export): Issue exporting Conversation with Assistants (#2769)
* 🚑 fix(export): use content as text if content is present in the message

If the endpoint is assistants, the text of the message goes into content, not message.text.

* refactor(ExportModel): TypeScript, remove unused code

---------

Co-authored-by: Yuichi Ohneda <ohneda@gmail.com>
2024-05-17 14:10:40 -04:00
bsu3338
31479d6a48 🐞 Fix: OpenID Profile Image Download (#2757)
* Add fetch requirement

Fixes - error: [openidStrategy] downloadImage: Error downloading image at URL "https://graph.microsoft.com/v1.0/me/photo/$value": TypeError: response.buffer is not a function

* Update openidStrategy.js

---------

Co-authored-by: Danny Avila <danacordially@gmail.com>
2024-05-17 14:03:31 -04:00
Anirudh
612a58737d 💻 fix: Prevent DataTable component Text Selection (#2749) 2024-05-16 11:12:31 -04:00
Danny Avila
8a7f36f581 🤖 fix: Azure Assistants, use deploymentName as Run Model (#2736) 2024-05-15 23:34:35 -04:00
Danny Avila
4a5d06a774 ❇️ style(ModelSpecs): optimize for Long/Chinese name and mobile styling (#2731)
* style: hide nav toggle for mobile

* ❇️ style: optimize for Long/Chinese `modelSpec` name and mobile styling
2024-05-15 09:53:00 -04:00
Danny Avila
fc9368e0e7 feat: Gemini-1.5 Flash, gpt-4o imports, modelSpec greeting fix (#2729)
* fix: Gemini Flash stream fix

* fix: correct `sender` field for gpt-4o imports from ChatGPT

* add flash model examples and fix vertex streaming

* style: modelSpec greeting fix
2024-05-15 09:02:48 -04:00
Yuichi Oneda
64bf0800a0 🚑 fix: Display Error Message when API Connection fails during Chat (#2710) 2024-05-14 18:29:52 -04:00
Danny Avila
94eeec354e 🔀 fix: Address Convo/Preset Switching Issues (#2709)
* fix(schemas): interchangeability between chatGptLabel <> modelLabel

* fix: display error message from enforceModelSpecs when conversation already existed

* fix(Mention): use activeIndex on mention tab/enter

* fix: correctly navigate to new conversation when deleting/archiving a new, active convo
2024-05-14 15:19:58 -04:00
229 changed files with 9570 additions and 2051 deletions

View File

@@ -116,10 +116,10 @@ GOOGLE_KEY=user_provided
# GOOGLE_REVERSE_PROXY=
# Gemini API
# GOOGLE_MODELS=gemini-1.0-pro,gemini-1.0-pro-001,gemini-1.0-pro-latest,gemini-1.0-pro-vision-latest,gemini-1.5-pro-latest,gemini-pro,gemini-pro-vision
# GOOGLE_MODELS=gemini-1.5-flash-latest,gemini-1.0-pro,gemini-1.0-pro-001,gemini-1.0-pro-latest,gemini-1.0-pro-vision-latest,gemini-1.5-pro-latest,gemini-pro,gemini-pro-vision
# Vertex AI
# GOOGLE_MODELS=gemini-1.5-pro-preview-0409,gemini-1.0-pro-vision-001,gemini-pro,gemini-pro-vision,chat-bison,chat-bison-32k,codechat-bison,codechat-bison-32k,text-bison,text-bison-32k,text-unicorn,code-gecko,code-bison,code-bison-32k
# GOOGLE_MODELS=gemini-1.5-flash-preview-0514,gemini-1.5-pro-preview-0409,gemini-1.0-pro-vision-001,gemini-pro,gemini-pro-vision,chat-bison,chat-bison-32k,codechat-bison,codechat-bison-32k,text-bison,text-bison-32k,text-unicorn,code-gecko,code-bison,code-bison-32k
# Google Gemini Safety Settings
# NOTE (Vertex AI): You do not have access to the BLOCK_NONE setting by default.
@@ -164,6 +164,16 @@ ASSISTANTS_API_KEY=user_provided
# ASSISTANTS_BASE_URL=
# ASSISTANTS_MODELS=gpt-4o,gpt-3.5-turbo-0125,gpt-3.5-turbo-16k-0613,gpt-3.5-turbo-16k,gpt-3.5-turbo,gpt-4,gpt-4-0314,gpt-4-32k-0314,gpt-4-0613,gpt-3.5-turbo-0613,gpt-3.5-turbo-1106,gpt-4-0125-preview,gpt-4-turbo-preview,gpt-4-1106-preview
#==========================#
# Azure Assistants API #
#==========================#
# Note: You should map your credentials with custom variables according to your Azure OpenAI Configuration
# The models for Azure Assistants are also determined by your Azure OpenAI configuration.
# More info, including how to enable use of Assistants with Azure here:
# https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints/azure#using-assistants-with-azure
#============#
# OpenRouter #
#============#

View File

@@ -683,11 +683,12 @@ class GoogleClient extends BaseClient {
const safetySettings = _payload.safetySettings;
requestOptions.safetySettings = safetySettings;
const delay = modelName.includes('flash') ? 8 : 14;
const result = await client.generateContentStream(requestOptions);
for await (const chunk of result.stream) {
const chunkText = chunk.text();
this.generateTextStream(chunkText, onProgress, {
delay: 12,
await this.generateTextStream(chunkText, onProgress, {
delay,
});
reply += chunkText;
}
@@ -701,10 +702,14 @@ class GoogleClient extends BaseClient {
safetySettings: safetySettings,
});
let delay = this.isGenerativeModel ? 12 : 8;
if (modelName.includes('flash')) {
delay = 5;
}
for await (const chunk of stream) {
const chunkText = chunk?.content ?? chunk;
this.generateTextStream(chunkText, onProgress, {
delay: this.isGenerativeModel ? 12 : 8,
await this.generateTextStream(chunkText, onProgress, {
delay,
});
reply += chunkText;
}

View File

@@ -756,6 +756,8 @@ class OpenAIClient extends BaseClient {
* In case of failure, it will return the default title, "New Chat".
*/
async titleConvo({ text, conversationId, responseText = '' }) {
this.conversationId = conversationId;
if (this.options.attachments) {
delete this.options.attachments;
}
@@ -838,13 +840,17 @@ ${convo}
try {
let useChatCompletion = true;
if (this.options.reverseProxyUrl === CohereConstants.API_URL) {
useChatCompletion = false;
}
title = (
await this.sendPayload(instructionsPayload, { modelOptions, useChatCompletion })
).replaceAll('"', '');
const completionTokens = this.getTokenCount(title);
this.recordTokenUsage({ promptTokens, completionTokens, context: 'title' });
} catch (e) {
logger.error(
@@ -868,6 +874,7 @@ ${convo}
context: 'title',
tokenBuffer: 150,
});
title = await runTitleChain({ llm, text, convo, signal: this.abortController.signal });
} catch (e) {
if (e?.message?.toLowerCase()?.includes('abort')) {
@@ -1005,9 +1012,9 @@ ${convo}
await spendTokens(
{
context,
user: this.user,
model: this.modelOptions.model,
conversationId: this.conversationId,
user: this.user ?? this.options.req.user?.id,
endpointTokenConfig: this.options.endpointTokenConfig,
},
{ promptTokens, completionTokens },

View File

@@ -144,6 +144,7 @@ describe('OpenAIClient', () => {
const defaultOptions = {
// debug: true,
req: {},
openaiApiKey: 'new-api-key',
modelOptions: {
model,

View File

@@ -62,8 +62,24 @@ const deleteAction = async (searchParams, session = null) => {
return await Action.findOneAndDelete(searchParams, options).lean();
};
module.exports = {
updateAction,
getActions,
deleteAction,
/**
* Deletes actions by params, within a transaction session if provided.
*
* @param {Object} searchParams - The search parameters to find the actions to delete.
* @param {string} searchParams.action_id - The ID of the action(s) to delete.
* @param {string} searchParams.user - The user ID of the action's author.
* @param {mongoose.ClientSession} [session] - The transaction session to use (optional).
* @returns {Promise<Number>} A promise that resolves to the number of deleted action documents.
*/
const deleteActions = async (searchParams, session = null) => {
const options = session ? { session } : {};
const result = await Action.deleteMany(searchParams, options);
return result.deletedCount;
};
module.exports = {
getActions,
updateAction,
deleteAction,
deleteActions,
};

View File

@@ -39,8 +39,21 @@ const getAssistants = async (searchParams) => {
return await Assistant.find(searchParams).lean();
};
/**
* Deletes an assistant based on the provided ID.
*
* @param {Object} searchParams - The search parameters to find the assistant to delete.
* @param {string} searchParams.assistant_id - The ID of the assistant to delete.
* @param {string} searchParams.user - The user ID of the assistant's author.
* @returns {Promise<void>} Resolves when the assistant has been successfully deleted.
*/
const deleteAssistant = async (searchParams) => {
return await Assistant.findOneAndDelete(searchParams);
};
module.exports = {
updateAssistant,
deleteAssistant,
getAssistants,
getAssistant,
};

89
api/models/Share.js Normal file
View File

@@ -0,0 +1,89 @@
const crypto = require('crypto');
const { getMessages } = require('./Message');
const SharedLink = require('./schema/shareSchema');
const logger = require('~/config/winston');
module.exports = {
SharedLink,
getSharedMessages: async (shareId) => {
try {
const share = await SharedLink.findOne({ shareId })
.populate({
path: 'messages',
select: '-_id -__v -user',
})
.select('-_id -__v -user')
.lean();
if (!share || !share.conversationId || !share.isPublic) {
return null;
}
return share;
} catch (error) {
logger.error('[getShare] Error getting share link', error);
return { message: 'Error getting share link' };
}
},
getSharedLinks: async (user, pageNumber = 1, pageSize = 25, isPublic = true) => {
const query = { user, isPublic };
try {
const totalConvos = (await SharedLink.countDocuments(query)) || 1;
const totalPages = Math.ceil(totalConvos / pageSize);
const shares = await SharedLink.find(query)
.sort({ updatedAt: -1 })
.skip((pageNumber - 1) * pageSize)
.limit(pageSize)
.select('-_id -__v -user')
.lean();
return { sharedLinks: shares, pages: totalPages, pageNumber, pageSize };
} catch (error) {
logger.error('[getShareByPage] Error getting shares', error);
return { message: 'Error getting shares' };
}
},
createSharedLink: async (user, { conversationId, ...shareData }) => {
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean();
if (share) {
return share;
}
try {
const shareId = crypto.randomUUID();
const messages = await getMessages({ conversationId });
const update = { ...shareData, shareId, messages, user };
return await SharedLink.findOneAndUpdate({ conversationId: conversationId, user }, update, {
new: true,
upsert: true,
});
} catch (error) {
logger.error('[saveShareMessage] Error saving conversation', error);
return { message: 'Error saving conversation' };
}
},
updateSharedLink: async (user, { conversationId, ...shareData }) => {
const share = await SharedLink.findOne({ conversationId }).select('-_id -__v -user').lean();
if (!share) {
return { message: 'Share not found' };
}
// update messages to the latest
const messages = await getMessages({ conversationId });
const update = { ...shareData, messages, user };
return await SharedLink.findOneAndUpdate({ conversationId: conversationId, user }, update, {
new: true,
upsert: false,
});
},
deleteSharedLink: async (user, { shareId }) => {
const share = await SharedLink.findOne({ shareId, user });
if (!share) {
return { message: 'Share not found' };
}
return await SharedLink.findOneAndDelete({ shareId, user });
},
};

View File

@@ -155,7 +155,7 @@ const createMeiliMongooseModel = function ({ index, attributesToIndex }) {
function (results, value, key) {
return { ...results, [key]: 1 };
},
{ _id: 1 },
{ _id: 1, __v: 1 },
),
).lean();

View File

@@ -0,0 +1,38 @@
const mongoose = require('mongoose');
const shareSchema = mongoose.Schema(
{
conversationId: {
type: String,
required: true,
},
title: {
type: String,
index: true,
},
user: {
type: String,
index: true,
},
messages: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Message' }],
shareId: {
type: String,
index: true,
},
isPublic: {
type: Boolean,
default: false,
},
isVisible: {
type: Boolean,
default: false,
},
isAnonymous: {
type: Boolean,
default: true,
},
},
{ timestamps: true },
);
module.exports = mongoose.model('SharedLink', shareSchema);

View File

@@ -40,7 +40,7 @@ const spendTokens = async (txData, tokenUsage) => {
});
}
if (!completionTokens) {
if (!completionTokens && isNaN(completionTokens)) {
logger.debug('[spendTokens] !completionTokens', { prompt, completion });
return;
}

View File

@@ -76,7 +76,7 @@
"nodejs-gpt": "^1.37.4",
"nodemailer": "^6.9.4",
"ollama": "^0.5.0",
"openai": "4.36.0",
"openai": "^4.47.1",
"openai-chat-tokens": "^0.2.8",
"openid-client": "^5.4.2",
"passport": "^0.6.0",

View File

@@ -16,10 +16,28 @@ async function endpointController(req, res) {
/** @type {TEndpointsConfig} */
const mergedConfig = { ...defaultEndpointsConfig, ...customConfigEndpoints };
if (mergedConfig[EModelEndpoint.assistants] && req.app.locals?.[EModelEndpoint.assistants]) {
const { disableBuilder, retrievalModels, capabilities, ..._rest } =
const { disableBuilder, retrievalModels, capabilities, version, ..._rest } =
req.app.locals[EModelEndpoint.assistants];
mergedConfig[EModelEndpoint.assistants] = {
...mergedConfig[EModelEndpoint.assistants],
version,
retrievalModels,
disableBuilder,
capabilities,
};
}
if (
mergedConfig[EModelEndpoint.azureAssistants] &&
req.app.locals?.[EModelEndpoint.azureAssistants]
) {
const { disableBuilder, retrievalModels, capabilities, version, ..._rest } =
req.app.locals[EModelEndpoint.azureAssistants];
mergedConfig[EModelEndpoint.azureAssistants] = {
...mergedConfig[EModelEndpoint.azureAssistants],
version,
retrievalModels,
disableBuilder,
capabilities,

View File

@@ -1,14 +1,13 @@
const { v4 } = require('uuid');
const express = require('express');
const {
Constants,
RunStatus,
CacheKeys,
FileSources,
ContentTypes,
EModelEndpoint,
ViolationTypes,
ImageVisionTool,
checkOpenAIStorage,
AssistantStreamEvents,
} = require('librechat-data-provider');
const {
@@ -21,27 +20,18 @@ const {
} = require('~/server/services/Threads');
const { sendResponse, sendMessage, sleep, isEnabled, countTokens } = require('~/server/utils');
const { runAssistant, createOnTextProgress } = require('~/server/services/AssistantService');
const { addTitle, initializeClient } = require('~/server/services/Endpoints/assistants');
const { formatMessage, createVisionPrompt } = require('~/app/clients/prompts');
const { createRun, StreamRunManager } = require('~/server/services/Runs');
const { addTitle } = require('~/server/services/Endpoints/assistants');
const { getTransactions } = require('~/models/Transaction');
const checkBalance = require('~/models/checkBalance');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const { getModelMaxTokens } = require('~/utils');
const { getOpenAIClient } = require('./helpers');
const { logger } = require('~/config');
const router = express.Router();
const {
setHeaders,
handleAbort,
validateModel,
handleAbortError,
// validateEndpoint,
buildEndpointOption,
} = require('~/server/middleware');
router.post('/abort', handleAbort());
const { handleAbortError } = require('~/server/middleware');
const ten_minutes = 1000 * 60 * 10;
@@ -49,16 +39,17 @@ const ten_minutes = 1000 * 60 * 10;
* @route POST /
* @desc Chat with an assistant
* @access Public
* @param {express.Request} req - The request object, containing the request data.
* @param {express.Response} res - The response object, used to send back a response.
* @param {Express.Request} req - The request object, containing the request data.
* @param {Express.Response} res - The response object, used to send back a response.
* @returns {void}
*/
router.post('/', validateModel, buildEndpointOption, setHeaders, async (req, res) => {
const chatV1 = async (req, res) => {
logger.debug('[/assistants/chat/] req.body', req.body);
const {
text,
model,
endpoint,
files = [],
promptPrefix,
assistant_id,
@@ -70,7 +61,7 @@ router.post('/', validateModel, buildEndpointOption, setHeaders, async (req, res
} = req.body;
/** @type {Partial<TAssistantEndpoint>} */
const assistantsConfig = req.app.locals?.[EModelEndpoint.assistants];
const assistantsConfig = req.app.locals?.[endpoint];
if (assistantsConfig) {
const { supportedIds, excludedIds } = assistantsConfig;
@@ -138,7 +129,7 @@ router.post('/', validateModel, buildEndpointOption, setHeaders, async (req, res
user: req.user.id,
shouldSaveMessage: false,
messageId: responseMessageId,
endpoint: EModelEndpoint.assistants,
endpoint,
};
if (error.message === 'Run cancelled') {
@@ -149,7 +140,7 @@ router.post('/', validateModel, buildEndpointOption, setHeaders, async (req, res
logger.debug('[/assistants/chat/] Request aborted on close');
} else if (/Files.*are invalid/.test(error.message)) {
const errorMessage = `Files are invalid, or may not have uploaded yet.${
req.app.locals?.[EModelEndpoint.azureOpenAI].assistants
endpoint === EModelEndpoint.azureAssistants
? ' If using Azure OpenAI, files are only available in the region of the assistant\'s model at the time of upload.'
: ''
}`;
@@ -205,6 +196,7 @@ router.post('/', validateModel, buildEndpointOption, setHeaders, async (req, res
const runMessages = await checkMessageGaps({
openai,
run_id,
endpoint,
thread_id,
conversationId,
latestMessageId: responseMessageId,
@@ -311,8 +303,7 @@ router.post('/', validateModel, buildEndpointOption, setHeaders, async (req, res
});
};
/** @type {{ openai: OpenAIClient }} */
const { openai: _openai, client } = await initializeClient({
const { openai: _openai, client } = await getOpenAIClient({
req,
res,
endpointOption: req.body.endpointOption,
@@ -370,10 +361,7 @@ router.post('/', validateModel, buildEndpointOption, setHeaders, async (req, res
/** @type {MongoFile[]} */
const attachments = await req.body.endpointOption.attachments;
if (
attachments &&
attachments.every((attachment) => attachment.source === FileSources.openai)
) {
if (attachments && attachments.every((attachment) => checkOpenAIStorage(attachment.source))) {
return;
}
@@ -431,7 +419,7 @@ router.post('/', validateModel, buildEndpointOption, setHeaders, async (req, res
if (processedFiles) {
for (const file of processedFiles) {
if (file.source !== FileSources.openai) {
if (!checkOpenAIStorage(file.source)) {
attachedFileIds.delete(file.file_id);
const index = file_ids.indexOf(file.file_id);
if (index > -1) {
@@ -467,6 +455,7 @@ router.post('/', validateModel, buildEndpointOption, setHeaders, async (req, res
assistant_id,
thread_id,
model: assistant_id,
endpoint,
};
previousMessages.push(requestMessage);
@@ -476,7 +465,7 @@ router.post('/', validateModel, buildEndpointOption, setHeaders, async (req, res
conversation = {
conversationId,
endpoint: EModelEndpoint.assistants,
endpoint,
promptPrefix: promptPrefix,
instructions: instructions,
assistant_id,
@@ -513,7 +502,8 @@ router.post('/', validateModel, buildEndpointOption, setHeaders, async (req, res
let response;
const processRun = async (retry = false) => {
if (req.app.locals[EModelEndpoint.azureOpenAI]?.assistants) {
if (endpoint === EModelEndpoint.azureAssistants) {
body.model = openai._options.model;
openai.attachedFileIds = attachedFileIds;
openai.visionPromise = visionPromise;
if (retry) {
@@ -602,6 +592,7 @@ router.post('/', validateModel, buildEndpointOption, setHeaders, async (req, res
assistant_id,
thread_id,
model: assistant_id,
endpoint,
};
sendMessage(res, {
@@ -654,6 +645,6 @@ router.post('/', validateModel, buildEndpointOption, setHeaders, async (req, res
} catch (error) {
await handleError(error);
}
});
};
module.exports = router;
module.exports = chatV1;

View File

@@ -0,0 +1,618 @@
const { v4 } = require('uuid');
const {
Constants,
RunStatus,
CacheKeys,
ContentTypes,
ToolCallTypes,
EModelEndpoint,
ViolationTypes,
retrievalMimeTypes,
AssistantStreamEvents,
} = require('librechat-data-provider');
const {
initThread,
recordUsage,
saveUserMessage,
checkMessageGaps,
addThreadMetadata,
saveAssistantMessage,
} = require('~/server/services/Threads');
const { sendResponse, sendMessage, sleep, isEnabled, countTokens } = require('~/server/utils');
const { runAssistant, createOnTextProgress } = require('~/server/services/AssistantService');
const { createRun, StreamRunManager } = require('~/server/services/Runs');
const { addTitle } = require('~/server/services/Endpoints/assistants');
const { getTransactions } = require('~/models/Transaction');
const checkBalance = require('~/models/checkBalance');
const { getConvo } = require('~/models/Conversation');
const getLogStores = require('~/cache/getLogStores');
const { getModelMaxTokens } = require('~/utils');
const { getOpenAIClient } = require('./helpers');
const { logger } = require('~/config');
const { handleAbortError } = require('~/server/middleware');
const ten_minutes = 1000 * 60 * 10;
/**
* @route POST /
* @desc Chat with an assistant
* @access Public
* @param {Express.Request} req - The request object, containing the request data.
* @param {Express.Response} res - The response object, used to send back a response.
* @returns {void}
*/
const chatV2 = async (req, res) => {
logger.debug('[/assistants/chat/] req.body', req.body);
/** @type {{ files: MongoFile[]}} */
const {
text,
model,
endpoint,
files = [],
promptPrefix,
assistant_id,
instructions,
thread_id: _thread_id,
messageId: _messageId,
conversationId: convoId,
parentMessageId: _parentId = Constants.NO_PARENT,
} = req.body;
/** @type {Partial<TAssistantEndpoint>} */
const assistantsConfig = req.app.locals?.[endpoint];
if (assistantsConfig) {
const { supportedIds, excludedIds } = assistantsConfig;
const error = { message: 'Assistant not supported' };
if (supportedIds?.length && !supportedIds.includes(assistant_id)) {
return await handleAbortError(res, req, error, {
sender: 'System',
conversationId: convoId,
messageId: v4(),
parentMessageId: _messageId,
error,
});
} else if (excludedIds?.length && excludedIds.includes(assistant_id)) {
return await handleAbortError(res, req, error, {
sender: 'System',
conversationId: convoId,
messageId: v4(),
parentMessageId: _messageId,
});
}
}
/** @type {OpenAIClient} */
let openai;
/** @type {string|undefined} - the current thread id */
let thread_id = _thread_id;
/** @type {string|undefined} - the current run id */
let run_id;
/** @type {string|undefined} - the parent messageId */
let parentMessageId = _parentId;
/** @type {TMessage[]} */
let previousMessages = [];
/** @type {import('librechat-data-provider').TConversation | null} */
let conversation = null;
/** @type {string[]} */
let file_ids = [];
/** @type {Set<string>} */
let attachedFileIds = new Set();
/** @type {TMessage | null} */
let requestMessage = null;
const userMessageId = v4();
const responseMessageId = v4();
/** @type {string} - The conversation UUID - created if undefined */
const conversationId = convoId ?? v4();
const cache = getLogStores(CacheKeys.ABORT_KEYS);
const cacheKey = `${req.user.id}:${conversationId}`;
/** @type {Run | undefined} - The completed run, undefined if incomplete */
let completedRun;
const handleError = async (error) => {
const defaultErrorMessage =
'The Assistant run failed to initialize. Try sending a message in a new conversation.';
const messageData = {
thread_id,
assistant_id,
conversationId,
parentMessageId,
sender: 'System',
user: req.user.id,
shouldSaveMessage: false,
messageId: responseMessageId,
endpoint,
};
if (error.message === 'Run cancelled') {
return res.end();
} else if (error.message === 'Request closed' && completedRun) {
return;
} else if (error.message === 'Request closed') {
logger.debug('[/assistants/chat/] Request aborted on close');
} else if (/Files.*are invalid/.test(error.message)) {
const errorMessage = `Files are invalid, or may not have uploaded yet.${
endpoint === EModelEndpoint.azureAssistants
? ' If using Azure OpenAI, files are only available in the region of the assistant\'s model at the time of upload.'
: ''
}`;
return sendResponse(res, messageData, errorMessage);
} else if (error?.message?.includes('string too long')) {
return sendResponse(
res,
messageData,
'Message too long. The Assistants API has a limit of 32,768 characters per message. Please shorten it and try again.',
);
} else if (error?.message?.includes(ViolationTypes.TOKEN_BALANCE)) {
return sendResponse(res, messageData, error.message);
} else {
logger.error('[/assistants/chat/]', error);
}
if (!openai || !thread_id || !run_id) {
return sendResponse(res, messageData, defaultErrorMessage);
}
await sleep(2000);
try {
const status = await cache.get(cacheKey);
if (status === 'cancelled') {
logger.debug('[/assistants/chat/] Run already cancelled');
return res.end();
}
await cache.delete(cacheKey);
const cancelledRun = await openai.beta.threads.runs.cancel(thread_id, run_id);
logger.debug('[/assistants/chat/] Cancelled run:', cancelledRun);
} catch (error) {
logger.error('[/assistants/chat/] Error cancelling run', error);
}
await sleep(2000);
let run;
try {
run = await openai.beta.threads.runs.retrieve(thread_id, run_id);
await recordUsage({
...run.usage,
model: run.model,
user: req.user.id,
conversationId,
});
} catch (error) {
logger.error('[/assistants/chat/] Error fetching or processing run', error);
}
let finalEvent;
try {
const runMessages = await checkMessageGaps({
openai,
run_id,
endpoint,
thread_id,
conversationId,
latestMessageId: responseMessageId,
});
const errorContentPart = {
text: {
value:
error?.message ?? 'There was an error processing your request. Please try again later.',
},
type: ContentTypes.ERROR,
};
if (!Array.isArray(runMessages[runMessages.length - 1]?.content)) {
runMessages[runMessages.length - 1].content = [errorContentPart];
} else {
const contentParts = runMessages[runMessages.length - 1].content;
for (let i = 0; i < contentParts.length; i++) {
const currentPart = contentParts[i];
/** @type {CodeToolCall | RetrievalToolCall | FunctionToolCall | undefined} */
const toolCall = currentPart?.[ContentTypes.TOOL_CALL];
if (
toolCall &&
toolCall?.function &&
!(toolCall?.function?.output || toolCall?.function?.output?.length)
) {
contentParts[i] = {
...currentPart,
[ContentTypes.TOOL_CALL]: {
...toolCall,
function: {
...toolCall.function,
output: 'error processing tool',
},
},
};
}
}
runMessages[runMessages.length - 1].content.push(errorContentPart);
}
finalEvent = {
final: true,
conversation: await getConvo(req.user.id, conversationId),
runMessages,
};
} catch (error) {
logger.error('[/assistants/chat/] Error finalizing error process', error);
return sendResponse(res, messageData, 'The Assistant run failed');
}
return sendResponse(res, finalEvent);
};
try {
res.on('close', async () => {
if (!completedRun) {
await handleError(new Error('Request closed'));
}
});
if (convoId && !_thread_id) {
completedRun = true;
throw new Error('Missing thread_id for existing conversation');
}
if (!assistant_id) {
completedRun = true;
throw new Error('Missing assistant_id');
}
const checkBalanceBeforeRun = async () => {
if (!isEnabled(process.env.CHECK_BALANCE)) {
return;
}
const transactions =
(await getTransactions({
user: req.user.id,
context: 'message',
conversationId,
})) ?? [];
const totalPreviousTokens = Math.abs(
transactions.reduce((acc, curr) => acc + curr.rawAmount, 0),
);
// TODO: make promptBuffer a config option; buffer for titles, needs buffer for system instructions
const promptBuffer = parentMessageId === Constants.NO_PARENT && !_thread_id ? 200 : 0;
// 5 is added for labels
let promptTokens = (await countTokens(text + (promptPrefix ?? ''))) + 5;
promptTokens += totalPreviousTokens + promptBuffer;
// Count tokens up to the current context window
promptTokens = Math.min(promptTokens, getModelMaxTokens(model));
await checkBalance({
req,
res,
txData: {
model,
user: req.user.id,
tokenType: 'prompt',
amount: promptTokens,
},
});
};
const { openai: _openai, client } = await getOpenAIClient({
req,
res,
endpointOption: req.body.endpointOption,
initAppClient: true,
});
openai = _openai;
if (previousMessages.length) {
parentMessageId = previousMessages[previousMessages.length - 1].messageId;
}
let userMessage = {
role: 'user',
content: [
{
type: ContentTypes.TEXT,
text,
},
],
metadata: {
messageId: userMessageId,
},
};
/** @type {CreateRunBody | undefined} */
const body = {
assistant_id,
model,
};
if (promptPrefix) {
body.additional_instructions = promptPrefix;
}
if (instructions) {
body.instructions = instructions;
}
const getRequestFileIds = async () => {
let thread_file_ids = [];
if (convoId) {
const convo = await getConvo(req.user.id, convoId);
if (convo && convo.file_ids) {
thread_file_ids = convo.file_ids;
}
}
if (files.length || thread_file_ids.length) {
attachedFileIds = new Set([...file_ids, ...thread_file_ids]);
let attachmentIndex = 0;
for (const file of files) {
file_ids.push(file.file_id);
if (file.type.startsWith('image')) {
userMessage.content.push({
type: ContentTypes.IMAGE_FILE,
[ContentTypes.IMAGE_FILE]: { file_id: file.file_id },
});
}
if (!userMessage.attachments) {
userMessage.attachments = [];
}
userMessage.attachments.push({
file_id: file.file_id,
tools: [{ type: ToolCallTypes.CODE_INTERPRETER }],
});
if (file.type.startsWith('image')) {
continue;
}
const mimeType = file.type;
const isSupportedByRetrieval = retrievalMimeTypes.some((regex) => regex.test(mimeType));
if (isSupportedByRetrieval) {
userMessage.attachments[attachmentIndex].tools.push({
type: ToolCallTypes.FILE_SEARCH,
});
}
attachmentIndex++;
}
}
};
const initializeThread = async () => {
await getRequestFileIds();
// TODO: may allow multiple messages to be created beforehand in a future update
const initThreadBody = {
messages: [userMessage],
metadata: {
user: req.user.id,
conversationId,
},
};
const result = await initThread({ openai, body: initThreadBody, thread_id });
thread_id = result.thread_id;
createOnTextProgress({
openai,
conversationId,
userMessageId,
messageId: responseMessageId,
thread_id,
});
requestMessage = {
user: req.user.id,
text,
messageId: userMessageId,
parentMessageId,
// TODO: make sure client sends correct format for `files`, use zod
files,
file_ids,
conversationId,
isCreatedByUser: true,
assistant_id,
thread_id,
model: assistant_id,
endpoint,
};
previousMessages.push(requestMessage);
/* asynchronous */
saveUserMessage({ ...requestMessage, model });
conversation = {
conversationId,
endpoint,
promptPrefix: promptPrefix,
instructions: instructions,
assistant_id,
// model,
};
if (file_ids.length) {
conversation.file_ids = file_ids;
}
};
const promises = [initializeThread(), checkBalanceBeforeRun()];
await Promise.all(promises);
const sendInitialResponse = () => {
sendMessage(res, {
sync: true,
conversationId,
// messages: previousMessages,
requestMessage,
responseMessage: {
user: req.user.id,
messageId: openai.responseMessage.messageId,
parentMessageId: userMessageId,
conversationId,
assistant_id,
thread_id,
model: assistant_id,
},
});
};
/** @type {RunResponse | typeof StreamRunManager | undefined} */
let response;
const processRun = async (retry = false) => {
if (endpoint === EModelEndpoint.azureAssistants) {
body.model = openai._options.model;
openai.attachedFileIds = attachedFileIds;
if (retry) {
response = await runAssistant({
openai,
thread_id,
run_id,
in_progress: openai.in_progress,
});
return;
}
/* 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,
});
run_id = run.id;
await cache.set(cacheKey, `${thread_id}:${run_id}`, ten_minutes);
sendInitialResponse();
// todo: retry logic
response = await runAssistant({ openai, thread_id, run_id });
return;
}
/** @type {{[AssistantStreamEvents.ThreadRunCreated]: (event: ThreadRunCreated) => Promise<void>}} */
const handlers = {
[AssistantStreamEvents.ThreadRunCreated]: async (event) => {
await cache.set(cacheKey, `${thread_id}:${event.data.id}`, ten_minutes);
run_id = event.data.id;
sendInitialResponse();
},
};
const streamRunManager = new StreamRunManager({
req,
res,
openai,
handlers,
thread_id,
attachedFileIds,
responseMessage: openai.responseMessage,
// streamOptions: {
// },
});
await streamRunManager.runAssistant({
thread_id,
body,
});
response = streamRunManager;
};
await processRun();
logger.debug('[/assistants/chat/] response', {
run: response.run,
steps: response.steps,
});
if (response.run.status === RunStatus.CANCELLED) {
logger.debug('[/assistants/chat/] Run cancelled, handled by `abortRun`');
return res.end();
}
if (response.run.status === RunStatus.IN_PROGRESS) {
processRun(true);
}
completedRun = response.run;
/** @type {ResponseMessage} */
const responseMessage = {
...(response.responseMessage ?? response.finalMessage),
parentMessageId: userMessageId,
conversationId,
user: req.user.id,
assistant_id,
thread_id,
model: assistant_id,
endpoint,
};
sendMessage(res, {
final: true,
conversation,
requestMessage: {
parentMessageId,
thread_id,
},
});
res.end();
await saveAssistantMessage({ ...responseMessage, model });
if (parentMessageId === Constants.NO_PARENT && !_thread_id) {
addTitle(req, {
text,
responseText: response.text,
conversationId,
client,
});
}
await addThreadMetadata({
openai,
thread_id,
messageId: responseMessage.messageId,
messages: response.messages,
});
if (!response.run.usage) {
await sleep(3000);
completedRun = await openai.beta.threads.runs.retrieve(thread_id, response.run.id);
if (completedRun.usage) {
await recordUsage({
...completedRun.usage,
user: req.user.id,
model: completedRun.model ?? model,
conversationId,
});
}
} else {
await recordUsage({
...response.run.usage,
user: req.user.id,
model: response.run.model ?? model,
conversationId,
});
}
} catch (error) {
await handleError(error);
}
};
module.exports = chatV2;

View File

@@ -0,0 +1,158 @@
const { EModelEndpoint, CacheKeys, defaultAssistantsVersion } = require('librechat-data-provider');
const {
initializeClient: initAzureClient,
} = require('~/server/services/Endpoints/azureAssistants');
const { initializeClient } = require('~/server/services/Endpoints/assistants');
const { getLogStores } = require('~/cache');
/**
* @param {Express.Request} req
* @param {string} [endpoint]
* @returns {Promise<string>}
*/
const getCurrentVersion = async (req, endpoint) => {
const index = req.baseUrl.lastIndexOf('/v');
let version = index !== -1 ? req.baseUrl.substring(index + 1, index + 3) : null;
if (!version && req.body.version) {
version = `v${req.body.version}`;
}
if (!version && endpoint) {
const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cachedEndpointsConfig = await cache.get(CacheKeys.ENDPOINT_CONFIG);
version = `v${
cachedEndpointsConfig?.[endpoint]?.version ?? defaultAssistantsVersion[endpoint]
}`;
}
if (!version?.startsWith('v') && version.length !== 2) {
throw new Error(`[${req.baseUrl}] Invalid version: ${version}`);
}
return version;
};
/**
* Asynchronously lists assistants based on provided query parameters.
*
* Initializes the client with the current request and response objects and lists assistants
* according to the query parameters. This function abstracts the logic for non-Azure paths.
*
* @async
* @param {object} params - The parameters object.
* @param {object} params.req - The request object, used for initializing the client.
* @param {object} params.res - The response object, used for initializing the client.
* @param {string} params.version - The API version to use.
* @param {object} params.query - The query parameters to list assistants (e.g., limit, order).
* @returns {Promise<object>} A promise that resolves to the response from the `openai.beta.assistants.list` method call.
*/
const listAssistants = async ({ req, res, version, query }) => {
const { openai } = await getOpenAIClient({ req, res, version });
return openai.beta.assistants.list(query);
};
/**
* Asynchronously lists assistants for Azure configured groups.
*
* Iterates through Azure configured assistant groups, initializes the client with the current request and response objects,
* lists assistants based on the provided query parameters, and merges their data alongside the model information into a single array.
*
* @async
* @param {object} params - The parameters object.
* @param {object} params.req - The request object, used for initializing the client and manipulating the request body.
* @param {object} params.res - The response object, used for initializing the client.
* @param {string} params.version - The API version to use.
* @param {TAzureConfig} params.azureConfig - The Azure configuration object containing assistantGroups and groupMap.
* @param {object} params.query - The query parameters to list assistants (e.g., limit, order).
* @returns {Promise<AssistantListResponse>} A promise that resolves to an array of assistant data merged with their respective model information.
*/
const listAssistantsForAzure = async ({ req, res, version, azureConfig = {}, query }) => {
/** @type {Array<[string, TAzureModelConfig]>} */
const groupModelTuples = [];
const promises = [];
/** @type {Array<TAzureGroup>} */
const groups = [];
const { groupMap, assistantGroups } = azureConfig;
for (const groupName of assistantGroups) {
const group = groupMap[groupName];
groups.push(group);
const currentModelTuples = Object.entries(group?.models);
groupModelTuples.push(currentModelTuples);
/* The specified model is only necessary to
fetch assistants for the shared instance */
req.body.model = currentModelTuples[0][0];
promises.push(listAssistants({ req, res, version, query }));
}
const resolvedQueries = await Promise.all(promises);
const data = resolvedQueries.flatMap((res, i) =>
res.data.map((assistant) => {
const deploymentName = assistant.model;
const currentGroup = groups[i];
const currentModelTuples = groupModelTuples[i];
const firstModel = currentModelTuples[0][0];
if (currentGroup.deploymentName === deploymentName) {
return { ...assistant, model: firstModel };
}
for (const [model, modelConfig] of currentModelTuples) {
if (modelConfig.deploymentName === deploymentName) {
return { ...assistant, model };
}
}
return { ...assistant, model: firstModel };
}),
);
return {
first_id: data[0]?.id,
last_id: data[data.length - 1]?.id,
object: 'list',
has_more: false,
data,
};
};
async function getOpenAIClient({ req, res, endpointOption, initAppClient, overrideEndpoint }) {
let endpoint = overrideEndpoint ?? req.body.endpoint ?? req.query.endpoint;
const version = await getCurrentVersion(req, endpoint);
if (!endpoint) {
throw new Error(`[${req.baseUrl}] Endpoint is required`);
}
let result;
if (endpoint === EModelEndpoint.assistants) {
result = await initializeClient({ req, res, version, endpointOption, initAppClient });
} else if (endpoint === EModelEndpoint.azureAssistants) {
result = await initAzureClient({ req, res, version, endpointOption, initAppClient });
}
return result;
}
const fetchAssistants = async (req, res) => {
const { limit = 100, order = 'desc', after, before, endpoint } = req.query;
const version = await getCurrentVersion(req, endpoint);
const query = { limit, order, after, before };
/** @type {AssistantListResponse} */
let body;
if (endpoint === EModelEndpoint.assistants) {
({ body } = await listAssistants({ req, res, version, query }));
} else if (endpoint === EModelEndpoint.azureAssistants) {
const azureConfig = req.app.locals[EModelEndpoint.azureOpenAI];
body = await listAssistantsForAzure({ req, res, version, azureConfig, query });
}
return body;
};
module.exports = {
getOpenAIClient,
fetchAssistants,
getCurrentVersion,
};

View File

@@ -1,34 +1,11 @@
const multer = require('multer');
const express = require('express');
const { FileContext, EModelEndpoint } = require('librechat-data-provider');
const {
initializeClient,
listAssistantsForAzure,
listAssistants,
} = require('~/server/services/Endpoints/assistants');
const { FileContext } = require('librechat-data-provider');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { deleteAssistantActions } = require('~/server/services/ActionService');
const { uploadImageBuffer } = require('~/server/services/Files/process');
const { updateAssistant, getAssistants } = require('~/models/Assistant');
const { getOpenAIClient, fetchAssistants } = require('./helpers');
const { deleteFileByFilter } = require('~/models/File');
const { logger } = require('~/config');
const actions = require('./actions');
const tools = require('./tools');
const upload = multer();
const router = express.Router();
/**
* Assistant actions route.
* @route GET|POST /assistants/actions
*/
router.use('/actions', actions);
/**
* Create an assistant.
* @route GET /assistants/tools
* @returns {TPlugin[]} 200 - application/json
*/
router.use('/tools', tools);
/**
* Create an assistant.
@@ -36,12 +13,11 @@ router.use('/tools', tools);
* @param {AssistantCreateParams} req.body - The assistant creation parameters.
* @returns {Assistant} 201 - success response - application/json
*/
router.post('/', async (req, res) => {
const createAssistant = async (req, res) => {
try {
/** @type {{ openai: OpenAI }} */
const { openai } = await initializeClient({ req, res });
const { openai } = await getOpenAIClient({ req, res });
const { tools = [], ...assistantData } = req.body;
const { tools = [], endpoint, ...assistantData } = req.body;
assistantData.tools = tools
.map((tool) => {
if (typeof tool !== 'string') {
@@ -52,18 +28,28 @@ router.post('/', async (req, res) => {
})
.filter((tool) => tool);
let azureModelIdentifier = null;
if (openai.locals?.azureOptions) {
azureModelIdentifier = assistantData.model;
assistantData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
}
assistantData.metadata = {
author: req.user.id,
endpoint,
};
const assistant = await openai.beta.assistants.create(assistantData);
if (azureModelIdentifier) {
assistant.model = azureModelIdentifier;
}
logger.debug('/assistants/', assistant);
res.status(201).json(assistant);
} catch (error) {
logger.error('[/assistants] Error creating assistant', error);
res.status(500).json({ error: error.message });
}
});
};
/**
* Retrieves an assistant.
@@ -71,10 +57,10 @@ router.post('/', async (req, res) => {
* @param {string} req.params.id - Assistant identifier.
* @returns {Assistant} 200 - success response - application/json
*/
router.get('/:id', async (req, res) => {
const retrieveAssistant = async (req, res) => {
try {
/** @type {{ openai: OpenAI }} */
const { openai } = await initializeClient({ req, res });
/* NOTE: not actually being used right now */
const { openai } = await getOpenAIClient({ req, res });
const assistant_id = req.params.id;
const assistant = await openai.beta.assistants.retrieve(assistant_id);
@@ -83,22 +69,23 @@ router.get('/:id', async (req, res) => {
logger.error('[/assistants/:id] Error retrieving assistant', error);
res.status(500).json({ error: error.message });
}
});
};
/**
* Modifies an assistant.
* @route PATCH /assistants/:id
* @param {object} req - Express Request
* @param {object} req.params - Request params
* @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) => {
const patchAssistant = async (req, res) => {
try {
/** @type {{ openai: OpenAI }} */
const { openai } = await initializeClient({ req, res });
const { openai } = await getOpenAIClient({ req, res });
const assistant_id = req.params.id;
const updateData = req.body;
const { endpoint: _e, ...updateData } = req.body;
updateData.tools = (updateData.tools ?? [])
.map((tool) => {
if (typeof tool !== 'string') {
@@ -119,52 +106,46 @@ router.patch('/:id', async (req, res) => {
logger.error('[/assistants/:id] Error updating assistant', error);
res.status(500).json({ error: error.message });
}
});
};
/**
* Deletes an assistant.
* @route DELETE /assistants/:id
* @param {object} req - Express Request
* @param {object} req.params - Request params
* @param {string} req.params.id - Assistant identifier.
* @returns {Assistant} 200 - success response - application/json
*/
router.delete('/:id', async (req, res) => {
const deleteAssistant = async (req, res) => {
try {
/** @type {{ openai: OpenAI }} */
const { openai } = await initializeClient({ req, res });
const { openai } = await getOpenAIClient({ req, res });
const assistant_id = req.params.id;
const deletionStatus = await openai.beta.assistants.del(assistant_id);
if (deletionStatus?.deleted) {
await deleteAssistantActions({ req, assistant_id });
}
res.json(deletionStatus);
} catch (error) {
logger.error('[/assistants/:id] Error deleting assistant', error);
res.status(500).json({ error: 'Error deleting assistant' });
}
});
};
/**
* Returns a list of assistants.
* @route GET /assistants
* @param {object} req - Express Request
* @param {AssistantListParams} req.query - The assistant list parameters for pagination and sorting.
* @returns {AssistantListResponse} 200 - success response - application/json
*/
router.get('/', async (req, res) => {
const listAssistants = async (req, res) => {
try {
const { limit = 100, order = 'desc', after, before } = req.query;
const query = { limit, order, after, before };
const body = await fetchAssistants(req, res);
const azureConfig = req.app.locals[EModelEndpoint.azureOpenAI];
/** @type {AssistantListResponse} */
let body;
if (azureConfig?.assistants) {
body = await listAssistantsForAzure({ req, res, azureConfig, query });
} else {
({ body } = await listAssistants({ req, res, query }));
}
if (req.app.locals?.[EModelEndpoint.assistants]) {
if (req.app.locals?.[req.query.endpoint]) {
/** @type {Partial<TAssistantEndpoint>} */
const assistantsConfig = req.app.locals[EModelEndpoint.assistants];
const assistantsConfig = req.app.locals[req.query.endpoint];
const { supportedIds, excludedIds } = assistantsConfig;
if (supportedIds?.length) {
body.data = body.data.filter((assistant) => supportedIds.includes(assistant.id));
@@ -178,31 +159,34 @@ router.get('/', async (req, res) => {
logger.error('[/assistants] Error listing assistants', error);
res.status(500).json({ message: 'Error listing assistants' });
}
});
};
/**
* Returns a list of the user's assistant documents (metadata saved to database).
* @route GET /assistants/documents
* @returns {AssistantDocument[]} 200 - success response - application/json
*/
router.get('/documents', async (req, res) => {
const getAssistantDocuments = async (req, res) => {
try {
res.json(await getAssistants({ user: req.user.id }));
} catch (error) {
logger.error('[/assistants/documents] Error listing assistant documents', error);
res.status(500).json({ error: error.message });
}
});
};
/**
* Uploads and updates an avatar for a specific assistant.
* @route POST /avatar/:assistant_id
* @param {object} req - Express Request
* @param {object} req.params - Request params
* @param {string} req.params.assistant_id - The ID of the assistant.
* @param {Express.Multer.File} req.file - The avatar image file.
* @param {object} req.body - Request body
* @param {string} [req.body.metadata] - Optional metadata for the assistant's avatar.
* @returns {Object} 200 - success response - application/json
*/
router.post('/avatar/:assistant_id', upload.single('file'), async (req, res) => {
const uploadAssistantAvatar = async (req, res) => {
try {
const { assistant_id } = req.params;
if (!assistant_id) {
@@ -210,8 +194,7 @@ router.post('/avatar/:assistant_id', upload.single('file'), async (req, res) =>
}
let { metadata: _metadata = '{}' } = req.body;
/** @type {{ openai: OpenAI }} */
const { openai } = await initializeClient({ req, res });
const { openai } = await getOpenAIClient({ req, res });
const image = await uploadImageBuffer({
req,
@@ -266,6 +249,14 @@ router.post('/avatar/:assistant_id', upload.single('file'), async (req, res) =>
logger.error(message, error);
res.status(500).json({ message });
}
});
};
module.exports = router;
module.exports = {
createAssistant,
retrieveAssistant,
patchAssistant,
deleteAssistant,
listAssistants,
getAssistantDocuments,
uploadAssistantAvatar,
};

View File

@@ -0,0 +1,208 @@
const { ToolCallTypes } = require('librechat-data-provider');
const { validateAndUpdateTool } = require('~/server/services/ActionService');
const { getOpenAIClient } = require('./helpers');
const { logger } = require('~/config');
/**
* Create an assistant.
* @route POST /assistants
* @param {AssistantCreateParams} req.body - The assistant creation parameters.
* @returns {Assistant} 201 - success response - application/json
*/
const createAssistant = async (req, res) => {
try {
/** @type {{ openai: OpenAIClient }} */
const { openai } = await getOpenAIClient({ req, res });
const { tools = [], endpoint, ...assistantData } = req.body;
assistantData.tools = tools
.map((tool) => {
if (typeof tool !== 'string') {
return tool;
}
return req.app.locals.availableTools[tool];
})
.filter((tool) => tool);
let azureModelIdentifier = null;
if (openai.locals?.azureOptions) {
azureModelIdentifier = assistantData.model;
assistantData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
}
assistantData.metadata = {
author: req.user.id,
endpoint,
};
const assistant = await openai.beta.assistants.create(assistantData);
if (azureModelIdentifier) {
assistant.model = azureModelIdentifier;
}
logger.debug('/assistants/', assistant);
res.status(201).json(assistant);
} catch (error) {
logger.error('[/assistants] Error creating assistant', error);
res.status(500).json({ error: error.message });
}
};
/**
* Modifies an assistant.
* @param {object} params
* @param {Express.Request} params.req
* @param {OpenAIClient} params.openai
* @param {string} params.assistant_id
* @param {AssistantUpdateParams} params.updateData
* @returns {Promise<Assistant>} The updated assistant.
*/
const updateAssistant = async ({ req, openai, assistant_id, updateData }) => {
const tools = [];
let hasFileSearch = false;
for (const tool of updateData.tools ?? []) {
let actualTool = typeof tool === 'string' ? req.app.locals.availableTools[tool] : tool;
if (!actualTool) {
continue;
}
if (actualTool.type === ToolCallTypes.FILE_SEARCH) {
hasFileSearch = true;
}
if (!actualTool.function) {
tools.push(actualTool);
continue;
}
const updatedTool = await validateAndUpdateTool({ req, tool: actualTool, assistant_id });
if (updatedTool) {
tools.push(updatedTool);
}
}
if (hasFileSearch && !updateData.tool_resources) {
const assistant = await openai.beta.assistants.retrieve(assistant_id);
updateData.tool_resources = assistant.tool_resources ?? null;
}
if (hasFileSearch && !updateData.tool_resources?.file_search) {
updateData.tool_resources = {
...(updateData.tool_resources ?? {}),
file_search: {
vector_store_ids: [],
},
};
}
updateData.tools = tools;
if (openai.locals?.azureOptions && updateData.model) {
updateData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
}
return await openai.beta.assistants.update(assistant_id, updateData);
};
/**
* Modifies an assistant with the resource file id.
* @param {object} params
* @param {Express.Request} params.req
* @param {OpenAIClient} params.openai
* @param {string} params.assistant_id
* @param {string} params.tool_resource
* @param {string} params.file_id
* @param {AssistantUpdateParams} params.updateData
* @returns {Promise<Assistant>} The updated assistant.
*/
const addResourceFileId = async ({ req, openai, assistant_id, tool_resource, file_id }) => {
const assistant = await openai.beta.assistants.retrieve(assistant_id);
const { tool_resources = {} } = assistant;
if (tool_resources[tool_resource]) {
tool_resources[tool_resource].file_ids.push(file_id);
} else {
tool_resources[tool_resource] = { file_ids: [file_id] };
}
delete assistant.id;
return await updateAssistant({
req,
openai,
assistant_id,
updateData: { tools: assistant.tools, tool_resources },
});
};
/**
* Deletes a file ID from an assistant's resource.
* @param {object} params
* @param {Express.Request} params.req
* @param {OpenAIClient} params.openai
* @param {string} params.assistant_id
* @param {string} [params.tool_resource]
* @param {string} params.file_id
* @param {AssistantUpdateParams} params.updateData
* @returns {Promise<Assistant>} The updated assistant.
*/
const deleteResourceFileId = async ({ req, openai, assistant_id, tool_resource, file_id }) => {
const assistant = await openai.beta.assistants.retrieve(assistant_id);
const { tool_resources = {} } = assistant;
if (tool_resource && tool_resources[tool_resource]) {
const resource = tool_resources[tool_resource];
const index = resource.file_ids.indexOf(file_id);
if (index !== -1) {
resource.file_ids.splice(index, 1);
}
} else {
for (const resourceKey in tool_resources) {
const resource = tool_resources[resourceKey];
const index = resource.file_ids.indexOf(file_id);
if (index !== -1) {
resource.file_ids.splice(index, 1);
break;
}
}
}
delete assistant.id;
return await updateAssistant({
req,
openai,
assistant_id,
updateData: { tools: assistant.tools, tool_resources },
});
};
/**
* Modifies an assistant.
* @route PATCH /assistants/:id
* @param {object} req - Express Request
* @param {object} req.params - Request params
* @param {string} req.params.id - Assistant identifier.
* @param {AssistantUpdateParams} req.body - The assistant update parameters.
* @returns {Assistant} 200 - success response - application/json
*/
const patchAssistant = async (req, res) => {
try {
const { openai } = await getOpenAIClient({ req, res });
const assistant_id = req.params.id;
const { endpoint: _e, ...updateData } = req.body;
updateData.tools = updateData.tools ?? [];
const updatedAssistant = await updateAssistant({ req, openai, assistant_id, updateData });
res.json(updatedAssistant);
} catch (error) {
logger.error('[/assistants/:id] Error updating assistant', error);
res.status(500).json({ error: error.message });
}
};
module.exports = {
patchAssistant,
createAssistant,
updateAssistant,
addResourceFileId,
deleteResourceFileId,
};

View File

@@ -85,6 +85,7 @@ const startServer = async () => {
app.use('/api/assistants', routes.assistants);
app.use('/api/files', await routes.files.initialize());
app.use('/images/', validateImageRequest, routes.staticRoute);
app.use('/api/share', routes.share);
app.use((req, res) => {
res.status(404).sendFile(path.join(app.locals.paths.dist, 'index.html'));

View File

@@ -1,4 +1,4 @@
const { EModelEndpoint } = require('librechat-data-provider');
const { isAssistantsEndpoint } = require('librechat-data-provider');
const { sendMessage, sendError, countTokens, isEnabled } = require('~/server/utils');
const { truncateText, smartTruncateText } = require('~/app/clients/prompts');
const { saveMessage, getConvo, getConvoTitle } = require('~/models');
@@ -15,7 +15,7 @@ async function abortMessage(req, res) {
abortKey = conversationId;
}
if (endpoint === EModelEndpoint.assistants) {
if (isAssistantsEndpoint(endpoint)) {
return await abortRun(req, res);
}

View File

@@ -10,7 +10,7 @@ const three_minutes = 1000 * 60 * 3;
async function abortRun(req, res) {
res.setHeader('Content-Type', 'application/json');
const { abortKey } = req.body;
const { abortKey, endpoint } = req.body;
const [conversationId, latestMessageId] = abortKey.split(':');
const conversation = await getConvo(req.user.id, conversationId);
@@ -68,9 +68,10 @@ async function abortRun(req, res) {
runMessages = await checkMessageGaps({
openai,
latestMessageId,
endpoint,
thread_id,
run_id,
latestMessageId,
conversationId,
});

View File

@@ -1,5 +1,6 @@
const { parseConvo, EModelEndpoint } = require('librechat-data-provider');
const { getModelsConfig } = require('~/server/controllers/ModelController');
const azureAssistants = require('~/server/services/Endpoints/azureAssistants');
const assistants = require('~/server/services/Endpoints/assistants');
const gptPlugins = require('~/server/services/Endpoints/gptPlugins');
const { processFiles } = require('~/server/services/Files/process');
@@ -18,6 +19,7 @@ const buildFunction = {
[EModelEndpoint.anthropic]: anthropic.buildOptions,
[EModelEndpoint.gptPlugins]: gptPlugins.buildOptions,
[EModelEndpoint.assistants]: assistants.buildOptions,
[EModelEndpoint.azureAssistants]: azureAssistants.buildOptions,
};
async function buildEndpointOption(req, res, next) {

View File

@@ -2,7 +2,7 @@ const { v4 } = require('uuid');
const express = require('express');
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
const { actionDelimiter, EModelEndpoint } = require('librechat-data-provider');
const { initializeClient } = require('~/server/services/Endpoints/assistants');
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
const { updateAction, getActions, deleteAction } = require('~/models/Action');
const { updateAssistant, getAssistant } = require('~/models/Assistant');
const { logger } = require('~/config');
@@ -45,7 +45,6 @@ router.post('/:assistant_id', async (req, res) => {
let metadata = encryptMetadata(_metadata);
let { domain } = metadata;
/* Azure doesn't support periods in function names */
domain = await domainParser(req, domain, true);
if (!domain) {
@@ -55,8 +54,7 @@ router.post('/:assistant_id', async (req, res) => {
const action_id = _action_id ?? v4();
const initialPromises = [];
/** @type {{ openai: OpenAI }} */
const { openai } = await initializeClient({ req, res });
const { openai } = await getOpenAIClient({ req, res });
initialPromises.push(getAssistant({ assistant_id }));
initialPromises.push(openai.beta.assistants.retrieve(assistant_id));
@@ -157,9 +155,7 @@ router.delete('/:assistant_id/:action_id/:model', async (req, res) => {
try {
const { assistant_id, action_id, model } = req.params;
req.body.model = model;
/** @type {{ openai: OpenAI }} */
const { openai } = await initializeClient({ req, res });
const { openai } = await getOpenAIClient({ req, res });
const initialPromises = [];
initialPromises.push(getAssistant({ assistant_id }));

View File

@@ -0,0 +1,25 @@
const express = require('express');
const router = express.Router();
const {
setHeaders,
handleAbort,
validateModel,
// validateEndpoint,
buildEndpointOption,
} = require('~/server/middleware');
const chatController = require('~/server/controllers/assistants/chatV1');
router.post('/abort', handleAbort());
/**
* @route POST /
* @desc Chat with an assistant
* @access Public
* @param {express.Request} req - The request object, containing the request data.
* @param {express.Response} res - The response object, used to send back a response.
* @returns {void}
*/
router.post('/', validateModel, buildEndpointOption, setHeaders, chatController);
module.exports = router;

View File

@@ -0,0 +1,25 @@
const express = require('express');
const router = express.Router();
const {
setHeaders,
handleAbort,
validateModel,
// validateEndpoint,
buildEndpointOption,
} = require('~/server/middleware');
const chatController = require('~/server/controllers/assistants/chatV2');
router.post('/abort', handleAbort());
/**
* @route POST /
* @desc Chat with an assistant
* @access Public
* @param {express.Request} req - The request object, containing the request data.
* @param {express.Response} res - The response object, used to send back a response.
* @returns {void}
*/
router.post('/', validateModel, buildEndpointOption, setHeaders, chatController);
module.exports = router;

View File

@@ -7,16 +7,19 @@ const {
// concurrentLimiter,
// messageIpLimiter,
// messageUserLimiter,
} = require('../../middleware');
} = require('~/server/middleware');
const assistants = require('./assistants');
const chat = require('./chat');
const v1 = require('./v1');
const chatV1 = require('./chatV1');
const v2 = require('./v2');
const chatV2 = require('./chatV2');
router.use(requireJwtAuth);
router.use(checkBan);
router.use(uaParser);
router.use('/', assistants);
router.use('/chat', chat);
router.use('/v1/', v1);
router.use('/v1/chat', chatV1);
router.use('/v2/', v2);
router.use('/v2/chat', chatV2);
module.exports = router;

View File

@@ -0,0 +1,81 @@
const multer = require('multer');
const express = require('express');
const controllers = require('~/server/controllers/assistants/v1');
const actions = require('./actions');
const tools = require('./tools');
const upload = multer();
const router = express.Router();
/**
* Assistant actions route.
* @route GET|POST /assistants/actions
*/
router.use('/actions', actions);
/**
* Create an assistant.
* @route GET /assistants/tools
* @returns {TPlugin[]} 200 - application/json
*/
router.use('/tools', tools);
/**
* Create an assistant.
* @route POST /assistants
* @param {AssistantCreateParams} req.body - The assistant creation parameters.
* @returns {Assistant} 201 - success response - application/json
*/
router.post('/', controllers.createAssistant);
/**
* Retrieves an assistant.
* @route GET /assistants/:id
* @param {string} req.params.id - Assistant identifier.
* @returns {Assistant} 200 - success response - application/json
*/
router.get('/:id', controllers.retrieveAssistant);
/**
* 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', controllers.patchAssistant);
/**
* Deletes an assistant.
* @route DELETE /assistants/:id
* @param {string} req.params.id - Assistant identifier.
* @returns {Assistant} 200 - success response - application/json
*/
router.delete('/:id', controllers.deleteAssistant);
/**
* Returns a list of assistants.
* @route GET /assistants
* @param {AssistantListParams} req.query - The assistant list parameters for pagination and sorting.
* @returns {AssistantListResponse} 200 - success response - application/json
*/
router.get('/', controllers.listAssistants);
/**
* Returns a list of the user's assistant documents (metadata saved to database).
* @route GET /assistants/documents
* @returns {AssistantDocument[]} 200 - success response - application/json
*/
router.get('/documents', controllers.getAssistantDocuments);
/**
* Uploads and updates an avatar for a specific assistant.
* @route POST /avatar/:assistant_id
* @param {string} req.params.assistant_id - The ID of the assistant.
* @param {Express.Multer.File} req.file - The avatar image file.
* @param {string} [req.body.metadata] - Optional metadata for the assistant's avatar.
* @returns {Object} 200 - success response - application/json
*/
router.post('/avatar/:assistant_id', upload.single('file'), controllers.uploadAssistantAvatar);
module.exports = router;

View File

@@ -0,0 +1,82 @@
const multer = require('multer');
const express = require('express');
const v1 = require('~/server/controllers/assistants/v1');
const v2 = require('~/server/controllers/assistants/v2');
const actions = require('./actions');
const tools = require('./tools');
const upload = multer();
const router = express.Router();
/**
* Assistant actions route.
* @route GET|POST /assistants/actions
*/
router.use('/actions', actions);
/**
* Create an assistant.
* @route GET /assistants/tools
* @returns {TPlugin[]} 200 - application/json
*/
router.use('/tools', tools);
/**
* Create an assistant.
* @route POST /assistants
* @param {AssistantCreateParams} req.body - The assistant creation parameters.
* @returns {Assistant} 201 - success response - application/json
*/
router.post('/', v2.createAssistant);
/**
* Retrieves an assistant.
* @route GET /assistants/:id
* @param {string} req.params.id - Assistant identifier.
* @returns {Assistant} 200 - success response - application/json
*/
router.get('/:id', v1.retrieveAssistant);
/**
* 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', v2.patchAssistant);
/**
* Deletes an assistant.
* @route DELETE /assistants/:id
* @param {string} req.params.id - Assistant identifier.
* @returns {Assistant} 200 - success response - application/json
*/
router.delete('/:id', v1.deleteAssistant);
/**
* Returns a list of assistants.
* @route GET /assistants
* @param {AssistantListParams} req.query - The assistant list parameters for pagination and sorting.
* @returns {AssistantListResponse} 200 - success response - application/json
*/
router.get('/', v1.listAssistants);
/**
* Returns a list of the user's assistant documents (metadata saved to database).
* @route GET /assistants/documents
* @returns {AssistantDocument[]} 200 - success response - application/json
*/
router.get('/documents', v1.getAssistantDocuments);
/**
* Uploads and updates an avatar for a specific assistant.
* @route POST /avatar/:assistant_id
* @param {string} req.params.assistant_id - The ID of the assistant.
* @param {Express.Multer.File} req.file - The avatar image file.
* @param {string} [req.body.metadata] - Optional metadata for the assistant's avatar.
* @returns {Object} 200 - success response - application/json
*/
router.post('/avatar/:assistant_id', upload.single('file'), v1.uploadAssistantAvatar);
module.exports = router;

View File

@@ -1,6 +1,6 @@
const fs = require('fs').promises;
const express = require('express');
const { isUUID, FileSources } = require('librechat-data-provider');
const { isUUID, checkOpenAIStorage } = require('librechat-data-provider');
const {
filterFile,
processFileUpload,
@@ -89,7 +89,7 @@ router.get('/download/:userId/:file_id', async (req, res) => {
return res.status(403).send('Forbidden');
}
if (file.source === FileSources.openai && !file.model) {
if (checkOpenAIStorage(file.source) && !file.model) {
logger.warn(`${errorPrefix} has no associated model: ${file_id}`);
return res.status(400).send('The model used when creating this file is not available');
}
@@ -110,7 +110,8 @@ router.get('/download/:userId/:file_id', async (req, res) => {
let passThrough;
/** @type {ReadableStream | undefined} */
let fileStream;
if (file.source === FileSources.openai) {
if (checkOpenAIStorage(file.source)) {
req.body = { model: file.model };
const { openai } = await initializeClient({ req, res });
logger.debug(`Downloading file ${file_id} from OpenAI`);

View File

@@ -18,6 +18,7 @@ const config = require('./config');
const assistants = require('./assistants');
const files = require('./files');
const staticRoute = require('./static');
const share = require('./share');
module.exports = {
search,
@@ -40,4 +41,5 @@ module.exports = {
assistants,
files,
staticRoute,
share,
};

View File

@@ -41,29 +41,10 @@ router.get('/', async function (req, res) {
return;
}
const messages = (
await Message.meiliSearch(
q,
{
attributesToHighlight: ['text'],
highlightPreTag: '**',
highlightPostTag: '**',
},
true,
)
).hits.map((message) => {
const { _formatted, ...rest } = message;
return {
...rest,
searchResult: true,
text: _formatted.text,
};
});
const messages = (await Message.meiliSearch(q, undefined, true)).hits;
const titles = (await Conversation.meiliSearch(q)).hits;
const sortedHits = reduceHits(messages, titles);
// debugging:
// logger.debug('user:', user, 'message hits:', messages.length, 'convo hits:', titles.length);
// logger.debug('sorted hits:', sortedHits.length);
const result = await getConvosQueried(user, sortedHits, pageNumber);
const activeMessages = [];
@@ -86,8 +67,7 @@ router.get('/', async function (req, res) {
delete result.cache;
}
delete result.convoMap;
// for debugging
// logger.debug(result, messages.length);
res.status(200).send(result);
} catch (error) {
logger.error('[/search] Error while searching messages & conversations', error);

View File

@@ -0,0 +1,75 @@
const express = require('express');
const {
getSharedMessages,
createSharedLink,
updateSharedLink,
getSharedLinks,
deleteSharedLink,
} = require('~/models/Share');
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
const router = express.Router();
/**
* Shared messages
* this route does not require authentication
*/
router.get('/:shareId', async (req, res) => {
const share = await getSharedMessages(req.params.shareId);
if (share) {
res.status(200).json(share);
} else {
res.status(404).end();
}
});
/**
* Shared links
*/
router.get('/', requireJwtAuth, async (req, res) => {
let pageNumber = req.query.pageNumber || 1;
pageNumber = parseInt(pageNumber, 10);
if (isNaN(pageNumber) || pageNumber < 1) {
return res.status(400).json({ error: 'Invalid page number' });
}
let pageSize = req.query.pageSize || 25;
pageSize = parseInt(pageSize, 10);
if (isNaN(pageSize) || pageSize < 1) {
return res.status(400).json({ error: 'Invalid page size' });
}
const isPublic = req.query.isPublic === 'true';
res.status(200).send(await getSharedLinks(req.user.id, pageNumber, pageSize, isPublic));
});
router.post('/', requireJwtAuth, async (req, res) => {
const created = await createSharedLink(req.user.id, req.body);
if (created) {
res.status(200).json(created);
} else {
res.status(404).end();
}
});
router.patch('/', requireJwtAuth, async (req, res) => {
const updated = await updateSharedLink(req.user.id, req.body);
if (updated) {
res.status(200).json(updated);
} else {
res.status(404).end();
}
});
router.delete('/:shareId', requireJwtAuth, async (req, res) => {
const deleted = await deleteSharedLink(req.user.id, { shareId: req.params.shareId });
if (deleted) {
res.status(200).json(deleted);
} else {
res.status(404).end();
}
});
module.exports = router;

View File

@@ -1,20 +1,59 @@
const {
AuthTypeEnum,
EModelEndpoint,
actionDomainSeparator,
CacheKeys,
Constants,
AuthTypeEnum,
actionDelimiter,
isImageVisionTool,
actionDomainSeparator,
} = require('librechat-data-provider');
const { encryptV2, decryptV2 } = require('~/server/utils/crypto');
const { getActions } = require('~/models/Action');
const { getActions, deleteActions } = require('~/models/Action');
const { deleteAssistant } = require('~/models/Assistant');
const { getLogStores } = require('~/cache');
const { logger } = require('~/config');
const toolNameRegex = /^[a-zA-Z0-9_-]+$/;
/**
* Validates tool name against regex pattern and updates if necessary.
* @param {object} params - The parameters for the function.
* @param {object} params.req - Express Request.
* @param {FunctionTool} params.tool - The tool object.
* @param {string} params.assistant_id - The assistant ID
* @returns {object|null} - Updated tool object or null if invalid and not an action.
*/
const validateAndUpdateTool = async ({ req, tool, assistant_id }) => {
let actions;
if (isImageVisionTool(tool)) {
return null;
}
if (!toolNameRegex.test(tool.function.name)) {
const [functionName, domain] = tool.function.name.split(actionDelimiter);
actions = await getActions({ assistant_id, user: req.user.id }, true);
const matchingActions = actions.filter((action) => {
const metadata = action.metadata;
return metadata && metadata.domain === domain;
});
const action = matchingActions[0];
if (!action) {
return null;
}
const parsedDomain = await domainParser(req, domain, true);
if (!parsedDomain) {
return null;
}
tool.function.name = `${functionName}${actionDelimiter}${parsedDomain}`;
}
return tool;
};
/**
* Encodes or decodes a domain name to/from base64, or replacing periods with a custom separator.
*
* Necessary because Azure OpenAI Assistants API doesn't support periods in function
* names due to `[a-zA-Z0-9_-]*` Regex Validation, limited to a 64-character maximum.
* Necessary due to `[a-zA-Z0-9_-]*` Regex Validation, limited to a 64-character maximum.
*
* @param {Express.Request} req - The Express Request object.
* @param {string} domain - The domain name to encode/decode.
@@ -26,10 +65,6 @@ async function domainParser(req, domain, inverse = false) {
return;
}
if (!req.app.locals[EModelEndpoint.azureOpenAI]?.assistants) {
return domain;
}
const domainsCache = getLogStores(CacheKeys.ENCODED_DOMAINS);
const cachedDomain = await domainsCache.get(domain);
if (inverse && cachedDomain) {
@@ -170,10 +205,29 @@ function decryptMetadata(metadata) {
return decryptedMetadata;
}
/**
* Deletes an action and its corresponding assistant.
* @param {Object} params - The parameters for the function.
* @param {OpenAIClient} params.req - The Express Request object.
* @param {string} params.assistant_id - The ID of the assistant.
*/
const deleteAssistantActions = async ({ req, assistant_id }) => {
try {
await deleteActions({ assistant_id, user: req.user.id });
await deleteAssistant({ assistant_id, user: req.user.id });
} catch (error) {
const message = 'Trouble deleting Assistant Actions for Assistant ID: ' + assistant_id;
logger.error(message, error);
throw new Error(message);
}
};
module.exports = {
loadActionSets,
deleteAssistantActions,
validateAndUpdateTool,
createActionTool,
encryptMetadata,
decryptMetadata,
loadActionSets,
domainParser,
};

View File

@@ -73,12 +73,12 @@ describe('domainParser', () => {
const TLD = '.com';
// Non-azure request
it('returns domain as is if not azure', async () => {
it('does not return domain as is if not azure', async () => {
const domain = `example.com${actionDomainSeparator}test${actionDomainSeparator}`;
const result1 = await domainParser(reqNoAzure, domain, false);
const result2 = await domainParser(reqNoAzure, domain, true);
expect(result1).toEqual(domain);
expect(result2).toEqual(domain);
expect(result1).not.toEqual(domain);
expect(result2).not.toEqual(domain);
});
// Test for Empty or Null Inputs

View File

@@ -72,7 +72,14 @@ const AppService = async (app) => {
}
if (config?.endpoints?.[EModelEndpoint.azureOpenAI]?.assistants) {
endpointLocals[EModelEndpoint.assistants] = azureAssistantsDefaults();
endpointLocals[EModelEndpoint.azureAssistants] = azureAssistantsDefaults();
}
if (config?.endpoints?.[EModelEndpoint.azureAssistants]) {
endpointLocals[EModelEndpoint.azureAssistants] = assistantsConfigSetup(
config,
endpointLocals[EModelEndpoint.azureAssistants],
);
}
if (config?.endpoints?.[EModelEndpoint.assistants]) {

View File

@@ -253,8 +253,8 @@ describe('AppService', () => {
process.env.EASTUS_API_KEY = 'eastus-key';
await AppService(app);
expect(app.locals).toHaveProperty(EModelEndpoint.assistants);
expect(app.locals[EModelEndpoint.assistants].capabilities.length).toEqual(3);
expect(app.locals).toHaveProperty(EModelEndpoint.azureAssistants);
expect(app.locals[EModelEndpoint.azureAssistants].capabilities.length).toEqual(3);
});
it('should correctly configure Azure OpenAI endpoint based on custom config', async () => {

View File

@@ -78,7 +78,7 @@ async function createOnTextProgress({
* @return {Promise<OpenAIAssistantFinish | OpenAIAssistantAction[] | ThreadMessage[] | RequiredActionFunctionToolCall[]>}
*/
async function getResponse({ openai, run_id, thread_id }) {
const run = await waitForRun({ openai, run_id, thread_id, pollIntervalMs: 500 });
const run = await waitForRun({ openai, run_id, thread_id, pollIntervalMs: 2000 });
if (run.status === RunStatus.COMPLETED) {
const messages = await openai.beta.threads.messages.list(thread_id, defaultOrderQuery);
@@ -393,8 +393,9 @@ async function runAssistant({
},
});
const { endpoint = EModelEndpoint.azureAssistants } = openai.req.body;
/** @type {TCustomConfig.endpoints.assistants} */
const assistantsEndpointConfig = openai.req.app.locals?.[EModelEndpoint.assistants] ?? {};
const assistantsEndpointConfig = openai.req.app.locals?.[endpoint] ?? {};
const { pollIntervalMs, timeoutMs } = assistantsEndpointConfig;
const run = await waitForRun({

View File

@@ -3,6 +3,7 @@ const { isUserProvided, generateConfig } = require('~/server/utils');
const {
OPENAI_API_KEY: openAIApiKey,
AZURE_ASSISTANTS_API_KEY: azureAssistantsApiKey,
ASSISTANTS_API_KEY: assistantsApiKey,
AZURE_API_KEY: azureOpenAIApiKey,
ANTHROPIC_API_KEY: anthropicApiKey,
@@ -13,6 +14,7 @@ const {
OPENAI_REVERSE_PROXY,
AZURE_OPENAI_BASEURL,
ASSISTANTS_BASE_URL,
AZURE_ASSISTANTS_BASE_URL,
} = process.env ?? {};
const useAzurePlugins = !!PLUGINS_USE_AZURE;
@@ -28,11 +30,20 @@ module.exports = {
useAzurePlugins,
userProvidedOpenAI,
googleKey,
[EModelEndpoint.openAI]: generateConfig(openAIApiKey, OPENAI_REVERSE_PROXY),
[EModelEndpoint.assistants]: generateConfig(assistantsApiKey, ASSISTANTS_BASE_URL, true),
[EModelEndpoint.azureOpenAI]: generateConfig(azureOpenAIApiKey, AZURE_OPENAI_BASEURL),
[EModelEndpoint.chatGPTBrowser]: generateConfig(chatGPTToken),
[EModelEndpoint.anthropic]: generateConfig(anthropicApiKey),
[EModelEndpoint.bingAI]: generateConfig(bingToken),
[EModelEndpoint.anthropic]: generateConfig(anthropicApiKey),
[EModelEndpoint.chatGPTBrowser]: generateConfig(chatGPTToken),
[EModelEndpoint.openAI]: generateConfig(openAIApiKey, OPENAI_REVERSE_PROXY),
[EModelEndpoint.azureOpenAI]: generateConfig(azureOpenAIApiKey, AZURE_OPENAI_BASEURL),
[EModelEndpoint.assistants]: generateConfig(
assistantsApiKey,
ASSISTANTS_BASE_URL,
EModelEndpoint.assistants,
),
[EModelEndpoint.azureAssistants]: generateConfig(
azureAssistantsApiKey,
AZURE_ASSISTANTS_BASE_URL,
EModelEndpoint.azureAssistants,
),
},
};

View File

@@ -53,7 +53,7 @@ async function loadConfigEndpoints(req) {
if (req.app.locals[EModelEndpoint.azureOpenAI]?.assistants) {
/** @type {Omit<TConfig, 'order'>} */
endpointsConfig[EModelEndpoint.assistants] = {
endpointsConfig[EModelEndpoint.azureAssistants] = {
userProvide: false,
};
}

View File

@@ -30,7 +30,7 @@ async function loadConfigModels(req) {
}
if (azureEndpoint?.assistants && azureConfig.assistantModels) {
modelsConfig[EModelEndpoint.assistants] = azureConfig.assistantModels;
modelsConfig[EModelEndpoint.azureAssistants] = azureConfig.assistantModels;
}
if (!Array.isArray(endpoints[EModelEndpoint.custom])) {

View File

@@ -9,13 +9,15 @@ const { config } = require('./EndpointService');
*/
async function loadDefaultEndpointsConfig(req) {
const { google, gptPlugins } = await loadAsyncEndpoints(req);
const { openAI, assistants, bingAI, anthropic, azureOpenAI, chatGPTBrowser } = config;
const { openAI, assistants, azureAssistants, bingAI, anthropic, azureOpenAI, chatGPTBrowser } =
config;
const enabledEndpoints = getEnabledEndpoints();
const endpointConfig = {
[EModelEndpoint.openAI]: openAI,
[EModelEndpoint.assistants]: assistants,
[EModelEndpoint.azureAssistants]: azureAssistants,
[EModelEndpoint.azureOpenAI]: azureOpenAI,
[EModelEndpoint.google]: google,
[EModelEndpoint.bingAI]: bingAI,

View File

@@ -25,6 +25,7 @@ async function loadDefaultModels(req) {
plugins: true,
});
const assistants = await getOpenAIModels({ assistants: true });
const azureAssistants = await getOpenAIModels({ azureAssistants: true });
return {
[EModelEndpoint.openAI]: openAI,
@@ -35,6 +36,7 @@ async function loadDefaultModels(req) {
[EModelEndpoint.bingAI]: ['BingAI', 'Sydney'],
[EModelEndpoint.chatGPTBrowser]: chatGPTBrowser,
[EModelEndpoint.assistants]: assistants,
[EModelEndpoint.azureAssistants]: azureAssistants,
};
}

View File

@@ -2,95 +2,8 @@ const addTitle = require('./addTitle');
const buildOptions = require('./buildOptions');
const initializeClient = require('./initializeClient');
/**
* Asynchronously lists assistants based on provided query parameters.
*
* Initializes the client with the current request and response objects and lists assistants
* according to the query parameters. This function abstracts the logic for non-Azure paths.
*
* @async
* @param {object} params - The parameters object.
* @param {object} params.req - The request object, used for initializing the client.
* @param {object} params.res - The response object, used for initializing the client.
* @param {object} params.query - The query parameters to list assistants (e.g., limit, order).
* @returns {Promise<object>} A promise that resolves to the response from the `openai.beta.assistants.list` method call.
*/
const listAssistants = async ({ req, res, query }) => {
const { openai } = await initializeClient({ req, res });
return openai.beta.assistants.list(query);
};
/**
* Asynchronously lists assistants for Azure configured groups.
*
* Iterates through Azure configured assistant groups, initializes the client with the current request and response objects,
* lists assistants based on the provided query parameters, and merges their data alongside the model information into a single array.
*
* @async
* @param {object} params - The parameters object.
* @param {object} params.req - The request object, used for initializing the client and manipulating the request body.
* @param {object} params.res - The response object, used for initializing the client.
* @param {TAzureConfig} params.azureConfig - The Azure configuration object containing assistantGroups and groupMap.
* @param {object} params.query - The query parameters to list assistants (e.g., limit, order).
* @returns {Promise<AssistantListResponse>} A promise that resolves to an array of assistant data merged with their respective model information.
*/
const listAssistantsForAzure = async ({ req, res, azureConfig = {}, query }) => {
/** @type {Array<[string, TAzureModelConfig]>} */
const groupModelTuples = [];
const promises = [];
/** @type {Array<TAzureGroup>} */
const groups = [];
const { groupMap, assistantGroups } = azureConfig;
for (const groupName of assistantGroups) {
const group = groupMap[groupName];
groups.push(group);
const currentModelTuples = Object.entries(group?.models);
groupModelTuples.push(currentModelTuples);
/* The specified model is only necessary to
fetch assistants for the shared instance */
req.body.model = currentModelTuples[0][0];
promises.push(listAssistants({ req, res, query }));
}
const resolvedQueries = await Promise.all(promises);
const data = resolvedQueries.flatMap((res, i) =>
res.data.map((assistant) => {
const deploymentName = assistant.model;
const currentGroup = groups[i];
const currentModelTuples = groupModelTuples[i];
const firstModel = currentModelTuples[0][0];
if (currentGroup.deploymentName === deploymentName) {
return { ...assistant, model: firstModel };
}
for (const [model, modelConfig] of currentModelTuples) {
if (modelConfig.deploymentName === deploymentName) {
return { ...assistant, model };
}
}
return { ...assistant, model: firstModel };
}),
);
return {
first_id: data[0]?.id,
last_id: data[data.length - 1]?.id,
object: 'list',
has_more: false,
data,
};
};
module.exports = {
addTitle,
buildOptions,
initializeClient,
listAssistants,
listAssistantsForAzure,
};

View File

@@ -1,11 +1,6 @@
const OpenAI = require('openai');
const { HttpsProxyAgent } = require('https-proxy-agent');
const {
ErrorTypes,
EModelEndpoint,
resolveHeaders,
mapModelToAzureConfig,
} = require('librechat-data-provider');
const { ErrorTypes, EModelEndpoint } = require('librechat-data-provider');
const {
getUserKeyValues,
getUserKeyExpiry,
@@ -13,9 +8,8 @@ const {
} = require('~/server/services/UserService');
const OpenAIClient = require('~/app/clients/OpenAIClient');
const { isUserProvided } = require('~/server/utils');
const { constructAzureURL } = require('~/utils');
const initializeClient = async ({ req, res, endpointOption, initAppClient = false }) => {
const initializeClient = async ({ req, res, endpointOption, version, initAppClient = false }) => {
const { PROXY, OPENAI_ORGANIZATION, ASSISTANTS_API_KEY, ASSISTANTS_BASE_URL } = process.env;
const userProvidesKey = isUserProvided(ASSISTANTS_API_KEY);
@@ -34,7 +28,11 @@ const initializeClient = async ({ req, res, endpointOption, initAppClient = fals
let apiKey = userProvidesKey ? userValues.apiKey : ASSISTANTS_API_KEY;
let baseURL = userProvidesURL ? userValues.baseURL : ASSISTANTS_BASE_URL;
const opts = {};
const opts = {
defaultHeaders: {
'OpenAI-Beta': `assistants=${version}`,
},
};
const clientOptions = {
reverseProxyUrl: baseURL ?? null,
@@ -44,54 +42,6 @@ const initializeClient = async ({ req, res, endpointOption, initAppClient = fals
...endpointOption,
};
/** @type {TAzureConfig | undefined} */
const azureConfig = req.app.locals[EModelEndpoint.azureOpenAI];
/** @type {AzureOptions | undefined} */
let azureOptions;
if (azureConfig && azureConfig.assistants) {
const { modelGroupMap, groupMap, assistantModels } = azureConfig;
const modelName = req.body.model ?? req.query.model ?? assistantModels[0];
const {
azureOptions: currentOptions,
baseURL: azureBaseURL,
headers = {},
serverless,
} = mapModelToAzureConfig({
modelName,
modelGroupMap,
groupMap,
});
azureOptions = currentOptions;
baseURL = constructAzureURL({
baseURL: azureBaseURL ?? 'https://${INSTANCE_NAME}.openai.azure.com/openai',
azureOptions,
});
apiKey = azureOptions.azureOpenAIApiKey;
opts.defaultQuery = { 'api-version': azureOptions.azureOpenAIApiVersion };
opts.defaultHeaders = resolveHeaders({ ...headers, 'api-key': apiKey });
opts.model = azureOptions.azureOpenAIApiDeploymentName;
if (initAppClient) {
clientOptions.titleConvo = azureConfig.titleConvo;
clientOptions.titleModel = azureConfig.titleModel;
clientOptions.titleMethod = azureConfig.titleMethod ?? 'completion';
const groupName = modelGroupMap[modelName].group;
clientOptions.addParams = azureConfig.groupMap[groupName].addParams;
clientOptions.dropParams = azureConfig.groupMap[groupName].dropParams;
clientOptions.forcePrompt = azureConfig.groupMap[groupName].forcePrompt;
clientOptions.reverseProxyUrl = baseURL ?? clientOptions.reverseProxyUrl;
clientOptions.headers = opts.defaultHeaders;
clientOptions.azure = !serverless && azureOptions;
}
}
if (userProvidesKey & !apiKey) {
throw new Error(
JSON.stringify({
@@ -125,10 +75,6 @@ const initializeClient = async ({ req, res, endpointOption, initAppClient = fals
openai.req = req;
openai.res = res;
if (azureOptions) {
openai.locals = { ...(openai.locals ?? {}), azureOptions };
}
if (endpointOption && initAppClient) {
const client = new OpenAIClient(apiKey, clientOptions);
return {

View File

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

View File

@@ -0,0 +1,7 @@
const buildOptions = require('./buildOptions');
const initializeClient = require('./initializeClient');
module.exports = {
buildOptions,
initializeClient,
};

View File

@@ -0,0 +1,195 @@
const OpenAI = require('openai');
const { HttpsProxyAgent } = require('https-proxy-agent');
const {
ErrorTypes,
EModelEndpoint,
resolveHeaders,
mapModelToAzureConfig,
} = require('librechat-data-provider');
const {
getUserKeyValues,
getUserKeyExpiry,
checkUserKeyExpiry,
} = require('~/server/services/UserService');
const OpenAIClient = require('~/app/clients/OpenAIClient');
const { isUserProvided } = require('~/server/utils');
const { constructAzureURL } = require('~/utils');
class Files {
constructor(client) {
this._client = client;
}
/**
* Create an assistant file by attaching a
* [File](https://platform.openai.com/docs/api-reference/files) to an
* [assistant](https://platform.openai.com/docs/api-reference/assistants).
*/
create(assistantId, body, options) {
return this._client.post(`/assistants/${assistantId}/files`, {
body,
...options,
headers: { 'OpenAI-Beta': 'assistants=v1', ...options?.headers },
});
}
/**
* Retrieves an AssistantFile.
*/
retrieve(assistantId, fileId, options) {
return this._client.get(`/assistants/${assistantId}/files/${fileId}`, {
...options,
headers: { 'OpenAI-Beta': 'assistants=v1', ...options?.headers },
});
}
/**
* Delete an assistant file.
*/
del(assistantId, fileId, options) {
return this._client.delete(`/assistants/${assistantId}/files/${fileId}`, {
...options,
headers: { 'OpenAI-Beta': 'assistants=v1', ...options?.headers },
});
}
}
const initializeClient = async ({ req, res, version, endpointOption, initAppClient = false }) => {
const { PROXY, OPENAI_ORGANIZATION, AZURE_ASSISTANTS_API_KEY, AZURE_ASSISTANTS_BASE_URL } =
process.env;
const userProvidesKey = isUserProvided(AZURE_ASSISTANTS_API_KEY);
const userProvidesURL = isUserProvided(AZURE_ASSISTANTS_BASE_URL);
let userValues = null;
if (userProvidesKey || userProvidesURL) {
const expiresAt = await getUserKeyExpiry({
userId: req.user.id,
name: EModelEndpoint.azureAssistants,
});
checkUserKeyExpiry(expiresAt, EModelEndpoint.azureAssistants);
userValues = await getUserKeyValues({
userId: req.user.id,
name: EModelEndpoint.azureAssistants,
});
}
let apiKey = userProvidesKey ? userValues.apiKey : AZURE_ASSISTANTS_API_KEY;
let baseURL = userProvidesURL ? userValues.baseURL : AZURE_ASSISTANTS_BASE_URL;
const opts = {};
const clientOptions = {
reverseProxyUrl: baseURL ?? null,
proxy: PROXY ?? null,
req,
res,
...endpointOption,
};
/** @type {TAzureConfig | undefined} */
const azureConfig = req.app.locals[EModelEndpoint.azureOpenAI];
/** @type {AzureOptions | undefined} */
let azureOptions;
if (azureConfig && azureConfig.assistants) {
const { modelGroupMap, groupMap, assistantModels } = azureConfig;
const modelName = req.body.model ?? req.query.model ?? assistantModels[0];
const {
azureOptions: currentOptions,
baseURL: azureBaseURL,
headers = {},
serverless,
} = mapModelToAzureConfig({
modelName,
modelGroupMap,
groupMap,
});
azureOptions = currentOptions;
baseURL = constructAzureURL({
baseURL: azureBaseURL ?? 'https://${INSTANCE_NAME}.openai.azure.com/openai',
azureOptions,
});
apiKey = azureOptions.azureOpenAIApiKey;
opts.defaultQuery = { 'api-version': azureOptions.azureOpenAIApiVersion };
opts.defaultHeaders = resolveHeaders({
...headers,
'api-key': apiKey,
'OpenAI-Beta': `assistants=${version}`,
});
opts.model = azureOptions.azureOpenAIApiDeploymentName;
if (initAppClient) {
clientOptions.titleConvo = azureConfig.titleConvo;
clientOptions.titleModel = azureConfig.titleModel;
clientOptions.titleMethod = azureConfig.titleMethod ?? 'completion';
const groupName = modelGroupMap[modelName].group;
clientOptions.addParams = azureConfig.groupMap[groupName].addParams;
clientOptions.dropParams = azureConfig.groupMap[groupName].dropParams;
clientOptions.forcePrompt = azureConfig.groupMap[groupName].forcePrompt;
clientOptions.reverseProxyUrl = baseURL ?? clientOptions.reverseProxyUrl;
clientOptions.headers = opts.defaultHeaders;
clientOptions.azure = !serverless && azureOptions;
}
}
if (userProvidesKey & !apiKey) {
throw new Error(
JSON.stringify({
type: ErrorTypes.NO_USER_KEY,
}),
);
}
if (!apiKey) {
throw new Error('Assistants API key not provided. Please provide it again.');
}
if (baseURL) {
opts.baseURL = baseURL;
}
if (PROXY) {
opts.httpAgent = new HttpsProxyAgent(PROXY);
}
if (OPENAI_ORGANIZATION) {
opts.organization = OPENAI_ORGANIZATION;
}
/** @type {OpenAIClient} */
const openai = new OpenAI({
apiKey,
...opts,
});
openai.beta.assistants.files = new Files(openai);
openai.req = req;
openai.res = res;
if (azureOptions) {
openai.locals = { ...(openai.locals ?? {}), azureOptions };
}
if (endpointOption && initAppClient) {
const client = new OpenAIClient(apiKey, clientOptions);
return {
client,
openai,
openAIApiKey: apiKey,
};
}
return {
openai,
openAIApiKey: apiKey,
};
};
module.exports = initializeClient;

View File

@@ -0,0 +1,112 @@
// const OpenAI = require('openai');
const { HttpsProxyAgent } = require('https-proxy-agent');
const { ErrorTypes } = require('librechat-data-provider');
const { getUserKey, getUserKeyExpiry, getUserKeyValues } = require('~/server/services/UserService');
const initializeClient = require('./initializeClient');
// const { OpenAIClient } = require('~/app');
jest.mock('~/server/services/UserService', () => ({
getUserKey: jest.fn(),
getUserKeyExpiry: jest.fn(),
getUserKeyValues: jest.fn(),
checkUserKeyExpiry: jest.requireActual('~/server/services/UserService').checkUserKeyExpiry,
}));
const today = new Date();
const tenDaysFromToday = new Date(today.setDate(today.getDate() + 10));
const isoString = tenDaysFromToday.toISOString();
describe('initializeClient', () => {
// Set up environment variables
const originalEnvironment = process.env;
const app = {
locals: {},
};
beforeEach(() => {
jest.resetModules(); // Clears the cache
process.env = { ...originalEnvironment }; // Make a copy
});
afterAll(() => {
process.env = originalEnvironment; // Restore original env vars
});
test('initializes OpenAI client with default API key and URL', async () => {
process.env.AZURE_ASSISTANTS_API_KEY = 'default-api-key';
process.env.AZURE_ASSISTANTS_BASE_URL = 'https://default.api.url';
// Assuming 'isUserProvided' to return false for this test case
jest.mock('~/server/utils', () => ({
isUserProvided: jest.fn().mockReturnValueOnce(false),
}));
const req = { user: { id: 'user123' }, app };
const res = {};
const { openai, openAIApiKey } = await initializeClient({ req, res });
expect(openai.apiKey).toBe('default-api-key');
expect(openAIApiKey).toBe('default-api-key');
expect(openai.baseURL).toBe('https://default.api.url');
});
test('initializes OpenAI client with user-provided API key and URL', async () => {
process.env.AZURE_ASSISTANTS_API_KEY = 'user_provided';
process.env.AZURE_ASSISTANTS_BASE_URL = 'user_provided';
getUserKeyValues.mockResolvedValue({ apiKey: 'user-api-key', baseURL: 'https://user.api.url' });
getUserKeyExpiry.mockResolvedValue(isoString);
const req = { user: { id: 'user123' }, app };
const res = {};
const { openai, openAIApiKey } = await initializeClient({ req, res });
expect(openAIApiKey).toBe('user-api-key');
expect(openai.apiKey).toBe('user-api-key');
expect(openai.baseURL).toBe('https://user.api.url');
});
test('throws error for invalid JSON in user-provided values', async () => {
process.env.AZURE_ASSISTANTS_API_KEY = 'user_provided';
getUserKey.mockResolvedValue('invalid-json');
getUserKeyExpiry.mockResolvedValue(isoString);
getUserKeyValues.mockImplementation(() => {
let userValues = getUserKey();
try {
userValues = JSON.parse(userValues);
} catch (e) {
throw new Error(
JSON.stringify({
type: ErrorTypes.INVALID_USER_KEY,
}),
);
}
return userValues;
});
const req = { user: { id: 'user123' } };
const res = {};
await expect(initializeClient({ req, res })).rejects.toThrow(/invalid_user_key/);
});
test('throws error if API key is not provided', async () => {
delete process.env.AZURE_ASSISTANTS_API_KEY; // Simulate missing API key
const req = { user: { id: 'user123' }, app };
const res = {};
await expect(initializeClient({ req, res })).rejects.toThrow(/Assistants API key not/);
});
test('initializes OpenAI client with proxy configuration', async () => {
process.env.AZURE_ASSISTANTS_API_KEY = 'test-key';
process.env.PROXY = 'http://proxy.server';
const req = { user: { id: 'user123' }, app };
const res = {};
const { openai } = await initializeClient({ req, res });
expect(openai.httpAgent).toBeInstanceOf(HttpsProxyAgent);
});
});

View File

@@ -180,7 +180,15 @@ const deleteFirebaseFile = async (req, file) => {
if (!fileName.includes(req.user.id)) {
throw new Error('Invalid file path');
}
await deleteFile('', fileName);
try {
await deleteFile('', fileName);
} catch (error) {
logger.error('Error deleting file from Firebase:', error);
if (error.code === 'storage/object-not-found') {
return;
}
throw error;
}
};
/**

View File

@@ -14,9 +14,11 @@ const { logger } = require('~/config');
* @returns {Promise<OpenAIFile>}
*/
async function uploadOpenAIFile({ req, file, openai }) {
const { height, width } = req.body;
const isImage = height && width;
const uploadedFile = await openai.files.create({
file: fs.createReadStream(file.path),
purpose: FilePurpose.Assistants,
purpose: isImage ? FilePurpose.Vision : FilePurpose.Assistants,
});
logger.debug(
@@ -34,7 +36,7 @@ async function uploadOpenAIFile({ req, file, openai }) {
await sleep(sleepTime);
}
return uploadedFile;
return isImage ? { ...uploadedFile, height, width } : uploadedFile;
}
/**

View File

@@ -10,10 +10,13 @@ const {
EModelEndpoint,
mergeFileConfig,
hostImageIdSuffix,
checkOpenAIStorage,
hostImageNamePrefix,
isAssistantsEndpoint,
} = require('librechat-data-provider');
const { addResourceFileId, deleteResourceFileId } = require('~/server/controllers/assistants/v2');
const { convertImage, resizeAndConvert } = require('~/server/services/Files/images');
const { initializeClient } = require('~/server/services/Endpoints/assistants');
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
const { createFile, updateFileUsage, deleteFiles } = require('~/models/File');
const { LB_QueueAsyncCall } = require('~/server/utils/queue');
const { getStrategyFunctions } = require('./strategies');
@@ -34,14 +37,16 @@ const processFiles = async (files) => {
/**
* Enqueues the delete operation to the leaky bucket queue if necessary, or adds it directly to promises.
*
* @param {Express.Request} req - The express request object.
* @param {MongoFile} file - The file object to delete.
* @param {Function} deleteFile - The delete file function.
* @param {Promise[]} promises - The array of promises to await.
* @param {OpenAI | undefined} [openai] - If an OpenAI file, the initialized OpenAI client.
* @param {object} params - The passed parameters.
* @param {Express.Request} params.req - The express request object.
* @param {MongoFile} params.file - The file object to delete.
* @param {Function} params.deleteFile - The delete file function.
* @param {Promise[]} params.promises - The array of promises to await.
* @param {string[]} params.resolvedFileIds - The array of promises to await.
* @param {OpenAI | undefined} [params.openai] - If an OpenAI file, the initialized OpenAI client.
*/
function enqueueDeleteOperation(req, file, deleteFile, promises, openai) {
if (file.source === FileSources.openai) {
function enqueueDeleteOperation({ req, file, deleteFile, promises, resolvedFileIds, openai }) {
if (checkOpenAIStorage(file.source)) {
// Enqueue to leaky bucket
promises.push(
new Promise((resolve, reject) => {
@@ -53,6 +58,7 @@ function enqueueDeleteOperation(req, file, deleteFile, promises, openai) {
logger.error('Error deleting file from OpenAI source', err);
reject(err);
} else {
resolvedFileIds.push(file.file_id);
resolve(result);
}
},
@@ -62,10 +68,12 @@ function enqueueDeleteOperation(req, file, deleteFile, promises, openai) {
} else {
// Add directly to promises
promises.push(
deleteFile(req, file).catch((err) => {
logger.error('Error deleting file', err);
return Promise.reject(err);
}),
deleteFile(req, file)
.then(() => resolvedFileIds.push(file.file_id))
.catch((err) => {
logger.error('Error deleting file', err);
return Promise.reject(err);
}),
);
}
}
@@ -80,35 +88,71 @@ function enqueueDeleteOperation(req, file, deleteFile, promises, openai) {
* @param {Express.Request} params.req - The express request object.
* @param {DeleteFilesBody} params.req.body - The request body.
* @param {string} [params.req.body.assistant_id] - The assistant ID if file uploaded is associated to an assistant.
* @param {string} [params.req.body.tool_resource] - The tool resource if assistant file uploaded is associated to a tool resource.
*
* @returns {Promise<void>}
*/
const processDeleteRequest = async ({ req, files }) => {
const file_ids = files.map((file) => file.file_id);
const resolvedFileIds = [];
const deletionMethods = {};
const promises = [];
promises.push(deleteFiles(file_ids));
/** @type {OpenAI | undefined} */
let openai;
if (req.body.assistant_id) {
({ openai } = await initializeClient({ req }));
/** @type {Record<string, OpenAI | undefined>} */
const client = { [FileSources.openai]: undefined, [FileSources.azure]: undefined };
const initializeClients = async () => {
const openAIClient = await getOpenAIClient({
req,
overrideEndpoint: EModelEndpoint.assistants,
});
client[FileSources.openai] = openAIClient.openai;
if (!req.app.locals[EModelEndpoint.azureOpenAI]?.assistants) {
return;
}
const azureClient = await getOpenAIClient({
req,
overrideEndpoint: EModelEndpoint.azureAssistants,
});
client[FileSources.azure] = azureClient.openai;
};
if (req.body.assistant_id !== undefined) {
await initializeClients();
}
for (const file of files) {
const source = file.source ?? FileSources.local;
if (source === FileSources.openai && !openai) {
({ openai } = await initializeClient({ req }));
if (checkOpenAIStorage(source) && !client[source]) {
await initializeClients();
}
if (req.body.assistant_id) {
const openai = client[source];
if (req.body.assistant_id && req.body.tool_resource) {
promises.push(
deleteResourceFileId({
req,
openai,
file_id: file.file_id,
assistant_id: req.body.assistant_id,
tool_resource: req.body.tool_resource,
}),
);
} else if (req.body.assistant_id) {
promises.push(openai.beta.assistants.files.del(req.body.assistant_id, file.file_id));
}
if (deletionMethods[source]) {
enqueueDeleteOperation(req, file, deletionMethods[source], promises, openai);
enqueueDeleteOperation({
req,
file,
deleteFile: deletionMethods[source],
promises,
resolvedFileIds,
openai,
});
continue;
}
@@ -118,10 +162,11 @@ const processDeleteRequest = async ({ req, files }) => {
}
deletionMethods[source] = deleteFile;
enqueueDeleteOperation(req, file, deleteFile, promises, openai);
enqueueDeleteOperation({ req, file, deleteFile, promises, resolvedFileIds, openai });
}
await Promise.allSettled(promises);
await deleteFiles(resolvedFileIds);
};
/**
@@ -180,12 +225,13 @@ const processFileURL = async ({ fileStrategy, userId, URL, fileName, basePath, c
*
* @param {Object} params - The parameters object.
* @param {Express.Request} params.req - The Express request object.
* @param {Express.Response} params.res - The Express response object.
* @param {Express.Response} [params.res] - The Express response object.
* @param {Express.Multer.File} params.file - The uploaded file.
* @param {ImageMetadata} params.metadata - Additional metadata for the file.
* @param {boolean} params.returnFile - Whether to return the file metadata or return response as normal.
* @returns {Promise<void>}
*/
const processImageFile = async ({ req, res, file, metadata }) => {
const processImageFile = async ({ req, res, file, metadata, returnFile = false }) => {
const source = req.app.locals.fileStrategy;
const { handleImageUpload } = getStrategyFunctions(source);
const { file_id, temp_file_id, endpoint } = metadata;
@@ -213,6 +259,10 @@ const processImageFile = async ({ req, res, file, metadata }) => {
},
true,
);
if (returnFile) {
return result;
}
res.status(200).json({ message: 'File uploaded and processed successfully', ...result });
};
@@ -274,28 +324,57 @@ const uploadImageBuffer = async ({ req, context, metadata = {}, resize = true })
* @returns {Promise<void>}
*/
const processFileUpload = async ({ req, res, file, metadata }) => {
const isAssistantUpload = metadata.endpoint === EModelEndpoint.assistants;
const source = isAssistantUpload ? FileSources.openai : FileSources.vectordb;
const isAssistantUpload = isAssistantsEndpoint(metadata.endpoint);
const assistantSource =
metadata.endpoint === EModelEndpoint.azureAssistants ? FileSources.azure : FileSources.openai;
const source = isAssistantUpload ? assistantSource : FileSources.vectordb;
const { handleFileUpload } = getStrategyFunctions(source);
const { file_id, temp_file_id } = metadata;
/** @type {OpenAI | undefined} */
let openai;
if (source === FileSources.openai) {
({ openai } = await initializeClient({ req }));
if (checkOpenAIStorage(source)) {
({ openai } = await getOpenAIClient({ req }));
}
const { id, bytes, filename, filepath, embedded } = await handleFileUpload({
const {
id,
bytes,
filename,
filepath: _filepath,
embedded,
height,
width,
} = await handleFileUpload({
req,
file,
file_id,
openai,
});
if (isAssistantUpload && !metadata.message_file) {
if (isAssistantUpload && !metadata.message_file && !metadata.tool_resource) {
await openai.beta.assistants.files.create(metadata.assistant_id, {
file_id: id,
});
} else if (isAssistantUpload && !metadata.message_file) {
await addResourceFileId({
req,
openai,
file_id: id,
assistant_id: metadata.assistant_id,
tool_resource: metadata.tool_resource,
});
}
let filepath = isAssistantUpload ? `${openai.baseURL}/files/${id}` : _filepath;
if (isAssistantUpload && file.mimetype.startsWith('image')) {
const result = await processImageFile({
req,
file,
metadata: { file_id: v4() },
returnFile: true,
});
filepath = result.filepath;
}
const result = await createFile(
@@ -304,13 +383,15 @@ const processFileUpload = async ({ req, res, file, metadata }) => {
file_id: id ?? file_id,
temp_file_id,
bytes,
filepath,
filename: filename ?? file.originalname,
filepath: isAssistantUpload ? `${openai.baseURL}/files/${id}` : filepath,
context: isAssistantUpload ? FileContext.assistants : FileContext.message_attachment,
model: isAssistantUpload ? req.body.model : undefined,
type: file.mimetype,
embedded,
source,
height,
width,
},
true,
);
@@ -340,7 +421,10 @@ const processOpenAIFile = async ({
originalName ? `/${originalName}` : ''
}`;
const type = mime.getType(originalName ?? file_id);
const source =
openai.req.body.endpoint === EModelEndpoint.azureAssistants
? FileSources.azure
: FileSources.openai;
const file = {
..._file,
type,
@@ -349,7 +433,7 @@ const processOpenAIFile = async ({
usage: 1,
user: userId,
context: _file.purpose,
source: FileSources.openai,
source,
model: openai.req.body.model,
filename: originalName ?? file_id,
};
@@ -394,12 +478,14 @@ const processOpenAIImageOutput = async ({ req, buffer, file_id, filename, fileEx
filename: `${hostImageNamePrefix}${filename}`,
};
createFile(file, true);
const source =
req.body.endpoint === EModelEndpoint.azureAssistants ? FileSources.azure : FileSources.openai;
createFile(
{
...file,
file_id,
filename,
source: FileSources.openai,
source,
type: mime.getType(fileExt),
},
true,
@@ -500,7 +586,12 @@ async function retrieveAndProcessFile({
* Filters a file based on its size and the endpoint origin.
*
* @param {Object} params - The parameters for the function.
* @param {Express.Request} params.req - The request object from Express.
* @param {object} params.req - The request object from Express.
* @param {string} [params.req.endpoint]
* @param {string} [params.req.file_id]
* @param {number} [params.req.width]
* @param {number} [params.req.height]
* @param {number} [params.req.version]
* @param {Express.Multer.File} params.file - The file uploaded to the server via multer.
* @param {boolean} [params.image] - Whether the file expected is an image.
* @returns {void}

View File

@@ -111,6 +111,8 @@ const getStrategyFunctions = (fileSource) => {
return localStrategy();
} else if (fileSource === FileSources.openai) {
return openAIStrategy();
} else if (fileSource === FileSources.azure) {
return openAIStrategy();
} else if (fileSource === FileSources.vectordb) {
return vectorStrategy();
} else {

View File

@@ -167,6 +167,8 @@ const getOpenAIModels = async (opts) => {
if (opts.assistants) {
models = defaultModels[EModelEndpoint.assistants];
} else if (opts.azure) {
models = defaultModels[EModelEndpoint.azureAssistants];
}
if (opts.plugins) {

View File

@@ -55,7 +55,7 @@ async function createRun({ openai, thread_id, body }) {
* @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=750] - The interval for polling the run status; default is 750 milliseconds.
* @param {number} [params.pollIntervalMs=2000] - The interval for polling the run status; default is 2000 milliseconds.
* @param {number} [params.timeout=180000] - The period to wait until timing out polling; default is 3 minutes (in ms).
* @return {Promise<Run>} A promise that resolves to the last fetched run object.
*/
@@ -64,7 +64,7 @@ async function waitForRun({
run_id,
thread_id,
runManager,
pollIntervalMs = 750,
pollIntervalMs = 2000,
timeout = 60000 * 3,
}) {
let timeElapsed = 0;
@@ -233,7 +233,7 @@ async function _handleRun({ openai, run_id, thread_id }) {
run_id,
thread_id,
runManager,
pollIntervalMs: 750,
pollIntervalMs: 2000,
timeout: 60000,
});
const actions = [];

View File

@@ -3,7 +3,6 @@ const { v4 } = require('uuid');
const {
Constants,
ContentTypes,
EModelEndpoint,
AnnotationTypes,
defaultOrderQuery,
} = require('librechat-data-provider');
@@ -50,6 +49,7 @@ async function initThread({ openai, body, thread_id: _thread_id }) {
* @param {string} params.assistant_id - The current assistant Id.
* @param {string} params.thread_id - The thread Id.
* @param {string} params.conversationId - The message's conversationId
* @param {string} params.endpoint - The conversation endpoint
* @param {string} [params.parentMessageId] - Optional if initial message.
* Defaults to Constants.NO_PARENT.
* @param {string} [params.instructions] - Optional: from preset for `instructions` field.
@@ -82,7 +82,7 @@ async function saveUserMessage(params) {
const userMessage = {
user: params.user,
endpoint: EModelEndpoint.assistants,
endpoint: params.endpoint,
messageId: params.messageId,
conversationId: params.conversationId,
parentMessageId: params.parentMessageId ?? Constants.NO_PARENT,
@@ -96,7 +96,7 @@ async function saveUserMessage(params) {
};
const convo = {
endpoint: EModelEndpoint.assistants,
endpoint: params.endpoint,
conversationId: params.conversationId,
promptPrefix: params.promptPrefix,
instructions: params.instructions,
@@ -126,6 +126,7 @@ async function saveUserMessage(params) {
* @param {string} params.model - The model used by the assistant.
* @param {ContentPart[]} params.content - The message content parts.
* @param {string} params.conversationId - The message's conversationId
* @param {string} params.endpoint - The conversation endpoint
* @param {string} params.parentMessageId - The latest user message that triggered this response.
* @param {string} [params.instructions] - Optional: from preset for `instructions` field.
* Overrides the instructions of the assistant.
@@ -145,7 +146,7 @@ async function saveAssistantMessage(params) {
const message = await recordMessage({
user: params.user,
endpoint: EModelEndpoint.assistants,
endpoint: params.endpoint,
messageId: params.messageId,
conversationId: params.conversationId,
parentMessageId: params.parentMessageId,
@@ -160,7 +161,7 @@ async function saveAssistantMessage(params) {
});
await saveConvo(params.user, {
endpoint: EModelEndpoint.assistants,
endpoint: params.endpoint,
conversationId: params.conversationId,
promptPrefix: params.promptPrefix,
instructions: params.instructions,
@@ -205,20 +206,22 @@ async function addThreadMetadata({ openai, thread_id, messageId, messages }) {
*
* @param {Object} params - The parameters for synchronizing messages.
* @param {OpenAIClient} params.openai - The OpenAI client instance.
* @param {string} params.endpoint - The current endpoint.
* @param {string} params.thread_id - The current thread ID.
* @param {TMessage[]} params.dbMessages - The LibreChat DB messages.
* @param {ThreadMessage[]} params.apiMessages - The thread messages from the API.
* @param {string} params.conversationId - The current conversation ID.
* @param {string} params.thread_id - The current thread ID.
* @param {string} [params.assistant_id] - The current assistant ID.
* @param {string} params.conversationId - The current conversation ID.
* @return {Promise<TMessage[]>} A promise that resolves to the updated messages
*/
async function syncMessages({
openai,
apiMessages,
dbMessages,
conversationId,
endpoint,
thread_id,
dbMessages,
apiMessages,
assistant_id,
conversationId,
}) {
let result = [];
let dbMessageMap = new Map(dbMessages.map((msg) => [msg.messageId, msg]));
@@ -290,7 +293,7 @@ async function syncMessages({
thread_id,
conversationId,
messageId: v4(),
endpoint: EModelEndpoint.assistants,
endpoint,
parentMessageId: lastMessage ? lastMessage.messageId : Constants.NO_PARENT,
role: apiMessage.role,
isCreatedByUser: apiMessage.role === 'user',
@@ -382,13 +385,21 @@ function mapMessagesToSteps(steps, messages) {
*
* @param {Object} params - The parameters for initializing a thread.
* @param {OpenAIClient} params.openai - The OpenAI client instance.
* @param {string} params.endpoint - The current endpoint.
* @param {string} [params.latestMessageId] - Optional: The latest message ID from LibreChat.
* @param {string} params.thread_id - Response thread ID.
* @param {string} params.run_id - Response Run ID.
* @param {string} params.conversationId - LibreChat conversation ID.
* @return {Promise<TMessage[]>} A promise that resolves to the updated messages
*/
async function checkMessageGaps({ openai, latestMessageId, thread_id, run_id, conversationId }) {
async function checkMessageGaps({
openai,
endpoint,
latestMessageId,
thread_id,
run_id,
conversationId,
}) {
const promises = [];
promises.push(openai.beta.threads.messages.list(thread_id, defaultOrderQuery));
promises.push(openai.beta.threads.runs.steps.list(thread_id, run_id));
@@ -406,6 +417,7 @@ async function checkMessageGaps({ openai, latestMessageId, thread_id, run_id, co
role: 'assistant',
run_id,
thread_id,
endpoint,
metadata: {
messageId: latestMessageId,
},
@@ -452,11 +464,12 @@ async function checkMessageGaps({ openai, latestMessageId, thread_id, run_id, co
const syncedMessages = await syncMessages({
openai,
endpoint,
thread_id,
dbMessages,
apiMessages,
thread_id,
conversationId,
assistant_id,
conversationId,
});
return Object.values(
@@ -498,41 +511,62 @@ const recordUsage = async ({
};
/**
* Safely replaces the annotated text within the specified range denoted by start_index and end_index,
* after verifying that the text within that range matches the given annotation text.
* Proceeds with the replacement even if a mismatch is found, but logs a warning.
* Creates a replaceAnnotation function with internal state for tracking the index offset.
*
* @param {string} originalText The original text content.
* @param {number} start_index The starting index where replacement should begin.
* @param {number} end_index The ending index where replacement should end.
* @param {string} expectedText The text expected to be found in the specified range.
* @param {string} replacementText The text to insert in place of the existing content.
* @returns {string} The text with the replacement applied, regardless of text match.
* @returns {function} The replaceAnnotation function with closure for index offset.
*/
function replaceAnnotation(originalText, start_index, end_index, expectedText, replacementText) {
if (start_index < 0 || end_index > originalText.length || start_index > end_index) {
logger.warn(`Invalid range specified for annotation replacement.
Attempting replacement with \`replace\` method instead...
length: ${originalText.length}
start_index: ${start_index}
end_index: ${end_index}`);
return originalText.replace(originalText, replacementText);
function createReplaceAnnotation() {
let indexOffset = 0;
/**
* Safely replaces the annotated text within the specified range denoted by start_index and end_index,
* after verifying that the text within that range matches the given annotation text.
* Proceeds with the replacement even if a mismatch is found, but logs a warning.
*
* @param {object} params The original text content.
* @param {string} params.currentText The current text content, with/without replacements.
* @param {number} params.start_index The starting index where replacement should begin.
* @param {number} params.end_index The ending index where replacement should end.
* @param {string} params.expectedText The text expected to be found in the specified range.
* @param {string} params.replacementText The text to insert in place of the existing content.
* @returns {string} The text with the replacement applied, regardless of text match.
*/
function replaceAnnotation({
currentText,
start_index,
end_index,
expectedText,
replacementText,
}) {
const adjustedStartIndex = start_index + indexOffset;
const adjustedEndIndex = end_index + indexOffset;
if (
adjustedStartIndex < 0 ||
adjustedEndIndex > currentText.length ||
adjustedStartIndex > adjustedEndIndex
) {
logger.warn(`Invalid range specified for annotation replacement.
Attempting replacement with \`replace\` method instead...
length: ${currentText.length}
start_index: ${adjustedStartIndex}
end_index: ${adjustedEndIndex}`);
return currentText.replace(expectedText, replacementText);
}
if (currentText.substring(adjustedStartIndex, adjustedEndIndex) !== expectedText) {
return currentText.replace(expectedText, replacementText);
}
indexOffset += replacementText.length - (adjustedEndIndex - adjustedStartIndex);
return (
currentText.slice(0, adjustedStartIndex) +
replacementText +
currentText.slice(adjustedEndIndex)
);
}
const actualTextInRange = originalText.substring(start_index, end_index);
if (actualTextInRange !== expectedText) {
logger.warn(`The text within the specified range does not match the expected annotation text.
Attempting replacement with \`replace\` method instead...
Expected: ${expectedText}
Actual: ${actualTextInRange}`);
return originalText.replace(originalText, replacementText);
}
const beforeText = originalText.substring(0, start_index);
const afterText = originalText.substring(end_index);
return beforeText + replacementText + afterText;
return replaceAnnotation;
}
/**
@@ -581,6 +615,11 @@ async function processMessages({ openai, client, messages = [] }) {
continue;
}
const originalText = currentText;
text += originalText;
const replaceAnnotation = createReplaceAnnotation();
logger.debug('[processMessages] Processing annotations:', annotations);
for (const annotation of annotations) {
let file;
@@ -589,14 +628,16 @@ async function processMessages({ openai, client, messages = [] }) {
const file_id = annotationType?.file_id;
const alreadyProcessed = client.processedFileIds.has(file_id);
const replaceCurrentAnnotation = (replacement = '') => {
currentText = replaceAnnotation(
const replaceCurrentAnnotation = (replacementText = '') => {
const { start_index, end_index, text: expectedText } = annotation;
currentText = replaceAnnotation({
originalText,
currentText,
annotation.start_index,
annotation.end_index,
annotation.text,
replacement,
);
start_index,
end_index,
expectedText,
replacementText,
});
edited = true;
};
@@ -623,7 +664,7 @@ async function processMessages({ openai, client, messages = [] }) {
replaceCurrentAnnotation(`^${sources.length}^`);
}
text += currentText + ' ';
text = currentText;
if (!file) {
continue;

View File

@@ -2,6 +2,7 @@ const {
Capabilities,
EModelEndpoint,
assistantEndpointSchema,
defaultAssistantsVersion,
} = require('librechat-data-provider');
const { logger } = require('~/config');
@@ -12,6 +13,7 @@ const { logger } = require('~/config');
function azureAssistantsDefaults() {
return {
capabilities: [Capabilities.tools, Capabilities.actions, Capabilities.code_interpreter],
version: defaultAssistantsVersion.azureAssistants,
};
}

View File

@@ -41,6 +41,17 @@ function azureConfigSetup(config) {
);
}
if (
azureConfiguration.assistants &&
process.env.ENDPOINTS &&
!process.env.ENDPOINTS.includes(EModelEndpoint.azureAssistants)
) {
logger.warn(
`Azure Assistants are configured, but the endpoint will not be accessible as it's not included in the ENDPOINTS environment variable.
Please add the value "${EModelEndpoint.azureAssistants}" to the ENDPOINTS list if expected.`,
);
}
return {
modelNames,
modelGroupMap,

View File

@@ -1,4 +1,10 @@
const { Capabilities, defaultRetrievalModels } = require('librechat-data-provider');
const {
Capabilities,
EModelEndpoint,
isAssistantsEndpoint,
defaultRetrievalModels,
defaultAssistantsVersion,
} = require('librechat-data-provider');
const { getCitations, citeText } = require('./citations');
const partialRight = require('lodash/partialRight');
const { sendMessage } = require('./streamResponse');
@@ -154,9 +160,10 @@ const isUserProvided = (value) => value === 'user_provided';
* Generate the configuration for a given key and base URL.
* @param {string} key
* @param {string} baseURL
* @param {string} endpoint
* @returns {boolean | { userProvide: boolean, userProvideURL?: boolean }}
*/
function generateConfig(key, baseURL, assistants = false) {
function generateConfig(key, baseURL, endpoint) {
if (!key) {
return false;
}
@@ -168,6 +175,8 @@ function generateConfig(key, baseURL, assistants = false) {
config.userProvideURL = isUserProvided(baseURL);
}
const assistants = isAssistantsEndpoint(endpoint);
if (assistants) {
config.retrievalModels = defaultRetrievalModels;
config.capabilities = [
@@ -179,6 +188,12 @@ function generateConfig(key, baseURL, assistants = false) {
];
}
if (assistants && endpoint === EModelEndpoint.azureAssistants) {
config.version = defaultAssistantsVersion.azureAssistants;
} else if (assistants) {
config.version = defaultAssistantsVersion.assistants;
}
return config;
}

View File

@@ -202,7 +202,7 @@ function processConversation(conv, importBatchBuilder, requestUserId) {
const isCreatedByUser = role === 'user';
let sender = isCreatedByUser ? 'user' : 'GPT-3.5';
const model = mapping.message.metadata.model_slug || openAISettings.model.default;
if (model === 'gpt-4') {
if (model.includes('gpt-4')) {
sender = 'GPT-4';
}

View File

@@ -1,3 +1,4 @@
const fetch = require('node-fetch');
const passport = require('passport');
const jwtDecode = require('jsonwebtoken/decode');
const { Issuer, Strategy: OpenIDStrategy } = require('openid-client');

View File

@@ -657,6 +657,12 @@
* @memberof typedefs
*/
/**
* @exports OpenAISpecClient
* @typedef {import('./app/clients/OpenAIClient')} OpenAISpecClient
* @memberof typedefs
*/
/**
* @exports ImportBatchBuilder
* @typedef {import('./server/utils/import/importBatchBuilder.js').ImportBatchBuilder} ImportBatchBuilder

View File

@@ -0,0 +1,5 @@
import { createContext, useContext } from 'react';
type TShareContext = { isSharedConvo?: boolean };
export const ShareContext = createContext<TShareContext>({} as TShareContext);
export const useShareContext = () => useContext(ShareContext);

View File

@@ -1,6 +1,7 @@
export { default as ToastProvider } from './ToastContext';
export { default as AssistantsProvider } from './AssistantsContext';
export * from './ChatContext';
export * from './ShareContext';
export * from './ToastContext';
export * from './SearchContext';
export * from './FileMapContext';

View File

@@ -4,7 +4,11 @@ import type { Option, ExtendedFile } from './types';
export type TAssistantOption =
| string
| (Option & Assistant & { files?: Array<[string, ExtendedFile]> });
| (Option &
Assistant & {
files?: Array<[string, ExtendedFile]>;
code_files?: Array<[string, ExtendedFile]>;
});
export type Actions = {
[Capabilities.code_interpreter]: boolean;

View File

@@ -8,10 +8,12 @@ import type {
TPreset,
TPlugin,
TMessage,
Assistant,
TLoginUser,
AuthTypeEnum,
TConversation,
EModelEndpoint,
AssistantsEndpoint,
AuthorizationTypeEnum,
TSetOption as SetOption,
TokenExchangeMethodEnum,
@@ -19,6 +21,13 @@ import type {
import type { UseMutationResult } from '@tanstack/react-query';
import type { LucideIcon } from 'lucide-react';
export type AssistantListItem = {
id: string;
name: string;
metadata: Assistant['metadata'];
model: string;
};
export type TPluginMap = Record<string, TPlugin>;
export type GenericSetter<T> = (value: T | ((currentValue: T) => T)) => void;
@@ -101,6 +110,8 @@ export type AssistantPanelProps = {
actions?: Action[];
assistant_id?: string;
activePanel?: string;
endpoint: AssistantsEndpoint;
version: number | string;
setAction: React.Dispatch<React.SetStateAction<Action | undefined>>;
setCurrentAssistantId: React.Dispatch<React.SetStateAction<string | undefined>>;
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
@@ -315,6 +326,7 @@ export type IconProps = Pick<TMessage, 'isCreatedByUser' | 'model'> &
iconURL?: string;
message?: boolean;
className?: string;
iconClassName?: string;
endpoint?: EModelEndpoint | string | null;
endpointType?: EModelEndpoint | null;
assistantName?: string;
@@ -327,7 +339,11 @@ export type Option = Record<string, unknown> & {
};
export type OptionWithIcon = Option & { icon?: React.ReactNode };
export type MentionOption = OptionWithIcon & { type: string; value: string; description?: string };
export type MentionOption = OptionWithIcon & {
type: string;
value: string;
description?: string;
};
export type TOptionSettings = {
showExamples?: boolean;

View File

@@ -0,0 +1,63 @@
import { useState } from 'react';
import { Upload } from 'lucide-react';
import { useRecoilValue } from 'recoil';
import { useLocation } from 'react-router-dom';
import type { TConversation } from 'librechat-data-provider';
import DropDownMenu from '../Conversations/DropDownMenu';
import ShareButton from '../Conversations/ShareButton';
import HoverToggle from '../Conversations/HoverToggle';
import ExportButton from './ExportButton';
import store from '~/store';
export default function ExportAndShareMenu() {
const location = useLocation();
const activeConvo = useRecoilValue(store.conversationByIndex(0));
const globalConvo = useRecoilValue(store.conversation) ?? ({} as TConversation);
const [isPopoverActive, setIsPopoverActive] = useState(false);
let conversation: TConversation | null | undefined;
if (location.state?.from?.pathname.includes('/chat')) {
conversation = globalConvo;
} else {
conversation = activeConvo;
}
const exportable =
conversation &&
conversation.conversationId &&
conversation.conversationId !== 'new' &&
conversation.conversationId !== 'search';
if (!exportable) {
return <></>;
}
const isActiveConvo = exportable;
return (
<HoverToggle
isActiveConvo={!!isActiveConvo}
isPopoverActive={isPopoverActive}
setIsPopoverActive={setIsPopoverActive}
>
<DropDownMenu
icon={<Upload />}
tooltip="Export/Share"
className="pointer-cursor relative z-50 flex h-[40px] min-w-4 flex-none flex-col items-center justify-center rounded-md border border-gray-100 bg-white px-3 text-left hover:bg-gray-50 focus:outline-none focus:ring-0 focus:ring-offset-0 radix-state-open:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700 dark:radix-state-open:bg-gray-700 sm:text-sm"
>
{conversation && conversation.conversationId && (
<>
<ExportButton conversation={conversation} setPopoverActive={setIsPopoverActive} />
<ShareButton
conversationId={conversation.conversationId}
title={conversation.title ?? ''}
appendLabel={true}
className="mb-[3.5px]"
setPopoverActive={setIsPopoverActive}
/>
</>
)}
</DropDownMenu>
</HoverToggle>
);
}

View File

@@ -1,68 +1,41 @@
import React from 'react';
import { useState } from 'react';
import { useLocation } from 'react-router-dom';
import type { TConversation } from 'librechat-data-provider';
import { Download } from 'lucide-react';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
import { Upload } from 'lucide-react';
import { useLocalize } from '~/hooks';
import { ExportModal } from '../Nav';
import { useRecoilValue } from 'recoil';
import store from '~/store';
function ExportButton() {
function ExportButton({
conversation,
setPopoverActive,
}: {
conversation: TConversation;
setPopoverActive: (value: boolean) => void;
}) {
const localize = useLocalize();
const location = useLocation();
const [showExports, setShowExports] = useState(false);
const activeConvo = useRecoilValue(store.conversationByIndex(0));
const globalConvo = useRecoilValue(store.conversation) ?? ({} as TConversation);
let conversation: TConversation | null | undefined;
if (location.state?.from?.pathname.includes('/chat')) {
conversation = globalConvo;
} else {
conversation = activeConvo;
}
const clickHandler = () => {
if (exportable) {
setShowExports(true);
}
setShowExports(true);
};
const exportable =
conversation &&
conversation.conversationId &&
conversation.conversationId !== 'new' &&
conversation.conversationId !== 'search';
const onOpenChange = (value: boolean) => {
setShowExports(value);
setPopoverActive(value);
};
return (
<>
{exportable && (
<div className="flex gap-1 gap-2 pr-1">
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger asChild>
<button
className="btn btn-neutral btn-small relative flex h-9 w-9 items-center justify-center whitespace-nowrap rounded-lg"
onClick={clickHandler}
>
<div className="flex w-full items-center justify-center gap-2">
<Download size={16} />
</div>
</button>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={5}>
{localize('com_nav_export_conversation')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
<button
onClick={clickHandler}
className="group m-1.5 flex w-full cursor-pointer items-center gap-2 rounded p-2.5 text-sm hover:bg-gray-200 focus-visible:bg-gray-200 focus-visible:outline-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-600 dark:focus-visible:bg-gray-600"
>
<Upload size={16} /> {localize('com_nav_export')}
</button>
{showExports && (
<ExportModal open={showExports} onOpenChange={setShowExports} conversation={conversation} />
<ExportModal open={showExports} onOpenChange={onOpenChange} conversation={conversation} />
)}
</>
);

View File

@@ -3,7 +3,7 @@ import { Constants } from 'librechat-data-provider';
import { useGetStartupConfig } from 'librechat-data-provider/react-query';
import { useLocalize } from '~/hooks';
export default function Footer() {
export default function Footer({ className }: { className?: string }) {
const { data: config } = useGetStartupConfig();
const localize = useLocalize();
@@ -52,7 +52,12 @@ export default function Footer() {
);
return (
<div className="relative flex items-center justify-center gap-2 px-2 py-2 text-xs text-gray-600 dark:text-gray-300 md:px-[60px]">
<div
className={
className ||
'relative flex items-center justify-center gap-2 px-2 py-2 text-xs text-gray-600 dark:text-gray-300 md:px-[60px]'
}
>
{footerElements.map((contentRender, index) => {
const isLastElement = index === footerElements.length - 1;
return (

View File

@@ -6,6 +6,7 @@ import type { ContextType } from '~/common';
import { EndpointsMenu, ModelSpecsMenu, PresetsMenu, HeaderNewChat } from './Menus';
import HeaderOptions from './Input/HeaderOptions';
import ExportButton from './ExportButton';
import ExportAndShareMenu from './ExportAndShareMenu';
const defaultInterface = getConfigDefaults().interface;
@@ -28,7 +29,7 @@ export default function Header() {
{<HeaderOptions interfaceConfig={interfaceConfig} />}
{interfaceConfig.presets && <PresetsMenu />}
</div>
<ExportButton />
<ExportAndShareMenu />
</div>
{/* Empty div for spacing */}
<div />

View File

@@ -3,8 +3,8 @@ import { useForm } from 'react-hook-form';
import { memo, useCallback, useRef, useMemo } from 'react';
import {
supportsFiles,
EModelEndpoint,
mergeFileConfig,
isAssistantsEndpoint,
fileConfig as defaultFileConfig,
} from 'librechat-data-provider';
import { useChatContext, useAssistantsMapContext } from '~/Providers';
@@ -74,8 +74,9 @@ const ChatForm = ({ index = 0 }) => {
const endpointFileConfig = fileConfig.endpoints[endpoint ?? ''];
const invalidAssistant = useMemo(
() =>
conversation?.endpoint === EModelEndpoint.assistants &&
(!conversation?.assistant_id || !assistantMap?.[conversation?.assistant_id ?? '']),
isAssistantsEndpoint(conversation?.endpoint) &&
(!conversation?.assistant_id ||
!assistantMap?.[conversation?.endpoint ?? '']?.[conversation?.assistant_id ?? '']),
[conversation?.assistant_id, conversation?.endpoint, assistantMap],
);
const disableInputs = useMemo(

View File

@@ -2,6 +2,7 @@ import type { TFile } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import FileIcon from '~/components/svg/Files/FileIcon';
import ProgressCircle from './ProgressCircle';
import SourceIcon from './SourceIcon';
import { useProgress } from '~/hooks';
import { cn } from '~/utils';
@@ -20,8 +21,7 @@ const FilePreview = ({
}) => {
const radius = 55; // Radius of the SVG circle
const circumference = 2 * Math.PI * radius;
const progress = useProgress(file?.['progress'] ?? 1, 0.001, file?.size ?? 1);
console.log(progress);
const progress = useProgress(file?.['progress'] ?? 1, 0.001, (file as ExtendedFile)?.size ?? 1);
// Calculate the offset based on the loading progress
const offset = circumference - progress * circumference;
@@ -32,6 +32,7 @@ const FilePreview = ({
return (
<div className={cn('h-10 w-10 shrink-0 overflow-hidden rounded-md', className)}>
<FileIcon file={file} fileType={fileType} />
<SourceIcon source={file?.source} />
{progress < 1 && (
<ProgressCircle
circumference={circumference}

View File

@@ -1,4 +1,5 @@
import { useEffect } from 'react';
import { EToolResources } from 'librechat-data-provider';
import type { ExtendedFile } from '~/common';
import { useDeleteFilesMutation } from '~/data-provider';
import { useFileDeletion } from '~/hooks/Files';
@@ -10,6 +11,7 @@ export default function FileRow({
setFiles,
setFilesLoading,
assistant_id,
tool_resource,
fileFilter,
Wrapper,
}: {
@@ -18,6 +20,7 @@ export default function FileRow({
setFilesLoading: React.Dispatch<React.SetStateAction<boolean>>;
fileFilter?: (file: ExtendedFile) => boolean;
assistant_id?: string;
tool_resource?: EToolResources;
Wrapper?: React.FC<{ children: React.ReactNode }>;
}) {
const files = Array.from(_files.values()).filter((file) =>
@@ -25,7 +28,8 @@ export default function FileRow({
);
const { mutateAsync } = useDeleteFilesMutation({
onMutate: async () => console.log('Deleting files: assistant_id', assistant_id),
onMutate: async () =>
console.log('Deleting files: assistant_id, tool_resource', assistant_id, tool_resource),
onSuccess: () => {
console.log('Files deleted');
},
@@ -34,7 +38,7 @@ export default function FileRow({
},
});
const { deleteFile } = useFileDeletion({ mutateAsync, assistant_id });
const { deleteFile } = useFileDeletion({ mutateAsync, assistant_id, tool_resource });
useEffect(() => {
if (!files) {
@@ -82,6 +86,7 @@ export default function FileRow({
url={file.preview}
onDelete={handleDelete}
progress={file.progress}
source={file.source}
/>
);
}

View File

@@ -12,16 +12,9 @@ export default function Files({ open, onOpenChange }) {
const { data: files = [] } = useGetFiles<TFile[]>({
select: (files) =>
files.map((file) => {
if (file.source === FileSources.local || file.source === FileSources.openai) {
file.context = file.context ?? FileContext.unknown;
return file;
} else {
return {
...file,
context: file.context ?? FileContext.unknown,
source: FileSources.local,
};
}
file.context = file.context ?? FileContext.unknown;
file.filterSource = file.source === FileSources.firebase ? FileSources.local : file.source;
return file;
}),
});

View File

@@ -1,3 +1,4 @@
import { FileSources } from 'librechat-data-provider';
import ImagePreview from './ImagePreview';
import RemoveFile from './RemoveFile';
@@ -6,16 +7,18 @@ const Image = ({
url,
onDelete,
progress = 1,
source = FileSources.local,
}: {
imageBase64?: string;
url?: string;
onDelete: () => void;
progress: number; // between 0 and 1
source?: FileSources;
}) => {
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">
<ImagePreview imageBase64={imageBase64} url={url} progress={progress} />
<ImagePreview source={source} imageBase64={imageBase64} url={url} progress={progress} />
</div>
<RemoveFile onRemove={onDelete} />
</div>

View File

@@ -1,4 +1,6 @@
import { FileSources } from 'librechat-data-provider';
import ProgressCircle from './ProgressCircle';
import SourceIcon from './SourceIcon';
import { cn } from '~/utils';
type styleProps = {
@@ -13,11 +15,13 @@ const ImagePreview = ({
url,
progress = 1,
className = '',
source,
}: {
imageBase64?: string;
url?: string;
progress?: number; // between 0 and 1
className?: string;
source?: FileSources;
}) => {
let style: styleProps = {
backgroundSize: 'cover',
@@ -65,6 +69,7 @@ const ImagePreview = ({
circleCSSProperties={circleCSSProperties}
/>
)}
<SourceIcon source={source} />
</div>
);
};

View File

@@ -0,0 +1,45 @@
import { EModelEndpoint, FileSources } from 'librechat-data-provider';
import { MinimalIcon } from '~/components/Endpoints';
import { cn } from '~/utils';
const sourceToEndpoint = {
[FileSources.openai]: EModelEndpoint.openAI,
[FileSources.azure]: EModelEndpoint.azureOpenAI,
};
const sourceToClassname = {
[FileSources.openai]: 'bg-black/65',
[FileSources.azure]: 'azure-bg-color opacity-85',
};
const defaultClassName =
'absolute right-0 bottom-0 rounded-full p-[0.15rem] text-gray-600 transition-colors';
export default function SourceIcon({
source,
className = defaultClassName,
}: {
source?: FileSources;
className?: string;
}) {
if (source === FileSources.local || source === FileSources.firebase) {
return null;
}
const endpoint = sourceToEndpoint[source ?? ''];
if (!endpoint) {
return null;
}
return (
<button type="button" className={cn(className, sourceToClassname[source ?? ''] ?? '')}>
<span className="flex items-center justify-center">
<MinimalIcon
endpoint={endpoint}
size={14}
isCreatedByUser={false}
iconClassName="h-3 w-3"
/>
</span>
</button>
);
}

View File

@@ -7,6 +7,7 @@ import ImagePreview from '~/components/Chat/Input/Files/ImagePreview';
import FilePreview from '~/components/Chat/Input/Files/FilePreview';
import { SortFilterHeader } from './SortFilterHeader';
import { OpenAIMinimalIcon } from '~/components/svg';
import { AzureMinimalIcon } from '~/components/svg';
import { Button, Checkbox } from '~/components/ui';
import { formatDate, getFileType } from '~/utils';
import useLocalize from '~/hooks/useLocalize';
@@ -71,10 +72,11 @@ export const columns: ColumnDef<TFile>[] = [
const file = row.original;
if (file.type?.startsWith('image')) {
return (
<div className="flex gap-2 ">
<div className="flex gap-2">
<ImagePreview
url={file.filepath}
className="h-10 w-10 shrink-0 overflow-hidden rounded-md"
className="relative h-10 w-10 shrink-0 overflow-hidden rounded-md"
source={file?.source}
/>
<span className="self-center truncate ">{file.filename}</span>
</div>
@@ -84,7 +86,7 @@ export const columns: ColumnDef<TFile>[] = [
const fileType = getFileType(file.type);
return (
<div className="flex gap-2">
{fileType && <FilePreview fileType={fileType} />}
{fileType && <FilePreview fileType={fileType} className="relative" file={file} />}
<span className="self-center truncate">{file.filename}</span>
</div>
);
@@ -108,7 +110,7 @@ export const columns: ColumnDef<TFile>[] = [
cell: ({ row }) => formatDate(row.original.updatedAt),
},
{
accessorKey: 'source',
accessorKey: 'filterSource',
header: ({ column }) => {
const localize = useLocalize();
return (
@@ -117,10 +119,14 @@ export const columns: ColumnDef<TFile>[] = [
title={localize('com_ui_storage')}
filters={{
Storage: Object.values(FileSources).filter(
(value) => value === FileSources.local || value === FileSources.openai,
(value) =>
value === FileSources.local ||
value === FileSources.openai ||
value === FileSources.azure,
),
}}
valueMap={{
[FileSources.azure]: 'Azure',
[FileSources.openai]: 'OpenAI',
[FileSources.local]: 'com_ui_host',
}}
@@ -137,6 +143,13 @@ export const columns: ColumnDef<TFile>[] = [
{'OpenAI'}
</div>
);
} else if (source === FileSources.azure) {
return (
<div className="flex flex-wrap items-center gap-2">
<AzureMinimalIcon className="icon-sm text-cyan-700" />
{'Azure'}
</div>
);
}
return (
<div className="flex flex-wrap items-center gap-2">

View File

@@ -48,7 +48,12 @@ const contextMap = {
[FileContext.bytes]: 'com_ui_size',
};
type Style = { width?: number | string; maxWidth?: number | string; minWidth?: number | string };
type Style = {
width?: number | string;
maxWidth?: number | string;
minWidth?: number | string;
zIndex?: number;
};
export default function DataTable<TData, TValue>({ columns, data }: DataTableProps<TData, TValue>) {
const localize = useLocalize();
@@ -142,7 +147,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header, index) => {
const style: Style = { maxWidth: '32px', minWidth: '125px' };
const style: Style = { maxWidth: '32px', minWidth: '125px', zIndex: 50 };
if (header.id === 'filename') {
style.maxWidth = '50%';
style.width = '50%';
@@ -220,7 +225,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
)}
</div>
<Button
className="dark:border-gray-500 dark:hover:bg-gray-600"
className="dark:border-gray-500 dark:hover:bg-gray-600 select-none"
variant="outline"
size="sm"
onClick={() => table.previousPage()}
@@ -229,7 +234,7 @@ export default function DataTable<TData, TValue>({ columns, data }: DataTablePro
{localize('com_ui_prev')}
</Button>
<Button
className="dark:border-gray-500 dark:hover:bg-gray-600"
className="dark:border-gray-500 dark:hover:bg-gray-600 select-none"
variant="outline"
size="sm"
onClick={() => table.nextPage()}

View File

@@ -17,7 +17,9 @@ export default function Mention({
}) {
const localize = useLocalize();
const assistantMap = useAssistantsMapContext();
const { options, modelsConfig, assistants, onSelectMention } = useMentions({ assistantMap });
const { options, modelsConfig, assistantListMap, onSelectMention } = useMentions({
assistantMap,
});
const [activeIndex, setActiveIndex] = useState(0);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -47,7 +49,12 @@ export default function Mention({
if (mention.type === 'endpoint' && mention.value === EModelEndpoint.assistants) {
setSearchValue('');
setInputOptions(assistants);
setInputOptions(assistantListMap[EModelEndpoint.assistants]);
setActiveIndex(0);
inputRef.current?.focus();
} else if (mention.type === 'endpoint' && mention.value === EModelEndpoint.azureAssistants) {
setSearchValue('');
setInputOptions(assistantListMap[EModelEndpoint.azureAssistants]);
setActiveIndex(0);
inputRef.current?.focus();
} else if (mention.type === 'endpoint') {
@@ -99,7 +106,7 @@ export default function Mention({
} else if (e.key === 'ArrowUp') {
setActiveIndex((prevIndex) => (prevIndex - 1 + matches.length) % matches.length);
} else if (e.key === 'Enter' || e.key === 'Tab') {
const mentionOption = matches[0] as MentionOption | undefined;
const mentionOption = matches[activeIndex] as MentionOption | undefined;
if (mentionOption?.type === 'endpoint') {
e.preventDefault();
} else if (e.key === 'Enter') {

View File

@@ -1,4 +1,4 @@
import { EModelEndpoint } from 'librechat-data-provider';
import { EModelEndpoint, isAssistantsEndpoint } from 'librechat-data-provider';
import { useGetEndpointsQuery, useGetStartupConfig } from 'librechat-data-provider/react-query';
import type { ReactNode } from 'react';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
@@ -30,7 +30,8 @@ export default function Landing({ Header }: { Header?: ReactNode }) {
const iconURL = conversation?.iconURL;
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
const assistant = endpoint === EModelEndpoint.assistants && assistantMap?.[assistant_id ?? ''];
const isAssistant = isAssistantsEndpoint(endpoint);
const assistant = isAssistant && assistantMap?.[endpoint]?.[assistant_id ?? ''];
const assistantName = (assistant && assistant?.name) || '';
const assistantDesc = (assistant && assistant?.description) || '';
const avatar = (assistant && (assistant?.metadata?.avatar as string)) || '';
@@ -76,8 +77,8 @@ export default function Landing({ Header }: { Header?: ReactNode }) {
</div> */}
</div>
) : (
<div className="mb-5 text-2xl font-medium dark:text-white">
{endpoint === EModelEndpoint.assistants
<div className="mb-5 max-w-[75vh] px-12 text-center text-lg font-medium dark:text-white md:px-0 md:text-2xl">
{isAssistant
? conversation?.greeting ?? localize('com_nav_welcome_assistant')
: conversation?.greeting ?? localize('com_nav_welcome_message')}
</div>

View File

@@ -15,6 +15,24 @@ import {
import UnknownIcon from './UnknownIcon';
import { cn } from '~/utils';
const AssistantAvatar = ({ className = '', assistantName, avatar, size }: IconMapProps) => {
if (assistantName && avatar) {
return (
<img
src={avatar}
className="bg-token-surface-secondary dark:bg-token-surface-tertiary h-full w-full rounded-full object-cover"
alt={assistantName}
width="80"
height="80"
/>
);
} else if (assistantName) {
return <AssistantIcon className={cn('text-token-secondary', className)} size={size} />;
}
return <Sparkles className={cn(assistantName === '' ? 'icon-2xl' : '', className)} />;
};
export const icons = {
[EModelEndpoint.azureOpenAI]: AzureMinimalIcon,
[EModelEndpoint.openAI]: GPTIcon,
@@ -24,22 +42,7 @@ export const icons = {
[EModelEndpoint.google]: GoogleMinimalIcon,
[EModelEndpoint.bingAI]: BingAIMinimalIcon,
[EModelEndpoint.custom]: CustomMinimalIcon,
[EModelEndpoint.assistants]: ({ className = '', assistantName, avatar, size }: IconMapProps) => {
if (assistantName && avatar) {
return (
<img
src={avatar}
className="bg-token-surface-secondary dark:bg-token-surface-tertiary h-full w-full rounded-full object-cover"
alt={assistantName}
width="80"
height="80"
/>
);
} else if (assistantName) {
return <AssistantIcon className={cn('text-token-secondary', className)} size={size} />;
}
return <Sparkles className={cn(assistantName === '' ? 'icon-2xl' : '', className)} />;
},
[EModelEndpoint.assistants]: AssistantAvatar,
[EModelEndpoint.azureAssistants]: AssistantAvatar,
unknown: UnknownIcon,
};

View File

@@ -1,5 +1,5 @@
import { Content, Portal, Root } from '@radix-ui/react-popover';
import { alternateName, EModelEndpoint } from 'librechat-data-provider';
import { alternateName, isAssistantsEndpoint } from 'librechat-data-provider';
import { useGetEndpointsQuery } from 'librechat-data-provider/react-query';
import type { FC } from 'react';
import { useChatContext, useAssistantsMapContext } from '~/Providers';
@@ -16,7 +16,8 @@ const EndpointsMenu: FC = () => {
const { endpoint = '', assistant_id = null } = conversation ?? {};
const assistantMap = useAssistantsMapContext();
const assistant = endpoint === EModelEndpoint.assistants && assistantMap?.[assistant_id ?? ''];
const assistant =
isAssistantsEndpoint(endpoint) && assistantMap?.[endpoint ?? '']?.[assistant_id ?? ''];
const assistantName = (assistant && assistant?.name) || 'Assistant';
if (!endpoint) {

View File

@@ -2,14 +2,19 @@ import { Trigger } from '@radix-ui/react-popover';
import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
import SpecIcon from './SpecIcon';
import { cn } from '~/utils';
export default function MenuButton({
selected,
className = '',
textClassName = '',
primaryText = '',
secondaryText = '',
endpointsConfig,
}: {
selected?: TModelSpec;
className?: string;
textClassName?: string;
primaryText?: string;
secondaryText?: string;
endpointsConfig: TEndpointsConfig;
@@ -18,13 +23,16 @@ export default function MenuButton({
return (
<Trigger asChild>
<div
className="group flex cursor-pointer items-center gap-1 rounded-xl px-3 py-2 text-lg font-medium hover:bg-gray-50 radix-state-open:bg-gray-50 dark:hover:bg-gray-700 dark:radix-state-open:bg-gray-700"
className={cn(
'group flex cursor-pointer items-center gap-1 rounded-xl px-3 py-2 text-lg font-medium hover:bg-gray-50 radix-state-open:bg-gray-50 dark:hover:bg-gray-700 dark:radix-state-open:bg-gray-700',
className,
)}
// type="button"
>
{selected && selected.showIconInHeader && (
<SpecIcon currentSpec={selected} endpointsConfig={endpointsConfig} />
)}
<div>
<div className={textClassName}>
{!selected ? localize('com_ui_none_selected') : primaryText}{' '}
{!!secondaryText && <span className="text-token-text-secondary">{secondaryText}</span>}
</div>

View File

@@ -64,7 +64,7 @@ const MenuItem: FC<MenuItemProps> = ({
<div>
<div className="flex items-center gap-2">
{showIconInMenu && <SpecIcon currentSpec={spec} endpointsConfig={endpointsConfig} />}
<div>
<div className="break-all">
{title}
<div className="text-token-text-tertiary">{description}</div>
</div>

View File

@@ -70,8 +70,10 @@ export default function ModelSpecsMenu({ modelSpecs }: { modelSpecs: TModelSpec[
return (
<Root>
<MenuButton
primaryText={selected?.label ?? ''}
selected={selected}
className="min-h-11"
textClassName="block items-center justify-start text-xs md:text-base whitespace-nowrap max-w-64 overflow-hidden shrink-0 text-ellipsis"
primaryText={selected?.label ?? ''}
endpointsConfig={endpointsConfig}
/>
<Portal>

View File

@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useRecoilValue } from 'recoil';
import ProgressCircle from './ProgressCircle';
import CancelledIcon from './CancelledIcon';
import ProgressText from './ProgressText';
import FinishedIcon from './FinishedIcon';
import MarkdownLite from './MarkdownLite';
@@ -11,10 +12,12 @@ export default function CodeAnalyze({
initialProgress = 0.1,
code,
outputs = [],
isSubmitting,
}: {
initialProgress: number;
code: string;
outputs: Record<string, unknown>[];
isSubmitting: boolean;
}) {
const showCodeDefault = useRecoilValue(store.showCode);
const [showCode, setShowCode] = useState(showCodeDefault);
@@ -35,7 +38,13 @@ export default function CodeAnalyze({
<div className="my-2.5 flex items-center gap-2.5">
<div className="relative h-5 w-5 shrink-0">
{progress < 1 ? (
<CodeInProgress offset={offset} circumference={circumference} radius={radius} />
<CodeInProgress
offset={offset}
radius={radius}
progress={progress}
isSubmitting={isSubmitting}
circumference={circumference}
/>
) : (
<FinishedIcon />
)}
@@ -74,18 +83,25 @@ const CodeInProgress = ({
offset,
circumference,
radius,
isSubmitting,
progress,
}: {
progress: number;
offset: number;
circumference: number;
radius: number;
isSubmitting: boolean;
}) => {
if (progress < 1 && !isSubmitting) {
return <CancelledIcon />;
}
return (
<div
className="absolute left-0 top-0 flex h-full w-full items-center justify-center rounded-full bg-transparent text-white"
style={{ opacity: 1, transform: 'none' }}
data-projection-id="77"
>
<div className='absolute right-[1.5px] bottom-[1.5px]'>
<div className="absolute bottom-[1.5px] right-[1.5px]">
<svg
xmlns="http://www.w3.org/2000/svg"
xmlnsXlink="http://www.w3.org/1999/xlink"

View File

@@ -79,11 +79,13 @@ export default function Part({
initialProgress={toolCall.progress ?? 0.1}
code={code_interpreter.input}
outputs={code_interpreter.outputs ?? []}
isSubmitting={isSubmitting}
/>
);
} else if (
part.type === ContentTypes.TOOL_CALL &&
part[ContentTypes.TOOL_CALL].type === ToolCallTypes.RETRIEVAL
(part[ContentTypes.TOOL_CALL].type === ToolCallTypes.RETRIEVAL ||
part[ContentTypes.TOOL_CALL].type === ToolCallTypes.FILE_SEARCH)
) {
const toolCall = part[ContentTypes.TOOL_CALL];
return <RetrievalCall initialProgress={toolCall.progress ?? 0.1} isSubmitting={isSubmitting} />;

View File

@@ -1,5 +1,4 @@
import { useState } from 'react';
import { EModelEndpoint } from 'librechat-data-provider';
import type { TConversation, TMessage } from 'librechat-data-provider';
import { Clipboard, CheckMark, EditIcon, RegenerateIcon, ContinueIcon } from '~/components/svg';
import { useGenerationsByLatest, useLocalize } from '~/hooks';
@@ -35,14 +34,19 @@ export default function HoverButtons({
const { endpoint: _endpoint, endpointType } = conversation ?? {};
const endpoint = endpointType ?? _endpoint;
const [isCopied, setIsCopied] = useState(false);
const { hideEditButton, regenerateEnabled, continueSupported, forkingSupported } =
useGenerationsByLatest({
isEditing,
isSubmitting,
message,
endpoint: endpoint ?? '',
latestMessage,
});
const {
hideEditButton,
regenerateEnabled,
continueSupported,
forkingSupported,
isEditableEndpoint,
} = useGenerationsByLatest({
isEditing,
isSubmitting,
message,
endpoint: endpoint ?? '',
latestMessage,
});
if (!conversation) {
return null;
}
@@ -58,7 +62,7 @@ export default function HoverButtons({
return (
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-400 lg:justify-start">
{endpoint !== EModelEndpoint.assistants && (
{isEditableEndpoint && (
<button
className={cn(
'hover-button rounded-md p-1 text-gray-400 hover:text-gray-900 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible',
@@ -87,7 +91,7 @@ export default function HoverButtons({
isCopied ? localize('com_ui_copied_to_clipboard') : localize('com_ui_copy_to_clipboard')
}
>
{isCopied ? <CheckMark /> : <Clipboard />}
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
</button>
{regenerateEnabled ? (
<button

View File

@@ -0,0 +1,29 @@
import { useState } from 'react';
import type { TMessage } from 'librechat-data-provider';
import { useLocalize, useCopyToClipboard } from '~/hooks';
import { Clipboard, CheckMark } from '~/components/svg';
type THoverButtons = {
message: TMessage;
};
export default function MinimalHoverButtons({ message }: THoverButtons) {
const localize = useLocalize();
const [isCopied, setIsCopied] = useState(false);
const copyToClipboard = useCopyToClipboard({ text: message.text, content: message.content });
return (
<div className="visible mt-0 flex justify-center gap-1 self-end text-gray-400 lg:justify-start">
<button
className="ml-0 flex items-center gap-1.5 rounded-md p-1 text-xs hover:text-gray-900 dark:text-gray-400/70 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:group-hover:visible md:group-[.final-completion]:visible"
onClick={() => copyToClipboard(setIsCopied)}
type="button"
title={
isCopied ? localize('com_ui_copied_to_clipboard') : localize('com_ui_copy_to_clipboard')
}
>
{isCopied ? <CheckMark className="h-[18px] w-[18px]" /> : <Clipboard />}
</button>
</div>
);
}

View File

@@ -1,6 +1,7 @@
import { useRecoilValue } from 'recoil';
import { useAuthContext, useLocalize } from '~/hooks';
import type { TMessageProps } from '~/common';
import MinimalHoverButtons from '~/components/Chat/Messages/MinimalHoverButtons';
import Icon from '~/components/Chat/Messages/MessageIcon';
import SearchContent from './Content/SearchContent';
import SearchButtons from './SearchButtons';
@@ -50,6 +51,7 @@ export default function Message({ message }: Pick<TMessageProps, 'message'>) {
</div>
</div>
<SubRow classes="text-xs">
<MinimalHoverButtons message={message} />
<SearchButtons message={message} />
</SubRow>
</div>

View File

@@ -1,12 +1,10 @@
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
import { useParams, useNavigate } from 'react-router-dom';
import type { MouseEvent, FocusEvent, KeyboardEvent } from 'react';
import { useParams } from 'react-router-dom';
import { useArchiveConversationMutation } from '~/data-provider';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
import { useConversations, useLocalize, useNewConvo } from '~/hooks';
import { useToastContext } from '~/Providers';
import { useArchiveConversationMutation } from '~/data-provider';
import { NotificationSeverity } from '~/common';
import { useToastContext } from '~/Providers';
type ArchiveButtonProps = {
conversationId: string;
@@ -23,8 +21,9 @@ export default function ArchiveButton({
className = '',
}: ArchiveButtonProps) {
const localize = useLocalize();
const { newConversation } = useNewConvo();
const navigate = useNavigate();
const { showToast } = useToastContext();
const { newConversation } = useNewConvo();
const { refreshConversations } = useConversations();
const { conversationId: currentConvoId } = useParams();
@@ -42,8 +41,9 @@ export default function ArchiveButton({
{ conversationId, isArchived: shouldArchive },
{
onSuccess: () => {
if (currentConvoId === conversationId) {
if (currentConvoId === conversationId || currentConvoId === 'new') {
newConversation();
navigate('/c/new', { replace: true });
}
refreshConversations();
retainView();

View File

@@ -9,13 +9,14 @@ import { useConversations, useNavigateToConvo } from '~/hooks';
import { NotificationSeverity } from '~/common';
import { ArchiveIcon } from '~/components/svg';
import { useToastContext } from '~/Providers';
import EditMenuButton from './EditMenuButton';
import DropDownMenu from './DropDownMenu';
import ArchiveButton from './ArchiveButton';
import DeleteButton from './DeleteButton';
import RenameButton from './RenameButton';
import HoverToggle from './HoverToggle';
import { cn } from '~/utils';
import store from '~/store';
import ShareButton from './ShareButton';
type KeyEvent = KeyboardEvent<HTMLInputElement>;
@@ -124,7 +125,15 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
isPopoverActive={isPopoverActive}
setIsPopoverActive={setIsPopoverActive}
>
<EditMenuButton>
<DropDownMenu>
<ShareButton
conversationId={conversationId}
title={title}
appendLabel={true}
className="mb-[3.5px]"
setPopoverActive={setIsPopoverActive}
/>
<RenameButton
renaming={renaming}
onRename={onRename}
@@ -140,7 +149,7 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
appendLabel={true}
className="group m-1.5 mt-[3.5px] flex w-full cursor-pointer items-center gap-2 rounded p-2.5 text-sm hover:bg-gray-200 focus-visible:bg-gray-200 focus-visible:outline-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-600 dark:focus-visible:bg-gray-600"
/>
</EditMenuButton>
</DropDownMenu>
<ArchiveButton
className="z-50 hover:text-black dark:hover:text-white"
conversationId={conversationId}
@@ -156,7 +165,7 @@ export default function Conversation({ conversation, retainView, toggleNav, isLa
onClick={clickHandler}
className={cn(
isActiveConvo || isPopoverActive
? 'group relative mt-2 flex cursor-pointer items-center gap-2 break-all rounded-lg rounded-lg bg-gray-200 px-2 py-2 active:opacity-50 dark:bg-gray-700'
? 'group relative mt-2 flex cursor-pointer items-center gap-2 break-all rounded-lg bg-gray-200 px-2 py-2 active:opacity-50 dark:bg-gray-700'
: 'group relative mt-2 flex grow cursor-pointer items-center gap-2 overflow-hidden whitespace-nowrap break-all rounded-lg rounded-lg px-2 py-2 hover:bg-gray-200 active:opacity-50 dark:hover:bg-gray-700',
!isActiveConvo && !renaming ? 'peer-hover:bg-gray-200 dark:peer-hover:bg-gray-800' : '',
)}

View File

@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { QueryKeys } from 'librechat-data-provider';
import { useQueryClient } from '@tanstack/react-query';
import { useParams, useNavigate } from 'react-router-dom';
import type { TMessage } from 'librechat-data-provider';
import { useDeleteConversationMutation } from '~/data-provider';
import {
@@ -26,13 +26,15 @@ export default function DeleteButton({
className = '',
}) {
const localize = useLocalize();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { newConversation } = useNewConvo();
const { conversationId: currentConvoId } = useParams();
const deleteConvoMutation = useDeleteConversationMutation({
onSuccess: () => {
if (currentConvoId === conversationId) {
if (currentConvoId === conversationId || currentConvoId === 'new') {
newConversation();
navigate('/c/new', { replace: true });
}
retainView();
},

View File

@@ -1,4 +1,4 @@
import type { FC } from 'react';
import { cloneElement, type FC } from 'react';
import { DotsIcon } from '~/components/svg';
import { Content, Portal, Root, Trigger } from '@radix-ui/react-popover';
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '~/components/ui';
@@ -6,15 +6,23 @@ import { useToggle } from './ToggleContext';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
type EditMenuButtonProps = {
type DropDownMenuProps = {
children: React.ReactNode;
icon?: React.ReactElement;
tooltip?: string;
className?: string;
};
const EditMenuButton: FC<EditMenuButtonProps> = ({ children }: EditMenuButtonProps) => {
const DropDownMenu: FC<DropDownMenuProps> = ({
children,
icon = <DotsIcon />,
tooltip = 'More',
className,
}: DropDownMenuProps) => {
const localize = useLocalize();
const { setPopoverActive } = useToggle();
const { isPopoverActive, setPopoverActive } = useToggle();
return (
<Root onOpenChange={(open) => setPopoverActive(open)}>
<Root open={isPopoverActive} onOpenChange={(open) => setPopoverActive(open)}>
<Trigger asChild>
<div
className={cn(
@@ -29,12 +37,15 @@ const EditMenuButton: FC<EditMenuButtonProps> = ({ children }: EditMenuButtonPro
<TooltipProvider delayDuration={500}>
<Tooltip>
<TooltipTrigger asChild>
<button type="button" className="">
<DotsIcon className="h-[18px] w-[18px] flex-shrink-0 text-gray-500 hover:text-gray-400 dark:text-gray-300 dark:hover:text-gray-400" />
<button type="button" className={className}>
{cloneElement(icon, {
className:
'h-[18px] w-[18px] flex-shrink-0 text-gray-500 hover:text-gray-400 dark:text-gray-300 dark:hover:text-gray-400',
})}
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={0}>
{localize('com_ui_more_options')}
{tooltip}
</TooltipContent>
</Tooltip>
</TooltipProvider>
@@ -57,4 +68,4 @@ const EditMenuButton: FC<EditMenuButtonProps> = ({ children }: EditMenuButtonPro
);
};
export default EditMenuButton;
export default DropDownMenu;

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React from 'react';
import { ToggleContext } from './ToggleContext';
import { cn } from '~/utils';
@@ -15,7 +15,7 @@ const HoverToggle = ({
}) => {
const setPopoverActive = (value: boolean) => setIsPopoverActive(value);
return (
<ToggleContext.Provider value={{ setPopoverActive }}>
<ToggleContext.Provider value={{ isPopoverActive, setPopoverActive }}>
<div
className={cn(
'peer absolute bottom-0 right-0 top-0 items-center gap-1.5 rounded-r-lg from-gray-500 from-gray-900 pl-2 pr-2 dark:text-white',

View File

@@ -0,0 +1,113 @@
import { useState } from 'react';
import {
Dialog,
Tooltip,
DialogTrigger,
TooltipContent,
TooltipTrigger,
TooltipProvider,
} from '~/components/ui';
import { Share2Icon } from 'lucide-react';
import type { TSharedLink } from 'librechat-data-provider';
import DialogTemplate from '~/components/ui/DialogTemplate';
import SharedLinkButton from './SharedLinkButton';
import ShareDialog from './ShareDialog';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function ShareButton({
conversationId,
title,
className,
appendLabel = false,
setPopoverActive,
}: {
conversationId: string;
title: string;
className?: string;
appendLabel?: boolean;
setPopoverActive: (isActive: boolean) => void;
}) {
const localize = useLocalize();
const [share, setShare] = useState<TSharedLink | null>(null);
const [open, setOpen] = useState(false);
const [isUpdated, setIsUpdated] = useState(false);
const classProp: { className?: string } = {
className: 'p-1 hover:text-black dark:hover:text-white',
};
if (className) {
classProp.className = className;
}
const renderShareButton = () => {
if (appendLabel) {
return (
<>
<Share2Icon className="h-4 w-4" /> {localize('com_ui_share')}
</>
);
}
return (
<TooltipProvider delayDuration={250}>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Share2Icon />
</span>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={0}>
{localize('com_ui_share')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
const buttons = share && (
<SharedLinkButton
share={share}
conversationId={conversationId}
setShare={setShare}
isUpdated={isUpdated}
setIsUpdated={setIsUpdated}
/>
);
const onOpenChange = (open: boolean) => {
setPopoverActive(open);
setOpen(open);
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogTrigger asChild>
<button
className={cn(
'group m-1.5 flex w-full cursor-pointer items-center gap-2 rounded p-2.5 text-sm hover:bg-gray-200 focus-visible:bg-gray-200 focus-visible:outline-0 radix-disabled:pointer-events-none radix-disabled:opacity-50 dark:hover:bg-gray-600 dark:focus-visible:bg-gray-600',
className,
)}
>
{renderShareButton()}
</button>
</DialogTrigger>
<DialogTemplate
buttons={buttons}
showCloseButton={true}
showCancelButton={false}
title={localize('com_ui_share_link_to_chat')}
className="max-w-[550px]"
main={
<>
<ShareDialog
setDialogOpen={setOpen}
conversationId={conversationId}
title={title}
share={share}
setShare={setShare}
isUpdated={isUpdated}
/>
</>
}
/>
</Dialog>
);
}

View File

@@ -0,0 +1,80 @@
import { useLocalize } from '~/hooks';
import { useCreateSharedLinkMutation } from '~/data-provider';
import { useEffect, useState } from 'react';
import { TSharedLink } from 'librechat-data-provider';
import { useToastContext } from '~/Providers';
import { NotificationSeverity } from '~/common';
import { Spinner } from '~/components/svg';
export default function ShareDialog({
conversationId,
title,
share,
setShare,
setDialogOpen,
isUpdated,
}: {
conversationId: string;
title: string;
share: TSharedLink | null;
setShare: (share: TSharedLink | null) => void;
setDialogOpen: (open: boolean) => void;
isUpdated: boolean;
}) {
const localize = useLocalize();
const { showToast } = useToastContext();
const { mutate, isLoading } = useCreateSharedLinkMutation();
const [isNewSharedLink, setIsNewSharedLink] = useState(false);
useEffect(() => {
if (isLoading || share) {
return;
}
const data = {
conversationId,
title,
isAnonymous: true,
};
mutate(data, {
onSuccess: (result) => {
setShare(result);
setIsNewSharedLink(!result.isPublic);
},
onError: () => {
showToast({
message: localize('com_ui_share_error'),
severity: NotificationSeverity.ERROR,
showIcon: true,
});
setDialogOpen(false);
},
});
// mutation.mutate should only be called once
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div>
<div className="h-full py-2 text-gray-400 dark:text-gray-200">
{(() => {
if (isLoading) {
return <Spinner className="m-auto h-14 animate-spin" />;
}
if (isUpdated) {
return isNewSharedLink
? localize('com_ui_share_created_message')
: localize('com_ui_share_updated_message');
}
return share?.isPublic
? localize('com_ui_share_update_message')
: localize('com_ui_share_create_message');
})()}
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { useState } from 'react';
import copy from 'copy-to-clipboard';
import { Copy, Link } from 'lucide-react';
import { useUpdateSharedLinkMutation } from '~/data-provider';
import type { TSharedLink } from 'librechat-data-provider';
import { Spinner } from '~/components/svg';
import { Button } from '~/components/ui';
import { useLocalize } from '~/hooks';
export default function SharedLinkButton({
conversationId,
share,
setShare,
isUpdated,
setIsUpdated,
}: {
conversationId: string;
share: TSharedLink;
setShare: (share: TSharedLink) => void;
isUpdated: boolean;
setIsUpdated: (isUpdated: boolean) => void;
}) {
const localize = useLocalize();
const [isCopying, setIsCopying] = useState(false);
const { mutateAsync, isLoading } = useUpdateSharedLinkMutation();
const copyLink = () => {
if (!share) {
return;
}
setIsCopying(true);
const sharedLink =
window.location.protocol + '//' + window.location.host + '/share/' + share.shareId;
copy(sharedLink);
setTimeout(() => {
setIsCopying(false);
}, 1500);
};
const updateSharedLink = async () => {
if (!share) {
return;
}
const result = await mutateAsync({
shareId: share.shareId,
conversationId: conversationId,
isPublic: true,
isVisible: true,
isAnonymous: true,
});
if (result) {
setShare(result);
setIsUpdated(true);
copyLink();
}
};
const getHandler = () => {
if (isUpdated) {
return {
handler: () => {
copyLink();
},
label: (
<>
<Copy className="mr-2 h-4 w-4" />
{localize('com_ui_copy_link')}
</>
),
};
}
if (share?.isPublic) {
return {
handler: async () => {
await updateSharedLink();
},
label: (
<>
<Link className="mr-2 h-4 w-4" />
{localize('com_ui_update_link')}
</>
),
};
}
return {
handler: updateSharedLink,
label: (
<>
<Link className="mr-2 h-4 w-4" />
{localize('com_ui_create_link')}
</>
),
};
};
const handlers = getHandler();
return (
<Button
disabled={isLoading || isCopying}
onClick={() => {
handlers.handler();
}}
className="min-w-32 whitespace-nowrap bg-green-500 text-white hover:bg-green-600 dark:bg-green-600 dark:text-white dark:hover:bg-green-800"
>
{isCopying && (
<>
<Copy className="mr-2 h-4 w-4" />
{localize('com_ui_copied')}
</>
)}
{!isCopying && !isLoading && handlers.label}
{!isCopying && isLoading && <Spinner className="h-4 w-4" />}
</Button>
);
}

View File

@@ -3,6 +3,7 @@ import { createContext, useContext } from 'react';
const defaultFunction: (value: boolean) => void = () => ({});
export const ToggleContext = createContext({
setPopoverActive: defaultFunction,
isPopoverActive: false,
});
export const useToggle = () => useContext(ToggleContext);

View File

@@ -1,8 +1,13 @@
import { EModelEndpoint } from 'librechat-data-provider';
import type { Assistant, TConversation, TEndpointsConfig, TPreset } from 'librechat-data-provider';
import { isAssistantsEndpoint } from 'librechat-data-provider';
import type {
TAssistantsMap,
TConversation,
TEndpointsConfig,
TPreset,
} from 'librechat-data-provider';
import { getEndpointField, getIconKey, getIconEndpoint } from '~/utils';
import { icons } from '~/components/Chat/Menus/Endpoints/Icons';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import { getEndpointField, getIconKey, getIconEndpoint } from '~/utils';
export default function ConvoIcon({
conversation,
@@ -15,7 +20,7 @@ export default function ConvoIcon({
}: {
conversation: TConversation | TPreset | null;
endpointsConfig: TEndpointsConfig;
assistantMap: Record<string, Assistant>;
assistantMap: TAssistantsMap;
containerClassName?: string;
context?: 'message' | 'nav' | 'landing' | 'menu-item';
className?: string;
@@ -25,7 +30,7 @@ export default function ConvoIcon({
let endpoint = conversation?.endpoint;
endpoint = getIconEndpoint({ endpointsConfig, iconURL, endpoint });
const assistant =
endpoint === EModelEndpoint.assistants && assistantMap?.[conversation?.assistant_id ?? ''];
isAssistantsEndpoint(endpoint) && assistantMap?.[endpoint]?.[conversation?.assistant_id ?? ''];
const assistantName = (assistant && assistant?.name) || '';
const avatar = (assistant && (assistant?.metadata?.avatar as string)) || '';

View File

@@ -1,5 +1,10 @@
import { EModelEndpoint } from 'librechat-data-provider';
import type { Assistant, TConversation, TEndpointsConfig, TPreset } from 'librechat-data-provider';
import { isAssistantsEndpoint } from 'librechat-data-provider';
import type {
TConversation,
TEndpointsConfig,
TPreset,
TAssistantsMap,
} from 'librechat-data-provider';
import ConvoIconURL from '~/components/Endpoints/ConvoIconURL';
import MinimalIcon from '~/components/Endpoints/MinimalIcon';
import { getEndpointField, getIconEndpoint } from '~/utils';
@@ -15,7 +20,7 @@ export default function EndpointIcon({
endpointsConfig: TEndpointsConfig;
containerClassName?: string;
context?: 'message' | 'nav' | 'landing' | 'menu-item';
assistantMap?: Record<string, Assistant>;
assistantMap?: TAssistantsMap;
className?: string;
size?: number;
}) {
@@ -27,7 +32,7 @@ export default function EndpointIcon({
const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL');
const assistant =
endpoint === EModelEndpoint.assistants && assistantMap?.[conversation?.assistant_id ?? ''];
isAssistantsEndpoint(endpoint) && assistantMap?.[endpoint]?.[conversation?.assistant_id ?? ''];
const assistantAvatar = (assistant && (assistant?.metadata?.avatar as string)) || '';
const assistantName = (assistant && assistant?.name) || '';

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