From a532ba183cd894c8a9140baed0dca3e876aa3611 Mon Sep 17 00:00:00 2001 From: dolphin Date: Wed, 16 Apr 2025 19:00:08 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=81=A2=E5=A4=8D=E8=A2=AB=E5=BF=BD?= =?UTF-8?q?=E7=95=A5=E6=96=87=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 - .../Messages/Content/Parts/Attachment.tsx | 19 ++ .../Messages/Content/Parts/CodeProgress.tsx | 90 ++++++++ .../Messages/Content/Parts/EditTextPart.tsx | 192 ++++++++++++++++++ .../Messages/Content/Parts/ExecuteCode.tsx | 113 +++++++++++ .../Messages/Content/Parts/LogContent.tsx | 108 ++++++++++ .../Chat/Messages/Content/Parts/LogLink.tsx | 52 +++++ .../Chat/Messages/Content/Parts/Reasoning.tsx | 41 ++++ .../Chat/Messages/Content/Parts/Text.tsx | 57 ++++++ 9 files changed, 672 insertions(+), 1 deletion(-) create mode 100644 src/frontend/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx create mode 100644 src/frontend/client/src/components/Chat/Messages/Content/Parts/CodeProgress.tsx create mode 100644 src/frontend/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx create mode 100644 src/frontend/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx create mode 100644 src/frontend/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx create mode 100644 src/frontend/client/src/components/Chat/Messages/Content/Parts/LogLink.tsx create mode 100644 src/frontend/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx create mode 100644 src/frontend/client/src/components/Chat/Messages/Content/Parts/Text.tsx diff --git a/.gitignore b/.gitignore index b939133ab..baa068260 100644 --- a/.gitignore +++ b/.gitignore @@ -140,7 +140,6 @@ eggs/ .eggs/ lib/ lib64/ -parts/ sdist/ var/ wheels/ diff --git a/src/frontend/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx b/src/frontend/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx new file mode 100644 index 000000000..6ea183e32 --- /dev/null +++ b/src/frontend/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx @@ -0,0 +1,19 @@ +import { imageExtRegex } from '~/data-provider/data-provider/src'; +import type { TAttachment, TFile, TAttachmentMetadata } from '~/data-provider/data-provider/src'; +import Image from '~/components/Chat/Messages/Content/Image'; + +export default function Attachment({ attachment }: { attachment?: TAttachment }) { + if (!attachment) { + return null; + } + const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata; + const isImage = + imageExtRegex.test(attachment.filename) && width != null && height != null && filepath != null; + + if (isImage) { + return ( + + ); + } + return null; +} diff --git a/src/frontend/client/src/components/Chat/Messages/Content/Parts/CodeProgress.tsx b/src/frontend/client/src/components/Chat/Messages/Content/Parts/CodeProgress.tsx new file mode 100644 index 000000000..6e70688cd --- /dev/null +++ b/src/frontend/client/src/components/Chat/Messages/Content/Parts/CodeProgress.tsx @@ -0,0 +1,90 @@ +import ProgressCircle from '~/components/Chat/Messages/Content/ProgressCircle'; +import CancelledIcon from '~/components/Chat/Messages/Content/CancelledIcon'; + +export const CodeInProgress = ({ + offset, + circumference, + radius, + isSubmitting, + progress, +}: { + progress: number; + offset: number; + circumference: number; + radius: number; + isSubmitting: boolean; +}) => { + if (progress < 1 && !isSubmitting) { + return ; + } + return ( +
+
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+ ); +}; diff --git a/src/frontend/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx b/src/frontend/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx new file mode 100644 index 000000000..2f665dd4a --- /dev/null +++ b/src/frontend/client/src/components/Chat/Messages/Content/Parts/EditTextPart.tsx @@ -0,0 +1,192 @@ +import { useForm } from 'react-hook-form'; +import { ContentTypes } from '~/data-provider/data-provider/src'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { useRef, useEffect, useCallback, useMemo } from 'react'; +import { useUpdateMessageContentMutation } from '~/data-provider/data-provider/src/react-query'; +import type { TEditProps } from '~/common'; +import Container from '~/components/Chat/Messages/Content/Container'; +import { useChatContext, useAddedChatContext } from '~/Providers'; +import { TextareaAutosize } from '~/components/ui'; +import { cn, removeFocusRings } from '~/utils'; +import { useLocalize } from '~/hooks'; +import store from '~/store'; + +const EditTextPart = ({ + text, + index, + messageId, + isSubmitting, + enterEdit, +}: Omit & { + index: number; + messageId: string; +}) => { + const localize = useLocalize(); + const { addedIndex } = useAddedChatContext(); + const { getMessages, setMessages, conversation } = useChatContext(); + const [latestMultiMessage, setLatestMultiMessage] = useRecoilState( + store.latestMessageFamily(addedIndex), + ); + + const { conversationId = '' } = conversation ?? {}; + const message = useMemo( + () => getMessages()?.find((msg) => msg.messageId === messageId), + [getMessages, messageId], + ); + + const textAreaRef = useRef(null); + const updateMessageContentMutation = useUpdateMessageContentMutation(conversationId ?? ''); + + const chatDirection = useRecoilValue(store.chatDirection).toLowerCase(); + const isRTL = chatDirection === 'rtl'; + + const { register, handleSubmit, setValue } = useForm({ + defaultValues: { + text: text ?? '', + }, + }); + + useEffect(() => { + const textArea = textAreaRef.current; + if (textArea) { + const length = textArea.value.length; + textArea.focus(); + textArea.setSelectionRange(length, length); + } + }, []); + + /* + const resubmitMessage = () => { + showToast({ + status: 'warning', + message: localize('com_warning_resubmit_unsupported'), + }); + + // const resubmitMessage = (data: { text: string }) => { + // Not supported by AWS Bedrock + const messages = getMessages(); + const parentMessage = messages?.find((msg) => msg.messageId === message?.parentMessageId); + + if (!parentMessage) { + return; + } + ask( + { ...parentMessage }, + { + editedText: data.text, + editedMessageId: messageId, + isRegenerate: true, + isEdited: true, + }, + ); + + setSiblingIdx((siblingIdx ?? 0) - 1); + enterEdit(true); + }; + */ + + const updateMessage = (data: { text: string }) => { + const messages = getMessages(); + if (!messages) { + return; + } + updateMessageContentMutation.mutate({ + index, + conversationId: conversationId ?? '', + text: data.text, + messageId, + }); + + if (messageId === latestMultiMessage?.messageId) { + setLatestMultiMessage({ ...latestMultiMessage, text: data.text }); + } + + const isInMessages = messages.some((msg) => msg.messageId === messageId); + if (!isInMessages) { + return enterEdit(true); + } + + const updatedContent = message?.content?.map((part, idx) => { + if (part.type === ContentTypes.TEXT && idx === index) { + return { ...part, text: data.text }; + } + return part; + }); + + setMessages( + messages.map((msg) => + msg.messageId === messageId + ? { + ...msg, + content: updatedContent, + } + : msg, + ), + ); + + enterEdit(true); + }; + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + enterEdit(true); + } + }, + [enterEdit], + ); + + const { ref, ...registerProps } = register('text', { + required: true, + onChange: (e) => { + setValue('text', e.target.value, { shouldValidate: true }); + }, + }); + + return ( + +
+ { + ref(e); + textAreaRef.current = e; + }} + onKeyDown={handleKeyDown} + data-testid="message-text-editor" + className={cn( + 'markdown prose dark:prose-invert light whitespace-pre-wrap break-words pl-3 md:pl-4', + 'm-0 w-full resize-none border-0 bg-transparent py-[10px]', + 'placeholder-text-secondary focus:ring-0 focus-visible:ring-0 md:py-3.5', + isRTL ? 'text-right' : 'text-left', + 'max-h-[65vh] pr-3 md:max-h-[75vh] md:pr-4', + removeFocusRings, + )} + dir={isRTL ? 'rtl' : 'ltr'} + /> +
+
+ {/* */} + + +
+
+ ); +}; + +export default EditTextPart; diff --git a/src/frontend/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx b/src/frontend/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx new file mode 100644 index 000000000..177ebe85d --- /dev/null +++ b/src/frontend/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx @@ -0,0 +1,113 @@ +import React, { useMemo, useState } from 'react'; +import { useRecoilValue } from 'recoil'; +import type { TAttachment } from '~/data-provider/data-provider/src'; +import ProgressText from '~/components/Chat/Messages/Content/ProgressText'; +import FinishedIcon from '~/components/Chat/Messages/Content/FinishedIcon'; +import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite'; +import { useProgress, useLocalize } from '~/hooks'; +import { CodeInProgress } from './CodeProgress'; +import Attachment from './Attachment'; +import LogContent from './LogContent'; +import store from '~/store'; + +interface ParsedArgs { + lang: string; + code: string; +} + +export function useParseArgs(args: string): ParsedArgs { + return useMemo(() => { + const langMatch = args.match(/"lang"\s*:\s*"(\w+)"/); + const codeMatch = args.match(/"code"\s*:\s*"(.+?)(?="\s*,\s*"args"|$)/s); + + let code = ''; + if (codeMatch) { + code = codeMatch[1]; + if (code.endsWith('"}')) { + code = code.slice(0, -2); + } + code = code.replace(/\\n/g, '\n').replace(/\\/g, ''); + } + + return { + lang: langMatch ? langMatch[1] : '', + code, + }; + }, [args]); +} + +const radius = 56.08695652173913; +const circumference = 2 * Math.PI * radius; + +export default function ExecuteCode({ + initialProgress = 0.1, + args, + output = '', + isSubmitting, + attachments, +}: { + initialProgress: number; + args: string; + output?: string; + isSubmitting: boolean; + attachments?: TAttachment[]; +}) { + const localize = useLocalize(); + const showAnalysisCode = useRecoilValue(store.showCode); + const [showCode, setShowCode] = useState(showAnalysisCode); + + const { lang, code } = useParseArgs(args); + const progress = useProgress(initialProgress); + const offset = circumference - progress * circumference; + + return ( + <> +
+
+ {progress < 1 ? ( + + ) : ( + + )} +
+ setShowCode((prev) => !prev)} + inProgressText={localize('com_ui_analyzing')} + finishedText={localize('com_ui_analyzing_finished')} + hasInput={!!code.length} + isExpanded={showCode} + /> +
+ {showCode && ( +
+ + {output.length > 0 && ( +
+
+
+                  
+                
+
+
+ )} +
+ )} + {attachments?.map((attachment, index) => )} + + ); +} diff --git a/src/frontend/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx b/src/frontend/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx new file mode 100644 index 000000000..955bd354d --- /dev/null +++ b/src/frontend/client/src/components/Chat/Messages/Content/Parts/LogContent.tsx @@ -0,0 +1,108 @@ +import { isAfter } from 'date-fns'; +import React, { useMemo } from 'react'; +import { imageExtRegex } from '~/data-provider/data-provider/src'; +import type { TFile, TAttachment, TAttachmentMetadata } from '~/data-provider/data-provider/src'; +import Image from '~/components/Chat/Messages/Content/Image'; +import { useLocalize } from '~/hooks'; +import LogLink from './LogLink'; + +interface LogContentProps { + output?: string; + renderImages?: boolean; + attachments?: TAttachment[]; +} + +type ImageAttachment = TFile & + TAttachmentMetadata & { + height: number; + width: number; + }; + +const LogContent: React.FC = ({ output = '', renderImages, attachments }) => { + const localize = useLocalize(); + + const processedContent = useMemo(() => { + if (!output) { + return ''; + } + + const parts = output.split('Generated files:'); + return parts[0].trim(); + }, [output]); + + const { imageAttachments, nonImageAttachments } = useMemo(() => { + const imageAtts: ImageAttachment[] = []; + const nonImageAtts: TAttachment[] = []; + + attachments?.forEach((attachment) => { + const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata; + const isImage = + imageExtRegex.test(attachment.filename) && + width != null && + height != null && + filepath != null; + if (isImage) { + imageAtts.push(attachment as ImageAttachment); + } else { + nonImageAtts.push(attachment); + } + }); + + return { + imageAttachments: renderImages === true ? imageAtts : null, + nonImageAttachments: nonImageAtts, + }; + }, [attachments, renderImages]); + + const renderAttachment = (file: TAttachment) => { + const now = new Date(); + const expiresAt = typeof file.expiresAt === 'number' ? new Date(file.expiresAt) : null; + const isExpired = expiresAt ? isAfter(now, expiresAt) : false; + + if (isExpired) { + return `${file.filename} ${localize('com_download_expired')}`; + } + + // const expirationText = expiresAt + // ? ` ${localize('com_download_expires', { 0: format(expiresAt, 'MM/dd/yy HH:mm') })}` + // : ` ${localize('com_click_to_download')}`; + + return ( + + {'- '} + {file.filename} {localize('com_click_to_download')} + + ); + }; + + return ( + <> + {processedContent &&
{processedContent}
} + {nonImageAttachments.length > 0 && ( +
+

{localize('com_generated_files')}

+ {nonImageAttachments.map((file, index) => ( + + {renderAttachment(file)} + {index < nonImageAttachments.length - 1 && ', '} + + ))} +
+ )} + {imageAttachments?.map((attachment, index) => { + const { width, height, filepath } = attachment; + return ( + + ); + })} + + ); +}; + +export default LogContent; diff --git a/src/frontend/client/src/components/Chat/Messages/Content/Parts/LogLink.tsx b/src/frontend/client/src/components/Chat/Messages/Content/Parts/LogLink.tsx new file mode 100644 index 000000000..c1886e746 --- /dev/null +++ b/src/frontend/client/src/components/Chat/Messages/Content/Parts/LogLink.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { useCodeOutputDownload } from '~/data-provider'; +import { useToastContext } from '~/Providers'; + +interface LogLinkProps { + href: string; + filename: string; + children: React.ReactNode; +} + +const LogLink: React.FC = ({ href, filename, children }) => { + const { showToast } = useToastContext(); + const { refetch: downloadFile } = useCodeOutputDownload(href); + + const handleDownload = async (event: React.MouseEvent) => { + event.preventDefault(); + try { + const stream = await downloadFile(); + if (stream.data == null || stream.data === '') { + console.error('Error downloading file: No data found'); + showToast({ + status: 'error', + message: 'Error downloading file', + }); + return; + } + const link = document.createElement('a'); + link.href = stream.data; + link.setAttribute('download', filename); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(stream.data); + } catch (error) { + console.error('Error downloading file:', error); + } + }; + + return ( + + {children} + + ); +}; + +export default LogLink; diff --git a/src/frontend/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx b/src/frontend/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx new file mode 100644 index 000000000..7b8fc5fb0 --- /dev/null +++ b/src/frontend/client/src/components/Chat/Messages/Content/Parts/Reasoning.tsx @@ -0,0 +1,41 @@ +import { memo, useMemo } from 'react'; +import { ContentTypes } from '~/data-provider/data-provider/src'; +import { ThinkingContent } from '~/components/Artifacts/Thinking'; +import { useMessageContext } from '~/Providers'; +import { cn } from '~/utils'; + +type ReasoningProps = { + reasoning: string; +}; + +const Reasoning = memo(({ reasoning }: ReasoningProps) => { + const { isExpanded, nextType } = useMessageContext(); + const reasoningText = useMemo(() => { + return reasoning + .replace(/^\s*/, '') + .replace(/\s*<\/think>$/, '') + .trim(); + }, [reasoning]); + + if (!reasoningText) { + return null; + } + + return ( +
+
+ {reasoningText} +
+
+ ); +}); + +export default Reasoning; diff --git a/src/frontend/client/src/components/Chat/Messages/Content/Parts/Text.tsx b/src/frontend/client/src/components/Chat/Messages/Content/Parts/Text.tsx new file mode 100644 index 000000000..7c207f151 --- /dev/null +++ b/src/frontend/client/src/components/Chat/Messages/Content/Parts/Text.tsx @@ -0,0 +1,57 @@ +import { memo, useMemo, ReactElement } from 'react'; +import { useRecoilValue } from 'recoil'; +import MarkdownLite from '~/components/Chat/Messages/Content/MarkdownLite'; +import Markdown from '~/components/Chat/Messages/Content/Markdown'; +import { useChatContext, useMessageContext } from '~/Providers'; +import { cn } from '~/utils'; +import store from '~/store'; + +type TextPartProps = { + text: string; + showCursor: boolean; + isCreatedByUser: boolean; +}; + +type ContentType = + | ReactElement> + | ReactElement> + | ReactElement; + +const TextPart = memo(({ text, isCreatedByUser, showCursor }: TextPartProps) => { + const { messageId } = useMessageContext(); + const { isSubmitting, latestMessage } = useChatContext(); + const enableUserMsgMarkdown = useRecoilValue(store.enableUserMsgMarkdown); + const showCursorState = useMemo(() => showCursor && isSubmitting, [showCursor, isSubmitting]); + const isLatestMessage = useMemo( + () => messageId === latestMessage?.messageId, + [messageId, latestMessage?.messageId], + ); + + const content: ContentType = useMemo(() => { + if (!isCreatedByUser) { + return ( + + ); + } else if (enableUserMsgMarkdown) { + return ; + } else { + return <>{text}; + } + }, [isCreatedByUser, enableUserMsgMarkdown, text, showCursorState, isLatestMessage]); + + return ( +
+ {content} +
+ ); +}); + +export default TextPart;