From ed57bb4711fee9ae2d7c112d92c4aa3c17456fed Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 23 Jan 2025 18:19:04 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=20feat:=20Artifact=20Editing=20&?= =?UTF-8?q?=20Downloads=20(#5428)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: expand container * chore: bump @codesandbox/sandpack-react to latest * WIP: first pass, show editor * feat: implement ArtifactCodeEditor and ArtifactTabs components for enhanced artifact management * refactor: fileKey * refactor: auto scrolling code editor and add messageId to artifact * feat: first pass, editing artifact * feat: first pass, robust artifact replacement * fix: robust artifact replacement & re-render when expected * feat: Download Artifacts * refactor: improve artifact editing UX * fix: layout shift of new download button * fix: enhance missing output checks and logging in StreamRunManager --- api/server/routes/messages.js | 72 ++++- api/server/services/Artifacts/update.js | 81 ++++++ api/server/services/Artifacts/update.spec.js | 267 ++++++++++++++++++ api/server/services/Runs/StreamRunManager.js | 27 +- client/package.json | 2 +- client/src/Providers/ArtifactContext.tsx | 32 +++ client/src/Providers/CodeBlockContext.tsx | 2 - client/src/Providers/EditorContext.tsx | 29 ++ client/src/Providers/index.ts | 2 + client/src/common/artifacts.ts | 12 + client/src/components/Artifacts/Artifact.tsx | 24 +- .../Artifacts/ArtifactCodeEditor.tsx | 151 ++++++++++ .../components/Artifacts/ArtifactPreview.tsx | 86 +++--- .../src/components/Artifacts/ArtifactTabs.tsx | 60 ++++ client/src/components/Artifacts/Artifacts.tsx | 72 ++--- .../components/Artifacts/DownloadArtifact.tsx | 54 ++++ .../Chat/Messages/Content/Markdown.tsx | 49 ++-- .../Chat/Messages/Content/MarkdownLite.tsx | 48 ++-- .../Chat/Messages/ui/MessageRender.tsx | 3 +- client/src/components/Chat/Presentation.tsx | 5 +- .../ConvoOptions/ConvoOptions.tsx | 12 +- .../src/components/Messages/ContentRender.tsx | 4 +- client/src/data-provider/Messages/index.ts | 2 + .../src/data-provider/Messages/mutations.ts | 46 +++ client/src/data-provider/index.ts | 1 + client/src/data-provider/mutations.ts | 3 - .../src/hooks/Artifacts/useArtifactProps.ts | 33 +++ client/src/hooks/Artifacts/useAutoScroll.ts | 65 +++-- client/src/localization/languages/Eng.ts | 2 + client/src/mobile.css | 24 ++ package-lock.json | 98 +++---- packages/data-provider/src/api-endpoints.ts | 2 +- packages/data-provider/src/data-service.ts | 7 + packages/data-provider/src/types/mutations.ts | 16 ++ 34 files changed, 1156 insertions(+), 237 deletions(-) create mode 100644 api/server/services/Artifacts/update.js create mode 100644 api/server/services/Artifacts/update.spec.js create mode 100644 client/src/Providers/ArtifactContext.tsx create mode 100644 client/src/Providers/EditorContext.tsx create mode 100644 client/src/components/Artifacts/ArtifactCodeEditor.tsx create mode 100644 client/src/components/Artifacts/ArtifactTabs.tsx create mode 100644 client/src/components/Artifacts/DownloadArtifact.tsx create mode 100644 client/src/data-provider/Messages/index.ts create mode 100644 client/src/data-provider/Messages/mutations.ts create mode 100644 client/src/hooks/Artifacts/useArtifactProps.ts diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js index 0abca92001..770cb0f67e 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -1,6 +1,14 @@ const express = require('express'); const { ContentTypes } = require('librechat-data-provider'); -const { saveConvo, saveMessage, getMessages, updateMessage, deleteMessages } = require('~/models'); +const { + saveConvo, + saveMessage, + getMessage, + getMessages, + updateMessage, + deleteMessages, +} = require('~/models'); +const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/Artifacts/update'); const { requireJwtAuth, validateMessageReq } = require('~/server/middleware'); const { countTokens } = require('~/server/utils'); const { logger } = require('~/config'); @@ -8,6 +16,68 @@ const { logger } = require('~/config'); const router = express.Router(); router.use(requireJwtAuth); +router.post('/artifact/:messageId', async (req, res) => { + try { + const { messageId } = req.params; + const { index, original, updated } = req.body; + + if (typeof index !== 'number' || index < 0 || !original || !updated) { + return res.status(400).json({ error: 'Invalid request parameters' }); + } + + const message = await getMessage({ user: req.user.id, messageId }); + if (!message) { + return res.status(404).json({ error: 'Message not found' }); + } + + const artifacts = findAllArtifacts(message); + if (index >= artifacts.length) { + return res.status(400).json({ error: 'Artifact index out of bounds' }); + } + + const targetArtifact = artifacts[index]; + let updatedText = null; + + if (targetArtifact.source === 'content') { + const part = message.content[targetArtifact.partIndex]; + updatedText = replaceArtifactContent(part.text, targetArtifact, original, updated); + if (updatedText) { + part.text = updatedText; + } + } else { + updatedText = replaceArtifactContent(message.text, targetArtifact, original, updated); + if (updatedText) { + message.text = updatedText; + } + } + + if (!updatedText) { + return res.status(400).json({ error: 'Original content not found in target artifact' }); + } + + const savedMessage = await saveMessage( + req, + { + messageId, + conversationId: message.conversationId, + text: message.text, + content: message.content, + user: req.user.id, + }, + { context: 'POST /api/messages/artifact/:messageId' }, + ); + + res.status(200).json({ + conversationId: savedMessage.conversationId, + content: savedMessage.content, + text: savedMessage.text, + }); + } catch (error) { + logger.error('Error editing artifact:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + /* Note: It's necessary to add `validateMessageReq` within route definition for correct params */ router.get('/:conversationId', validateMessageReq, async (req, res) => { try { diff --git a/api/server/services/Artifacts/update.js b/api/server/services/Artifacts/update.js new file mode 100644 index 0000000000..f17c2c0b67 --- /dev/null +++ b/api/server/services/Artifacts/update.js @@ -0,0 +1,81 @@ +const ARTIFACT_START = ':::artifact'; +const ARTIFACT_END = ':::'; + +/** + * Find all artifact boundaries in the message + * @param {TMessage} message + * @returns {Array<{start: number, end: number, source: 'content'|'text', partIndex?: number}>} + */ +const findAllArtifacts = (message) => { + const artifacts = []; + + // Check content parts first + if (message.content?.length) { + message.content.forEach((part, partIndex) => { + if (part.type === 'text' && typeof part.text === 'string') { + let currentIndex = 0; + let start = part.text.indexOf(ARTIFACT_START, currentIndex); + + while (start !== -1) { + const end = part.text.indexOf(ARTIFACT_END, start + ARTIFACT_START.length); + artifacts.push({ + start, + end: end !== -1 ? end + ARTIFACT_END.length : part.text.length, + source: 'content', + partIndex, + text: part.text, + }); + + currentIndex = end !== -1 ? end + ARTIFACT_END.length : part.text.length; + start = part.text.indexOf(ARTIFACT_START, currentIndex); + } + } + }); + } + + // Check message.text if no content parts + if (!artifacts.length && message.text) { + let currentIndex = 0; + let start = message.text.indexOf(ARTIFACT_START, currentIndex); + + while (start !== -1) { + const end = message.text.indexOf(ARTIFACT_END, start + ARTIFACT_START.length); + artifacts.push({ + start, + end: end !== -1 ? end + ARTIFACT_END.length : message.text.length, + source: 'text', + text: message.text, + }); + + currentIndex = end !== -1 ? end + ARTIFACT_END.length : message.text.length; + start = message.text.indexOf(ARTIFACT_START, currentIndex); + } + } + + return artifacts; +}; + +const replaceArtifactContent = (originalText, artifact, original, updated) => { + const artifactContent = artifact.text.substring(artifact.start, artifact.end); + const relativeIndex = artifactContent.indexOf(original); + + if (relativeIndex === -1) { + return null; + } + + const absoluteIndex = artifact.start + relativeIndex; + const endText = originalText.substring(absoluteIndex + original.length); + const hasTrailingNewline = endText.startsWith('\n'); + + const updatedText = + originalText.substring(0, absoluteIndex) + updated + (hasTrailingNewline ? '' : '\n') + endText; + + return updatedText.replace(/\n+(?=```\n:::)/g, '\n'); +}; + +module.exports = { + ARTIFACT_START, + ARTIFACT_END, + findAllArtifacts, + replaceArtifactContent, +}; diff --git a/api/server/services/Artifacts/update.spec.js b/api/server/services/Artifacts/update.spec.js new file mode 100644 index 0000000000..8008e553ba --- /dev/null +++ b/api/server/services/Artifacts/update.spec.js @@ -0,0 +1,267 @@ +const { + ARTIFACT_START, + ARTIFACT_END, + findAllArtifacts, + replaceArtifactContent, +} = require('./update'); + +const createArtifactText = (options = {}) => { + const { content = '', wrapCode = true, isClosed = true, prefix = '', suffix = '' } = options; + + const codeBlock = wrapCode ? '```\n' + content + '\n```' : content; + const end = isClosed ? `\n${ARTIFACT_END}` : ''; + + return `${ARTIFACT_START}${prefix}\n${codeBlock}${end}${suffix}`; +}; + +describe('findAllArtifacts', () => { + test('should return empty array for message with no artifacts', () => { + const message = { + content: [ + { + type: 'text', + text: 'No artifacts here', + }, + ], + }; + expect(findAllArtifacts(message)).toEqual([]); + }); + + test('should find artifacts in content parts', () => { + const message = { + content: [ + { type: 'text', text: createArtifactText({ content: 'content1' }) }, + { type: 'text', text: createArtifactText({ content: 'content2' }) }, + ], + }; + + const result = findAllArtifacts(message); + expect(result).toHaveLength(2); + expect(result[0].source).toBe('content'); + expect(result[1].partIndex).toBe(1); + }); + + test('should find artifacts in message.text when content is empty', () => { + const artifact1 = createArtifactText({ content: 'text1' }); + const artifact2 = createArtifactText({ content: 'text2' }); + const message = { text: [artifact1, artifact2].join('\n') }; + + const result = findAllArtifacts(message); + expect(result).toHaveLength(2); + expect(result[0].source).toBe('text'); + }); + + test('should handle unclosed artifacts', () => { + const message = { + text: createArtifactText({ content: 'unclosed', isClosed: false }), + }; + const result = findAllArtifacts(message); + expect(result[0].end).toBe(message.text.length); + }); + + test('should handle multiple artifacts in single part', () => { + const artifact1 = createArtifactText({ content: 'first' }); + const artifact2 = createArtifactText({ content: 'second' }); + const message = { + content: [ + { + type: 'text', + text: [artifact1, artifact2].join('\n'), + }, + ], + }; + + const result = findAllArtifacts(message); + expect(result).toHaveLength(2); + expect(result[1].start).toBeGreaterThan(result[0].end); + }); +}); + +describe('replaceArtifactContent', () => { + const createTestArtifact = (content, options) => { + const text = createArtifactText({ content, ...options }); + return { + start: 0, + end: text.length, + text, + source: 'text', + }; + }; + + test('should replace content within artifact boundaries', () => { + const original = 'console.log(\'hello\')'; + const artifact = createTestArtifact(original); + const updated = 'console.log(\'updated\')'; + + const result = replaceArtifactContent(artifact.text, artifact, original, updated); + expect(result).toContain(updated); + expect(result).toMatch(ARTIFACT_START); + expect(result).toMatch(ARTIFACT_END); + }); + + test('should return null when original not found', () => { + const artifact = createTestArtifact('function test() {}'); + const result = replaceArtifactContent(artifact.text, artifact, 'missing', 'updated'); + expect(result).toBeNull(); + }); + + test('should handle dedented content', () => { + const original = 'function test() {'; + const artifact = createTestArtifact(original); + const updated = 'function updated() {'; + + const result = replaceArtifactContent(artifact.text, artifact, original, updated); + expect(result).toContain(updated); + }); + + test('should preserve text outside artifact', () => { + const artifactContent = createArtifactText({ content: 'original' }); + const fullText = `prefix\n${artifactContent}\nsuffix`; + const artifact = createTestArtifact('original', { + prefix: 'prefix\n', + suffix: '\nsuffix', + }); + + const result = replaceArtifactContent(fullText, artifact, 'original', 'updated'); + expect(result).toMatch(/^prefix/); + expect(result).toMatch(/suffix$/); + }); + + test('should handle replacement at artifact boundaries', () => { + const original = 'console.log("hello")'; + const updated = 'console.log("updated")'; + + const artifactText = `${ARTIFACT_START}\n${original}\n${ARTIFACT_END}`; + const artifact = { + start: 0, + end: artifactText.length, + text: artifactText, + source: 'text', + }; + + const result = replaceArtifactContent(artifactText, artifact, original, updated); + + expect(result).toBe(`${ARTIFACT_START}\n${updated}\n${ARTIFACT_END}`); + }); +}); + +describe('replaceArtifactContent with shared text', () => { + test('should replace correct artifact when text is shared', () => { + const artifactContent = ' hi '; // Preserve exact spacing + const sharedText = `LOREM IPSUM + +:::artifact{identifier="calculator" type="application/vnd.react" title="Calculator"} +\`\`\` +${artifactContent} +\`\`\` +::: + +LOREM IPSUM + +:::artifact{identifier="calculator2" type="application/vnd.react" title="Calculator"} +\`\`\` +${artifactContent} +\`\`\` +:::`; + + const message = { text: sharedText }; + const artifacts = findAllArtifacts(message); + expect(artifacts).toHaveLength(2); + + const targetArtifact = artifacts[1]; + const updatedContent = ' updated content '; + const result = replaceArtifactContent( + sharedText, + targetArtifact, + artifactContent, + updatedContent, + ); + + // Verify exact matches with preserved formatting + expect(result).toContain(artifactContent); // First artifact unchanged + expect(result).toContain(updatedContent); // Second artifact updated + expect(result.indexOf(updatedContent)).toBeGreaterThan(result.indexOf(artifactContent)); + }); + + const codeExample = ` +function greetPerson(name) { + return \`Hello, \${name}! Welcome to JavaScript programming.\`; +} + +const personName = "Alice"; +const greeting = greetPerson(personName); +console.log(greeting);`; + + test('should handle random number of artifacts in content array', () => { + const numArtifacts = 5; // Fixed number for predictability + const targetIndex = 2; // Fixed target for predictability + + // Create content array with multiple parts + const contentParts = Array.from({ length: numArtifacts }, (_, i) => ({ + type: 'text', + text: createArtifactText({ + content: `content-${i}`, + wrapCode: true, + prefix: i > 0 ? '\n' : '', + }), + })); + + const message = { content: contentParts }; + const artifacts = findAllArtifacts(message); + expect(artifacts).toHaveLength(numArtifacts); + + const targetArtifact = artifacts[targetIndex]; + const originalContent = `content-${targetIndex}`; + const updatedContent = 'updated-content'; + + const result = replaceArtifactContent( + contentParts[targetIndex].text, + targetArtifact, + originalContent, + updatedContent, + ); + + // Verify the specific content was updated + expect(result).toContain(updatedContent); + expect(result).not.toContain(originalContent); + expect(result).toMatch( + new RegExp(`${ARTIFACT_START}.*${updatedContent}.*${ARTIFACT_END}`, 's'), + ); + }); + + test('should handle artifacts with identical content but different metadata in content array', () => { + const contentParts = [ + { + type: 'text', + text: createArtifactText({ + wrapCode: true, + content: codeExample, + prefix: '{id="1", title="First"}', + }), + }, + { + type: 'text', + text: createArtifactText({ + wrapCode: true, + content: codeExample, + prefix: '{id="2", title="Second"}', + }), + }, + ]; + + const message = { content: contentParts }; + const artifacts = findAllArtifacts(message); + + // Target second artifact + const targetArtifact = artifacts[1]; + const result = replaceArtifactContent( + contentParts[1].text, + targetArtifact, + codeExample, + 'updated content', + ); + console.log(result); + expect(result).toMatch(/id="2".*updated content/s); + expect(result).toMatch(new RegExp(`${ARTIFACT_START}.*updated content.*${ARTIFACT_END}`, 's')); + }); +}); diff --git a/api/server/services/Runs/StreamRunManager.js b/api/server/services/Runs/StreamRunManager.js index ab1217939f..ae00659983 100644 --- a/api/server/services/Runs/StreamRunManager.js +++ b/api/server/services/Runs/StreamRunManager.js @@ -508,12 +508,30 @@ class StreamRunManager { * @param {RequiredAction[]} actions - The required actions. * @returns {ToolOutput[]} completeOutputs - The complete outputs. */ - checkMissingOutputs(tool_outputs, actions) { + checkMissingOutputs(tool_outputs = [], actions = []) { const missingOutputs = []; + const MISSING_OUTPUT_MESSAGE = + 'The tool failed to produce an output. The tool may not be currently available or experienced an unhandled error.'; + const outputIds = new Set(); + const validatedOutputs = tool_outputs.map((output) => { + if (!output) { + logger.warn('Tool output is undefined'); + return; + } + outputIds.add(output.tool_call_id); + if (!output.output) { + logger.warn(`Tool output exists but has no output property (ID: ${output.tool_call_id})`); + return { + ...output, + output: MISSING_OUTPUT_MESSAGE, + }; + } + return output; + }); for (const item of actions) { const { tool, toolCallId, run_id, thread_id } = item; - const outputExists = tool_outputs.some((output) => output.tool_call_id === toolCallId); + const outputExists = outputIds.has(toolCallId); if (!outputExists) { logger.warn( @@ -521,13 +539,12 @@ class StreamRunManager { ); missingOutputs.push({ tool_call_id: toolCallId, - output: - 'The tool failed to produce an output. The tool may not be currently available or experienced an unhandled error.', + output: MISSING_OUTPUT_MESSAGE, }); } } - return [...tool_outputs, ...missingOutputs]; + return [...validatedOutputs, ...missingOutputs]; } /* <------------------ Run Event handlers ------------------> */ diff --git a/client/package.json b/client/package.json index 54876f8fa1..3dac89dbf3 100644 --- a/client/package.json +++ b/client/package.json @@ -29,7 +29,7 @@ "homepage": "https://librechat.ai", "dependencies": { "@ariakit/react": "^0.4.11", - "@codesandbox/sandpack-react": "^2.18.2", + "@codesandbox/sandpack-react": "^2.19.10", "@dicebear/collection": "^7.0.4", "@dicebear/core": "^7.0.4", "@headlessui/react": "^2.1.2", diff --git a/client/src/Providers/ArtifactContext.tsx b/client/src/Providers/ArtifactContext.tsx new file mode 100644 index 0000000000..938f26d6e8 --- /dev/null +++ b/client/src/Providers/ArtifactContext.tsx @@ -0,0 +1,32 @@ +import { createContext, useContext, ReactNode, useCallback, useRef } from 'react'; + +type TArtifactContext = { + getNextIndex: (skip: boolean) => number; + resetCounter: () => void; +}; + +export const ArtifactContext = createContext({} as TArtifactContext); +export const useArtifactContext = () => useContext(ArtifactContext); + +export function ArtifactProvider({ children }: { children: ReactNode }) { + const counterRef = useRef(0); + + const getNextIndex = useCallback((skip: boolean) => { + if (skip) { + return counterRef.current; + } + const nextIndex = counterRef.current; + counterRef.current += 1; + return nextIndex; + }, []); + + const resetCounter = useCallback(() => { + counterRef.current = 0; + }, []); + + return ( + + {children} + + ); +} diff --git a/client/src/Providers/CodeBlockContext.tsx b/client/src/Providers/CodeBlockContext.tsx index 915e740840..2823f532be 100644 --- a/client/src/Providers/CodeBlockContext.tsx +++ b/client/src/Providers/CodeBlockContext.tsx @@ -3,7 +3,6 @@ import { createContext, useContext, ReactNode, useCallback, useRef } from 'react type TCodeBlockContext = { getNextIndex: (skip: boolean) => number; resetCounter: () => void; - // codeBlocks: Map; }; export const CodeBlockContext = createContext({} as TCodeBlockContext); @@ -11,7 +10,6 @@ export const useCodeBlockContext = () => useContext(CodeBlockContext); export function CodeBlockProvider({ children }: { children: ReactNode }) { const counterRef = useRef(0); - // const codeBlocks = useRef(new Map()).current; const getNextIndex = useCallback((skip: boolean) => { if (skip) { diff --git a/client/src/Providers/EditorContext.tsx b/client/src/Providers/EditorContext.tsx new file mode 100644 index 0000000000..7cdf6e5de8 --- /dev/null +++ b/client/src/Providers/EditorContext.tsx @@ -0,0 +1,29 @@ +import React, { createContext, useContext, useState } from 'react'; + +interface EditorContextType { + isMutating: boolean; + setIsMutating: React.Dispatch>; + currentCode?: string; + setCurrentCode: React.Dispatch>; +} + +const EditorContext = createContext(undefined); + +export function EditorProvider({ children }: { children: React.ReactNode }) { + const [isMutating, setIsMutating] = useState(false); + const [currentCode, setCurrentCode] = useState(); + + return ( + + {children} + + ); +} + +export function useEditorContext() { + const context = useContext(EditorContext); + if (context === undefined) { + throw new Error('useEditorContext must be used within an EditorProvider'); + } + return context; +} diff --git a/client/src/Providers/index.ts b/client/src/Providers/index.ts index a11da6504b..7363c97d41 100644 --- a/client/src/Providers/index.ts +++ b/client/src/Providers/index.ts @@ -7,6 +7,7 @@ export * from './ToastContext'; export * from './SearchContext'; export * from './FileMapContext'; export * from './AddedChatContext'; +export * from './EditorContext'; export * from './ChatFormContext'; export * from './BookmarkContext'; export * from './MessageContext'; @@ -16,6 +17,7 @@ export * from './AgentsContext'; export * from './AssistantsMapContext'; export * from './AnnouncerContext'; export * from './AgentsMapContext'; +export * from './ArtifactContext'; export * from './CodeBlockContext'; export * from './ToolCallsMapContext'; export * from './SetConvoContext'; diff --git a/client/src/common/artifacts.ts b/client/src/common/artifacts.ts index 08c9bfd3b6..168b0d56e3 100644 --- a/client/src/common/artifacts.ts +++ b/client/src/common/artifacts.ts @@ -7,9 +7,21 @@ export interface CodeBlock { export interface Artifact { id: string; lastUpdateTime: number; + index?: number; + messageId?: string; identifier?: string; language?: string; content?: string; title?: string; type?: string; } + +export type ArtifactFiles = + | { + 'App.tsx': string; + 'index.tsx': string; + '/components/ui/MermaidDiagram.tsx': string; + } + | Partial<{ + [x: string]: string | undefined; + }>; diff --git a/client/src/components/Artifacts/Artifact.tsx b/client/src/components/Artifacts/Artifact.tsx index a8ddc6e944..b8e21c4790 100644 --- a/client/src/components/Artifacts/Artifact.tsx +++ b/client/src/components/Artifacts/Artifact.tsx @@ -4,6 +4,7 @@ import { visit } from 'unist-util-visit'; import { useSetRecoilState } from 'recoil'; import type { Pluggable } from 'unified'; import type { Artifact } from '~/common'; +import { useMessageContext, useArtifactContext } from '~/Providers'; import { artifactsState } from '~/store/artifacts'; import ArtifactButton from './ArtifactButton'; import { logger } from '~/utils'; @@ -44,6 +45,10 @@ export function Artifact({ children: React.ReactNode | { props: { children: React.ReactNode } }; node: unknown; }) { + const { messageId } = useMessageContext(); + const { getNextIndex, resetCounter } = useArtifactContext(); + const artifactIndex = useRef(getNextIndex(false)).current; + const setArtifacts = useSetRecoilState(artifactsState); const [artifact, setArtifact] = useState(null); @@ -64,7 +69,9 @@ export function Artifact({ const title = props.title ?? 'Untitled Artifact'; const type = props.type ?? 'unknown'; const identifier = props.identifier ?? 'no-identifier'; - const artifactKey = `${identifier}_${type}_${title}`.replace(/\s+/g, '_').toLowerCase(); + const artifactKey = `${identifier}_${type}_${title}_${messageId}` + .replace(/\s+/g, '_') + .toLowerCase(); throttledUpdateRef.current(() => { const now = Date.now(); @@ -75,6 +82,8 @@ export function Artifact({ title, type, content, + messageId, + index: artifactIndex, lastUpdateTime: now, }; @@ -94,11 +103,20 @@ export function Artifact({ setArtifact(currentArtifact); }); - }, [props.type, props.title, setArtifacts, props.children, props.identifier]); + }, [ + props.type, + props.title, + setArtifacts, + props.children, + props.identifier, + messageId, + artifactIndex, + ]); useEffect(() => { + resetCounter(); updateArtifact(); - }, [updateArtifact]); + }, [updateArtifact, resetCounter]); return ; } diff --git a/client/src/components/Artifacts/ArtifactCodeEditor.tsx b/client/src/components/Artifacts/ArtifactCodeEditor.tsx new file mode 100644 index 0000000000..0f52e43f01 --- /dev/null +++ b/client/src/components/Artifacts/ArtifactCodeEditor.tsx @@ -0,0 +1,151 @@ +import debounce from 'lodash/debounce'; +import React, { memo, useEffect, useMemo, useCallback } from 'react'; +import { + useSandpack, + SandpackCodeEditor, + SandpackProvider as StyledProvider, +} from '@codesandbox/sandpack-react'; +import { SandpackProviderProps } from '@codesandbox/sandpack-react/unstyled'; +import type { CodeEditorRef } from '@codesandbox/sandpack-react'; +import type { ArtifactFiles, Artifact } from '~/common'; +import { sharedFiles, sharedOptions } from '~/utils/artifacts'; +import { useEditArtifact } from '~/data-provider'; +import { useEditorContext } from '~/Providers'; + +const createDebouncedMutation = ( + callback: (params: { + index: number; + messageId: string; + original: string; + updated: string; + }) => void, +) => debounce(callback, 500); + +const CodeEditor = ({ + fileKey, + readOnly, + artifact, + editorRef, +}: { + fileKey: string; + readOnly: boolean; + artifact: Artifact; + editorRef: React.MutableRefObject; +}) => { + const { sandpack } = useSandpack(); + const { isMutating, setIsMutating, setCurrentCode } = useEditorContext(); + const editArtifact = useEditArtifact({ + onMutate: () => { + setIsMutating(true); + }, + onSuccess: () => { + setIsMutating(false); + }, + onError: () => { + setIsMutating(false); + setCurrentCode(artifact.content); + }, + }); + + const mutationCallback = useCallback( + (params: { index: number; messageId: string; original: string; updated: string }) => { + editArtifact.mutate(params); + }, + [editArtifact], + ); + + const debouncedMutation = useMemo( + () => createDebouncedMutation(mutationCallback), + [mutationCallback], + ); + + useEffect(() => { + if (readOnly) { + return; + } + if (isMutating) { + return; + } + + const currentCode = sandpack.files['/' + fileKey].code; + + if (currentCode && artifact.content != null && currentCode.trim() !== artifact.content.trim()) { + setCurrentCode(currentCode); + debouncedMutation({ + index: artifact.index, + messageId: artifact.messageId ?? '', + original: artifact.content, + updated: currentCode, + }); + } + + return () => { + debouncedMutation.cancel(); + }; + }, [ + fileKey, + artifact.index, + artifact.content, + artifact.messageId, + readOnly, + isMutating, + sandpack.files, + setIsMutating, + setCurrentCode, + debouncedMutation, + ]); + + return ( + + ); +}; + +export const ArtifactCodeEditor = memo(function ({ + files, + fileKey, + template, + artifact, + editorRef, + sharedProps, + isSubmitting, +}: { + fileKey: string; + artifact: Artifact; + files: ArtifactFiles; + isSubmitting: boolean; + template: SandpackProviderProps['template']; + sharedProps: Partial; + editorRef: React.MutableRefObject; +}) { + if (Object.keys(files).length === 0) { + return null; + } + + return ( + + + + ); +}); diff --git a/client/src/components/Artifacts/ArtifactPreview.tsx b/client/src/components/Artifacts/ArtifactPreview.tsx index b0ade2b0ef..9cb06d413c 100644 --- a/client/src/components/Artifacts/ArtifactPreview.tsx +++ b/client/src/components/Artifacts/ArtifactPreview.tsx @@ -1,67 +1,51 @@ -import React, { useMemo, memo } from 'react'; -import { Sandpack } from '@codesandbox/sandpack-react'; -import { removeNullishValues } from 'librechat-data-provider'; -import { SandpackPreview, SandpackProvider } from '@codesandbox/sandpack-react/unstyled'; -import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled'; -import type { Artifact } from '~/common'; +import React, { memo, useMemo } from 'react'; import { - getKey, - getProps, - sharedFiles, - getTemplate, - sharedOptions, - getArtifactFilename, -} from '~/utils/artifacts'; -import { getMermaidFiles } from '~/utils/mermaid'; + SandpackPreview, + SandpackProvider, + SandpackProviderProps, +} from '@codesandbox/sandpack-react/unstyled'; +import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled'; +import type { ArtifactFiles } from '~/common'; +import { sharedFiles, sharedOptions } from '~/utils/artifacts'; +import { useEditorContext } from '~/Providers'; export const ArtifactPreview = memo(function ({ - showEditor = false, - artifact, + files, + fileKey, previewRef, + sharedProps, + template, }: { - showEditor?: boolean; - artifact: Artifact; + files: ArtifactFiles; + fileKey: string; + template: SandpackProviderProps['template']; + sharedProps: Partial; previewRef: React.MutableRefObject; }) { - const files = useMemo(() => { - if (getKey(artifact.type ?? '', artifact.language).includes('mermaid')) { - return getMermaidFiles(artifact.content ?? ''); + const { currentCode } = useEditorContext(); + const artifactFiles = useMemo(() => { + if (Object.keys(files).length === 0) { + return files; } - return removeNullishValues({ - [getArtifactFilename(artifact.type ?? '', artifact.language)]: artifact.content, - }); - }, [artifact.type, artifact.content, artifact.language]); - - const template = useMemo( - () => getTemplate(artifact.type ?? '', artifact.language), - [artifact.type, artifact.language], - ); - - const sharedProps = useMemo(() => getProps(artifact.type ?? ''), [artifact.type]); - - if (Object.keys(files).length === 0) { + const code = currentCode ?? ''; + if (!code) { + return files; + } + return { + ...files, + [fileKey]: { + code, + }, + }; + }, [currentCode, files, fileKey]); + if (Object.keys(artifactFiles).length === 0) { return null; } - return showEditor ? ( - - ) : ( + return ( ; + previewRef: React.MutableRefObject; +}) { + const content = artifact.content ?? ''; + const contentRef = useRef(null); + useAutoScroll({ ref: contentRef, content, isSubmitting }); + const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact }); + return ( + <> + + + + + + + + ); +} diff --git a/client/src/components/Artifacts/Artifacts.tsx b/client/src/components/Artifacts/Artifacts.tsx index 9a2bef3495..57825005ec 100644 --- a/client/src/components/Artifacts/Artifacts.tsx +++ b/client/src/components/Artifacts/Artifacts.tsx @@ -2,18 +2,22 @@ import { useRef, useState, useEffect } from 'react'; import { RefreshCw } from 'lucide-react'; import { useSetRecoilState } from 'recoil'; import * as Tabs from '@radix-ui/react-tabs'; -import { SandpackPreviewRef } from '@codesandbox/sandpack-react'; +import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react'; import useArtifacts from '~/hooks/Artifacts/useArtifacts'; -import { CodeMarkdown, CopyCodeButton } from './Code'; -import { getFileExtension } from '~/utils/artifacts'; -import { ArtifactPreview } from './ArtifactPreview'; -import { cn } from '~/utils'; +import DownloadArtifact from './DownloadArtifact'; +import { useEditorContext } from '~/Providers'; +import useLocalize from '~/hooks/useLocalize'; +import ArtifactTabs from './ArtifactTabs'; +import { CopyCodeButton } from './Code'; import store from '~/store'; export default function Artifacts() { + const localize = useLocalize(); + const { isMutating } = useEditorContext(); + const editorRef = useRef(); const previewRef = useRef(); - const [isRefreshing, setIsRefreshing] = useState(false); const [isVisible, setIsVisible] = useState(false); + const [isRefreshing, setIsRefreshing] = useState(false); const setArtifactsVisible = useSetRecoilState(store.artifactsVisible); useEffect(() => { @@ -23,9 +27,9 @@ export default function Artifacts() { const { activeTab, isMermaid, - isSubmitting, setActiveTab, currentIndex, + isSubmitting, cycleArtifact, currentArtifact, orderedArtifactIds, @@ -47,10 +51,10 @@ export default function Artifacts() { return ( {/* Main Parent */} -
+
{/* Main Container */}
)} + {activeTab !== 'preview' && isMutating && ( + + )} + {/* Tabs */} - Preview + {localize('com_ui_preview')} - Code + {localize('com_ui_code')}
{/* Content */} - - - - - } - /> - + } + previewRef={previewRef as React.MutableRefObject} + /> {/* Footer */}
@@ -178,20 +174,10 @@ export default function Artifacts() {
-
+
{/* Download Button */} - {/* */} + {/* Publish button */} {/* + ); +}; + +export default DownloadArtifact; diff --git a/client/src/components/Chat/Messages/Content/Markdown.tsx b/client/src/components/Chat/Messages/Content/Markdown.tsx index 59cbc70481..176cc90191 100644 --- a/client/src/components/Chat/Messages/Content/Markdown.tsx +++ b/client/src/components/Chat/Messages/Content/Markdown.tsx @@ -8,9 +8,14 @@ import ReactMarkdown from 'react-markdown'; import rehypeHighlight from 'rehype-highlight'; import remarkDirective from 'remark-directive'; import type { Pluggable } from 'unified'; +import { + useToastContext, + ArtifactProvider, + CodeBlockProvider, + useCodeBlockContext, +} from '~/Providers'; import { Artifact, artifactPlugin } from '~/components/Artifacts/Artifact'; import { langSubset, preprocessLaTeX, handleDoubleClick } from '~/utils'; -import { useToastContext, CodeBlockProvider, useCodeBlockContext } from '~/Providers'; import CodeBlock from '~/components/Messages/Content/CodeBlock'; import { useFileDownload } from '~/data-provider'; import useLocalize from '~/hooks/useLocalize'; @@ -194,27 +199,29 @@ const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentPr : [supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]]; return ( - - + + - {isLatestMessage && showCursor === true ? currentContent + cursor : currentContent} - - + > + {isLatestMessage && showCursor === true ? currentContent + cursor : currentContent} + + + ); }); diff --git a/client/src/components/Chat/Messages/Content/MarkdownLite.tsx b/client/src/components/Chat/Messages/Content/MarkdownLite.tsx index 1571d23448..b92ab2e7ce 100644 --- a/client/src/components/Chat/Messages/Content/MarkdownLite.tsx +++ b/client/src/components/Chat/Messages/Content/MarkdownLite.tsx @@ -7,7 +7,7 @@ import ReactMarkdown from 'react-markdown'; import rehypeHighlight from 'rehype-highlight'; import type { PluggableList } from 'unified'; import { code, codeNoExecution, a, p } from './Markdown'; -import { CodeBlockProvider } from '~/Providers'; +import { CodeBlockProvider, ArtifactProvider } from '~/Providers'; import { langSubset } from '~/utils'; const MarkdownLite = memo( @@ -25,30 +25,32 @@ const MarkdownLite = memo( ]; return ( - - + + - {content} - - + > + {content} + + + ); }, ); diff --git a/client/src/components/Chat/Messages/ui/MessageRender.tsx b/client/src/components/Chat/Messages/ui/MessageRender.tsx index df1b6c8072..dbbee5869f 100644 --- a/client/src/components/Chat/Messages/ui/MessageRender.tsx +++ b/client/src/components/Chat/Messages/ui/MessageRender.tsx @@ -121,13 +121,14 @@ const MessageRender = memo( return (
{ diff --git a/client/src/components/Chat/Presentation.tsx b/client/src/components/Chat/Presentation.tsx index bae2338e74..9ab483924b 100644 --- a/client/src/components/Chat/Presentation.tsx +++ b/client/src/components/Chat/Presentation.tsx @@ -8,6 +8,7 @@ import { useDeleteFilesMutation } from '~/data-provider'; import Artifacts from '~/components/Artifacts/Artifacts'; import { SidePanel } from '~/components/SidePanel'; import { useSetFilesToDelete } from '~/hooks'; +import { EditorProvider } from '~/Providers'; import store from '~/store'; const defaultInterface = getConfigDefaults().interface; @@ -94,7 +95,9 @@ export default function Presentation({ artifactsVisible === true && codeArtifacts === true && Object.keys(artifacts ?? {}).length > 0 ? ( - + + + ) : null } > diff --git a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx index 42b8b2db43..1b04a27d78 100644 --- a/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx +++ b/client/src/components/Conversations/ConvoOptions/ConvoOptions.tsx @@ -42,13 +42,11 @@ function ConvoOptions({ const duplicateConversation = useDuplicateConversationMutation({ onSuccess: (data) => { - if (data != null) { - navigateToConvo(data.conversation); - showToast({ - message: localize('com_ui_duplication_success'), - status: 'success', - }); - } + navigateToConvo(data.conversation); + showToast({ + message: localize('com_ui_duplication_success'), + status: 'success', + }); }, onMutate: () => { showToast({ diff --git a/client/src/components/Messages/ContentRender.tsx b/client/src/components/Messages/ContentRender.tsx index 0761e3f902..b06819aaaa 100644 --- a/client/src/components/Messages/ContentRender.tsx +++ b/client/src/components/Messages/ContentRender.tsx @@ -120,13 +120,15 @@ const ContentRender = memo( return (
{ diff --git a/client/src/data-provider/Messages/index.ts b/client/src/data-provider/Messages/index.ts new file mode 100644 index 0000000000..0fb57594c6 --- /dev/null +++ b/client/src/data-provider/Messages/index.ts @@ -0,0 +1,2 @@ +// export * from './queries'; +export * from './mutations'; diff --git a/client/src/data-provider/Messages/mutations.ts b/client/src/data-provider/Messages/mutations.ts new file mode 100644 index 0000000000..d92ace3ef3 --- /dev/null +++ b/client/src/data-provider/Messages/mutations.ts @@ -0,0 +1,46 @@ +import { dataService, QueryKeys } from 'librechat-data-provider'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import type * as t from 'librechat-data-provider'; +import type { UseMutationResult } from '@tanstack/react-query'; + +export const useEditArtifact = ( + _options?: t.EditArtifactOptions, +): UseMutationResult => { + const queryClient = useQueryClient(); + const { onSuccess, ...options } = _options ?? {}; + return useMutation({ + mutationFn: (variables: t.TEditArtifactRequest) => dataService.editArtifact(variables), + onSuccess: (data, vars, context) => { + queryClient.setQueryData([QueryKeys.messages, data.conversationId], (prev) => { + if (!prev) { + return prev; + } + + const newArray = [...prev]; + let targetIndex: number | undefined; + + for (let i = newArray.length - 1; i >= 0; i--) { + if (newArray[i].messageId === vars.messageId) { + targetIndex = i; + break; + } + } + + if (targetIndex == null) { + return prev; + } + + newArray[targetIndex] = { + ...newArray[targetIndex], + content: data.content, + text: data.text, + }; + + return newArray; + }); + + onSuccess?.(data, vars, context); + }, + ...options, + }); +}; diff --git a/client/src/data-provider/index.ts b/client/src/data-provider/index.ts index 5e56a113c6..c6d832687f 100644 --- a/client/src/data-provider/index.ts +++ b/client/src/data-provider/index.ts @@ -1,6 +1,7 @@ export * from './Auth'; export * from './Agents'; export * from './Files'; +export * from './Messages'; export * from './Tools'; export * from './connection'; export * from './mutations'; diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index b3b25c35a4..60f65eeeec 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -579,9 +579,6 @@ export const useDuplicateConversationMutation = ( if (originalId.length === 0) { return; } - if (data == null) { - return; - } queryClient.setQueryData( [QueryKeys.conversation, data.conversation.conversationId], data.conversation, diff --git a/client/src/hooks/Artifacts/useArtifactProps.ts b/client/src/hooks/Artifacts/useArtifactProps.ts new file mode 100644 index 0000000000..6de90f5893 --- /dev/null +++ b/client/src/hooks/Artifacts/useArtifactProps.ts @@ -0,0 +1,33 @@ +import { useMemo } from 'react'; +import { removeNullishValues } from 'librechat-data-provider'; +import type { Artifact } from '~/common'; +import { getKey, getProps, getTemplate, getArtifactFilename } from '~/utils/artifacts'; +import { getMermaidFiles } from '~/utils/mermaid'; + +export default function useArtifactProps({ artifact }: { artifact: Artifact }) { + const [fileKey, files] = useMemo(() => { + if (getKey(artifact.type ?? '', artifact.language).includes('mermaid')) { + return ['App.tsx', getMermaidFiles(artifact.content ?? '')]; + } + + const fileKey = getArtifactFilename(artifact.type ?? '', artifact.language); + const files = removeNullishValues({ + [fileKey]: artifact.content, + }); + return [fileKey, files]; + }, [artifact.type, artifact.content, artifact.language]); + + const template = useMemo( + () => getTemplate(artifact.type ?? '', artifact.language), + [artifact.type, artifact.language], + ); + + const sharedProps = useMemo(() => getProps(artifact.type ?? ''), [artifact.type]); + + return { + files, + fileKey, + template, + sharedProps, + }; +} diff --git a/client/src/hooks/Artifacts/useAutoScroll.ts b/client/src/hooks/Artifacts/useAutoScroll.ts index 3a03fc20fd..1ddb9feb98 100644 --- a/client/src/hooks/Artifacts/useAutoScroll.ts +++ b/client/src/hooks/Artifacts/useAutoScroll.ts @@ -1,30 +1,47 @@ -import { useState, useRef, useCallback, useEffect } from 'react'; -import { useChatContext } from '~/Providers'; +// hooks/useAutoScroll.ts +import { useEffect, useState } from 'react'; -export default function useAutoScroll() { - const { isSubmitting } = useChatContext(); - const [showScrollButton, setShowScrollButton] = useState(false); - const scrollableRef = useRef(null); - const contentEndRef = useRef(null); +interface UseAutoScrollProps { + ref: React.RefObject; + content: string; + isSubmitting: boolean; +} - const scrollToBottom = useCallback(() => { - if (scrollableRef.current) { - scrollableRef.current.scrollTop = scrollableRef.current.scrollHeight; - } - }, []); - - const handleScroll = useCallback(() => { - if (scrollableRef.current) { - const { scrollTop, scrollHeight, clientHeight } = scrollableRef.current; - setShowScrollButton(scrollHeight - scrollTop - clientHeight > 100); - } - }, []); +export const useAutoScroll = ({ ref, content, isSubmitting }: UseAutoScrollProps) => { + const [userScrolled, setUserScrolled] = useState(false); useEffect(() => { - if (isSubmitting) { - scrollToBottom(); + const scrollContainer = ref.current; + if (!scrollContainer) { + return; } - }, [isSubmitting, scrollToBottom]); - return { scrollableRef, contentEndRef, handleScroll, scrollToBottom, showScrollButton }; -} + const handleScroll = () => { + const { scrollTop, scrollHeight, clientHeight } = scrollContainer; + const isNearBottom = scrollHeight - scrollTop - clientHeight < 50; + + if (!isNearBottom) { + setUserScrolled(true); + } else { + setUserScrolled(false); + } + }; + + scrollContainer.addEventListener('scroll', handleScroll); + + return () => { + scrollContainer.removeEventListener('scroll', handleScroll); + }; + }, [ref]); + + useEffect(() => { + const scrollContainer = ref.current; + if (!scrollContainer || !isSubmitting || userScrolled) { + return; + } + + scrollContainer.scrollTop = scrollContainer.scrollHeight; + }, [content, isSubmitting, userScrolled, ref]); + + return { userScrolled }; +}; diff --git a/client/src/localization/languages/Eng.ts b/client/src/localization/languages/Eng.ts index 45a6e819e6..2975e83ff8 100644 --- a/client/src/localization/languages/Eng.ts +++ b/client/src/localization/languages/Eng.ts @@ -147,6 +147,8 @@ export default { com_ui_date_november: 'November', com_ui_date_december: 'December', com_ui_field_required: 'This field is required', + com_ui_download_artifact: 'Download Artifact', + com_ui_download: 'Download', com_ui_download_error: 'Error downloading file. The file may have been deleted.', com_ui_attach_error_type: 'Unsupported file type for endpoint:', com_ui_attach_error_openai: 'Cannot attach Assistant files to other endpoints', diff --git a/client/src/mobile.css b/client/src/mobile.css index 7257cab2bf..3c68d8d0ea 100644 --- a/client/src/mobile.css +++ b/client/src/mobile.css @@ -337,4 +337,28 @@ .shake { animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both; +} + +div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"] { + scrollbar-gutter: stable !important; + background-color: rgba(21, 21, 21, 0.5) !important; +} + +div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"]::-webkit-scrollbar { + width: 12px !important; +} + +div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"]::-webkit-scrollbar-thumb { + background-color: rgba(56, 56, 56) !important; + border-radius: 6px !important; + border: 2px solid transparent !important; + background-clip: padding-box !important; +} + +div[role="tabpanel"][data-state="active"][data-orientation="horizontal"][aria-labelledby^="radix-"][id^="radix-"][id$="-content-preview"]::-webkit-scrollbar-track { + background-color: transparent !important; +} + +.cm-content:focus { + outline: none !important; } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 62ce968807..40d66e13ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -891,7 +891,7 @@ "license": "ISC", "dependencies": { "@ariakit/react": "^0.4.11", - "@codesandbox/sandpack-react": "^2.18.2", + "@codesandbox/sandpack-react": "^2.19.10", "@dicebear/collection": "^7.0.4", "@dicebear/core": "^7.0.4", "@headlessui/react": "^2.1.2", @@ -1005,6 +1005,54 @@ "vite-plugin-pwa": "^0.21.1" } }, + "client/node_modules/@codesandbox/sandpack-client": { + "version": "2.19.8", + "resolved": "https://registry.npmjs.org/@codesandbox/sandpack-client/-/sandpack-client-2.19.8.tgz", + "integrity": "sha512-CMV4nr1zgKzVpx4I3FYvGRM5YT0VaQhALMW9vy4wZRhEyWAtJITQIqZzrTGWqB1JvV7V72dVEUCUPLfYz5hgJQ==", + "dependencies": { + "@codesandbox/nodebox": "0.1.8", + "buffer": "^6.0.3", + "dequal": "^2.0.2", + "mime-db": "^1.52.0", + "outvariant": "1.4.0", + "static-browser-server": "1.0.3" + } + }, + "client/node_modules/@codesandbox/sandpack-react": { + "version": "2.19.10", + "resolved": "https://registry.npmjs.org/@codesandbox/sandpack-react/-/sandpack-react-2.19.10.tgz", + "integrity": "sha512-X/7NzhR7R5pp5qYS+Gc31OzJvy+EzGz++H1YN9bJlDE+VzxTBsMN9dv3adzeo5wtxUhqexVOJS7mGr//e7KP2A==", + "dependencies": { + "@codemirror/autocomplete": "^6.4.0", + "@codemirror/commands": "^6.1.3", + "@codemirror/lang-css": "^6.0.1", + "@codemirror/lang-html": "^6.4.0", + "@codemirror/lang-javascript": "^6.1.2", + "@codemirror/language": "^6.3.2", + "@codemirror/state": "^6.2.0", + "@codemirror/view": "^6.7.1", + "@codesandbox/sandpack-client": "^2.19.8", + "@lezer/highlight": "^1.1.3", + "@react-hook/intersection-observer": "^3.1.1", + "@stitches/core": "^1.2.6", + "anser": "^2.1.1", + "clean-set": "^1.1.2", + "dequal": "^2.0.2", + "escape-carriage": "^1.3.1", + "lz-string": "^1.4.4", + "react-devtools-inline": "4.4.0", + "react-is": "^17.0.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18", + "react-dom": "^16.8.0 || ^17 || ^18" + } + }, + "client/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, "client/node_modules/vite": { "version": "5.4.14", "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", @@ -6151,54 +6199,6 @@ "strict-event-emitter": "^0.4.3" } }, - "node_modules/@codesandbox/sandpack-client": { - "version": "2.18.2", - "resolved": "https://registry.npmjs.org/@codesandbox/sandpack-client/-/sandpack-client-2.18.2.tgz", - "integrity": "sha512-zKSZWoCqpUFHqSbG1Q88ICqbY/nKKTY3rKKxTdNCSv0miI3JAR671kFcq6fkoCYVHFg6WIVpW7EzYwA/TYzX0w==", - "dependencies": { - "@codesandbox/nodebox": "0.1.8", - "buffer": "^6.0.3", - "dequal": "^2.0.2", - "mime-db": "^1.52.0", - "outvariant": "1.4.0", - "static-browser-server": "1.0.3" - } - }, - "node_modules/@codesandbox/sandpack-react": { - "version": "2.18.2", - "resolved": "https://registry.npmjs.org/@codesandbox/sandpack-react/-/sandpack-react-2.18.2.tgz", - "integrity": "sha512-OvXHAUKTjqXfjB9qd+6Pt3Pzjpk2/SRxVFUAC7cRwnSap3X7T0uBDdoRIJTlueXVrD+F1FkaGRzIE5GgGm4FEQ==", - "dependencies": { - "@codemirror/autocomplete": "^6.4.0", - "@codemirror/commands": "^6.1.3", - "@codemirror/lang-css": "^6.0.1", - "@codemirror/lang-html": "^6.4.0", - "@codemirror/lang-javascript": "^6.1.2", - "@codemirror/language": "^6.3.2", - "@codemirror/state": "^6.2.0", - "@codemirror/view": "^6.7.1", - "@codesandbox/sandpack-client": "^2.18.2", - "@lezer/highlight": "^1.1.3", - "@react-hook/intersection-observer": "^3.1.1", - "@stitches/core": "^1.2.6", - "anser": "^2.1.1", - "clean-set": "^1.1.2", - "dequal": "^2.0.2", - "escape-carriage": "^1.3.1", - "lz-string": "^1.4.4", - "react-devtools-inline": "4.4.0", - "react-is": "^17.0.2" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17 || ^18", - "react-dom": "^16.8.0 || ^17 || ^18" - } - }, - "node_modules/@codesandbox/sandpack-react/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" - }, "node_modules/@colors/colors": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index bf6b780bdc..27cc221d72 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -10,7 +10,7 @@ export const userPlugins = () => '/api/user/plugins'; export const deleteUser = () => '/api/user/delete'; export const messages = (conversationId: string, messageId?: string) => - `/api/messages/${conversationId}${messageId ? `/${messageId}` : ''}`; + `/api/messages/${conversationId}${messageId != null && messageId ? `/${messageId}` : ''}`; const shareRoot = '/api/share'; export const shareMessages = (shareId: string) => `${shareRoot}/${shareId}`; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 9574e48670..0eadd3b725 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -76,6 +76,13 @@ export function updateMessage(payload: t.TUpdateMessageRequest): Promise => { + return request.post(`/api/messages/artifact/${messageId}`, params); +}; + export function updateMessageContent(payload: t.TUpdateMessageContent): Promise { const { conversationId, messageId, index, text } = payload; if (!conversationId) { diff --git a/packages/data-provider/src/types/mutations.ts b/packages/data-provider/src/types/mutations.ts index 74dd18bcf3..754bed65ec 100644 --- a/packages/data-provider/src/types/mutations.ts +++ b/packages/data-provider/src/types/mutations.ts @@ -315,3 +315,19 @@ export type TDeleteSharedLinkResponse = { shareId: string; message: string; }; + +export type TEditArtifactRequest = { + index: number; + messageId: string; + original: string; + updated: string; +}; + +export type TEditArtifactResponse = Pick; + +export type EditArtifactOptions = MutationOptions< + TEditArtifactResponse, + TEditArtifactRequest, + unknown, + Error +>;