mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
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:
parent
db77163f5d
commit
7dc27b10f1
45 changed files with 908 additions and 459 deletions
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
|
28
api/server/middleware/validateMessageReq.js
Normal file
28
api/server/middleware/validateMessageReq.js
Normal 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;
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 = () => {
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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} />;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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={{
|
||||
components={
|
||||
{
|
||||
code,
|
||||
p,
|
||||
}}
|
||||
} as {
|
||||
[nodeType: string]: React.ElementType;
|
||||
}
|
||||
}
|
||||
>
|
||||
{isLatestMessage && isSubmitting && !isInitializing
|
||||
? currentContent + cursor
|
194
client/src/components/Messages/Content/MessageContent.tsx
Normal file
194
client/src/components/Messages/Content/MessageContent.tsx
Normal 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;
|
|
@ -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;
|
|
@ -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
|
3
client/src/components/Messages/Content/index.ts
Normal file
3
client/src/components/Messages/Content/index.ts
Normal file
|
@ -0,0 +1,3 @@
|
|||
export { default as SubRow } from './SubRow';
|
||||
export { default as Plugin } from './Plugin';
|
||||
export { default as MessageContent } from './MessageContent';
|
|
@ -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"
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
212
client/src/components/Messages/Message.tsx
Normal file
212
client/src/components/Messages/Message.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
|
@ -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>
|
||||
</>
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
</>
|
|
@ -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"
|
||||
|
|
|
@ -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>({});
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
40
client/src/hooks/useScrollToRef.ts
Normal file
40
client/src/hooks/useScrollToRef.ts
Normal 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,
|
||||
};
|
||||
}
|
|
@ -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]);
|
||||
}
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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
4
package-lock.json
generated
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue