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 (
+
+ );
+});
+
+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;