diff --git a/client/src/components/Artifacts/Artifact.tsx b/client/src/components/Artifacts/Artifact.tsx index db193fe1eb..2b06a2ccc0 100644 --- a/client/src/components/Artifacts/Artifact.tsx +++ b/client/src/components/Artifacts/Artifact.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useCallback, useRef, useState } from 'react'; import throttle from 'lodash/throttle'; import { visit } from 'unist-util-visit'; import { useSetRecoilState } from 'recoil'; +import { useLocation } from 'react-router-dom'; import type { Pluggable } from 'unified'; import type { Artifact } from '~/common'; import { useMessageContext, useArtifactContext } from '~/Providers'; @@ -45,6 +46,7 @@ export function Artifact({ children: React.ReactNode | { props: { children: React.ReactNode } }; node: unknown; }) { + const location = useLocation(); const { messageId } = useMessageContext(); const { getNextIndex, resetCounter } = useArtifactContext(); const artifactIndex = useRef(getNextIndex(false)).current; @@ -86,6 +88,10 @@ export function Artifact({ lastUpdateTime: now, }; + if (!location.pathname.includes('/c/')) { + return setArtifact(currentArtifact); + } + setArtifacts((prevArtifacts) => { if ( prevArtifacts?.[artifactKey] != null && @@ -110,6 +116,7 @@ export function Artifact({ props.identifier, messageId, artifactIndex, + location.pathname, ]); useEffect(() => { diff --git a/client/src/components/Artifacts/ArtifactButton.tsx b/client/src/components/Artifacts/ArtifactButton.tsx index 67082f490c..162e7d717c 100644 --- a/client/src/components/Artifacts/ArtifactButton.tsx +++ b/client/src/components/Artifacts/ArtifactButton.tsx @@ -1,15 +1,52 @@ -import { useSetRecoilState, useResetRecoilState } from 'recoil'; +import { useEffect, useRef } from 'react'; +import debounce from 'lodash/debounce'; +import { useLocation } from 'react-router-dom'; +import { useRecoilState, useSetRecoilState, useResetRecoilState } from 'recoil'; import type { Artifact } from '~/common'; import FilePreview from '~/components/Chat/Input/Files/FilePreview'; +import { getFileType, logger } from '~/utils'; import { useLocalize } from '~/hooks'; -import { getFileType } from '~/utils'; import store from '~/store'; const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => { const localize = useLocalize(); - const setVisible = useSetRecoilState(store.artifactsVisible); + const location = useLocation(); + const setVisible = useSetRecoilState(store.artifactsVisibility); + const [artifacts, setArtifacts] = useRecoilState(store.artifactsState); const setCurrentArtifactId = useSetRecoilState(store.currentArtifactId); const resetCurrentArtifactId = useResetRecoilState(store.currentArtifactId); + const [visibleArtifacts, setVisibleArtifacts] = useRecoilState(store.visibleArtifacts); + + const debouncedSetVisibleRef = useRef( + debounce((artifactToSet: Artifact) => { + logger.log( + 'artifacts_visibility', + 'Setting artifact to visible state from Artifact button', + artifactToSet, + ); + setVisibleArtifacts((prev) => ({ + ...prev, + [artifactToSet.id]: artifactToSet, + })); + }, 750), + ); + + useEffect(() => { + if (artifact == null || artifact?.id == null || artifact.id === '') { + return; + } + + if (!location.pathname.includes('/c/')) { + return; + } + + const debouncedSetVisible = debouncedSetVisibleRef.current; + debouncedSetVisible(artifact); + return () => { + debouncedSetVisible.cancel(); + }; + }, [artifact, location.pathname]); + if (artifact === null || artifact === undefined) { return null; } @@ -20,8 +57,14 @@ const ArtifactButton = ({ artifact }: { artifact: Artifact | null }) => {

{currentArtifact.title}

@@ -118,22 +107,8 @@ export default function Artifacts() { {localize('com_ui_code')} - @@ -149,29 +124,13 @@ export default function Artifacts() {
{`${currentIndex + 1} / ${ orderedArtifactIds.length }`}
diff --git a/client/src/components/Chat/Messages/Content/Parts/Text.tsx b/client/src/components/Chat/Messages/Content/Parts/Text.tsx index d4a605aea5..4d1be03095 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Text.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Text.tsx @@ -35,7 +35,7 @@ const TextPart = memo(({ text, isCreatedByUser, showCursor }: TextPartProps) => } else { return <>{text}; } - }, [isCreatedByUser, enableUserMsgMarkdown, text, showCursorState, isLatestMessage]); + }, [isCreatedByUser, enableUserMsgMarkdown, text, isLatestMessage]); return (
0 ? ( + artifactsVisibility === true && Object.keys(artifacts ?? {}).length > 0 ? ( diff --git a/client/src/hooks/Artifacts/useArtifacts.ts b/client/src/hooks/Artifacts/useArtifacts.ts index c9c3ddd25d..e1159d7327 100644 --- a/client/src/hooks/Artifacts/useArtifacts.ts +++ b/client/src/hooks/Artifacts/useArtifacts.ts @@ -1,9 +1,9 @@ import { useMemo, useState, useEffect, useRef } from 'react'; import { Constants } from 'librechat-data-provider'; import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil'; +import { getLatestText, logger } from '~/utils'; import { useChatContext } from '~/Providers'; import { getKey } from '~/utils/artifacts'; -import { getLatestText } from '~/utils'; import store from '~/store'; export default function useArtifacts() { @@ -37,16 +37,20 @@ export default function useArtifacts() { hasEnclosedArtifactRef.current = false; }; if ( - conversation && - conversation.conversationId !== prevConversationIdRef.current && + conversation?.conversationId !== prevConversationIdRef.current && prevConversationIdRef.current != null ) { resetState(); - } else if (conversation && conversation.conversationId === Constants.NEW_CONVO) { + } else if (conversation?.conversationId === Constants.NEW_CONVO) { resetState(); } prevConversationIdRef.current = conversation?.conversationId ?? null; - }, [conversation, resetArtifacts, resetCurrentArtifactId]); + /** Resets artifacts when unmounting */ + return () => { + logger.log('artifacts_visibility', 'Unmounting artifacts'); + resetState(); + }; + }, [conversation?.conversationId, resetArtifacts, resetCurrentArtifactId]); useEffect(() => { if (orderedArtifactIds.length > 0) { @@ -56,30 +60,39 @@ export default function useArtifacts() { }, [setCurrentArtifactId, orderedArtifactIds]); useEffect(() => { - if (isSubmitting && orderedArtifactIds.length > 0 && latestMessage) { - const latestArtifactId = orderedArtifactIds[orderedArtifactIds.length - 1]; - const latestArtifact = artifacts?.[latestArtifactId]; + if (!isSubmitting) { + return; + } + if (orderedArtifactIds.length === 0) { + return; + } + if (latestMessage == null) { + return; + } + const latestArtifactId = orderedArtifactIds[orderedArtifactIds.length - 1]; + const latestArtifact = artifacts?.[latestArtifactId]; + if (latestArtifact?.content === lastContentRef.current) { + return; + } - if (latestArtifact?.content !== lastContentRef.current) { - setCurrentArtifactId(latestArtifactId); - lastContentRef.current = latestArtifact?.content ?? null; + setCurrentArtifactId(latestArtifactId); + lastContentRef.current = latestArtifact?.content ?? null; - const latestMessageText = getLatestText(latestMessage); - const hasEnclosedArtifact = /:::artifact[\s\S]*?(```|:::)\s*$/.test( - latestMessageText.trim(), - ); + const latestMessageText = getLatestText(latestMessage); + const hasEnclosedArtifact = + /:::artifact(?:\{[^}]*\})?(?:\s|\n)*(?:```[\s\S]*?```(?:\s|\n)*)?:::/m.test( + latestMessageText.trim(), + ); - if (hasEnclosedArtifact && !hasEnclosedArtifactRef.current) { - setActiveTab('preview'); - hasEnclosedArtifactRef.current = true; - hasAutoSwitchedToCodeRef.current = false; - } else if (!hasEnclosedArtifactRef.current && !hasAutoSwitchedToCodeRef.current) { - const artifactStartContent = latestArtifact?.content?.slice(0, 50) ?? ''; - if (artifactStartContent.length > 0 && latestMessageText.includes(artifactStartContent)) { - setActiveTab('code'); - hasAutoSwitchedToCodeRef.current = true; - } - } + if (hasEnclosedArtifact && !hasEnclosedArtifactRef.current) { + setActiveTab('preview'); + hasEnclosedArtifactRef.current = true; + hasAutoSwitchedToCodeRef.current = false; + } else if (!hasEnclosedArtifactRef.current && !hasAutoSwitchedToCodeRef.current) { + const artifactStartContent = latestArtifact?.content?.slice(0, 50) ?? ''; + if (artifactStartContent.length > 0 && latestMessageText.includes(artifactStartContent)) { + setActiveTab('code'); + hasAutoSwitchedToCodeRef.current = true; } } }, [setCurrentArtifactId, isSubmitting, orderedArtifactIds, artifacts, latestMessage]); diff --git a/client/src/hooks/Chat/useIdChangeEffect.ts b/client/src/hooks/Chat/useIdChangeEffect.ts index 77b066ef36..7d0378915d 100644 --- a/client/src/hooks/Chat/useIdChangeEffect.ts +++ b/client/src/hooks/Chat/useIdChangeEffect.ts @@ -4,18 +4,18 @@ import { logger } from '~/utils'; import store from '~/store'; /** - * Hook to reset artifacts when the conversation ID changes + * Hook to reset visible artifacts when the conversation ID changes * @param conversationId - The current conversation ID */ export default function useIdChangeEffect(conversationId: string) { const lastConvoId = useRef(null); - const resetArtifacts = useResetRecoilState(store.artifactsState); + const resetVisibleArtifacts = useResetRecoilState(store.visibleArtifacts); useEffect(() => { if (conversationId !== lastConvoId.current) { logger.log('conversation', 'Conversation ID change'); - resetArtifacts(); + resetVisibleArtifacts(); } lastConvoId.current = conversationId; - }, [conversationId, resetArtifacts]); + }, [conversationId, resetVisibleArtifacts]); } diff --git a/client/src/store/artifacts.ts b/client/src/store/artifacts.ts index 26359a06a6..d8a23d1dbe 100644 --- a/client/src/store/artifacts.ts +++ b/client/src/store/artifacts.ts @@ -32,13 +32,28 @@ export const currentArtifactId = atom({ ] as const, }); -export const artifactsVisible = atom({ - key: 'artifactsVisible', +export const artifactsVisibility = atom({ + key: 'artifactsVisibility', default: true, effects: [ ({ onSet, node }) => { onSet(async (newValue) => { - logger.log('artifacts', 'Recoil Effect: Setting artifactsVisible', { + logger.log('artifacts', 'Recoil Effect: Setting artifactsVisibility', { + key: node.key, + newValue, + }); + }); + }, + ] as const, +}); + +export const visibleArtifacts = atom | null>({ + key: 'visibleArtifacts', + default: null, + effects: [ + ({ onSet, node }) => { + onSet(async (newValue) => { + logger.log('artifacts', 'Recoil Effect: Setting `visibleArtifacts`', { key: node.key, newValue, }); diff --git a/client/src/utils/artifacts.spec.ts b/client/src/utils/artifacts.spec.ts deleted file mode 100644 index b71dcd852f..0000000000 --- a/client/src/utils/artifacts.spec.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { preprocessCodeArtifacts } from './artifacts'; - -describe('preprocessCodeArtifacts', () => { - test('should return non-string inputs unchanged', () => { - expect(preprocessCodeArtifacts(123 as unknown as string)).toBe(''); - expect(preprocessCodeArtifacts(null as unknown as string)).toBe(''); - expect(preprocessCodeArtifacts(undefined)).toBe(''); - expect(preprocessCodeArtifacts({} as unknown as string)).toEqual(''); - }); - - test('should remove tags and their content', () => { - const input = 'This should be removedSome content'; - const expected = 'Some content'; - expect(preprocessCodeArtifacts(input)).toBe(expected); - }); - - test('should remove unclosed tags and their content', () => { - const input = 'This should be removed\nSome content'; - const expected = ''; - expect(preprocessCodeArtifacts(input)).toBe(expected); - }); - - test('should remove artifact headers up to and including empty code block', () => { - const input = ':::artifact{identifier="test"}\n```\n```\nSome content'; - const expected = ':::artifact{identifier="test"}\n```\n```\nSome content'; - expect(preprocessCodeArtifacts(input)).toBe(expected); - }); - - test('should keep artifact headers when followed by empty code block and content', () => { - const input = ':::artifact{identifier="test"}\n```\n```\nSome content'; - const expected = ':::artifact{identifier="test"}\n```\n```\nSome content'; - expect(preprocessCodeArtifacts(input)).toBe(expected); - }); - - test('should handle multiple artifact headers correctly', () => { - const input = ':::artifact{id="1"}\n```\n```\n:::artifact{id="2"}\n```\ncode\n```\nContent'; - const expected = ':::artifact{id="1"}\n```\n```\n:::artifact{id="2"}\n```\ncode\n```\nContent'; - expect(preprocessCodeArtifacts(input)).toBe(expected); - }); - - test('should handle complex input with multiple patterns', () => { - const input = ` - Remove this - Some text - :::artifact{id="1"} - \`\`\` - \`\`\` - And this - :::artifact{id="2"} - \`\`\` - keep this code - \`\`\` - More text - `; - const expected = ` - - Some text - :::artifact{id="1"} - \`\`\` - \`\`\` - - :::artifact{id="2"} - \`\`\` - keep this code - \`\`\` - More text - `; - expect(preprocessCodeArtifacts(input)).toBe(expected); - }); - - test('should remove artifact headers without code blocks', () => { - const input = ':::artifact{identifier="test"}\nSome content without code block'; - const expected = ''; - expect(preprocessCodeArtifacts(input)).toBe(expected); - }); - - test('should remove artifact headers up to incomplete code block', () => { - const input = ':::artifact{identifier="react-cal'; - const expected = ''; - expect(preprocessCodeArtifacts(input)).toBe(expected); - }); - - test('should keep artifact headers when any character follows code block', () => { - const input = ':::artifact{identifier="react-calculator"}\n```t'; - const expected = ':::artifact{identifier="react-calculator"}\n```t'; - expect(preprocessCodeArtifacts(input)).toBe(expected); - }); - - test('should keep artifact headers when whitespace follows code block', () => { - const input = ':::artifact{identifier="react-calculator"}\n``` '; - const expected = ':::artifact{identifier="react-calculator"}\n``` '; - expect(preprocessCodeArtifacts(input)).toBe(expected); - }); -}); diff --git a/client/src/utils/artifacts.ts b/client/src/utils/artifacts.ts index 69e2b91ee5..55658d07c0 100644 --- a/client/src/utils/artifacts.ts +++ b/client/src/utils/artifacts.ts @@ -214,23 +214,3 @@ export const sharedFiles = { `, }; - -export function preprocessCodeArtifacts(text?: string): string { - if (typeof text !== 'string') { - return ''; - } - - // Remove tags and their content - text = text.replace(/[\s\S]*?<\/thinking>|[\s\S]*/g, ''); - - // Process artifact headers - const regex = /(^|\n)(:::artifact[\s\S]*?(?:```[\s\S]*?```|$))/g; - return text.replace(regex, (match, newline, artifactBlock) => { - if (artifactBlock.includes('```') === true) { - // Keep artifact headers with code blocks (empty or not) - return newline + artifactBlock; - } - // Remove artifact headers without code blocks, but keep the newline - return newline; - }); -}