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

@ -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

@ -0,0 +1,135 @@
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';
type PluginsMap = {
[pluginKey: string]: string;
};
type PluginIconProps = LucideProps & {
className?: string;
};
function formatInputs(inputs: TInput[]) {
let output = '';
for (let i = 0; i < inputs.length; i++) {
output += `${inputs[i].inputStr}`;
if (inputs.length > 1 && i !== inputs.length - 1) {
output += ',\n';
}
}
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;
const plugins: PluginsMap = useRecoilValue(store.plugins);
const getPluginName = useCallback(
(pluginKey: string) => {
if (!pluginKey) {
return null;
}
if (pluginKey === 'n/a' || pluginKey === 'self reflection') {
return pluginKey;
}
return plugins[pluginKey] ?? 'self reflection';
},
[plugins],
);
if (!plugin || !plugin.latest) {
return null;
}
const latestPlugin = getPluginName(plugin.latest);
if (!latestPlugin || (latestPlugin && latestPlugin === 'n/a')) {
return null;
}
if (finished && loading) {
setLoading(false);
}
const generateStatus = (): ReactNode => {
if (!loading && latestPlugin === 'self reflection') {
return 'Finished';
} else if (latestPlugin === 'self reflection') {
return 'I\'m thinking...';
} else {
return (
<>
{loading ? 'Using' : 'Used'} <b>{latestPlugin}</b>
{loading ? '...' : ''}
</>
);
}
};
return (
<div className="flex flex-col items-start">
<Disclosure>
{({ open }) => {
const iconProps: PluginIconProps = {
className: cn(open ? 'rotate-180 transform' : '', 'h-4 w-4'),
};
return (
<>
<div
className={cn(
loading ? 'bg-green-100' : 'bg-[#ECECF1]',
'flex items-center rounded p-3 text-sm text-gray-900',
)}
>
<div>
<div className="flex items-center gap-3">
<div>{generateStatus()}</div>
</div>
</div>
{loading && <Spinner className="ml-1" />}
<Disclosure.Button className="ml-12 flex items-center gap-2">
<ChevronDownIcon {...iconProps} />
</Disclosure.Button>
</div>
<Disclosure.Panel className="my-3 flex max-w-full flex-col gap-3">
<CodeBlock
lang={latestPlugin?.toUpperCase() || 'INPUTS'}
codeChildren={formatInputs(plugin.inputs ?? [])}
plugin={true}
classProp="max-h-[450px]"
/>
{finished && (
<CodeBlock
lang="OUTPUTS"
codeChildren={plugin.outputs ?? ''}
plugin={true}
classProp="max-h-[450px]"
/>
)}
</Disclosure.Panel>
</>
);
}}
</Disclosure>
</div>
);
};
export default memo(Plugin);

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';