mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 00:40:14 +01:00
🚀 feat: Artifact Editing & Downloads (#5428)
* 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
This commit is contained in:
parent
87383fec27
commit
ed57bb4711
34 changed files with 1156 additions and 237 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
81
api/server/services/Artifacts/update.js
Normal file
81
api/server/services/Artifacts/update.js
Normal file
|
|
@ -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,
|
||||
};
|
||||
267
api/server/services/Artifacts/update.spec.js
Normal file
267
api/server/services/Artifacts/update.spec.js
Normal file
|
|
@ -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'));
|
||||
});
|
||||
});
|
||||
|
|
@ -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 ------------------> */
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
32
client/src/Providers/ArtifactContext.tsx
Normal file
32
client/src/Providers/ArtifactContext.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { createContext, useContext, ReactNode, useCallback, useRef } from 'react';
|
||||
|
||||
type TArtifactContext = {
|
||||
getNextIndex: (skip: boolean) => number;
|
||||
resetCounter: () => void;
|
||||
};
|
||||
|
||||
export const ArtifactContext = createContext<TArtifactContext>({} 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 (
|
||||
<ArtifactContext.Provider value={{ getNextIndex, resetCounter }}>
|
||||
{children}
|
||||
</ArtifactContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -3,7 +3,6 @@ import { createContext, useContext, ReactNode, useCallback, useRef } from 'react
|
|||
type TCodeBlockContext = {
|
||||
getNextIndex: (skip: boolean) => number;
|
||||
resetCounter: () => void;
|
||||
// codeBlocks: Map<number, string>;
|
||||
};
|
||||
|
||||
export const CodeBlockContext = createContext<TCodeBlockContext>({} 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<number, string>()).current;
|
||||
|
||||
const getNextIndex = useCallback((skip: boolean) => {
|
||||
if (skip) {
|
||||
|
|
|
|||
29
client/src/Providers/EditorContext.tsx
Normal file
29
client/src/Providers/EditorContext.tsx
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import React, { createContext, useContext, useState } from 'react';
|
||||
|
||||
interface EditorContextType {
|
||||
isMutating: boolean;
|
||||
setIsMutating: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
currentCode?: string;
|
||||
setCurrentCode: React.Dispatch<React.SetStateAction<string | undefined>>;
|
||||
}
|
||||
|
||||
const EditorContext = createContext<EditorContextType | undefined>(undefined);
|
||||
|
||||
export function EditorProvider({ children }: { children: React.ReactNode }) {
|
||||
const [isMutating, setIsMutating] = useState(false);
|
||||
const [currentCode, setCurrentCode] = useState<string | undefined>();
|
||||
|
||||
return (
|
||||
<EditorContext.Provider value={{ isMutating, setIsMutating, currentCode, setCurrentCode }}>
|
||||
{children}
|
||||
</EditorContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useEditorContext() {
|
||||
const context = useContext(EditorContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useEditorContext must be used within an EditorProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}>;
|
||||
|
|
|
|||
|
|
@ -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<Artifact | null>(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 <ArtifactButton artifact={artifact} />;
|
||||
}
|
||||
|
|
|
|||
151
client/src/components/Artifacts/ArtifactCodeEditor.tsx
Normal file
151
client/src/components/Artifacts/ArtifactCodeEditor.tsx
Normal file
|
|
@ -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<CodeEditorRef>;
|
||||
}) => {
|
||||
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 (
|
||||
<SandpackCodeEditor
|
||||
ref={editorRef}
|
||||
showTabs={false}
|
||||
readOnly={readOnly}
|
||||
showRunButton={false}
|
||||
showLineNumbers={true}
|
||||
showInlineErrors={true}
|
||||
className="hljs language-javascript bg-black"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
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<SandpackProviderProps>;
|
||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||
}) {
|
||||
if (Object.keys(files).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledProvider
|
||||
theme="dark"
|
||||
files={{
|
||||
...files,
|
||||
...sharedFiles,
|
||||
}}
|
||||
options={{ ...sharedOptions }}
|
||||
{...sharedProps}
|
||||
template={template}
|
||||
>
|
||||
<CodeEditor
|
||||
editorRef={editorRef}
|
||||
fileKey={fileKey}
|
||||
readOnly={isSubmitting}
|
||||
artifact={artifact}
|
||||
/>
|
||||
</StyledProvider>
|
||||
);
|
||||
});
|
||||
|
|
@ -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<SandpackProviderProps>;
|
||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||
}) {
|
||||
const files = useMemo(() => {
|
||||
if (getKey(artifact.type ?? '', artifact.language).includes('mermaid')) {
|
||||
return getMermaidFiles(artifact.content ?? '');
|
||||
}
|
||||
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]);
|
||||
|
||||
const { currentCode } = useEditorContext();
|
||||
const artifactFiles = useMemo(() => {
|
||||
if (Object.keys(files).length === 0) {
|
||||
return files;
|
||||
}
|
||||
const code = currentCode ?? '';
|
||||
if (!code) {
|
||||
return files;
|
||||
}
|
||||
return {
|
||||
...files,
|
||||
[fileKey]: {
|
||||
code,
|
||||
},
|
||||
};
|
||||
}, [currentCode, files, fileKey]);
|
||||
if (Object.keys(artifactFiles).length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return showEditor ? (
|
||||
<Sandpack
|
||||
options={{
|
||||
showNavigator: true,
|
||||
editorHeight: '80vh',
|
||||
showTabs: true,
|
||||
...sharedOptions,
|
||||
}}
|
||||
files={{
|
||||
...files,
|
||||
...sharedFiles,
|
||||
}}
|
||||
{...sharedProps}
|
||||
template={template}
|
||||
/>
|
||||
) : (
|
||||
return (
|
||||
<SandpackProvider
|
||||
files={{
|
||||
...files,
|
||||
...artifactFiles,
|
||||
...sharedFiles,
|
||||
}}
|
||||
options={{ ...sharedOptions }}
|
||||
|
|
|
|||
60
client/src/components/Artifacts/ArtifactTabs.tsx
Normal file
60
client/src/components/Artifacts/ArtifactTabs.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import { useRef } from 'react';
|
||||
import * as Tabs from '@radix-ui/react-tabs';
|
||||
import type { SandpackPreviewRef, CodeEditorRef } from '@codesandbox/sandpack-react';
|
||||
import type { Artifact } from '~/common';
|
||||
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
|
||||
import { useAutoScroll } from '~/hooks/Artifacts/useAutoScroll';
|
||||
import { ArtifactCodeEditor } from './ArtifactCodeEditor';
|
||||
import { ArtifactPreview } from './ArtifactPreview';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function ArtifactTabs({
|
||||
artifact,
|
||||
isMermaid,
|
||||
editorRef,
|
||||
previewRef,
|
||||
isSubmitting,
|
||||
}: {
|
||||
artifact: Artifact;
|
||||
isMermaid: boolean;
|
||||
isSubmitting: boolean;
|
||||
editorRef: React.MutableRefObject<CodeEditorRef>;
|
||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||
}) {
|
||||
const content = artifact.content ?? '';
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
useAutoScroll({ ref: contentRef, content, isSubmitting });
|
||||
const { files, fileKey, template, sharedProps } = useArtifactProps({ artifact });
|
||||
return (
|
||||
<>
|
||||
<Tabs.Content
|
||||
ref={contentRef}
|
||||
value="code"
|
||||
id="artifacts-code"
|
||||
className={cn('flex-grow overflow-auto')}
|
||||
>
|
||||
<ArtifactCodeEditor
|
||||
files={files}
|
||||
fileKey={fileKey}
|
||||
template={template}
|
||||
artifact={artifact}
|
||||
editorRef={editorRef}
|
||||
sharedProps={sharedProps}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
value="preview"
|
||||
className={cn('flex-grow overflow-auto', isMermaid ? 'bg-[#282C34]' : 'bg-white')}
|
||||
>
|
||||
<ArtifactPreview
|
||||
files={files}
|
||||
fileKey={fileKey}
|
||||
template={template}
|
||||
previewRef={previewRef}
|
||||
sharedProps={sharedProps}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<CodeEditorRef>();
|
||||
const previewRef = useRef<SandpackPreviewRef>();
|
||||
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 (
|
||||
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
|
||||
{/* Main Parent */}
|
||||
<div className="flex h-full w-full items-center justify-center py-2">
|
||||
<div className="flex h-full w-full items-center justify-center">
|
||||
{/* Main Container */}
|
||||
<div
|
||||
className={`flex h-[97%] w-[97%] flex-col overflow-hidden rounded-xl border border-border-medium bg-surface-primary text-xl text-text-primary shadow-xl transition-all duration-300 ease-in-out ${
|
||||
className={`flex h-full w-full flex-col overflow-hidden border border-border-medium bg-surface-primary text-xl text-text-primary shadow-xl transition-all duration-300 ease-in-out ${
|
||||
isVisible
|
||||
? 'translate-x-0 scale-100 opacity-100'
|
||||
: 'translate-x-full scale-95 opacity-0'
|
||||
|
|
@ -95,18 +99,23 @@ export default function Artifacts() {
|
|||
/>
|
||||
</button>
|
||||
)}
|
||||
{activeTab !== 'preview' && isMutating && (
|
||||
<RefreshCw size={16} className="mr-2 animate-spin text-text-secondary" />
|
||||
)}
|
||||
{/* Tabs */}
|
||||
<Tabs.List className="mr-2 inline-flex h-7 rounded-full border border-border-medium bg-surface-tertiary">
|
||||
<Tabs.Trigger
|
||||
value="preview"
|
||||
disabled={isMutating}
|
||||
className="border-0.5 flex items-center gap-1 rounded-full border-transparent py-1 pl-2.5 pr-2.5 text-xs font-medium text-text-secondary data-[state=active]:border-border-light data-[state=active]:bg-surface-primary-alt data-[state=active]:text-text-primary"
|
||||
>
|
||||
Preview
|
||||
{localize('com_ui_preview')}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value="code"
|
||||
className="border-0.5 flex items-center gap-1 rounded-full border-transparent py-1 pl-2.5 pr-2.5 text-xs font-medium text-text-secondary data-[state=active]:border-border-light data-[state=active]:bg-surface-primary-alt data-[state=active]:text-text-primary"
|
||||
>
|
||||
Code
|
||||
{localize('com_ui_code')}
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<button
|
||||
|
|
@ -129,26 +138,13 @@ export default function Artifacts() {
|
|||
</div>
|
||||
</div>
|
||||
{/* Content */}
|
||||
<Tabs.Content
|
||||
value="code"
|
||||
className={cn('flex-grow overflow-x-auto overflow-y-scroll bg-gray-900 p-4')}
|
||||
>
|
||||
<CodeMarkdown
|
||||
content={`\`\`\`${getFileExtension(currentArtifact.type)}\n${
|
||||
currentArtifact.content ?? ''
|
||||
}\`\`\``}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content
|
||||
value="preview"
|
||||
className={cn('flex-grow overflow-auto', isMermaid ? 'bg-[#282C34]' : 'bg-white')}
|
||||
>
|
||||
<ArtifactPreview
|
||||
<ArtifactTabs
|
||||
isMermaid={isMermaid}
|
||||
artifact={currentArtifact}
|
||||
isSubmitting={isSubmitting}
|
||||
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
|
||||
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between border-t border-border-medium bg-surface-primary-alt p-2 text-sm text-text-secondary">
|
||||
<div className="flex items-center">
|
||||
|
|
@ -178,20 +174,10 @@ export default function Artifacts() {
|
|||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<div className="flex items-center gap-2">
|
||||
<CopyCodeButton content={currentArtifact.content ?? ''} />
|
||||
{/* Download Button */}
|
||||
{/* <button className="mr-2 text-text-secondary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 256 256"
|
||||
>
|
||||
<path d="M224,144v64a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V144a8,8,0,0,1,16,0v56H208V144a8,8,0,0,1,16,0Zm-101.66,5.66a8,8,0,0,0,11.32,0l40-40a8,8,0,0,0-11.32-11.32L136,124.69V32a8,8,0,0,0-16,0v92.69L93.66,98.34a8,8,0,0,0-11.32,11.32Z" />
|
||||
</svg>
|
||||
</button> */}
|
||||
<DownloadArtifact artifact={currentArtifact} />
|
||||
{/* Publish button */}
|
||||
{/* <button className="border-0.5 min-w-[4rem] whitespace-nowrap rounded-md border-border-medium bg-[radial-gradient(ellipse,_var(--tw-gradient-stops))] from-surface-active from-50% to-surface-active px-3 py-1 text-xs font-medium text-text-primary transition-colors hover:bg-surface-active hover:text-text-primary active:scale-[0.985] active:bg-surface-active">
|
||||
Publish
|
||||
|
|
|
|||
54
client/src/components/Artifacts/DownloadArtifact.tsx
Normal file
54
client/src/components/Artifacts/DownloadArtifact.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Download } from 'lucide-react';
|
||||
import type { Artifact } from '~/common';
|
||||
import useArtifactProps from '~/hooks/Artifacts/useArtifactProps';
|
||||
import { useEditorContext } from '~/Providers';
|
||||
import { CheckMark } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const DownloadArtifact = ({
|
||||
artifact,
|
||||
className = '',
|
||||
}: {
|
||||
artifact: Artifact;
|
||||
className?: string;
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
const { currentCode } = useEditorContext();
|
||||
const [isDownloaded, setIsDownloaded] = useState(false);
|
||||
const { fileKey: fileName } = useArtifactProps({ artifact });
|
||||
|
||||
const handleDownload = () => {
|
||||
try {
|
||||
const content = currentCode ?? artifact.content ?? '';
|
||||
if (!content) {
|
||||
return;
|
||||
}
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(url);
|
||||
setIsDownloaded(true);
|
||||
setTimeout(() => setIsDownloaded(false), 3000);
|
||||
} catch (error) {
|
||||
console.error('Download failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`mr-2 text-text-secondary ${className}`}
|
||||
onClick={handleDownload}
|
||||
aria-label={localize('com_ui_download_artifact')}
|
||||
>
|
||||
{isDownloaded ? <CheckMark className="h-4 w-4" /> : <Download className="h-4 w-4" />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadArtifact;
|
||||
|
|
@ -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,6 +199,7 @@ const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentPr
|
|||
: [supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]];
|
||||
|
||||
return (
|
||||
<ArtifactProvider>
|
||||
<CodeBlockProvider>
|
||||
<ReactMarkdown
|
||||
/** @ts-ignore */
|
||||
|
|
@ -215,6 +221,7 @@ const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentPr
|
|||
{isLatestMessage && showCursor === true ? currentContent + cursor : currentContent}
|
||||
</ReactMarkdown>
|
||||
</CodeBlockProvider>
|
||||
</ArtifactProvider>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,6 +25,7 @@ const MarkdownLite = memo(
|
|||
];
|
||||
|
||||
return (
|
||||
<ArtifactProvider>
|
||||
<CodeBlockProvider>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[
|
||||
|
|
@ -49,6 +50,7 @@ const MarkdownLite = memo(
|
|||
{content}
|
||||
</ReactMarkdown>
|
||||
</CodeBlockProvider>
|
||||
</ArtifactProvider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -121,13 +121,14 @@ const MessageRender = memo(
|
|||
|
||||
return (
|
||||
<div
|
||||
id={msg.messageId}
|
||||
aria-label={`message-${msg.depth}-${msg.messageId}`}
|
||||
className={cn(
|
||||
baseClasses,
|
||||
layoutClasses,
|
||||
latestCardClasses,
|
||||
showRenderClasses,
|
||||
'focus:outline-none focus:ring-2 focus:ring-border-xheavy',
|
||||
'message-render focus:outline-none focus:ring-2 focus:ring-border-xheavy',
|
||||
)}
|
||||
onClick={clickHandler}
|
||||
onKeyDown={(e) => {
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
<EditorProvider>
|
||||
<Artifacts />
|
||||
</EditorProvider>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
},
|
||||
onMutate: () => {
|
||||
showToast({
|
||||
|
|
|
|||
|
|
@ -120,13 +120,15 @@ const ContentRender = memo(
|
|||
|
||||
return (
|
||||
<div
|
||||
id={msg.messageId}
|
||||
aria-label={`message-${msg.depth}-${msg.messageId}`}
|
||||
className={cn(
|
||||
baseClasses,
|
||||
isCard ? cardClasses : chatSpaceClasses,
|
||||
isCard === true ? cardClasses : chatSpaceClasses,
|
||||
conditionalClasses.latestCard,
|
||||
conditionalClasses.cardRender,
|
||||
conditionalClasses.focus,
|
||||
'message-render',
|
||||
)}
|
||||
onClick={clickHandler}
|
||||
onKeyDown={(e) => {
|
||||
|
|
|
|||
2
client/src/data-provider/Messages/index.ts
Normal file
2
client/src/data-provider/Messages/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// export * from './queries';
|
||||
export * from './mutations';
|
||||
46
client/src/data-provider/Messages/mutations.ts
Normal file
46
client/src/data-provider/Messages/mutations.ts
Normal file
|
|
@ -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<t.TEditArtifactResponse, Error, t.TEditArtifactRequest> => {
|
||||
const queryClient = useQueryClient();
|
||||
const { onSuccess, ...options } = _options ?? {};
|
||||
return useMutation({
|
||||
mutationFn: (variables: t.TEditArtifactRequest) => dataService.editArtifact(variables),
|
||||
onSuccess: (data, vars, context) => {
|
||||
queryClient.setQueryData<t.TMessage[]>([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,
|
||||
});
|
||||
};
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
33
client/src/hooks/Artifacts/useArtifactProps.ts
Normal file
33
client/src/hooks/Artifacts/useArtifactProps.ts
Normal file
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement | null>(null);
|
||||
const contentEndRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
if (scrollableRef.current) {
|
||||
scrollableRef.current.scrollTop = scrollableRef.current.scrollHeight;
|
||||
interface UseAutoScrollProps {
|
||||
ref: React.RefObject<HTMLElement>;
|
||||
content: string;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
}, []);
|
||||
|
||||
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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -338,3 +338,27 @@
|
|||
.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;
|
||||
}
|
||||
98
package-lock.json
generated
98
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -76,6 +76,13 @@ export function updateMessage(payload: t.TUpdateMessageRequest): Promise<unknown
|
|||
return request.put(endpoints.messages(conversationId, messageId), { text });
|
||||
}
|
||||
|
||||
export const editArtifact = async ({
|
||||
messageId,
|
||||
...params
|
||||
}: m.TEditArtifactRequest): Promise<m.TEditArtifactResponse> => {
|
||||
return request.post(`/api/messages/artifact/${messageId}`, params);
|
||||
};
|
||||
|
||||
export function updateMessageContent(payload: t.TUpdateMessageContent): Promise<unknown> {
|
||||
const { conversationId, messageId, index, text } = payload;
|
||||
if (!conversationId) {
|
||||
|
|
|
|||
|
|
@ -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<types.TMessage, 'content' | 'text' | 'conversationId'>;
|
||||
|
||||
export type EditArtifactOptions = MutationOptions<
|
||||
TEditArtifactResponse,
|
||||
TEditArtifactRequest,
|
||||
unknown,
|
||||
Error
|
||||
>;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue