Skip to content

Commit

Permalink
💬 feat: assistant conversation starter (#3699)
Browse files Browse the repository at this point in the history
* feat: initial UI convoStart

* fix: ConvoStarter UI

* fix: convoStarters bug

* feat: Add input field focus on conversation starters

* style: conversation starter UI update

* feat: apply fixes for starters

* style: update conversationStarters UI and fixed typo

* general UI update

* feat: Add onClick functionality to ConvoStarter component

* fix: quick fix test

* fix(AssistantSelect): remove object check

* fix: updateAssistant `conversation_starters` var

* chore: remove starter autofocus

* fix: no empty conversation starters, always show input, use Constants value for max count

* style: Update defaultTextPropsLabel styles, for a11y placeholder

* refactor: Update ConvoStarter component styles and class names for a11y and theme

* refactor: convostarter, move plus button to within persistent element

* fix: types

* chore: Update landing page assistant description styling with theming

* chore: assistant types

* refactor: documents routes

* refactor: optimize conversation starter mutations/queries

* refactor: Update listAllAssistants return type to Promise<Array<Assistant>>

* feat: edit existing starters

* feat(convo-starters): enhance ConvoStarter component and add animations

    - Update ConvoStarter component styling for better visual appeal
    - Implement fade-in animation for smoother appearance
    - Add hover effect with background color change
    - Improve text overflow handling with line-clamp and text-balance
    - Ensure responsive design for various screen sizes

* feat(assistant): add conversation starters to assistant builder

- Add localization strings for conversation starters
- Update mobile.css with shake animation for max starters reached
- Enhance user experience with tooltips and dynamic input handling

* refactor: select specific fields for assistant documents fetch

* refactor: remove endpoint query key, fetch all assistant docs for now, add conversation_starters to v1 methods

* refactor: add document filters based on endpoint config

* fix: starters not applied during creation

* refactor: update AssistantSelect component to handle undefined lastSelectedModels

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
  • Loading branch information
berry-13 and danny-avila authored Aug 31, 2024
1 parent 63b80c3 commit 79f9cd5
Show file tree
Hide file tree
Showing 58 changed files with 601 additions and 213 deletions.
17 changes: 12 additions & 5 deletions api/models/Assistant.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const Assistant = mongoose.model('assistant', assistantSchema);
* @param {string} searchParams.user - The user ID of the assistant's author.
* @param {Object} updateData - An object containing the properties to update.
* @param {mongoose.ClientSession} [session] - The transaction session to use (optional).
* @returns {Promise<Object>} The updated or newly created assistant document as a plain object.
* @returns {Promise<AssistantDocument>} The updated or newly created assistant document as a plain object.
*/
const updateAssistantDoc = async (searchParams, updateData, session = null) => {
const options = { new: true, upsert: true, session };
Expand All @@ -25,18 +25,25 @@ const updateAssistantDoc = async (searchParams, updateData, session = null) => {
* @param {Object} searchParams - The search parameters to find the assistant to update.
* @param {string} searchParams.assistant_id - The ID of the assistant to update.
* @param {string} searchParams.user - The user ID of the assistant's author.
* @returns {Promise<Object|null>} The assistant document as a plain object, or null if not found.
* @returns {Promise<AssistantDocument|null>} The assistant document as a plain object, or null if not found.
*/
const getAssistant = async (searchParams) => await Assistant.findOne(searchParams).lean();

/**
* Retrieves all assistants that match the given search parameters.
*
* @param {Object} searchParams - The search parameters to find matching assistants.
* @returns {Promise<Array<Object>>} A promise that resolves to an array of action documents as plain objects.
* @param {Object} [select] - Optional. Specifies which document fields to include or exclude.
* @returns {Promise<Array<AssistantDocument>>} A promise that resolves to an array of assistant documents as plain objects.
*/
const getAssistants = async (searchParams) => {
return await Assistant.find(searchParams).lean();
const getAssistants = async (searchParams, select = null) => {
let query = Assistant.find(searchParams);

if (select) {
query = query.select(select);
}

return await query.lean();
};

/**
Expand Down
4 changes: 4 additions & 0 deletions api/models/schema/assistant.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const assistantSchema = mongoose.Schema(
},
default: undefined,
},
conversation_starters: {
type: [String],
default: [],
},
access_level: {
type: Number,
},
Expand Down
2 changes: 1 addition & 1 deletion api/server/controllers/assistants/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const _listAssistants = async ({ req, res, version, query }) => {
* @param {object} params.res - The response object, used for initializing the client.
* @param {string} params.version - The API version to use.
* @param {Omit<AssistantListParams, 'endpoint'>} 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.
* @returns {Promise<Array<Assistant>>} A promise that resolves to the response from the `openai.beta.assistants.list` method call.
*/
const listAllAssistants = async ({ req, res, version, query }) => {
/** @type {{ openai: OpenAIClient }} */
Expand Down
76 changes: 71 additions & 5 deletions api/server/controllers/assistants/v1.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ const createAssistant = async (req, res) => {
try {
const { openai } = await getOpenAIClient({ req, res });

const { tools = [], endpoint, ...assistantData } = req.body;
const { tools = [], endpoint, conversation_starters, ...assistantData } = req.body;
delete assistantData.conversation_starters;

assistantData.tools = tools
.map((tool) => {
if (typeof tool !== 'string') {
Expand All @@ -41,11 +43,22 @@ const createAssistant = async (req, res) => {
};

const assistant = await openai.beta.assistants.create(assistantData);
const promise = updateAssistantDoc({ assistant_id: assistant.id }, { user: req.user.id });

const createData = { user: req.user.id };
if (conversation_starters) {
createData.conversation_starters = conversation_starters;
}

const document = await updateAssistantDoc({ assistant_id: assistant.id }, createData);

if (azureModelIdentifier) {
assistant.model = azureModelIdentifier;
}
await promise;

if (document.conversation_starters) {
assistant.conversation_starters = document.conversation_starters;
}

logger.debug('/assistants/', assistant);
res.status(201).json(assistant);
} catch (error) {
Expand Down Expand Up @@ -88,7 +101,7 @@ const patchAssistant = async (req, res) => {
await validateAuthor({ req, openai });

const assistant_id = req.params.id;
const { endpoint: _e, ...updateData } = req.body;
const { endpoint: _e, conversation_starters, ...updateData } = req.body;
updateData.tools = (updateData.tools ?? [])
.map((tool) => {
if (typeof tool !== 'string') {
Expand All @@ -104,6 +117,15 @@ const patchAssistant = async (req, res) => {
}

const updatedAssistant = await openai.beta.assistants.update(assistant_id, updateData);

if (conversation_starters !== undefined) {
const conversationStartersUpdate = await updateAssistantDoc(
{ assistant_id },
{ conversation_starters },
);
updatedAssistant.conversation_starters = conversationStartersUpdate.conversation_starters;
}

res.json(updatedAssistant);
} catch (error) {
logger.error('[/assistants/:id] Error updating assistant', error);
Expand Down Expand Up @@ -153,14 +175,58 @@ const listAssistants = async (req, res) => {
}
};

/**
* Filter assistants based on configuration.
*
* @param {object} params - The parameters object.
* @param {string} params.userId - The user ID to filter private assistants.
* @param {AssistantDocument[]} params.assistants - The list of assistants to filter.
* @param {Partial<TAssistantEndpoint>} [params.assistantsConfig] - The assistant configuration.
* @returns {AssistantDocument[]} - The filtered list of assistants.
*/
function filterAssistantDocs({ documents, userId, assistantsConfig = {} }) {
const { supportedIds, excludedIds, privateAssistants } = assistantsConfig;
const removeUserId = (doc) => {
const { user: _u, ...document } = doc;
return document;
};

if (privateAssistants) {
return documents.filter((doc) => userId === doc.user.toString()).map(removeUserId);
} else if (supportedIds?.length) {
return documents.filter((doc) => supportedIds.includes(doc.assistant_id)).map(removeUserId);
} else if (excludedIds?.length) {
return documents.filter((doc) => !excludedIds.includes(doc.assistant_id)).map(removeUserId);
}
return documents.map(removeUserId);
}

/**
* Returns a list of the user's assistant documents (metadata saved to database).
* @route GET /assistants/documents
* @returns {AssistantDocument[]} 200 - success response - application/json
*/
const getAssistantDocuments = async (req, res) => {
try {
res.json(await getAssistants({ user: req.user.id }));
const endpoint = req.query;
const assistantsConfig = req.app.locals[endpoint];
const documents = await getAssistants(
{},
{
user: 1,
assistant_id: 1,
conversation_starters: 1,
createdAt: 1,
updatedAt: 1,
},
);

const docs = filterAssistantDocs({
documents,
userId: req.user.id,
assistantsConfig,
});
res.json(docs);
} catch (error) {
logger.error('[/assistants/documents] Error listing assistant documents', error);
res.status(500).json({ error: error.message });
Expand Down
38 changes: 34 additions & 4 deletions api/server/controllers/assistants/v2.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ const createAssistant = async (req, res) => {
/** @type {{ openai: OpenAIClient }} */
const { openai } = await getOpenAIClient({ req, res });

const { tools = [], endpoint, ...assistantData } = req.body;
const { tools = [], endpoint, conversation_starters, ...assistantData } = req.body;
delete assistantData.conversation_starters;

assistantData.tools = tools
.map((tool) => {
if (typeof tool !== 'string') {
Expand All @@ -39,11 +41,22 @@ const createAssistant = async (req, res) => {
};

const assistant = await openai.beta.assistants.create(assistantData);
const promise = updateAssistantDoc({ assistant_id: assistant.id }, { user: req.user.id });

const createData = { user: req.user.id };
if (conversation_starters) {
createData.conversation_starters = conversation_starters;
}

const document = await updateAssistantDoc({ assistant_id: assistant.id }, createData);

if (azureModelIdentifier) {
assistant.model = azureModelIdentifier;
}
await promise;

if (document.conversation_starters) {
assistant.conversation_starters = document.conversation_starters;
}

logger.debug('/assistants/', assistant);
res.status(201).json(assistant);
} catch (error) {
Expand All @@ -64,6 +77,17 @@ const createAssistant = async (req, res) => {
const updateAssistant = async ({ req, openai, assistant_id, updateData }) => {
await validateAuthor({ req, openai });
const tools = [];
let conversation_starters = null;

if (updateData?.conversation_starters) {
const conversationStartersUpdate = await updateAssistantDoc(
{ assistant_id: assistant_id },
{ conversation_starters: updateData.conversation_starters },
);
conversation_starters = conversationStartersUpdate.conversation_starters;

delete updateData.conversation_starters;
}

let hasFileSearch = false;
for (const tool of updateData.tools ?? []) {
Expand Down Expand Up @@ -108,7 +132,13 @@ const updateAssistant = async ({ req, openai, assistant_id, updateData }) => {
updateData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
}

return await openai.beta.assistants.update(assistant_id, updateData);
const assistant = await openai.beta.assistants.update(assistant_id, updateData);

if (conversation_starters) {
assistant.conversation_starters = conversation_starters;
}

return assistant;
};

/**
Expand Down
13 changes: 13 additions & 0 deletions api/server/routes/assistants/documents.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const express = require('express');
const controllers = require('~/server/controllers/assistants/v1');

const router = express.Router();

/**
* 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('/', controllers.getAssistantDocuments);

module.exports = router;
15 changes: 8 additions & 7 deletions api/server/routes/assistants/v1.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
const multer = require('multer');
const express = require('express');
const controllers = require('~/server/controllers/assistants/v1');
const documents = require('./documents');
const actions = require('./actions');
const tools = require('./tools');

Expand All @@ -20,6 +21,13 @@ router.use('/actions', actions);
*/
router.use('/tools', tools);

/**
* Create an assistant.
* @route GET /assistants/documents
* @returns {AssistantDocument[]} 200 - application/json
*/
router.use('/documents', documents);

/**
* Create an assistant.
* @route POST /assistants
Expand Down Expand Up @@ -61,13 +69,6 @@ router.delete('/:id', controllers.deleteAssistant);
*/
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
Expand Down
15 changes: 8 additions & 7 deletions api/server/routes/assistants/v2.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const multer = require('multer');
const express = require('express');
const v1 = require('~/server/controllers/assistants/v1');
const v2 = require('~/server/controllers/assistants/v2');
const documents = require('./documents');
const actions = require('./actions');
const tools = require('./tools');

Expand All @@ -21,6 +22,13 @@ router.use('/actions', actions);
*/
router.use('/tools', tools);

/**
* Create an assistant.
* @route GET /assistants/documents
* @returns {AssistantDocument[]} 200 - application/json
*/
router.use('/documents', documents);

/**
* Create an assistant.
* @route POST /assistants
Expand Down Expand Up @@ -62,13 +70,6 @@ router.delete('/:id', v1.deleteAssistant);
*/
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
Expand Down
1 change: 1 addition & 0 deletions client/src/common/assistants-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export type AssistantForm = {
name: string | null;
description: string | null;
instructions: string | null;
conversation_starters: string[];
model: string;
functions: string[];
} & Actions;
7 changes: 6 additions & 1 deletion client/src/common/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import type {
TConversation,
TStartupConfig,
EModelEndpoint,
ActionMetadata,
AssistantsEndpoint,
TMessageContentParts,
AuthorizationTypeEnum,
Expand Down Expand Up @@ -146,9 +147,13 @@ export type ActionAuthForm = {
token_exchange_method: TokenExchangeMethodEnum;
};

export type ActionWithNullableMetadata = Omit<Action, 'metadata'> & {
metadata: ActionMetadata | null;
};

export type AssistantPanelProps = {
index?: number;
action?: Action;
action?: ActionWithNullableMetadata;
actions?: Action[];
assistant_id?: string;
activePanel?: string;
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Bookmarks/DeleteBookmarkButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const DeleteBookmarkButton: FC<{
</Label>
}
confirm={confirmDelete}
className="transition-color flex size-7 items-center justify-center rounded-lg duration-200 hover:bg-surface-hover"
className="transition-colors flex size-7 items-center justify-center rounded-lg duration-200 hover:bg-surface-hover"
icon={<TrashIcon className="size-4" />}
tabIndex={tabIndex}
onFocus={onFocus}
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/Bookmarks/EditBookmarkButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const EditBookmarkButton: FC<{
/>
<button
type="button"
className="transition-color flex size-7 items-center justify-center rounded-lg duration-200 hover:bg-surface-hover"
className="transition-colors flex size-7 items-center justify-center rounded-lg duration-200 hover:bg-surface-hover"
tabIndex={tabIndex}
onFocus={onFocus}
onBlur={onBlur}
Expand Down
Loading

0 comments on commit 79f9cd5

Please sign in to comment.