feat: Edit AI Messages, Edit Messages in Place (#825)

* refactor: replace lodash import with specific function import

fix(api): esm imports to cjs

* refactor(Messages.tsx): convert to TS, out-source scrollToDiv logic to a custom hook
fix(ScreenshotContext.tsx): change Ref to RefObject in ScreenshotContextType
feat(useScrollToRef.ts): add useScrollToRef hook for scrolling to a ref with throttle
fix(Chat.tsx): update import path for Messages component
fix(Search.tsx): update import path for Messages component

* chore(types.ts): add TAskProps and TOptions types
refactor(useMessageHandler.ts): use TAskFunction type for ask function signature

* refactor(Message/Content): convert to TS, move Plugin component to Content dir

* feat(MessageContent.tsx): add MessageContent component for displaying and editing message content
feat(index.ts): export MessageContent component from Messages/Content directory

* wip(Message.jsx): conversion and use of new component in progress

* refactor: convert Message.jsx to TS and fix typing/imports based on changes

* refactor: add typed props and refactor MultiMessage to TS, fix typing issues resulting from the conversion

* edit message in progress

* feat: complete edit AI message logic, refactor continue logic

* feat(middleware): add validateMessageReq middleware
feat(routes): add validation for message requests using validateMessageReq middleware
feat(routes): add create, read, update, and delete routes for messages

* feat: complete frontend logic for editing messages in place
feat(messages.js): update route for updating a specific message
- Change the route for updating a message to include the messageId in the URL
- Update the request handler to use the messageId from the request parameters and the text from the request body
- Call the updateMessage function with the updated parameters

feat(MessageContent.tsx): add functionality to update a message
- Import the useUpdateMessageMutation hook from the data provider
- Destructure the conversationId, parentMessageId, and messageId from the message object
- Create a mutation function using the useUpdateMessageMutation hook
- Implement the updateMessage function to call the mutation function with the updated message parameters
- Update the messages state to reflect the updated message text

feat(api-endpoints.ts): update messages endpoint to include messageId
- Update the messages endpoint to include the messageId as an optional parameter

feat(data-service.ts): add updateMessage function
- Implement the updateMessage function to make a PUT request to

* fix(messages.js): make updateMessage function asynchronous and await its execution

* style(EditIcon): make icon active for AI message

* feat(gptPlugins/anthropic): add edit support

* fix(validateMessageReq.js): handle case when conversationId is 'new' and return empty array
feat(Message.tsx): pass message prop to SiblingSwitch component
refactor(SiblingSwitch.tsx): convert to TS

* fix(useMessageHandler.ts): remove message from currentMessages if isContinued is true
feat(useMessageHandler.ts): add support for submission messages in setMessages
fix(useServerStream.ts): remove unnecessary conditional in setMessages
fix(useServerStream.ts): remove isContinued variable from submission

* fix(continue): switch to continued message generation when continuing an earlier branch in conversation

* fix(abortMiddleware.js): fix condition to check partialText length
chore(abortMiddleware.js): add error logging when abortMessage fails

* refactor(MessageHeader.tsx): convert to TS
fix(Plugin.tsx): add default value for className prop in Plugin component

* refactor(MultiMessage.tsx): remove commented out code
docs(MultiMessage.tsx): update comment to clarify when siblingIdx is reset

* fix(GenerationButtons): optimistic state for continue button

* fix(MessageContent.tsx): add data-testid attribute to message text editor
fix(messages.spec.ts): update waitForServerStream function to include edit endpoint check
feat(messages.spec.ts): add test case for editing messages

* fix(HoverButtons & Message & useGenerations): Refactor edit functionality and related conditions

- Update enterEdit function signature and prop
- Create and utilize hideEditButton variable
- Enhance conditions for edit button visibility and active state
- Update button event handlers
- Introduce isEditableEndpoint in useGenerations and refine continueSupported condition.

* fix(useGenerations.ts): fix condition for hideEditButton to include error and searchResult
chore(data-provider): bump version to 0.1.6
fix(types.ts): add status property to TError type

* chore: bump @dqbd/tiktoken to 1.0.7

* fix(abortMiddleware.js): add required isCreatedByUser property to the error response object

* refactor(Message.tsx): remove unnecessary props from SiblingSwitch component, as setLatestMessage is firing on every switch already
refactor(SiblingSwitch.tsx): remove unused imports and code

* chore(BaseClient.js): move console.debug statements back inside if block
This commit is contained in:
Danny Avila 2023-08-22 18:44:59 -04:00 committed by GitHub
parent db77163f5d
commit 7dc27b10f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
45 changed files with 908 additions and 459 deletions

View file

@ -52,15 +52,23 @@ class BaseClient {
if (opts && typeof opts === 'object') { if (opts && typeof opts === 'object') {
this.setOptions(opts); this.setOptions(opts);
} }
const { isEdited, isContinued } = opts;
const user = opts.user ?? null; const user = opts.user ?? null;
const saveOptions = this.getSaveOptions();
this.abortController = opts.abortController ?? new AbortController();
const conversationId = opts.conversationId ?? crypto.randomUUID(); const conversationId = opts.conversationId ?? crypto.randomUUID();
const parentMessageId = opts.parentMessageId ?? '00000000-0000-0000-0000-000000000000'; const parentMessageId = opts.parentMessageId ?? '00000000-0000-0000-0000-000000000000';
const userMessageId = opts.overrideParentMessageId ?? crypto.randomUUID(); const userMessageId = opts.overrideParentMessageId ?? crypto.randomUUID();
const responseMessageId = opts.responseMessageId ?? crypto.randomUUID(); let responseMessageId = opts.responseMessageId ?? crypto.randomUUID();
const saveOptions = this.getSaveOptions(); let head = isEdited ? responseMessageId : parentMessageId;
const head = opts.isEdited ? responseMessageId : parentMessageId;
this.currentMessages = (await this.loadHistory(conversationId, head)) ?? []; this.currentMessages = (await this.loadHistory(conversationId, head)) ?? [];
this.abortController = opts.abortController ?? new AbortController();
if (isEdited && !isContinued) {
responseMessageId = crypto.randomUUID();
head = responseMessageId;
this.currentMessages[this.currentMessages.length - 1].messageId = head;
}
return { return {
...opts, ...opts,
@ -397,11 +405,16 @@ class BaseClient {
const { user, head, isEdited, conversationId, responseMessageId, saveOptions, userMessage } = const { user, head, isEdited, conversationId, responseMessageId, saveOptions, userMessage } =
await this.handleStartMethods(message, opts); await this.handleStartMethods(message, opts);
const { generation = '' } = opts;
this.user = user; this.user = user;
// It's not necessary to push to currentMessages // It's not necessary to push to currentMessages
// depending on subclass implementation of handling messages // depending on subclass implementation of handling messages
// When this is an edit, all messages are already in currentMessages, both user and response // When this is an edit, all messages are already in currentMessages, both user and response
if (!isEdited) { if (isEdited) {
/* TODO: edge case where latest message doesn't exist */
this.currentMessages[this.currentMessages.length - 1].text = generation;
} else {
this.currentMessages.push(userMessage); this.currentMessages.push(userMessage);
} }
@ -419,7 +432,7 @@ class BaseClient {
if (this.options.debug) { if (this.options.debug) {
console.debug('payload'); console.debug('payload');
// console.debug(payload); console.debug(payload);
} }
if (tokenCountMap) { if (tokenCountMap) {
@ -442,7 +455,6 @@ class BaseClient {
await this.saveMessageToDatabase(userMessage, saveOptions, user); await this.saveMessageToDatabase(userMessage, saveOptions, user);
} }
const generation = isEdited ? this.currentMessages[this.currentMessages.length - 1].text : '';
const responseMessage = { const responseMessage = {
messageId: responseMessageId, messageId: responseMessageId,
conversationId, conversationId,

View file

@ -1,4 +1,4 @@
const _ = require('lodash'); const throttle = require('lodash/throttle');
const { genAzureChatCompletion, getAzureCredentials } = require('../utils/'); const { genAzureChatCompletion, getAzureCredentials } = require('../utils/');
const titleConvo = async ({ text, response, openAIApiKey, azure = false }) => { const titleConvo = async ({ text, response, openAIApiKey, azure = false }) => {
@ -52,6 +52,6 @@ const titleConvo = async ({ text, response, openAIApiKey, azure = false }) => {
return title; return title;
}; };
const throttledTitleConvo = _.throttle(titleConvo, 1000); const throttledTitleConvo = throttle(titleConvo, 1000);
module.exports = throttledTitleConvo; module.exports = throttledTitleConvo;

View file

@ -1,4 +1,4 @@
const _ = require('lodash'); const throttle = require('lodash/throttle');
const titleConvo = async ({ text, response }) => { const titleConvo = async ({ text, response }) => {
let title = 'New Chat'; let title = 'New Chat';
@ -32,6 +32,6 @@ const titleConvo = async ({ text, response }) => {
return title; return title;
}; };
const throttledTitleConvo = _.throttle(titleConvo, 3000); const throttledTitleConvo = throttle(titleConvo, 3000);
module.exports = throttledTitleConvo; module.exports = throttledTitleConvo;

View file

@ -22,7 +22,7 @@
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.5.4", "@anthropic-ai/sdk": "^0.5.4",
"@azure/search-documents": "^11.3.2", "@azure/search-documents": "^11.3.2",
"@dqbd/tiktoken": "^1.0.2", "@dqbd/tiktoken": "^1.0.7",
"@fortaine/fetch-event-source": "^3.0.6", "@fortaine/fetch-event-source": "^3.0.6",
"@keyv/mongo": "^2.1.8", "@keyv/mongo": "^2.1.8",
"@waylaidwanderer/chatgpt-api": "^1.37.2", "@waylaidwanderer/chatgpt-api": "^1.37.2",

View file

@ -79,6 +79,7 @@ const handleAbortError = async (res, req, error, data) => {
cancelled: false, cancelled: false,
error: true, error: true,
text: error.message, text: error.message,
isCreatedByUser: false,
}; };
if (abortControllers.has(conversationId)) { if (abortControllers.has(conversationId)) {
const { abortController } = abortControllers.get(conversationId); const { abortController } = abortControllers.get(conversationId);
@ -89,10 +90,11 @@ const handleAbortError = async (res, req, error, data) => {
handleError(res, errorMessage); handleError(res, errorMessage);
}; };
if (partialText?.length > 2) { if (partialText && partialText.length > 5) {
try { try {
return await abortMessage(req, res); return await abortMessage(req, res);
} catch (err) { } catch (err) {
console.error(err);
return respondWithError(); return respondWithError();
} }
} else { } else {

View file

@ -3,6 +3,7 @@ const setHeaders = require('./setHeaders');
const requireJwtAuth = require('./requireJwtAuth'); const requireJwtAuth = require('./requireJwtAuth');
const requireLocalAuth = require('./requireLocalAuth'); const requireLocalAuth = require('./requireLocalAuth');
const validateEndpoint = require('./validateEndpoint'); const validateEndpoint = require('./validateEndpoint');
const validateMessageReq = require('./validateMessageReq');
const buildEndpointOption = require('./buildEndpointOption'); const buildEndpointOption = require('./buildEndpointOption');
const validateRegistration = require('./validateRegistration'); const validateRegistration = require('./validateRegistration');
@ -12,6 +13,7 @@ module.exports = {
requireJwtAuth, requireJwtAuth,
requireLocalAuth, requireLocalAuth,
validateEndpoint, validateEndpoint,
validateMessageReq,
buildEndpointOption, buildEndpointOption,
validateRegistration, validateRegistration,
}; };

View file

@ -0,0 +1,28 @@
const { getConvo } = require('../../models');
// Middleware to validate conversationId and user relationship
const validateMessageReq = async (req, res, next) => {
let conversationId = req.params.conversationId || req.body.conversationId;
if (conversationId === 'new') {
return res.status(200).send([]);
}
if (!conversationId && req.body.message) {
conversationId = req.body.message.conversationId;
}
const conversation = await getConvo(req.user.id, conversationId);
if (!conversation) {
return res.status(404).json({ error: 'Conversation not found' });
}
if (conversation.user !== req.user.id) {
return res.status(403).json({ error: 'User not authorized for this conversation' });
}
next();
};
module.exports = validateMessageReq;

View file

@ -29,11 +29,12 @@ router.post(
endpointOption, endpointOption,
conversationId, conversationId,
responseMessageId, responseMessageId,
isContinued = false,
parentMessageId = null, parentMessageId = null,
overrideParentMessageId = null, overrideParentMessageId = null,
} = req.body; } = req.body;
console.log('edit log'); console.log('edit log');
console.dir({ text, conversationId, endpointOption }, { depth: null }); console.dir({ text, generation, isContinued, conversationId, endpointOption }, { depth: null });
let metadata; let metadata;
let userMessage; let userMessage;
let lastSavedTimestamp = 0; let lastSavedTimestamp = 0;
@ -41,7 +42,10 @@ router.post(
const userMessageId = parentMessageId; const userMessageId = parentMessageId;
const addMetadata = (data) => (metadata = data); const addMetadata = (data) => (metadata = data);
const getIds = (data) => (userMessage = data.userMessage); const getIds = (data) => {
userMessage = data.userMessage;
responseMessageId = data.responseMessageId;
};
const { onProgress: progressCallback, getPartialText } = createOnProgress({ const { onProgress: progressCallback, getPartialText } = createOnProgress({
generation, generation,
@ -87,6 +91,8 @@ router.post(
let response = await client.sendMessage(text, { let response = await client.sendMessage(text, {
user: req.user.id, user: req.user.id,
generation,
isContinued,
isEdited: true, isEdited: true,
conversationId, conversationId,
parentMessageId, parentMessageId,

View file

@ -30,11 +30,12 @@ router.post(
endpointOption, endpointOption,
conversationId, conversationId,
responseMessageId, responseMessageId,
isContinued = false,
parentMessageId = null, parentMessageId = null,
overrideParentMessageId = null, overrideParentMessageId = null,
} = req.body; } = req.body;
console.log('edit log'); console.log('edit log');
console.dir({ text, conversationId, endpointOption }, { depth: null }); console.dir({ text, generation, isContinued, conversationId, endpointOption }, { depth: null });
let metadata; let metadata;
let userMessage; let userMessage;
let lastSavedTimestamp = 0; let lastSavedTimestamp = 0;
@ -50,7 +51,10 @@ router.post(
}; };
const addMetadata = (data) => (metadata = data); const addMetadata = (data) => (metadata = data);
const getIds = (data) => (userMessage = data.userMessage); const getIds = (data) => {
userMessage = data.userMessage;
responseMessageId = data.responseMessageId;
};
const { const {
onProgress: progressCallback, onProgress: progressCallback,
@ -128,6 +132,8 @@ router.post(
let response = await client.sendMessage(text, { let response = await client.sendMessage(text, {
user, user,
generation,
isContinued,
isEdited: true, isEdited: true,
conversationId, conversationId,
parentMessageId, parentMessageId,

View file

@ -29,11 +29,12 @@ router.post(
endpointOption, endpointOption,
conversationId, conversationId,
responseMessageId, responseMessageId,
isContinued = false,
parentMessageId = null, parentMessageId = null,
overrideParentMessageId = null, overrideParentMessageId = null,
} = req.body; } = req.body;
console.log('edit log'); console.log('edit log');
console.dir({ text, conversationId, endpointOption }, { depth: null }); console.dir({ text, generation, isContinued, conversationId, endpointOption }, { depth: null });
let metadata; let metadata;
let userMessage; let userMessage;
let lastSavedTimestamp = 0; let lastSavedTimestamp = 0;
@ -41,7 +42,10 @@ router.post(
const userMessageId = parentMessageId; const userMessageId = parentMessageId;
const addMetadata = (data) => (metadata = data); const addMetadata = (data) => (metadata = data);
const getIds = (data) => (userMessage = data.userMessage); const getIds = (data) => {
userMessage = data.userMessage;
responseMessageId = data.responseMessageId;
};
const { onProgress: progressCallback, getPartialText } = createOnProgress({ const { onProgress: progressCallback, getPartialText } = createOnProgress({
generation, generation,
@ -90,6 +94,8 @@ router.post(
let response = await client.sendMessage(text, { let response = await client.sendMessage(text, {
user: req.user.id, user: req.user.id,
generation,
isContinued,
isEdited: true, isEdited: true,
conversationId, conversationId,
parentMessageId, parentMessageId,

View file

@ -1,11 +1,50 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getMessages } = require('../../models/Message'); const {
const requireJwtAuth = require('../middleware/requireJwtAuth'); getMessages,
updateMessage,
saveConvo,
saveMessage,
deleteMessages,
} = require('../../models');
const { requireJwtAuth, validateMessageReq } = require('../middleware/');
router.get('/:conversationId', requireJwtAuth, async (req, res) => { router.get('/:conversationId', requireJwtAuth, validateMessageReq, async (req, res) => {
const { conversationId } = req.params; const { conversationId } = req.params;
res.status(200).send(await getMessages({ conversationId })); res.status(200).send(await getMessages({ conversationId }));
}); });
// CREATE
router.post('/:conversationId', requireJwtAuth, validateMessageReq, async (req, res) => {
const message = req.body;
const savedMessage = await saveMessage(message);
await saveConvo(req.user.id, savedMessage);
res.status(201).send(savedMessage);
});
// READ
router.get('/:conversationId/:messageId', requireJwtAuth, validateMessageReq, async (req, res) => {
const { conversationId, messageId } = req.params;
res.status(200).send(await getMessages({ conversationId, messageId }));
});
// UPDATE
router.put('/:conversationId/:messageId', requireJwtAuth, validateMessageReq, async (req, res) => {
const { messageId } = req.params;
const { text } = req.body;
res.status(201).send(await updateMessage({ messageId, text }));
});
// DELETE
router.delete(
'/:conversationId/:messageId',
requireJwtAuth,
validateMessageReq,
async (req, res) => {
const { messageId } = req.params;
await deleteMessages({ messageId });
res.status(204).send();
},
);
module.exports = router; module.exports = router;

View file

@ -1,4 +1,4 @@
const _ = require('lodash'); const partialRight = require('lodash/partialRight');
const citationRegex = /\[\^\d+?\^]/g; const citationRegex = /\[\^\d+?\^]/g;
const { getCitations, citeText } = require('./citations'); const { getCitations, citeText } = require('./citations');
const cursor = '<span className="result-streaming">█</span>'; const cursor = '<span className="result-streaming">█</span>';
@ -73,7 +73,7 @@ const createOnProgress = ({ generation = '', onProgress: _onProgress }) => {
}; };
const onProgress = (opts) => { const onProgress = (opts) => {
return _.partialRight(progressCallback, opts); return partialRight(progressCallback, opts);
}; };
const getPartialText = () => { const getPartialText = () => {

View file

@ -1,4 +1,4 @@
import { TConversation, TPreset } from 'librechat-data-provider'; import { TConversation, TMessage, TPreset } from 'librechat-data-provider';
export type TSetOption = (param: number | string) => (newValue: number | string | boolean) => void; export type TSetOption = (param: number | string) => (newValue: number | string | boolean) => void;
export type TSetExample = ( export type TSetExample = (
@ -62,3 +62,34 @@ export type TOnClick = (e: React.MouseEvent<HTMLButtonElement>) => void;
export type TGenButtonProps = { export type TGenButtonProps = {
onClick: TOnClick; onClick: TOnClick;
}; };
export type TAskProps = {
text: string;
parentMessageId?: string | null;
conversationId?: string | null;
messageId?: string | null;
};
export type TOptions = {
editedMessageId?: string | null;
editedText?: string | null;
isRegenerate?: boolean;
isContinued?: boolean;
isEdited?: boolean;
};
export type TAskFunction = (props: TAskProps, options?: TOptions) => void;
export type TMessageProps = {
conversation?: TConversation | null;
messageId?: string | null;
message?: TMessage;
messagesTree?: TMessage[];
currentEditId: string | number | null;
isSearchView?: boolean;
siblingIdx?: number;
siblingCount?: number;
scrollToBottom?: () => void;
setCurrentEditId?: React.Dispatch<React.SetStateAction<string | number | null>> | null;
setSiblingIdx?: ((value: number) => void | React.Dispatch<React.SetStateAction<number>>) | null;
};

View file

@ -1,3 +1,4 @@
import { useEffect, useState } from 'react';
import type { TMessage } from 'librechat-data-provider'; import type { TMessage } from 'librechat-data-provider';
import { useMessageHandler, useMediaQuery, useGenerations } from '~/hooks'; import { useMessageHandler, useMediaQuery, useGenerations } from '~/hooks';
import { cn } from '~/utils'; import { cn } from '~/utils';
@ -31,6 +32,27 @@ export default function GenerationButtons({
isSubmitting, isSubmitting,
}); });
const [userStopped, setUserStopped] = useState(false);
const handleStop = (e: React.MouseEvent<HTMLButtonElement>) => {
setUserStopped(true);
handleStopGenerating(e);
};
useEffect(() => {
let timer: NodeJS.Timeout;
if (userStopped) {
timer = setTimeout(() => {
setUserStopped(false);
}, 200);
}
return () => {
clearTimeout(timer);
};
}, [userStopped]);
if (isSmallScreen) { if (isSmallScreen) {
return null; return null;
} }
@ -38,8 +60,8 @@ export default function GenerationButtons({
let button: React.ReactNode = null; let button: React.ReactNode = null;
if (isSubmitting) { if (isSubmitting) {
button = <Stop onClick={handleStopGenerating} />; button = <Stop onClick={handleStop} />;
} else if (continueSupported) { } else if (userStopped || continueSupported) {
button = <Continue onClick={handleContinue} />; button = <Continue onClick={handleContinue} />;
} else if (messages && messages.length > 0 && regenerateEnabled) { } else if (messages && messages.length > 0 && regenerateEnabled) {
button = <Regenerate onClick={handleRegenerate} />; button = <Regenerate onClick={handleRegenerate} />;

View file

@ -51,7 +51,7 @@ const CodeBar: React.FC<CodeBarProps> = React.memo(({ lang, codeRef, plugin = nu
interface CodeBlockProps { interface CodeBlockProps {
lang: string; lang: string;
codeChildren: string; codeChildren: React.ReactNode;
classProp?: string; classProp?: string;
plugin?: boolean; plugin?: boolean;
} }

View file

@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import type { TMessage } from 'librechat-data-provider';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import type { PluggableList } from 'unified';
import rehypeKatex from 'rehype-katex'; import rehypeKatex from 'rehype-katex';
import rehypeHighlight from 'rehype-highlight'; import rehypeHighlight from 'rehype-highlight';
import remarkMath from 'remark-math'; import remarkMath from 'remark-math';
@ -11,8 +13,18 @@ import CodeBlock from './CodeBlock';
import store from '~/store'; import store from '~/store';
import { langSubset } from '~/utils'; import { langSubset } from '~/utils';
const code = React.memo((props) => { type TCodeProps = {
const { inline, className, children } = props; inline: boolean;
className: string;
children: React.ReactNode;
};
type TContentProps = {
content: string;
message: TMessage;
};
const code = React.memo(({ inline, className, children }: TCodeProps) => {
const match = /language-(\w+)/.exec(className || ''); const match = /language-(\w+)/.exec(className || '');
const lang = match && match[1]; const lang = match && match[1];
@ -23,11 +35,11 @@ const code = React.memo((props) => {
} }
}); });
const p = React.memo((props) => { const p = React.memo(({ children }: { children: React.ReactNode }) => {
return <p className="mb-2 whitespace-pre-wrap">{props?.children}</p>; return <p className="mb-2 whitespace-pre-wrap">{children}</p>;
}); });
const Content = React.memo(({ content, message }) => { const Content = React.memo(({ content, message }: TContentProps) => {
const [cursor, setCursor] = useState('█'); const [cursor, setCursor] = useState('█');
const isSubmitting = useRecoilValue(store.isSubmitting); const isSubmitting = useRecoilValue(store.isSubmitting);
const latestMessage = useRecoilValue(store.latestMessage); const latestMessage = useRecoilValue(store.latestMessage);
@ -57,7 +69,7 @@ const Content = React.memo(({ content, message }) => {
}; };
}, [isSubmitting, isLatestMessage]); }, [isSubmitting, isLatestMessage]);
let rehypePlugins = [ const rehypePlugins: PluggableList = [
[rehypeKatex, { output: 'mathml' }], [rehypeKatex, { output: 'mathml' }],
[ [
rehypeHighlight, rehypeHighlight,
@ -79,10 +91,14 @@ const Content = React.memo(({ content, message }) => {
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]} remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
rehypePlugins={rehypePlugins} rehypePlugins={rehypePlugins}
linkTarget="_new" linkTarget="_new"
components={{ components={
code, {
p, code,
}} p,
} as {
[nodeType: string]: React.ElementType;
}
}
> >
{isLatestMessage && isSubmitting && !isInitializing {isLatestMessage && isSubmitting && !isInitializing
? currentContent + cursor ? currentContent + cursor

View file

@ -0,0 +1,194 @@
import { useRef } from 'react';
import { useRecoilState } from 'recoil';
import { useUpdateMessageMutation } from 'librechat-data-provider';
import type { TMessage } from 'librechat-data-provider';
import type { TAskFunction } from '~/common';
import { cn, getError } from '~/utils';
import store from '~/store';
import Content from './Content';
type TInitialProps = {
text: string;
edit: boolean;
error: boolean;
unfinished: boolean;
isSubmitting: boolean;
};
type TAdditionalProps = {
ask: TAskFunction;
message: TMessage;
isCreatedByUser: boolean;
siblingIdx: number;
enterEdit: (cancel: boolean) => void;
setSiblingIdx: (value: number) => void;
};
type TMessageContent = TInitialProps & TAdditionalProps;
type TText = Pick<TInitialProps, 'text'>;
type TEditProps = Pick<TInitialProps, 'text' | 'isSubmitting'> &
Omit<TAdditionalProps, 'isCreatedByUser'>;
type TDisplayProps = TText & Pick<TAdditionalProps, 'isCreatedByUser' | 'message'>;
// Container Component
const Container = ({ children }: { children: React.ReactNode }) => (
<div className="flex min-h-[20px] flex-grow flex-col items-start gap-4">{children}</div>
);
// Error Message Component
const ErrorMessage = ({ text }: TText) => (
<Container>
<div className="rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-100">
{getError(text)}
</div>
</Container>
);
// Edit Message Component
const EditMessage = ({
text,
message,
isSubmitting,
ask,
enterEdit,
siblingIdx,
setSiblingIdx,
}: TEditProps) => {
const [messages, setMessages] = useRecoilState(store.messages);
const textEditor = useRef<HTMLDivElement | null>(null);
const { conversationId, parentMessageId, messageId } = message;
const updateMessageMutation = useUpdateMessageMutation(conversationId ?? '');
const resubmitMessage = () => {
const text = textEditor?.current?.innerText ?? '';
console.log('siblingIdx:', siblingIdx);
if (message.isCreatedByUser) {
ask({
text,
parentMessageId,
conversationId,
});
setSiblingIdx((siblingIdx ?? 0) - 1);
} else {
const parentMessage = messages?.find((msg) => msg.messageId === parentMessageId);
if (!parentMessage) {
return;
}
ask(
{ ...parentMessage },
{
editedText: text,
editedMessageId: messageId,
isRegenerate: true,
isEdited: true,
},
);
setSiblingIdx((siblingIdx ?? 0) - 1);
}
enterEdit(true);
};
const updateMessage = () => {
if (!messages) {
return;
}
const text = textEditor?.current?.innerText ?? '';
updateMessageMutation.mutate({
conversationId: conversationId ?? '',
messageId,
text,
});
setMessages(() =>
messages.map((msg) =>
msg.messageId === messageId
? {
...msg,
text,
}
: msg,
),
);
enterEdit(true);
};
return (
<Container>
<div
data-testid="message-text-editor"
className="markdown prose dark:prose-invert light w-full whitespace-pre-wrap break-words border-none focus:outline-none"
contentEditable={true}
ref={textEditor}
suppressContentEditableWarning={true}
>
{text}
</div>
<div className="mt-2 flex w-full justify-center text-center">
<button
className="btn btn-primary relative mr-2"
disabled={isSubmitting}
onClick={resubmitMessage}
>
Save & Submit
</button>
<button
className="btn btn-secondary relative mr-2"
disabled={isSubmitting}
onClick={updateMessage}
>
Save
</button>
<button className="btn btn-neutral relative" onClick={() => enterEdit(true)}>
Cancel
</button>
</div>
</Container>
);
};
// Display Message Component
const DisplayMessage = ({ text, isCreatedByUser, message }: TDisplayProps) => (
<Container>
<div
className={cn(
'markdown prose dark:prose-invert light w-full break-words',
isCreatedByUser ? 'whitespace-pre-wrap' : '',
)}
>
{!isCreatedByUser ? <Content content={text} message={message} /> : <>{text}</>}
</div>
</Container>
);
// Unfinished Message Component
const UnfinishedMessage = () => (
<ErrorMessage text="This is an unfinished message. The AI may still be generating a response, it was aborted, or a censor was triggered. Refresh or visit later to see more updates." />
);
// Content Component
const MessageContent = ({
text,
edit,
error,
unfinished,
isSubmitting,
...props
}: TMessageContent) => {
if (error) {
return <ErrorMessage text={text} />;
} else if (edit) {
return <EditMessage text={text} isSubmitting={isSubmitting} {...props} />;
} else {
return (
<>
<DisplayMessage text={text} {...props} />
{!isSubmitting && unfinished && <UnfinishedMessage />}
</>
);
}
};
export default MessageContent;

View file

@ -1,28 +1,13 @@
import React, { useState, useCallback, memo, ReactNode } from 'react'; import { useState, useCallback, memo, ReactNode } from 'react';
import { Spinner } from '~/components'; import type { TResPlugin, TInput } from 'librechat-data-provider';
import { useRecoilValue } from 'recoil';
import CodeBlock from './Content/CodeBlock.jsx';
import { Disclosure } from '@headlessui/react';
import { ChevronDownIcon, LucideProps } from 'lucide-react'; import { ChevronDownIcon, LucideProps } from 'lucide-react';
import { Disclosure } from '@headlessui/react';
import { useRecoilValue } from 'recoil';
import { Spinner } from '~/components';
import CodeBlock from './CodeBlock';
import { cn } from '~/utils/'; import { cn } from '~/utils/';
import store from '~/store'; import store from '~/store';
interface Input {
inputStr: string;
}
interface PluginProps {
plugin: {
plugin: string;
input: string;
thought: string;
loading?: boolean;
outputs?: string;
latest?: string;
inputs?: Input[];
};
}
type PluginsMap = { type PluginsMap = {
[pluginKey: string]: string; [pluginKey: string]: string;
}; };
@ -31,7 +16,7 @@ type PluginIconProps = LucideProps & {
className?: string; className?: string;
}; };
function formatInputs(inputs: Input[]) { function formatInputs(inputs: TInput[]) {
let output = ''; let output = '';
for (let i = 0; i < inputs.length; i++) { for (let i = 0; i < inputs.length; i++) {
@ -45,6 +30,10 @@ function formatInputs(inputs: Input[]) {
return output; return output;
} }
type PluginProps = {
plugin: TResPlugin;
};
const Plugin: React.FC<PluginProps> = ({ plugin }) => { const Plugin: React.FC<PluginProps> = ({ plugin }) => {
const [loading, setLoading] = useState(plugin.loading); const [loading, setLoading] = useState(plugin.loading);
const finished = plugin.outputs && plugin.outputs.length > 0; const finished = plugin.outputs && plugin.outputs.length > 0;

View file

@ -1,6 +1,11 @@
import React from 'react'; type TSubRowProps = {
children: React.ReactNode;
classes?: string;
subclasses?: string;
onClick?: () => void;
};
export default function SubRow({ children, classes = '', subclasses = '', onClick }) { export default function SubRow({ children, classes = '', subclasses = '', onClick }: TSubRowProps) {
return ( return (
<div className={`flex justify-between ${classes}`} onClick={onClick}> <div className={`flex justify-between ${classes}`} onClick={onClick}>
<div <div

View file

@ -0,0 +1,3 @@
export { default as SubRow } from './SubRow';
export { default as Plugin } from './Plugin';
export { default as MessageContent } from './MessageContent';

View file

@ -6,9 +6,9 @@ import { cn } from '~/utils';
type THoverButtons = { type THoverButtons = {
isEditing: boolean; isEditing: boolean;
enterEdit: () => void; enterEdit: (cancel?: boolean) => void;
copyToClipboard: (setIsCopied: (isCopied: boolean) => void) => void; copyToClipboard: (setIsCopied: React.Dispatch<React.SetStateAction<boolean>>) => void;
conversation: TConversation; conversation: TConversation | null;
isSubmitting: boolean; isSubmitting: boolean;
message: TMessage; message: TMessage;
regenerate: () => void; regenerate: () => void;
@ -25,33 +25,47 @@ export default function HoverButtons({
regenerate, regenerate,
handleContinue, handleContinue,
}: THoverButtons) { }: THoverButtons) {
const { endpoint } = conversation; const { endpoint } = conversation ?? {};
const [isCopied, setIsCopied] = useState(false); const [isCopied, setIsCopied] = useState(false);
const { editEnabled, regenerateEnabled, continueSupported } = useGenerations({ const { hideEditButton, regenerateEnabled, continueSupported } = useGenerations({
isEditing, isEditing,
isSubmitting, isSubmitting,
message, message,
endpoint: endpoint ?? '', endpoint: endpoint ?? '',
}); });
if (!conversation) {
return null;
}
const { isCreatedByUser } = message;
const onEdit = () => {
if (isEditing) {
return enterEdit(true);
}
enterEdit();
};
return ( return (
<div className="visible mt-2 flex justify-center gap-3 self-end text-gray-400 md:gap-4 lg:absolute lg:right-0 lg:top-0 lg:mt-0 lg:translate-x-full lg:gap-1 lg:self-center lg:pl-2"> <div className="visible mt-2 flex justify-center gap-3 self-end text-gray-400 md:gap-4 lg:absolute lg:right-0 lg:top-0 lg:mt-0 lg:translate-x-full lg:gap-1 lg:self-center lg:pl-2">
<button <button
className={cn( className={cn(
'hover-button rounded-md p-1 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible', 'hover-button rounded-md p-1 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible',
message?.isCreatedByUser ? '' : 'opacity-0', isCreatedByUser ? '' : 'active',
hideEditButton ? 'opacity-0' : '',
isEditing ? 'active bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200' : '',
)} )}
onClick={enterEdit} onClick={onEdit}
type="button" type="button"
title="edit" title="edit"
disabled={!editEnabled} disabled={hideEditButton}
> >
<EditIcon /> <EditIcon />
</button> </button>
<button <button
className={cn( className={cn(
'hover-button rounded-md p-1 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible', 'hover-button rounded-md p-1 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-200 disabled:dark:hover:text-gray-400 md:invisible md:group-hover:visible',
message?.isCreatedByUser ? '' : 'active', isCreatedByUser ? '' : 'active',
)} )}
onClick={() => copyToClipboard(setIsCopied)} onClick={() => copyToClipboard(setIsCopied)}
type="button" type="button"

View file

@ -1,254 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { useState, useEffect, useRef } from 'react';
import { useSetRecoilState } from 'recoil';
import copy from 'copy-to-clipboard';
import Plugin from './Plugin';
import SubRow from './Content/SubRow';
import Content from './Content/Content';
import MultiMessage from './MultiMessage';
import HoverButtons from './HoverButtons';
import SiblingSwitch from './SiblingSwitch';
import { getIcon } from '~/components/Endpoints';
import { useMessageHandler } from '~/hooks';
import { useGetConversationByIdQuery } from 'librechat-data-provider';
import { cn, getError } from '~/utils/';
import store from '~/store';
export default function Message({
conversation,
message,
scrollToBottom,
currentEditId,
setCurrentEditId,
siblingIdx,
siblingCount,
setSiblingIdx,
}) {
const { text, searchResult, isCreatedByUser, error, submitting, unfinished } = message;
const setLatestMessage = useSetRecoilState(store.latestMessage);
const [abortScroll, setAbort] = useState(false);
const textEditor = useRef(null);
const last = !message?.children?.length;
const edit = message.messageId == currentEditId;
const { isSubmitting, ask, regenerate, handleContinue } = useMessageHandler();
const { switchToConversation } = store.useConversation();
const blinker = submitting && isSubmitting;
const getConversationQuery = useGetConversationByIdQuery(message.conversationId, {
enabled: false,
});
// debugging
// useEffect(() => {
// console.log('isSubmitting:', isSubmitting);
// console.log('unfinished:', unfinished);
// }, [isSubmitting, unfinished]);
useEffect(() => {
if (blinker && !abortScroll) {
scrollToBottom();
}
}, [isSubmitting, blinker, text, scrollToBottom]);
useEffect(() => {
if (last) {
setLatestMessage({ ...message });
}
}, [last, message]);
const enterEdit = (cancel) => setCurrentEditId(cancel ? -1 : message.messageId);
const handleWheel = () => {
if (blinker) {
setAbort(true);
} else {
setAbort(false);
}
};
const props = {
className:
'w-full border-b border-black/10 dark:border-gray-900/50 text-gray-800 bg-white dark:text-gray-100 group dark:bg-gray-800',
};
const icon = getIcon({
...conversation,
...message,
model: message?.model || conversation?.model,
});
if (!isCreatedByUser) {
props.className =
'w-full border-b border-black/10 bg-gray-50 dark:border-gray-900/50 text-gray-800 dark:text-gray-100 group bg-gray-100 dark:bg-gray-1000';
}
if (message.bg && searchResult) {
props.className = message.bg.split('hover')[0];
props.titleclass = message.bg.split(props.className)[1] + ' cursor-pointer';
}
const resubmitMessage = () => {
const text = textEditor.current.innerText;
ask({
text,
parentMessageId: message?.parentMessageId,
conversationId: message?.conversationId,
});
setSiblingIdx(siblingCount - 1);
enterEdit(true);
};
const regenerateMessage = () => {
if (!isSubmitting && !message?.isCreatedByUser) {
regenerate(message);
}
};
const copyToClipboard = (setIsCopied) => {
setIsCopied(true);
copy(message?.text);
setTimeout(() => {
setIsCopied(false);
}, 3000);
};
const clickSearchResult = async () => {
if (!searchResult) {
return;
}
getConversationQuery.refetch(message.conversationId).then((response) => {
switchToConversation(response.data);
});
};
return (
<>
<div {...props} onWheel={handleWheel}>
<div className="relative m-auto flex gap-4 p-4 text-base md:max-w-2xl md:gap-6 md:py-6 lg:max-w-2xl lg:px-0 xl:max-w-3xl">
<div className="relative flex h-[30px] w-[30px] flex-col items-end text-right text-xs md:text-sm">
{typeof icon === 'string' && icon.match(/[^\\x00-\\x7F]+/) ? (
<span className=" direction-rtl w-40 overflow-x-scroll">{icon}</span>
) : (
icon
)}
<div className="sibling-switch invisible absolute left-0 top-2 -ml-4 flex -translate-x-full items-center justify-center gap-1 text-xs group-hover:visible">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
</div>
</div>
<div className="relative flex w-[calc(100%-50px)] flex-col gap-1 md:gap-3 lg:w-[calc(100%-115px)]">
{searchResult && (
<SubRow
classes={props.titleclass + ' rounded'}
subclasses="switch-result pl-2 pb-2"
onClick={clickSearchResult}
>
<strong>{`${message.title} | ${message.sender}`}</strong>
</SubRow>
)}
<div className="flex flex-grow flex-col gap-3">
{message.plugin && <Plugin plugin={message.plugin} />}
{error ? (
<div className="flex flex min-h-[20px] flex-grow flex-col items-start gap-2 gap-4 text-red-500">
<div className="rounded-md border border-red-500 bg-red-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-100">
{getError(text)}
</div>
</div>
) : edit ? (
<div className="flex min-h-[20px] flex-grow flex-col items-start gap-4 ">
{/* <div className={`${blinker ? 'result-streaming' : ''} markdown prose dark:prose-invert light w-full break-words`}> */}
<div
className="markdown prose dark:prose-invert light w-full whitespace-pre-wrap break-words border-none focus:outline-none"
contentEditable={true}
ref={textEditor}
suppressContentEditableWarning={true}
>
{text}
</div>
<div className="mt-2 flex w-full justify-center text-center">
<button
className="btn btn-primary relative mr-2"
disabled={isSubmitting}
onClick={resubmitMessage}
>
Save & Submit
</button>
<button className="btn btn-neutral relative" onClick={() => enterEdit(true)}>
Cancel
</button>
</div>
</div>
) : (
<>
<div
className={cn(
'flex min-h-[20px] flex-grow flex-col items-start gap-4 ',
isCreatedByUser ? 'whitespace-pre-wrap' : '',
)}
>
{/* <div className={`${blinker ? 'result-streaming' : ''} markdown prose dark:prose-invert light w-full break-words`}> */}
<div className="markdown prose dark:prose-invert light w-full break-words">
{!isCreatedByUser ? (
<>
<Content content={text} message={message} />
</>
) : (
<>{text}</>
)}
</div>
</div>
{/* {!isSubmitting && cancelled ? (
<div className="flex flex min-h-[20px] flex-grow flex-col items-start gap-2 gap-4 text-red-500">
<div className="rounded-md border border-blue-400 bg-blue-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-100">
{`This is a cancelled message.`}
</div>
</div>
) : null} */}
{!isSubmitting && unfinished ? (
<div className="flex flex min-h-[20px] flex-grow flex-col items-start gap-2 gap-4 text-red-500">
<div className="rounded-md border border-blue-400 bg-blue-500/10 px-3 py-2 text-sm text-gray-600 dark:text-gray-100">
{
'This is an unfinished message. The AI may still be generating a response, it was aborted, or a censor was triggered. Refresh or visit later to see more updates.'
}
</div>
</div>
) : null}
</>
)}
</div>
<HoverButtons
isEditing={edit}
isSubmitting={isSubmitting}
message={message}
conversation={conversation}
enterEdit={() => enterEdit()}
regenerate={() => regenerateMessage()}
handleContinue={handleContinue}
copyToClipboard={copyToClipboard}
/>
<SubRow subclasses="switch-container">
<SiblingSwitch
siblingIdx={siblingIdx}
siblingCount={siblingCount}
setSiblingIdx={setSiblingIdx}
/>
</SubRow>
</div>
</div>
</div>
<MultiMessage
messageId={message.messageId}
conversation={conversation}
messagesTree={message.children}
scrollToBottom={scrollToBottom}
currentEditId={currentEditId}
setCurrentEditId={setCurrentEditId}
/>
</>
);
}

View file

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

View file

@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useRecoilValue } from 'recoil'; import { useRecoilValue } from 'recoil';
import type { TPreset } from 'librechat-data-provider';
import { Plugin } from '~/components/svg'; import { Plugin } from '~/components/svg';
import EndpointOptionsDialog from '../Endpoints/EndpointOptionsDialog'; import EndpointOptionsDialog from '../Endpoints/EndpointOptionsDialog';
import { cn, alternateName } from '~/utils/'; import { cn, alternateName } from '~/utils/';
@ -10,7 +11,17 @@ const MessageHeader = ({ isSearchView = false }) => {
const [saveAsDialogShow, setSaveAsDialogShow] = useState(false); const [saveAsDialogShow, setSaveAsDialogShow] = useState(false);
const conversation = useRecoilValue(store.conversation); const conversation = useRecoilValue(store.conversation);
const searchQuery = useRecoilValue(store.searchQuery); const searchQuery = useRecoilValue(store.searchQuery);
if (!conversation) {
return null;
}
const { endpoint, model } = conversation; const { endpoint, model } = conversation;
if (!endpoint) {
return null;
}
const isNotClickable = endpoint === 'chatGPTBrowser'; const isNotClickable = endpoint === 'chatGPTBrowser';
const plugins = ( const plugins = (
@ -89,7 +100,7 @@ const MessageHeader = ({ isSearchView = false }) => {
<EndpointOptionsDialog <EndpointOptionsDialog
open={saveAsDialogShow} open={saveAsDialogShow}
onOpenChange={setSaveAsDialogShow} onOpenChange={setSaveAsDialogShow}
preset={conversation} preset={conversation as TPreset}
/> />
</> </>
); );

View file

@ -1,20 +1,20 @@
import React, { useEffect, useState, useRef, useCallback } from 'react'; import { useEffect, useState, useRef } from 'react';
import { useRecoilValue } from 'recoil';
import { Spinner } from '~/components';
import throttle from 'lodash/throttle';
import { CSSTransition } from 'react-transition-group'; import { CSSTransition } from 'react-transition-group';
import { useRecoilValue } from 'recoil';
import ScrollToBottom from './ScrollToBottom'; import ScrollToBottom from './ScrollToBottom';
import MultiMessage from './MultiMessage';
import MessageHeader from './MessageHeader'; import MessageHeader from './MessageHeader';
import { useScreenshot } from '~/hooks'; import MultiMessage from './MultiMessage';
import { Spinner } from '~/components';
import { useScreenshot, useScrollToRef } from '~/hooks';
import store from '~/store'; import store from '~/store';
export default function Messages({ isSearchView = false }) { export default function Messages({ isSearchView = false }) {
const [currentEditId, setCurrentEditId] = useState(-1); const [currentEditId, setCurrentEditId] = useState<number | string | null>(-1);
const [showScrollButton, setShowScrollButton] = useState(false); const [showScrollButton, setShowScrollButton] = useState(false);
const scrollableRef = useRef(null); const scrollableRef = useRef<HTMLDivElement | null>(null);
const messagesEndRef = useRef(null); const messagesEndRef = useRef<HTMLDivElement | null>(null);
const messagesTree = useRecoilValue(store.messagesTree); const messagesTree = useRecoilValue(store.messagesTree);
const showPopover = useRecoilValue(store.showPopover); const showPopover = useRecoilValue(store.showPopover);
@ -22,8 +22,8 @@ export default function Messages({ isSearchView = false }) {
const _messagesTree = isSearchView ? searchResultMessagesTree : messagesTree; const _messagesTree = isSearchView ? searchResultMessagesTree : messagesTree;
const conversation = useRecoilValue(store.conversation) || {}; const conversation = useRecoilValue(store.conversation);
const { conversationId } = conversation; const { conversationId } = conversation ?? {};
const { screenshotTargetRef } = useScreenshot(); const { screenshotTargetRef } = useScreenshot();
@ -62,42 +62,15 @@ export default function Messages({ isSearchView = false }) {
}; };
}, [_messagesTree]); }, [_messagesTree]);
// eslint-disable-next-line react-hooks/exhaustive-deps let timeoutId: ReturnType<typeof setTimeout> | undefined;
const scrollToBottom = useCallback(
throttle(
() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'instant' });
setShowScrollButton(false);
},
450,
{ leading: true },
),
[messagesEndRef],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const scrollToBottomSmooth = useCallback(
throttle(
() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
setShowScrollButton(false);
},
750,
{ leading: true },
),
[messagesEndRef],
);
let timeoutId = null;
const debouncedHandleScroll = () => { const debouncedHandleScroll = () => {
clearTimeout(timeoutId); clearTimeout(timeoutId);
timeoutId = setTimeout(handleScroll, 100); timeoutId = setTimeout(handleScroll, 100);
}; };
const scrollHandler = (e) => { const { scrollToRef: scrollToBottom, handleSmoothToRef } = useScrollToRef(messagesEndRef, () =>
e.preventDefault(); setShowScrollButton(false),
scrollToBottomSmooth(); );
};
return ( return (
<div <div
@ -120,11 +93,11 @@ export default function Messages({ isSearchView = false }) {
<> <>
<MultiMessage <MultiMessage
key={conversationId} // avoid internal state mixture key={conversationId} // avoid internal state mixture
messageId={conversationId} messageId={conversationId ?? null}
conversation={conversation} conversation={conversation}
messagesTree={_messagesTree} messagesTree={_messagesTree}
scrollToBottom={scrollToBottom} scrollToBottom={scrollToBottom}
currentEditId={currentEditId} currentEditId={currentEditId ?? null}
setCurrentEditId={setCurrentEditId} setCurrentEditId={setCurrentEditId}
isSearchView={isSearchView} isSearchView={isSearchView}
/> />
@ -137,7 +110,7 @@ export default function Messages({ isSearchView = false }) {
> >
{() => {() =>
showScrollButton && showScrollButton &&
!showPopover && <ScrollToBottom scrollHandler={scrollHandler} /> !showPopover && <ScrollToBottom scrollHandler={handleSmoothToRef} />
} }
</CSSTransition> </CSSTransition>
</> </>

View file

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

View file

@ -1,13 +1,26 @@
import React from 'react'; import type { TMessageProps } from '~/common';
type TSiblingSwitchProps = Pick<TMessageProps, 'siblingIdx' | 'siblingCount' | 'setSiblingIdx'>;
export default function SiblingSwitch({
siblingIdx,
siblingCount,
setSiblingIdx,
}: TSiblingSwitchProps) {
if (siblingIdx === undefined) {
return null;
} else if (siblingCount === undefined) {
return null;
}
export default function SiblingSwitch({ siblingIdx, siblingCount, setSiblingIdx }) {
const previous = () => { const previous = () => {
setSiblingIdx(siblingIdx - 1); setSiblingIdx && setSiblingIdx(siblingIdx - 1);
}; };
const next = () => { const next = () => {
setSiblingIdx(siblingIdx + 1); setSiblingIdx && setSiblingIdx(siblingIdx + 1);
}; };
return siblingCount > 1 ? ( return siblingCount > 1 ? (
<> <>
<button <button
@ -27,7 +40,7 @@ export default function SiblingSwitch({ siblingIdx, siblingCount, setSiblingIdx
width="1em" width="1em"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<polyline points="15 18 9 12 15 6"></polyline> <polyline points="15 18 9 12 15 6" />
</svg> </svg>
</button> </button>
<span className="flex-shrink-0 flex-grow"> <span className="flex-shrink-0 flex-grow">
@ -50,7 +63,7 @@ export default function SiblingSwitch({ siblingIdx, siblingCount, setSiblingIdx
width="1em" width="1em"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
> >
<polyline points="9 18 15 12 9 6"></polyline> <polyline points="9 18 15 12 9 6" />
</svg> </svg>
</button> </button>
</> </>

View file

@ -1,6 +1,6 @@
import { cn } from '~/utils/'; import { cn } from '~/utils/';
export default function Plugin({ className, ...props }) { export default function Plugin({ className = '', ...props }) {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"

View file

@ -1,8 +1,8 @@
import { createContext, useRef, useContext, Ref } from 'react'; import { createContext, useRef, useContext, RefObject } from 'react';
import { toCanvas } from 'html-to-image'; import { toCanvas } from 'html-to-image';
type ScreenshotContextType = { type ScreenshotContextType = {
ref?: Ref<HTMLElement>; ref?: RefObject<HTMLDivElement>;
}; };
const ScreenshotContext = createContext<ScreenshotContextType>({}); const ScreenshotContext = createContext<ScreenshotContextType>({});

View file

@ -7,5 +7,6 @@ export { default as useLocalize } from './useLocalize';
export { default as useMediaQuery } from './useMediaQuery'; export { default as useMediaQuery } from './useMediaQuery';
export { default as useSetOptions } from './useSetOptions'; export { default as useSetOptions } from './useSetOptions';
export { default as useGenerations } from './useGenerations'; export { default as useGenerations } from './useGenerations';
export { default as useScrollToRef } from './useScrollToRef';
export { default as useServerStream } from './useServerStream'; export { default as useServerStream } from './useServerStream';
export { default as useMessageHandler } from './useMessageHandler'; export { default as useMessageHandler } from './useMessageHandler';

View file

@ -18,12 +18,17 @@ export default function useGenerations({
const latestMessage = useRecoilValue(store.latestMessage); const latestMessage = useRecoilValue(store.latestMessage);
const { error, messageId, searchResult, finish_reason, isCreatedByUser } = message ?? {}; const { error, messageId, searchResult, finish_reason, isCreatedByUser } = message ?? {};
const isEditableEndpoint = !!['azureOpenAI', 'openAI', 'gptPlugins', 'anthropic'].find(
(e) => e === endpoint,
);
const continueSupported = const continueSupported =
latestMessage?.messageId === messageId && latestMessage?.messageId === messageId &&
finish_reason && finish_reason &&
finish_reason !== 'stop' && finish_reason !== 'stop' &&
!!['azureOpenAI', 'openAI', 'gptPlugins', 'anthropic'].find((e) => e === endpoint); !isEditing &&
!searchResult &&
isEditableEndpoint;
const branchingSupported = const branchingSupported =
// 5/21/23: Bing is allowing editing and Message regenerating // 5/21/23: Bing is allowing editing and Message regenerating
@ -37,19 +42,15 @@ export default function useGenerations({
'anthropic', 'anthropic',
].find((e) => e === endpoint); ].find((e) => e === endpoint);
const editEnabled =
!error &&
isCreatedByUser && // TODO: allow AI editing
!searchResult &&
!isEditing &&
branchingSupported;
const regenerateEnabled = const regenerateEnabled =
!isCreatedByUser && !searchResult && !isEditing && !isSubmitting && branchingSupported; !isCreatedByUser && !searchResult && !isEditing && !isSubmitting && branchingSupported;
const hideEditButton =
error || searchResult || !branchingSupported || (!isEditableEndpoint && !isCreatedByUser);
return { return {
continueSupported, continueSupported,
editEnabled,
regenerateEnabled, regenerateEnabled,
hideEditButton,
}; };
} }

View file

@ -2,28 +2,31 @@ import { v4 } from 'uuid';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { parseConvo, getResponseSender } from 'librechat-data-provider'; import { parseConvo, getResponseSender } from 'librechat-data-provider';
import type { TMessage, TSubmission } from 'librechat-data-provider'; import type { TMessage, TSubmission } from 'librechat-data-provider';
import type { TAskFunction } from '~/common';
import store from '~/store'; import store from '~/store';
type TAskProps = {
text: string;
parentMessageId?: string | null;
conversationId?: string | null;
messageId?: string | null;
};
const useMessageHandler = () => { const useMessageHandler = () => {
const latestMessage = useRecoilValue(store.latestMessage);
const setSiblingIdx = useSetRecoilState(
store.messagesSiblingIdxFamily(latestMessage?.parentMessageId),
);
const currentConversation = useRecoilValue(store.conversation) || { endpoint: null }; const currentConversation = useRecoilValue(store.conversation) || { endpoint: null };
const setSubmission = useSetRecoilState(store.submission); const setSubmission = useSetRecoilState(store.submission);
const isSubmitting = useRecoilValue(store.isSubmitting); const isSubmitting = useRecoilValue(store.isSubmitting);
const endpointsConfig = useRecoilValue(store.endpointsConfig); const endpointsConfig = useRecoilValue(store.endpointsConfig);
const latestMessage = useRecoilValue(store.latestMessage);
const [messages, setMessages] = useRecoilState(store.messages); const [messages, setMessages] = useRecoilState(store.messages);
const { endpoint } = currentConversation; const { endpoint } = currentConversation;
const { getToken } = store.useToken(endpoint ?? ''); const { getToken } = store.useToken(endpoint ?? '');
const ask = ( const ask: TAskFunction = (
{ text, parentMessageId = null, conversationId = null, messageId = null }: TAskProps, { text, parentMessageId = null, conversationId = null, messageId = null },
{ isRegenerate = false, isEdited = false } = {}, {
editedText = null,
editedMessageId = null,
isRegenerate = false,
isContinued = false,
isEdited = false,
} = {},
) => { ) => {
if (!!isSubmitting || text === '') { if (!!isSubmitting || text === '') {
return; return;
@ -40,11 +43,12 @@ const useMessageHandler = () => {
return; return;
} }
if (isEdited && !latestMessage) { if (isContinued && !latestMessage) {
console.error('cannot edit AI message without latestMessage!'); console.error('cannot continue AI message without latestMessage!');
return; return;
} }
const isEditOrContinue = isEdited || isContinued;
const { userProvide } = endpointsConfig[endpoint] ?? {}; const { userProvide } = endpointsConfig[endpoint] ?? {};
// set the endpoint option // set the endpoint option
@ -77,15 +81,17 @@ const useMessageHandler = () => {
isCreatedByUser: true, isCreatedByUser: true,
parentMessageId, parentMessageId,
conversationId, conversationId,
messageId: isEdited && messageId ? messageId : fakeMessageId, messageId: isContinued && messageId ? messageId : fakeMessageId,
error: false, error: false,
}; };
// construct the placeholder response message // construct the placeholder response message
const generation = latestMessage?.text ?? ''; const generation = editedText ?? latestMessage?.text ?? '';
const responseText = isEdited ? generation : '<span className="result-streaming">█</span>'; const responseText = isEditOrContinue
? generation
: '<span className="result-streaming">█</span>';
const responseMessageId = isEdited ? latestMessage?.messageId : null; const responseMessageId = editedMessageId ?? latestMessage?.messageId ?? null;
const initialResponse: TMessage = { const initialResponse: TMessage = {
sender: responseSender, sender: responseSender,
text: responseText, text: responseText,
@ -98,6 +104,10 @@ const useMessageHandler = () => {
error: false, error: false,
}; };
if (isContinued) {
currentMessages = currentMessages.filter((msg) => msg.messageId !== responseMessageId);
}
const submission: TSubmission = { const submission: TSubmission = {
conversation: { conversation: {
...currentConversation, ...currentConversation,
@ -111,7 +121,8 @@ const useMessageHandler = () => {
overrideParentMessageId: isRegenerate ? messageId : null, overrideParentMessageId: isRegenerate ? messageId : null,
}, },
messages: currentMessages, messages: currentMessages,
isEdited, isEdited: isEditOrContinue,
isContinued,
isRegenerate, isRegenerate,
initialResponse, initialResponse,
}; };
@ -119,12 +130,9 @@ const useMessageHandler = () => {
console.log('User Input:', text, submission); console.log('User Input:', text, submission);
if (isRegenerate) { if (isRegenerate) {
setMessages([ setMessages([...submission.messages, initialResponse]);
...(isEdited ? currentMessages.slice(0, -1) : currentMessages),
initialResponse,
]);
} else { } else {
setMessages([...currentMessages, currentMsg, initialResponse]); setMessages([...submission.messages, currentMsg, initialResponse]);
} }
setSubmission(submission); setSubmission(submission);
}; };
@ -152,7 +160,7 @@ const useMessageHandler = () => {
); );
if (parentMessage && parentMessage.isCreatedByUser) { if (parentMessage && parentMessage.isCreatedByUser) {
ask({ ...parentMessage }, { isRegenerate: true, isEdited: true }); ask({ ...parentMessage }, { isContinued: true, isRegenerate: true, isEdited: true });
} else { } else {
console.error( console.error(
'Failed to regenerate the message: parentMessage not found, or not created by user.', 'Failed to regenerate the message: parentMessage not found, or not created by user.',
@ -182,6 +190,7 @@ const useMessageHandler = () => {
const handleContinue = (e: React.MouseEvent<HTMLButtonElement>) => { const handleContinue = (e: React.MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
continueGeneration(); continueGeneration();
setSiblingIdx(0);
}; };
return { return {

View file

@ -0,0 +1,40 @@
import { RefObject, useCallback } from 'react';
import throttle from 'lodash/throttle';
export default function useScrollToRef(targetRef: RefObject<HTMLDivElement>, callback: () => void) {
// eslint-disable-next-line react-hooks/exhaustive-deps
const scrollToRef = useCallback(
throttle(
() => {
targetRef.current?.scrollIntoView({ behavior: 'instant' });
callback();
},
450,
{ leading: true },
),
[targetRef],
);
// eslint-disable-next-line react-hooks/exhaustive-deps
const scrollToRefSmooth = useCallback(
throttle(
() => {
targetRef.current?.scrollIntoView({ behavior: 'smooth' });
callback();
},
750,
{ leading: true },
),
[targetRef],
);
const handleSmoothToRef: React.MouseEventHandler<HTMLButtonElement> = (e) => {
e.preventDefault();
scrollToRefSmooth();
};
return {
scrollToRef,
handleSmoothToRef,
};
}

View file

@ -1,12 +1,12 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { useResetRecoilState, useSetRecoilState } from 'recoil'; import { useResetRecoilState, useSetRecoilState } from 'recoil';
import { SSE, createPayload, tMessageSchema, tConversationSchema } from 'librechat-data-provider'; import { SSE, createPayload, tMessageSchema, tConversationSchema } from 'librechat-data-provider';
import type { TPlugin, TMessage, TConversation, TSubmission } from 'librechat-data-provider'; import type { TResPlugin, TMessage, TConversation, TSubmission } from 'librechat-data-provider';
import { useAuthContext } from '~/hooks/AuthContext'; import { useAuthContext } from '~/hooks/AuthContext';
import store from '~/store'; import store from '~/store';
type TResData = { type TResData = {
plugin: TPlugin; plugin: TResPlugin;
final?: boolean; final?: boolean;
initial?: boolean; initial?: boolean;
requestMessage: TMessage; requestMessage: TMessage;
@ -24,18 +24,11 @@ export default function useServerStream(submission: TSubmission | null) {
const { refreshConversations } = store.useConversations(); const { refreshConversations } = store.useConversations();
const messageHandler = (data: string, submission: TSubmission) => { const messageHandler = (data: string, submission: TSubmission) => {
const { const { messages, message, plugin, initialResponse, isRegenerate = false } = submission;
messages,
message,
plugin,
initialResponse,
isRegenerate = false,
isEdited = false,
} = submission;
if (isRegenerate) { if (isRegenerate) {
setMessages([ setMessages([
...(isEdited ? messages.slice(0, -1) : messages), ...messages,
{ {
...initialResponse, ...initialResponse,
text: data, text: data,
@ -65,11 +58,11 @@ export default function useServerStream(submission: TSubmission | null) {
const cancelHandler = (data: TResData, submission: TSubmission) => { const cancelHandler = (data: TResData, submission: TSubmission) => {
const { requestMessage, responseMessage, conversation } = data; const { requestMessage, responseMessage, conversation } = data;
const { messages, isRegenerate = false, isEdited = false } = submission; const { messages, isRegenerate = false } = submission;
// update the messages // update the messages
if (isRegenerate) { if (isRegenerate) {
setMessages([...(isEdited ? messages.slice(0, -1) : messages), responseMessage]); setMessages([...messages, responseMessage]);
} else { } else {
setMessages([...messages, requestMessage, responseMessage]); setMessages([...messages, requestMessage, responseMessage]);
} }
@ -94,17 +87,11 @@ export default function useServerStream(submission: TSubmission | null) {
}; };
const createdHandler = (data: TResData, submission: TSubmission) => { const createdHandler = (data: TResData, submission: TSubmission) => {
const { const { messages, message, initialResponse, isRegenerate = false } = submission;
messages,
message,
initialResponse,
isRegenerate = false,
isEdited = false,
} = submission;
if (isRegenerate) { if (isRegenerate) {
setMessages([ setMessages([
...(isEdited ? messages.slice(0, -1) : messages), ...messages,
{ {
...initialResponse, ...initialResponse,
parentMessageId: message?.overrideParentMessageId ?? null, parentMessageId: message?.overrideParentMessageId ?? null,
@ -137,11 +124,11 @@ export default function useServerStream(submission: TSubmission | null) {
const finalHandler = (data: TResData, submission: TSubmission) => { const finalHandler = (data: TResData, submission: TSubmission) => {
const { requestMessage, responseMessage, conversation } = data; const { requestMessage, responseMessage, conversation } = data;
const { messages, isRegenerate = false, isEdited = false } = submission; const { messages, isRegenerate = false } = submission;
// update the messages // update the messages
if (isRegenerate) { if (isRegenerate) {
setMessages([...(isEdited ? messages.slice(0, -1) : messages), responseMessage]); setMessages([...messages, responseMessage]);
} else { } else {
setMessages([...messages, requestMessage, responseMessage]); setMessages([...messages, requestMessage, responseMessage]);
} }

View file

@ -4,7 +4,7 @@ import { useNavigate, useParams } from 'react-router-dom';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import Landing from '~/components/ui/Landing'; import Landing from '~/components/ui/Landing';
import Messages from '~/components/Messages'; import Messages from '~/components/Messages/Messages';
import TextChat from '~/components/Input/TextChat'; import TextChat from '~/components/Input/TextChat';
import store from '~/store'; import store from '~/store';

View file

@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { useRecoilState, useRecoilValue } from 'recoil'; import { useRecoilState, useRecoilValue } from 'recoil';
import Messages from '~/components/Messages'; import Messages from '~/components/Messages/Messages';
import TextChat from '~/components/Input/TextChat'; import TextChat from '~/components/Input/TextChat';
import store from '~/store'; import store from '~/store';

View file

@ -12,7 +12,10 @@ function isUUID(uuid: string) {
} }
const waitForServerStream = async (response: Response) => { const waitForServerStream = async (response: Response) => {
return response.url().includes(`/api/ask/${endpoint}`) && response.status() === 200; const endpointCheck =
response.url().includes(`/api/ask/${endpoint}`) ||
response.url().includes(`/api/edit/${endpoint}`);
return endpointCheck && response.status() === 200;
}; };
async function clearConvos(page: Page) { async function clearConvos(page: Page) {
@ -52,7 +55,7 @@ test.afterEach(async ({ page }) => {
}); });
test.describe('Messaging suite', () => { test.describe('Messaging suite', () => {
test('textbox should be focused after receiving message & test expected navigation', async ({ test('textbox should be focused after generation, test expected navigation, & test editing messages', async ({
page, page,
}) => { }) => {
test.setTimeout(120000); test.setTimeout(120000);
@ -91,6 +94,33 @@ test.describe('Messaging suite', () => {
const finalUrl = page.url(); const finalUrl = page.url();
const conversationId = finalUrl.split(basePath).pop() ?? ''; const conversationId = finalUrl.split(basePath).pop() ?? '';
expect(isUUID(conversationId)).toBeTruthy(); expect(isUUID(conversationId)).toBeTruthy();
// Check if editing works
const editText = 'All work and no play makes Johnny a poor boy';
await page.getByRole('button', { name: 'edit' }).click();
const textEditor = page.getByTestId('message-text-editor');
await textEditor.click();
await textEditor.fill(editText);
await page.getByRole('button', { name: 'Save', exact: true }).click();
const updatedTextElement = page.getByText(editText);
expect(updatedTextElement).toBeTruthy();
// Check edit response
await page.getByRole('button', { name: 'edit' }).click();
const editResponsePromise = [
page.waitForResponse(waitForServerStream),
await page.getByRole('button', { name: 'Save & Submit' }).click(),
];
const [editResponse] = (await Promise.all(editResponsePromise)) as [Response];
const editResponseBody = await editResponse.body();
const editSuccess = editResponseBody.includes('"final":true');
expect(editSuccess).toBe(true);
// The generated message should include the edited text
const currentTextContent = await updatedTextElement.innerText();
expect(currentTextContent.includes(editText)).toBeTruthy();
}); });
test('message should stop and continue', async ({ page }) => { test('message should stop and continue', async ({ page }) => {
@ -124,6 +154,9 @@ test.describe('Messaging suite', () => {
const regenerateButton = page.getByRole('button', { name: 'Regenerate' }); const regenerateButton = page.getByRole('button', { name: 'Regenerate' });
expect(regenerateButton).toBeTruthy(); expect(regenerateButton).toBeTruthy();
// Clear conversation since it seems to persist despite other tests clearing it
await page.getByTestId('convo-item').getByRole('button').nth(1).click();
}); });
// in this spec as we are testing post-message navigation, we are not testing the message response // in this spec as we are testing post-message navigation, we are not testing the message response

4
package-lock.json generated
View file

@ -48,7 +48,7 @@
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.5.4", "@anthropic-ai/sdk": "^0.5.4",
"@azure/search-documents": "^11.3.2", "@azure/search-documents": "^11.3.2",
"@dqbd/tiktoken": "^1.0.2", "@dqbd/tiktoken": "^1.0.7",
"@fortaine/fetch-event-source": "^3.0.6", "@fortaine/fetch-event-source": "^3.0.6",
"@keyv/mongo": "^2.1.8", "@keyv/mongo": "^2.1.8",
"@waylaidwanderer/chatgpt-api": "^1.37.2", "@waylaidwanderer/chatgpt-api": "^1.37.2",
@ -26431,7 +26431,7 @@
}, },
"packages/data-provider": { "packages/data-provider": {
"name": "librechat-data-provider", "name": "librechat-data-provider",
"version": "0.1.5", "version": "0.1.6",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@tanstack/react-query": "^4.28.0", "@tanstack/react-query": "^4.28.0",

View file

@ -1,6 +1,6 @@
{ {
"name": "librechat-data-provider", "name": "librechat-data-provider",
"version": "0.1.5", "version": "0.1.6",
"description": "data services for librechat apps", "description": "data services for librechat apps",
"main": "dist/index.js", "main": "dist/index.js",
"module": "dist/index.es.js", "module": "dist/index.es.js",

View file

@ -6,8 +6,8 @@ export const userPlugins = () => {
return '/api/user/plugins'; return '/api/user/plugins';
}; };
export const messages = (id: string) => { export const messages = (conversationId: string, messageId?: string) => {
return `/api/messages/${id}`; return `/api/messages/${conversationId}${messageId ? `/${messageId}` : ''}`;
}; };
export const abortRequest = (endpoint: string) => { export const abortRequest = (endpoint: string) => {

View file

@ -2,7 +2,7 @@ import { tConversationSchema } from './schemas';
import { TSubmission, EModelEndpoint } from './types'; import { TSubmission, EModelEndpoint } from './types';
export default function createPayload(submission: TSubmission) { export default function createPayload(submission: TSubmission) {
const { conversation, message, endpointOption, isEdited } = submission; const { conversation, message, endpointOption, isEdited, isContinued } = submission;
const { conversationId } = tConversationSchema.parse(conversation); const { conversationId } = tConversationSchema.parse(conversation);
const { endpoint } = endpointOption as { endpoint: EModelEndpoint }; const { endpoint } = endpointOption as { endpoint: EModelEndpoint };
@ -26,6 +26,7 @@ export default function createPayload(submission: TSubmission) {
const payload = { const payload = {
...message, ...message,
...endpointOption, ...endpointOption,
isContinued: isEdited && isContinued,
conversationId, conversationId,
}; };

View file

@ -24,8 +24,8 @@ export function clearAllConversations(): Promise<unknown> {
return request.post(endpoints.deleteConversation(), { arg: {} }); return request.post(endpoints.deleteConversation(), { arg: {} });
} }
export function getMessagesByConvoId(id: string): Promise<s.TMessage[]> { export function getMessagesByConvoId(conversationId: string): Promise<s.TMessage[]> {
return request.get(endpoints.messages(id)); return request.get(endpoints.messages(conversationId));
} }
export function getConversationById(id: string): Promise<s.TConversation> { export function getConversationById(id: string): Promise<s.TConversation> {
@ -38,6 +38,15 @@ export function updateConversation(
return request.post(endpoints.updateConversation(), { arg: payload }); return request.post(endpoints.updateConversation(), { arg: payload });
} }
export function updateMessage(payload: t.TUpdateMessageRequest): Promise<unknown> {
const { conversationId, messageId, text } = payload;
if (!conversationId) {
throw new Error('conversationId is required');
}
return request.put(endpoints.messages(conversationId, messageId), { text });
}
export function getPresets(): Promise<s.TPreset[]> { export function getPresets(): Promise<s.TPreset[]> {
return request.get(endpoints.presets()); return request.get(endpoints.presets());
} }

View file

@ -110,6 +110,17 @@ export const useUpdateConversationMutation = (
); );
}; };
export const useUpdateMessageMutation = (
id: string,
): UseMutationResult<unknown, unknown, t.TUpdateMessageRequest, unknown> => {
const queryClient = useQueryClient();
return useMutation((payload: t.TUpdateMessageRequest) => dataService.updateMessage(payload), {
onSuccess: () => {
queryClient.invalidateQueries([QueryKeys.messages, id]);
},
});
};
export const useDeleteConversationMutation = ( export const useDeleteConversationMutation = (
id?: string, id?: string,
): UseMutationResult< ): UseMutationResult<

View file

@ -32,6 +32,20 @@ export const tPluginSchema = z.object({
export type TPlugin = z.infer<typeof tPluginSchema>; export type TPlugin = z.infer<typeof tPluginSchema>;
export type TInput = {
inputStr: string;
};
export type TResPlugin = {
plugin: string;
input: string;
thought: string;
loading?: boolean;
outputs?: string;
latest?: string;
inputs?: TInput[];
};
export const tExampleSchema = z.object({ export const tExampleSchema = z.object({
input: z.object({ input: z.object({
content: z.string(), content: z.string(),
@ -57,7 +71,9 @@ export const tMessageSchema = z.object({
parentMessageId: z.string().nullable(), parentMessageId: z.string().nullable(),
responseMessageId: z.string().nullable().optional(), responseMessageId: z.string().nullable().optional(),
overrideParentMessageId: z.string().nullable().optional(), overrideParentMessageId: z.string().nullable().optional(),
plugin: tPluginSchema.nullable().optional(), bg: z.string().nullable().optional(),
model: z.string().nullable().optional(),
title: z.string().nullable().optional(),
sender: z.string(), sender: z.string(),
text: z.string(), text: z.string(),
generation: z.string().nullable().optional(), generation: z.string().nullable().optional(),
@ -78,7 +94,10 @@ export const tMessageSchema = z.object({
finish_reason: z.string().optional(), finish_reason: z.string().optional(),
}); });
export type TMessage = z.input<typeof tMessageSchema>; export type TMessage = z.input<typeof tMessageSchema> & {
children?: TMessage[];
plugin?: TResPlugin | null;
};
export const tConversationSchema = z.object({ export const tConversationSchema = z.object({
conversationId: z.string().nullable(), conversationId: z.string().nullable(),

View file

@ -1,4 +1,4 @@
import type { TPlugin, TMessage, TConversation, TEndpointOption } from './schemas'; import type { TResPlugin, TMessage, TConversation, TEndpointOption } from './schemas';
export * from './schemas'; export * from './schemas';
@ -7,9 +7,10 @@ export type TMessages = TMessage[];
export type TMessagesAtom = TMessages | null; export type TMessagesAtom = TMessages | null;
export type TSubmission = { export type TSubmission = {
plugin?: TPlugin; plugin?: TResPlugin;
message: TMessage; message: TMessage;
isEdited?: boolean; isEdited?: boolean;
isContinued?: boolean;
messages: TMessage[]; messages: TMessage[];
isRegenerate?: boolean; isRegenerate?: boolean;
conversationId?: string; conversationId?: string;
@ -37,6 +38,7 @@ export type TError = {
data?: { data?: {
message?: string; message?: string;
}; };
status?: number;
}; };
}; };
@ -60,6 +62,12 @@ export type TGetConversationsResponse = {
pages: string | number; pages: string | number;
}; };
export type TUpdateMessageRequest = {
conversationId: string;
messageId: string;
text: string;
};
export type TUpdateConversationRequest = { export type TUpdateConversationRequest = {
conversationId: string; conversationId: string;
title: string; title: string;