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') {
this.setOptions(opts);
}
const { isEdited, isContinued } = opts;
const user = opts.user ?? null;
const saveOptions = this.getSaveOptions();
this.abortController = opts.abortController ?? new AbortController();
const conversationId = opts.conversationId ?? crypto.randomUUID();
const parentMessageId = opts.parentMessageId ?? '00000000-0000-0000-0000-000000000000';
const userMessageId = opts.overrideParentMessageId ?? crypto.randomUUID();
const responseMessageId = opts.responseMessageId ?? crypto.randomUUID();
const saveOptions = this.getSaveOptions();
const head = opts.isEdited ? responseMessageId : parentMessageId;
let responseMessageId = opts.responseMessageId ?? crypto.randomUUID();
let head = isEdited ? responseMessageId : parentMessageId;
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 {
...opts,
@ -397,11 +405,16 @@ class BaseClient {
const { user, head, isEdited, conversationId, responseMessageId, saveOptions, userMessage } =
await this.handleStartMethods(message, opts);
const { generation = '' } = opts;
this.user = user;
// It's not necessary to push to currentMessages
// depending on subclass implementation of handling messages
// 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);
}
@ -419,7 +432,7 @@ class BaseClient {
if (this.options.debug) {
console.debug('payload');
// console.debug(payload);
console.debug(payload);
}
if (tokenCountMap) {
@ -442,7 +455,6 @@ class BaseClient {
await this.saveMessageToDatabase(userMessage, saveOptions, user);
}
const generation = isEdited ? this.currentMessages[this.currentMessages.length - 1].text : '';
const responseMessage = {
messageId: responseMessageId,
conversationId,

View file

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

View file

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

View file

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

View file

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

View file

@ -3,6 +3,7 @@ const setHeaders = require('./setHeaders');
const requireJwtAuth = require('./requireJwtAuth');
const requireLocalAuth = require('./requireLocalAuth');
const validateEndpoint = require('./validateEndpoint');
const validateMessageReq = require('./validateMessageReq');
const buildEndpointOption = require('./buildEndpointOption');
const validateRegistration = require('./validateRegistration');
@ -12,6 +13,7 @@ module.exports = {
requireJwtAuth,
requireLocalAuth,
validateEndpoint,
validateMessageReq,
buildEndpointOption,
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,
conversationId,
responseMessageId,
isContinued = false,
parentMessageId = null,
overrideParentMessageId = null,
} = req.body;
console.log('edit log');
console.dir({ text, conversationId, endpointOption }, { depth: null });
console.dir({ text, generation, isContinued, conversationId, endpointOption }, { depth: null });
let metadata;
let userMessage;
let lastSavedTimestamp = 0;
@ -41,7 +42,10 @@ router.post(
const userMessageId = parentMessageId;
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({
generation,
@ -87,6 +91,8 @@ router.post(
let response = await client.sendMessage(text, {
user: req.user.id,
generation,
isContinued,
isEdited: true,
conversationId,
parentMessageId,

View file

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

View file

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

View file

@ -1,11 +1,50 @@
const express = require('express');
const router = express.Router();
const { getMessages } = require('../../models/Message');
const requireJwtAuth = require('../middleware/requireJwtAuth');
const {
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;
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;

View file

@ -1,4 +1,4 @@
const _ = require('lodash');
const partialRight = require('lodash/partialRight');
const citationRegex = /\[\^\d+?\^]/g;
const { getCitations, citeText } = require('./citations');
const cursor = '<span className="result-streaming">█</span>';
@ -73,7 +73,7 @@ const createOnProgress = ({ generation = '', onProgress: _onProgress }) => {
};
const onProgress = (opts) => {
return _.partialRight(progressCallback, opts);
return partialRight(progressCallback, opts);
};
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 TSetExample = (
@ -62,3 +62,34 @@ export type TOnClick = (e: React.MouseEvent<HTMLButtonElement>) => void;
export type TGenButtonProps = {
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 { useMessageHandler, useMediaQuery, useGenerations } from '~/hooks';
import { cn } from '~/utils';
@ -31,6 +32,27 @@ export default function GenerationButtons({
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) {
return null;
}
@ -38,8 +60,8 @@ export default function GenerationButtons({
let button: React.ReactNode = null;
if (isSubmitting) {
button = <Stop onClick={handleStopGenerating} />;
} else if (continueSupported) {
button = <Stop onClick={handleStop} />;
} else if (userStopped || continueSupported) {
button = <Continue onClick={handleContinue} />;
} else if (messages && messages.length > 0 && regenerateEnabled) {
button = <Regenerate onClick={handleRegenerate} />;

View file

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

View file

@ -1,6 +1,8 @@
import React, { useState, useEffect } from 'react';
import type { TMessage } from 'librechat-data-provider';
import { useRecoilValue } from 'recoil';
import ReactMarkdown from 'react-markdown';
import type { PluggableList } from 'unified';
import rehypeKatex from 'rehype-katex';
import rehypeHighlight from 'rehype-highlight';
import remarkMath from 'remark-math';
@ -11,8 +13,18 @@ import CodeBlock from './CodeBlock';
import store from '~/store';
import { langSubset } from '~/utils';
const code = React.memo((props) => {
const { inline, className, children } = props;
type TCodeProps = {
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 lang = match && match[1];
@ -23,11 +35,11 @@ const code = React.memo((props) => {
}
});
const p = React.memo((props) => {
return <p className="mb-2 whitespace-pre-wrap">{props?.children}</p>;
const p = React.memo(({ children }: { children: React.ReactNode }) => {
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 isSubmitting = useRecoilValue(store.isSubmitting);
const latestMessage = useRecoilValue(store.latestMessage);
@ -57,7 +69,7 @@ const Content = React.memo(({ content, message }) => {
};
}, [isSubmitting, isLatestMessage]);
let rehypePlugins = [
const rehypePlugins: PluggableList = [
[rehypeKatex, { output: 'mathml' }],
[
rehypeHighlight,
@ -79,10 +91,14 @@ const Content = React.memo(({ content, message }) => {
remarkPlugins={[supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]}
rehypePlugins={rehypePlugins}
linkTarget="_new"
components={{
code,
p,
}}
components={
{
code,
p,
} as {
[nodeType: string]: React.ElementType;
}
}
>
{isLatestMessage && isSubmitting && !isInitializing
? 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 { Spinner } from '~/components';
import { useRecoilValue } from 'recoil';
import CodeBlock from './Content/CodeBlock.jsx';
import { Disclosure } from '@headlessui/react';
import { useState, useCallback, memo, ReactNode } from 'react';
import type { TResPlugin, TInput } from 'librechat-data-provider';
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 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 = {
[pluginKey: string]: string;
};
@ -31,7 +16,7 @@ type PluginIconProps = LucideProps & {
className?: string;
};
function formatInputs(inputs: Input[]) {
function formatInputs(inputs: TInput[]) {
let output = '';
for (let i = 0; i < inputs.length; i++) {
@ -45,6 +30,10 @@ function formatInputs(inputs: Input[]) {
return output;
}
type PluginProps = {
plugin: TResPlugin;
};
const Plugin: React.FC<PluginProps> = ({ plugin }) => {
const [loading, setLoading] = useState(plugin.loading);
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 (
<div className={`flex justify-between ${classes}`} onClick={onClick}>
<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 = {
isEditing: boolean;
enterEdit: () => void;
copyToClipboard: (setIsCopied: (isCopied: boolean) => void) => void;
conversation: TConversation;
enterEdit: (cancel?: boolean) => void;
copyToClipboard: (setIsCopied: React.Dispatch<React.SetStateAction<boolean>>) => void;
conversation: TConversation | null;
isSubmitting: boolean;
message: TMessage;
regenerate: () => void;
@ -25,33 +25,47 @@ export default function HoverButtons({
regenerate,
handleContinue,
}: THoverButtons) {
const { endpoint } = conversation;
const { endpoint } = conversation ?? {};
const [isCopied, setIsCopied] = useState(false);
const { editEnabled, regenerateEnabled, continueSupported } = useGenerations({
const { hideEditButton, regenerateEnabled, continueSupported } = useGenerations({
isEditing,
isSubmitting,
message,
endpoint: endpoint ?? '',
});
if (!conversation) {
return null;
}
const { isCreatedByUser } = message;
const onEdit = () => {
if (isEditing) {
return enterEdit(true);
}
enterEdit();
};
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">
<button
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',
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"
title="edit"
disabled={!editEnabled}
disabled={hideEditButton}
>
<EditIcon />
</button>
<button
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',
message?.isCreatedByUser ? '' : 'active',
isCreatedByUser ? '' : 'active',
)}
onClick={() => copyToClipboard(setIsCopied)}
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 { useRecoilValue } from 'recoil';
import type { TPreset } from 'librechat-data-provider';
import { Plugin } from '~/components/svg';
import EndpointOptionsDialog from '../Endpoints/EndpointOptionsDialog';
import { cn, alternateName } from '~/utils/';
@ -10,7 +11,17 @@ const MessageHeader = ({ isSearchView = false }) => {
const [saveAsDialogShow, setSaveAsDialogShow] = useState(false);
const conversation = useRecoilValue(store.conversation);
const searchQuery = useRecoilValue(store.searchQuery);
if (!conversation) {
return null;
}
const { endpoint, model } = conversation;
if (!endpoint) {
return null;
}
const isNotClickable = endpoint === 'chatGPTBrowser';
const plugins = (
@ -89,7 +100,7 @@ const MessageHeader = ({ isSearchView = false }) => {
<EndpointOptionsDialog
open={saveAsDialogShow}
onOpenChange={setSaveAsDialogShow}
preset={conversation}
preset={conversation as TPreset}
/>
</>
);

View file

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

View file

@ -1,5 +1,7 @@
import { useEffect } from 'react';
import { useRecoilState } from 'recoil';
import type { TMessageProps } from '~/common';
// eslint-disable-next-line import/no-cycle
import Message from './Message';
import store from '~/store';
@ -11,23 +13,21 @@ export default function MultiMessage({
currentEditId,
setCurrentEditId,
isSearchView,
}) {
// const [siblingIdx, setSiblingIdx] = useState(0);
}: TMessageProps) {
const [siblingIdx, setSiblingIdx] = useRecoilState(store.messagesSiblingIdxFamily(messageId));
const setSiblingIdxRev = (value) => {
setSiblingIdx(messagesTree?.length - value - 1);
const setSiblingIdxRev = (value: number) => {
setSiblingIdx((messagesTree?.length ?? 0) - value - 1);
};
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);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messagesTree?.length]);
// if (!messageList?.length) return null;
if (!(messagesTree && messagesTree.length)) {
if (!(messagesTree && messagesTree?.length)) {
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 = () => {
setSiblingIdx(siblingIdx - 1);
setSiblingIdx && setSiblingIdx(siblingIdx - 1);
};
const next = () => {
setSiblingIdx(siblingIdx + 1);
setSiblingIdx && setSiblingIdx(siblingIdx + 1);
};
return siblingCount > 1 ? (
<>
<button
@ -27,7 +40,7 @@ export default function SiblingSwitch({ siblingIdx, siblingCount, setSiblingIdx
width="1em"
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>
</button>
<span className="flex-shrink-0 flex-grow">
@ -50,7 +63,7 @@ export default function SiblingSwitch({ siblingIdx, siblingCount, setSiblingIdx
width="1em"
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>
</button>
</>

View file

@ -1,6 +1,6 @@
import { cn } from '~/utils/';
export default function Plugin({ className, ...props }) {
export default function Plugin({ className = '', ...props }) {
return (
<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';
type ScreenshotContextType = {
ref?: Ref<HTMLElement>;
ref?: RefObject<HTMLDivElement>;
};
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 useSetOptions } from './useSetOptions';
export { default as useGenerations } from './useGenerations';
export { default as useScrollToRef } from './useScrollToRef';
export { default as useServerStream } from './useServerStream';
export { default as useMessageHandler } from './useMessageHandler';

View file

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

View file

@ -2,28 +2,31 @@ import { v4 } from 'uuid';
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil';
import { parseConvo, getResponseSender } from 'librechat-data-provider';
import type { TMessage, TSubmission } from 'librechat-data-provider';
import type { TAskFunction } from '~/common';
import store from '~/store';
type TAskProps = {
text: string;
parentMessageId?: string | null;
conversationId?: string | null;
messageId?: string | null;
};
const useMessageHandler = () => {
const latestMessage = useRecoilValue(store.latestMessage);
const setSiblingIdx = useSetRecoilState(
store.messagesSiblingIdxFamily(latestMessage?.parentMessageId),
);
const currentConversation = useRecoilValue(store.conversation) || { endpoint: null };
const setSubmission = useSetRecoilState(store.submission);
const isSubmitting = useRecoilValue(store.isSubmitting);
const endpointsConfig = useRecoilValue(store.endpointsConfig);
const latestMessage = useRecoilValue(store.latestMessage);
const [messages, setMessages] = useRecoilState(store.messages);
const { endpoint } = currentConversation;
const { getToken } = store.useToken(endpoint ?? '');
const ask = (
{ text, parentMessageId = null, conversationId = null, messageId = null }: TAskProps,
{ isRegenerate = false, isEdited = false } = {},
const ask: TAskFunction = (
{ text, parentMessageId = null, conversationId = null, messageId = null },
{
editedText = null,
editedMessageId = null,
isRegenerate = false,
isContinued = false,
isEdited = false,
} = {},
) => {
if (!!isSubmitting || text === '') {
return;
@ -40,11 +43,12 @@ const useMessageHandler = () => {
return;
}
if (isEdited && !latestMessage) {
console.error('cannot edit AI message without latestMessage!');
if (isContinued && !latestMessage) {
console.error('cannot continue AI message without latestMessage!');
return;
}
const isEditOrContinue = isEdited || isContinued;
const { userProvide } = endpointsConfig[endpoint] ?? {};
// set the endpoint option
@ -77,15 +81,17 @@ const useMessageHandler = () => {
isCreatedByUser: true,
parentMessageId,
conversationId,
messageId: isEdited && messageId ? messageId : fakeMessageId,
messageId: isContinued && messageId ? messageId : fakeMessageId,
error: false,
};
// construct the placeholder response message
const generation = latestMessage?.text ?? '';
const responseText = isEdited ? generation : '<span className="result-streaming">█</span>';
const generation = editedText ?? latestMessage?.text ?? '';
const responseText = isEditOrContinue
? generation
: '<span className="result-streaming">█</span>';
const responseMessageId = isEdited ? latestMessage?.messageId : null;
const responseMessageId = editedMessageId ?? latestMessage?.messageId ?? null;
const initialResponse: TMessage = {
sender: responseSender,
text: responseText,
@ -98,6 +104,10 @@ const useMessageHandler = () => {
error: false,
};
if (isContinued) {
currentMessages = currentMessages.filter((msg) => msg.messageId !== responseMessageId);
}
const submission: TSubmission = {
conversation: {
...currentConversation,
@ -111,7 +121,8 @@ const useMessageHandler = () => {
overrideParentMessageId: isRegenerate ? messageId : null,
},
messages: currentMessages,
isEdited,
isEdited: isEditOrContinue,
isContinued,
isRegenerate,
initialResponse,
};
@ -119,12 +130,9 @@ const useMessageHandler = () => {
console.log('User Input:', text, submission);
if (isRegenerate) {
setMessages([
...(isEdited ? currentMessages.slice(0, -1) : currentMessages),
initialResponse,
]);
setMessages([...submission.messages, initialResponse]);
} else {
setMessages([...currentMessages, currentMsg, initialResponse]);
setMessages([...submission.messages, currentMsg, initialResponse]);
}
setSubmission(submission);
};
@ -152,7 +160,7 @@ const useMessageHandler = () => {
);
if (parentMessage && parentMessage.isCreatedByUser) {
ask({ ...parentMessage }, { isRegenerate: true, isEdited: true });
ask({ ...parentMessage }, { isContinued: true, isRegenerate: true, isEdited: true });
} else {
console.error(
'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>) => {
e.preventDefault();
continueGeneration();
setSiblingIdx(0);
};
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 { useResetRecoilState, useSetRecoilState } from 'recoil';
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 store from '~/store';
type TResData = {
plugin: TPlugin;
plugin: TResPlugin;
final?: boolean;
initial?: boolean;
requestMessage: TMessage;
@ -24,18 +24,11 @@ export default function useServerStream(submission: TSubmission | null) {
const { refreshConversations } = store.useConversations();
const messageHandler = (data: string, submission: TSubmission) => {
const {
messages,
message,
plugin,
initialResponse,
isRegenerate = false,
isEdited = false,
} = submission;
const { messages, message, plugin, initialResponse, isRegenerate = false } = submission;
if (isRegenerate) {
setMessages([
...(isEdited ? messages.slice(0, -1) : messages),
...messages,
{
...initialResponse,
text: data,
@ -65,11 +58,11 @@ export default function useServerStream(submission: TSubmission | null) {
const cancelHandler = (data: TResData, submission: TSubmission) => {
const { requestMessage, responseMessage, conversation } = data;
const { messages, isRegenerate = false, isEdited = false } = submission;
const { messages, isRegenerate = false } = submission;
// update the messages
if (isRegenerate) {
setMessages([...(isEdited ? messages.slice(0, -1) : messages), responseMessage]);
setMessages([...messages, responseMessage]);
} else {
setMessages([...messages, requestMessage, responseMessage]);
}
@ -94,17 +87,11 @@ export default function useServerStream(submission: TSubmission | null) {
};
const createdHandler = (data: TResData, submission: TSubmission) => {
const {
messages,
message,
initialResponse,
isRegenerate = false,
isEdited = false,
} = submission;
const { messages, message, initialResponse, isRegenerate = false } = submission;
if (isRegenerate) {
setMessages([
...(isEdited ? messages.slice(0, -1) : messages),
...messages,
{
...initialResponse,
parentMessageId: message?.overrideParentMessageId ?? null,
@ -137,11 +124,11 @@ export default function useServerStream(submission: TSubmission | null) {
const finalHandler = (data: TResData, submission: TSubmission) => {
const { requestMessage, responseMessage, conversation } = data;
const { messages, isRegenerate = false, isEdited = false } = submission;
const { messages, isRegenerate = false } = submission;
// update the messages
if (isRegenerate) {
setMessages([...(isEdited ? messages.slice(0, -1) : messages), responseMessage]);
setMessages([...messages, responseMessage]);
} else {
setMessages([...messages, requestMessage, responseMessage]);
}

View file

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

View file

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

View file

@ -12,7 +12,10 @@ function isUUID(uuid: string) {
}
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) {
@ -52,7 +55,7 @@ test.afterEach(async ({ page }) => {
});
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,
}) => {
test.setTimeout(120000);
@ -91,6 +94,33 @@ test.describe('Messaging suite', () => {
const finalUrl = page.url();
const conversationId = finalUrl.split(basePath).pop() ?? '';
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 }) => {
@ -124,6 +154,9 @@ test.describe('Messaging suite', () => {
const regenerateButton = page.getByRole('button', { name: 'Regenerate' });
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

4
package-lock.json generated
View file

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

View file

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

View file

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

View file

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

View file

@ -24,8 +24,8 @@ export function clearAllConversations(): Promise<unknown> {
return request.post(endpoints.deleteConversation(), { arg: {} });
}
export function getMessagesByConvoId(id: string): Promise<s.TMessage[]> {
return request.get(endpoints.messages(id));
export function getMessagesByConvoId(conversationId: string): Promise<s.TMessage[]> {
return request.get(endpoints.messages(conversationId));
}
export function getConversationById(id: string): Promise<s.TConversation> {
@ -38,6 +38,15 @@ export function updateConversation(
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[]> {
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 = (
id?: string,
): UseMutationResult<

View file

@ -32,6 +32,20 @@ export const tPluginSchema = z.object({
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({
input: z.object({
content: z.string(),
@ -57,7 +71,9 @@ export const tMessageSchema = z.object({
parentMessageId: z.string().nullable(),
responseMessageId: 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(),
text: z.string(),
generation: z.string().nullable().optional(),
@ -78,7 +94,10 @@ export const tMessageSchema = z.object({
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({
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';
@ -7,9 +7,10 @@ export type TMessages = TMessage[];
export type TMessagesAtom = TMessages | null;
export type TSubmission = {
plugin?: TPlugin;
plugin?: TResPlugin;
message: TMessage;
isEdited?: boolean;
isContinued?: boolean;
messages: TMessage[];
isRegenerate?: boolean;
conversationId?: string;
@ -37,6 +38,7 @@ export type TError = {
data?: {
message?: string;
};
status?: number;
};
};
@ -60,6 +62,12 @@ export type TGetConversationsResponse = {
pages: string | number;
};
export type TUpdateMessageRequest = {
conversationId: string;
messageId: string;
text: string;
};
export type TUpdateConversationRequest = {
conversationId: string;
title: string;