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 express = require('express');
|
||||||
const { ContentTypes } = require('librechat-data-provider');
|
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 { requireJwtAuth, validateMessageReq } = require('~/server/middleware');
|
||||||
const { countTokens } = require('~/server/utils');
|
const { countTokens } = require('~/server/utils');
|
||||||
const { logger } = require('~/config');
|
const { logger } = require('~/config');
|
||||||
|
|
@ -8,6 +16,68 @@ const { logger } = require('~/config');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use(requireJwtAuth);
|
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 */
|
/* Note: It's necessary to add `validateMessageReq` within route definition for correct params */
|
||||||
router.get('/:conversationId', validateMessageReq, async (req, res) => {
|
router.get('/:conversationId', validateMessageReq, async (req, res) => {
|
||||||
try {
|
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.
|
* @param {RequiredAction[]} actions - The required actions.
|
||||||
* @returns {ToolOutput[]} completeOutputs - The complete outputs.
|
* @returns {ToolOutput[]} completeOutputs - The complete outputs.
|
||||||
*/
|
*/
|
||||||
checkMissingOutputs(tool_outputs, actions) {
|
checkMissingOutputs(tool_outputs = [], actions = []) {
|
||||||
const missingOutputs = [];
|
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) {
|
for (const item of actions) {
|
||||||
const { tool, toolCallId, run_id, thread_id } = item;
|
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) {
|
if (!outputExists) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
|
|
@ -521,13 +539,12 @@ class StreamRunManager {
|
||||||
);
|
);
|
||||||
missingOutputs.push({
|
missingOutputs.push({
|
||||||
tool_call_id: toolCallId,
|
tool_call_id: toolCallId,
|
||||||
output:
|
output: MISSING_OUTPUT_MESSAGE,
|
||||||
'The tool failed to produce an output. The tool may not be currently available or experienced an unhandled error.',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...tool_outputs, ...missingOutputs];
|
return [...validatedOutputs, ...missingOutputs];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* <------------------ Run Event handlers ------------------> */
|
/* <------------------ Run Event handlers ------------------> */
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@
|
||||||
"homepage": "https://librechat.ai",
|
"homepage": "https://librechat.ai",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ariakit/react": "^0.4.11",
|
"@ariakit/react": "^0.4.11",
|
||||||
"@codesandbox/sandpack-react": "^2.18.2",
|
"@codesandbox/sandpack-react": "^2.19.10",
|
||||||
"@dicebear/collection": "^7.0.4",
|
"@dicebear/collection": "^7.0.4",
|
||||||
"@dicebear/core": "^7.0.4",
|
"@dicebear/core": "^7.0.4",
|
||||||
"@headlessui/react": "^2.1.2",
|
"@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 = {
|
type TCodeBlockContext = {
|
||||||
getNextIndex: (skip: boolean) => number;
|
getNextIndex: (skip: boolean) => number;
|
||||||
resetCounter: () => void;
|
resetCounter: () => void;
|
||||||
// codeBlocks: Map<number, string>;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CodeBlockContext = createContext<TCodeBlockContext>({} as TCodeBlockContext);
|
export const CodeBlockContext = createContext<TCodeBlockContext>({} as TCodeBlockContext);
|
||||||
|
|
@ -11,7 +10,6 @@ export const useCodeBlockContext = () => useContext(CodeBlockContext);
|
||||||
|
|
||||||
export function CodeBlockProvider({ children }: { children: ReactNode }) {
|
export function CodeBlockProvider({ children }: { children: ReactNode }) {
|
||||||
const counterRef = useRef(0);
|
const counterRef = useRef(0);
|
||||||
// const codeBlocks = useRef(new Map<number, string>()).current;
|
|
||||||
|
|
||||||
const getNextIndex = useCallback((skip: boolean) => {
|
const getNextIndex = useCallback((skip: boolean) => {
|
||||||
if (skip) {
|
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 './SearchContext';
|
||||||
export * from './FileMapContext';
|
export * from './FileMapContext';
|
||||||
export * from './AddedChatContext';
|
export * from './AddedChatContext';
|
||||||
|
export * from './EditorContext';
|
||||||
export * from './ChatFormContext';
|
export * from './ChatFormContext';
|
||||||
export * from './BookmarkContext';
|
export * from './BookmarkContext';
|
||||||
export * from './MessageContext';
|
export * from './MessageContext';
|
||||||
|
|
@ -16,6 +17,7 @@ export * from './AgentsContext';
|
||||||
export * from './AssistantsMapContext';
|
export * from './AssistantsMapContext';
|
||||||
export * from './AnnouncerContext';
|
export * from './AnnouncerContext';
|
||||||
export * from './AgentsMapContext';
|
export * from './AgentsMapContext';
|
||||||
|
export * from './ArtifactContext';
|
||||||
export * from './CodeBlockContext';
|
export * from './CodeBlockContext';
|
||||||
export * from './ToolCallsMapContext';
|
export * from './ToolCallsMapContext';
|
||||||
export * from './SetConvoContext';
|
export * from './SetConvoContext';
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,21 @@ export interface CodeBlock {
|
||||||
export interface Artifact {
|
export interface Artifact {
|
||||||
id: string;
|
id: string;
|
||||||
lastUpdateTime: number;
|
lastUpdateTime: number;
|
||||||
|
index?: number;
|
||||||
|
messageId?: string;
|
||||||
identifier?: string;
|
identifier?: string;
|
||||||
language?: string;
|
language?: string;
|
||||||
content?: string;
|
content?: string;
|
||||||
title?: string;
|
title?: string;
|
||||||
type?: 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 { useSetRecoilState } from 'recoil';
|
||||||
import type { Pluggable } from 'unified';
|
import type { Pluggable } from 'unified';
|
||||||
import type { Artifact } from '~/common';
|
import type { Artifact } from '~/common';
|
||||||
|
import { useMessageContext, useArtifactContext } from '~/Providers';
|
||||||
import { artifactsState } from '~/store/artifacts';
|
import { artifactsState } from '~/store/artifacts';
|
||||||
import ArtifactButton from './ArtifactButton';
|
import ArtifactButton from './ArtifactButton';
|
||||||
import { logger } from '~/utils';
|
import { logger } from '~/utils';
|
||||||
|
|
@ -44,6 +45,10 @@ export function Artifact({
|
||||||
children: React.ReactNode | { props: { children: React.ReactNode } };
|
children: React.ReactNode | { props: { children: React.ReactNode } };
|
||||||
node: unknown;
|
node: unknown;
|
||||||
}) {
|
}) {
|
||||||
|
const { messageId } = useMessageContext();
|
||||||
|
const { getNextIndex, resetCounter } = useArtifactContext();
|
||||||
|
const artifactIndex = useRef(getNextIndex(false)).current;
|
||||||
|
|
||||||
const setArtifacts = useSetRecoilState(artifactsState);
|
const setArtifacts = useSetRecoilState(artifactsState);
|
||||||
const [artifact, setArtifact] = useState<Artifact | null>(null);
|
const [artifact, setArtifact] = useState<Artifact | null>(null);
|
||||||
|
|
||||||
|
|
@ -64,7 +69,9 @@ export function Artifact({
|
||||||
const title = props.title ?? 'Untitled Artifact';
|
const title = props.title ?? 'Untitled Artifact';
|
||||||
const type = props.type ?? 'unknown';
|
const type = props.type ?? 'unknown';
|
||||||
const identifier = props.identifier ?? 'no-identifier';
|
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(() => {
|
throttledUpdateRef.current(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
@ -75,6 +82,8 @@ export function Artifact({
|
||||||
title,
|
title,
|
||||||
type,
|
type,
|
||||||
content,
|
content,
|
||||||
|
messageId,
|
||||||
|
index: artifactIndex,
|
||||||
lastUpdateTime: now,
|
lastUpdateTime: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -94,11 +103,20 @@ export function Artifact({
|
||||||
|
|
||||||
setArtifact(currentArtifact);
|
setArtifact(currentArtifact);
|
||||||
});
|
});
|
||||||
}, [props.type, props.title, setArtifacts, props.children, props.identifier]);
|
}, [
|
||||||
|
props.type,
|
||||||
|
props.title,
|
||||||
|
setArtifacts,
|
||||||
|
props.children,
|
||||||
|
props.identifier,
|
||||||
|
messageId,
|
||||||
|
artifactIndex,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
resetCounter();
|
||||||
updateArtifact();
|
updateArtifact();
|
||||||
}, [updateArtifact]);
|
}, [updateArtifact, resetCounter]);
|
||||||
|
|
||||||
return <ArtifactButton artifact={artifact} />;
|
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 React, { memo, useMemo } 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 {
|
import {
|
||||||
getKey,
|
SandpackPreview,
|
||||||
getProps,
|
SandpackProvider,
|
||||||
sharedFiles,
|
SandpackProviderProps,
|
||||||
getTemplate,
|
} from '@codesandbox/sandpack-react/unstyled';
|
||||||
sharedOptions,
|
import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled';
|
||||||
getArtifactFilename,
|
import type { ArtifactFiles } from '~/common';
|
||||||
} from '~/utils/artifacts';
|
import { sharedFiles, sharedOptions } from '~/utils/artifacts';
|
||||||
import { getMermaidFiles } from '~/utils/mermaid';
|
import { useEditorContext } from '~/Providers';
|
||||||
|
|
||||||
export const ArtifactPreview = memo(function ({
|
export const ArtifactPreview = memo(function ({
|
||||||
showEditor = false,
|
files,
|
||||||
artifact,
|
fileKey,
|
||||||
previewRef,
|
previewRef,
|
||||||
|
sharedProps,
|
||||||
|
template,
|
||||||
}: {
|
}: {
|
||||||
showEditor?: boolean;
|
files: ArtifactFiles;
|
||||||
artifact: Artifact;
|
fileKey: string;
|
||||||
|
template: SandpackProviderProps['template'];
|
||||||
|
sharedProps: Partial<SandpackProviderProps>;
|
||||||
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
previewRef: React.MutableRefObject<SandpackPreviewRef>;
|
||||||
}) {
|
}) {
|
||||||
const files = useMemo(() => {
|
const { currentCode } = useEditorContext();
|
||||||
if (getKey(artifact.type ?? '', artifact.language).includes('mermaid')) {
|
const artifactFiles = useMemo(() => {
|
||||||
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]);
|
|
||||||
|
|
||||||
if (Object.keys(files).length === 0) {
|
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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return showEditor ? (
|
return (
|
||||||
<Sandpack
|
|
||||||
options={{
|
|
||||||
showNavigator: true,
|
|
||||||
editorHeight: '80vh',
|
|
||||||
showTabs: true,
|
|
||||||
...sharedOptions,
|
|
||||||
}}
|
|
||||||
files={{
|
|
||||||
...files,
|
|
||||||
...sharedFiles,
|
|
||||||
}}
|
|
||||||
{...sharedProps}
|
|
||||||
template={template}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<SandpackProvider
|
<SandpackProvider
|
||||||
files={{
|
files={{
|
||||||
...files,
|
...artifactFiles,
|
||||||
...sharedFiles,
|
...sharedFiles,
|
||||||
}}
|
}}
|
||||||
options={{ ...sharedOptions }}
|
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 { RefreshCw } from 'lucide-react';
|
||||||
import { useSetRecoilState } from 'recoil';
|
import { useSetRecoilState } from 'recoil';
|
||||||
import * as Tabs from '@radix-ui/react-tabs';
|
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 useArtifacts from '~/hooks/Artifacts/useArtifacts';
|
||||||
import { CodeMarkdown, CopyCodeButton } from './Code';
|
import DownloadArtifact from './DownloadArtifact';
|
||||||
import { getFileExtension } from '~/utils/artifacts';
|
import { useEditorContext } from '~/Providers';
|
||||||
import { ArtifactPreview } from './ArtifactPreview';
|
import useLocalize from '~/hooks/useLocalize';
|
||||||
import { cn } from '~/utils';
|
import ArtifactTabs from './ArtifactTabs';
|
||||||
|
import { CopyCodeButton } from './Code';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
export default function Artifacts() {
|
export default function Artifacts() {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const { isMutating } = useEditorContext();
|
||||||
|
const editorRef = useRef<CodeEditorRef>();
|
||||||
const previewRef = useRef<SandpackPreviewRef>();
|
const previewRef = useRef<SandpackPreviewRef>();
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
const setArtifactsVisible = useSetRecoilState(store.artifactsVisible);
|
const setArtifactsVisible = useSetRecoilState(store.artifactsVisible);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -23,9 +27,9 @@ export default function Artifacts() {
|
||||||
const {
|
const {
|
||||||
activeTab,
|
activeTab,
|
||||||
isMermaid,
|
isMermaid,
|
||||||
isSubmitting,
|
|
||||||
setActiveTab,
|
setActiveTab,
|
||||||
currentIndex,
|
currentIndex,
|
||||||
|
isSubmitting,
|
||||||
cycleArtifact,
|
cycleArtifact,
|
||||||
currentArtifact,
|
currentArtifact,
|
||||||
orderedArtifactIds,
|
orderedArtifactIds,
|
||||||
|
|
@ -47,10 +51,10 @@ export default function Artifacts() {
|
||||||
return (
|
return (
|
||||||
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
|
<Tabs.Root value={activeTab} onValueChange={setActiveTab} asChild>
|
||||||
{/* Main Parent */}
|
{/* 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 */}
|
{/* Main Container */}
|
||||||
<div
|
<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
|
isVisible
|
||||||
? 'translate-x-0 scale-100 opacity-100'
|
? 'translate-x-0 scale-100 opacity-100'
|
||||||
: 'translate-x-full scale-95 opacity-0'
|
: 'translate-x-full scale-95 opacity-0'
|
||||||
|
|
@ -95,18 +99,23 @@ export default function Artifacts() {
|
||||||
/>
|
/>
|
||||||
</button>
|
</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.List className="mr-2 inline-flex h-7 rounded-full border border-border-medium bg-surface-tertiary">
|
||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
value="preview"
|
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"
|
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>
|
||||||
<Tabs.Trigger
|
<Tabs.Trigger
|
||||||
value="code"
|
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"
|
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.Trigger>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
<button
|
<button
|
||||||
|
|
@ -129,26 +138,13 @@ export default function Artifacts() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<Tabs.Content
|
<ArtifactTabs
|
||||||
value="code"
|
isMermaid={isMermaid}
|
||||||
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
|
|
||||||
artifact={currentArtifact}
|
artifact={currentArtifact}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
editorRef={editorRef as React.MutableRefObject<CodeEditorRef>}
|
||||||
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
|
previewRef={previewRef as React.MutableRefObject<SandpackPreviewRef>}
|
||||||
/>
|
/>
|
||||||
</Tabs.Content>
|
|
||||||
{/* Footer */}
|
{/* 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 justify-between border-t border-border-medium bg-surface-primary-alt p-2 text-sm text-text-secondary">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
|
|
@ -178,20 +174,10 @@ export default function Artifacts() {
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center gap-2">
|
||||||
<CopyCodeButton content={currentArtifact.content ?? ''} />
|
<CopyCodeButton content={currentArtifact.content ?? ''} />
|
||||||
{/* Download Button */}
|
{/* Download Button */}
|
||||||
{/* <button className="mr-2 text-text-secondary">
|
<DownloadArtifact artifact={currentArtifact} />
|
||||||
<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> */}
|
|
||||||
{/* Publish button */}
|
{/* 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">
|
{/* <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
|
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 rehypeHighlight from 'rehype-highlight';
|
||||||
import remarkDirective from 'remark-directive';
|
import remarkDirective from 'remark-directive';
|
||||||
import type { Pluggable } from 'unified';
|
import type { Pluggable } from 'unified';
|
||||||
|
import {
|
||||||
|
useToastContext,
|
||||||
|
ArtifactProvider,
|
||||||
|
CodeBlockProvider,
|
||||||
|
useCodeBlockContext,
|
||||||
|
} from '~/Providers';
|
||||||
import { Artifact, artifactPlugin } from '~/components/Artifacts/Artifact';
|
import { Artifact, artifactPlugin } from '~/components/Artifacts/Artifact';
|
||||||
import { langSubset, preprocessLaTeX, handleDoubleClick } from '~/utils';
|
import { langSubset, preprocessLaTeX, handleDoubleClick } from '~/utils';
|
||||||
import { useToastContext, CodeBlockProvider, useCodeBlockContext } from '~/Providers';
|
|
||||||
import CodeBlock from '~/components/Messages/Content/CodeBlock';
|
import CodeBlock from '~/components/Messages/Content/CodeBlock';
|
||||||
import { useFileDownload } from '~/data-provider';
|
import { useFileDownload } from '~/data-provider';
|
||||||
import useLocalize from '~/hooks/useLocalize';
|
import useLocalize from '~/hooks/useLocalize';
|
||||||
|
|
@ -194,6 +199,7 @@ const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentPr
|
||||||
: [supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]];
|
: [supersub, remarkGfm, [remarkMath, { singleDollarTextMath: true }]];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ArtifactProvider>
|
||||||
<CodeBlockProvider>
|
<CodeBlockProvider>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
/** @ts-ignore */
|
/** @ts-ignore */
|
||||||
|
|
@ -215,6 +221,7 @@ const Markdown = memo(({ content = '', showCursor, isLatestMessage }: TContentPr
|
||||||
{isLatestMessage && showCursor === true ? currentContent + cursor : currentContent}
|
{isLatestMessage && showCursor === true ? currentContent + cursor : currentContent}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</CodeBlockProvider>
|
</CodeBlockProvider>
|
||||||
|
</ArtifactProvider>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import ReactMarkdown from 'react-markdown';
|
||||||
import rehypeHighlight from 'rehype-highlight';
|
import rehypeHighlight from 'rehype-highlight';
|
||||||
import type { PluggableList } from 'unified';
|
import type { PluggableList } from 'unified';
|
||||||
import { code, codeNoExecution, a, p } from './Markdown';
|
import { code, codeNoExecution, a, p } from './Markdown';
|
||||||
import { CodeBlockProvider } from '~/Providers';
|
import { CodeBlockProvider, ArtifactProvider } from '~/Providers';
|
||||||
import { langSubset } from '~/utils';
|
import { langSubset } from '~/utils';
|
||||||
|
|
||||||
const MarkdownLite = memo(
|
const MarkdownLite = memo(
|
||||||
|
|
@ -25,6 +25,7 @@ const MarkdownLite = memo(
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ArtifactProvider>
|
||||||
<CodeBlockProvider>
|
<CodeBlockProvider>
|
||||||
<ReactMarkdown
|
<ReactMarkdown
|
||||||
remarkPlugins={[
|
remarkPlugins={[
|
||||||
|
|
@ -49,6 +50,7 @@ const MarkdownLite = memo(
|
||||||
{content}
|
{content}
|
||||||
</ReactMarkdown>
|
</ReactMarkdown>
|
||||||
</CodeBlockProvider>
|
</CodeBlockProvider>
|
||||||
|
</ArtifactProvider>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -121,13 +121,14 @@ const MessageRender = memo(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
id={msg.messageId}
|
||||||
aria-label={`message-${msg.depth}-${msg.messageId}`}
|
aria-label={`message-${msg.depth}-${msg.messageId}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
baseClasses,
|
baseClasses,
|
||||||
layoutClasses,
|
layoutClasses,
|
||||||
latestCardClasses,
|
latestCardClasses,
|
||||||
showRenderClasses,
|
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}
|
onClick={clickHandler}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import { useDeleteFilesMutation } from '~/data-provider';
|
||||||
import Artifacts from '~/components/Artifacts/Artifacts';
|
import Artifacts from '~/components/Artifacts/Artifacts';
|
||||||
import { SidePanel } from '~/components/SidePanel';
|
import { SidePanel } from '~/components/SidePanel';
|
||||||
import { useSetFilesToDelete } from '~/hooks';
|
import { useSetFilesToDelete } from '~/hooks';
|
||||||
|
import { EditorProvider } from '~/Providers';
|
||||||
import store from '~/store';
|
import store from '~/store';
|
||||||
|
|
||||||
const defaultInterface = getConfigDefaults().interface;
|
const defaultInterface = getConfigDefaults().interface;
|
||||||
|
|
@ -94,7 +95,9 @@ export default function Presentation({
|
||||||
artifactsVisible === true &&
|
artifactsVisible === true &&
|
||||||
codeArtifacts === true &&
|
codeArtifacts === true &&
|
||||||
Object.keys(artifacts ?? {}).length > 0 ? (
|
Object.keys(artifacts ?? {}).length > 0 ? (
|
||||||
|
<EditorProvider>
|
||||||
<Artifacts />
|
<Artifacts />
|
||||||
|
</EditorProvider>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -42,13 +42,11 @@ function ConvoOptions({
|
||||||
|
|
||||||
const duplicateConversation = useDuplicateConversationMutation({
|
const duplicateConversation = useDuplicateConversationMutation({
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
if (data != null) {
|
|
||||||
navigateToConvo(data.conversation);
|
navigateToConvo(data.conversation);
|
||||||
showToast({
|
showToast({
|
||||||
message: localize('com_ui_duplication_success'),
|
message: localize('com_ui_duplication_success'),
|
||||||
status: 'success',
|
status: 'success',
|
||||||
});
|
});
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onMutate: () => {
|
onMutate: () => {
|
||||||
showToast({
|
showToast({
|
||||||
|
|
|
||||||
|
|
@ -120,13 +120,15 @@ const ContentRender = memo(
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
id={msg.messageId}
|
||||||
aria-label={`message-${msg.depth}-${msg.messageId}`}
|
aria-label={`message-${msg.depth}-${msg.messageId}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
baseClasses,
|
baseClasses,
|
||||||
isCard ? cardClasses : chatSpaceClasses,
|
isCard === true ? cardClasses : chatSpaceClasses,
|
||||||
conditionalClasses.latestCard,
|
conditionalClasses.latestCard,
|
||||||
conditionalClasses.cardRender,
|
conditionalClasses.cardRender,
|
||||||
conditionalClasses.focus,
|
conditionalClasses.focus,
|
||||||
|
'message-render',
|
||||||
)}
|
)}
|
||||||
onClick={clickHandler}
|
onClick={clickHandler}
|
||||||
onKeyDown={(e) => {
|
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 './Auth';
|
||||||
export * from './Agents';
|
export * from './Agents';
|
||||||
export * from './Files';
|
export * from './Files';
|
||||||
|
export * from './Messages';
|
||||||
export * from './Tools';
|
export * from './Tools';
|
||||||
export * from './connection';
|
export * from './connection';
|
||||||
export * from './mutations';
|
export * from './mutations';
|
||||||
|
|
|
||||||
|
|
@ -579,9 +579,6 @@ export const useDuplicateConversationMutation = (
|
||||||
if (originalId.length === 0) {
|
if (originalId.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (data == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
queryClient.setQueryData(
|
queryClient.setQueryData(
|
||||||
[QueryKeys.conversation, data.conversation.conversationId],
|
[QueryKeys.conversation, data.conversation.conversationId],
|
||||||
data.conversation,
|
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';
|
// hooks/useAutoScroll.ts
|
||||||
import { useChatContext } from '~/Providers';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
export default function useAutoScroll() {
|
interface UseAutoScrollProps {
|
||||||
const { isSubmitting } = useChatContext();
|
ref: React.RefObject<HTMLElement>;
|
||||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
content: string;
|
||||||
const scrollableRef = useRef<HTMLDivElement | null>(null);
|
isSubmitting: boolean;
|
||||||
const contentEndRef = useRef<HTMLDivElement | null>(null);
|
}
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
export const useAutoScroll = ({ ref, content, isSubmitting }: UseAutoScrollProps) => {
|
||||||
if (scrollableRef.current) {
|
const [userScrolled, setUserScrolled] = useState(false);
|
||||||
scrollableRef.current.scrollTop = scrollableRef.current.scrollHeight;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleScroll = useCallback(() => {
|
|
||||||
if (scrollableRef.current) {
|
|
||||||
const { scrollTop, scrollHeight, clientHeight } = scrollableRef.current;
|
|
||||||
setShowScrollButton(scrollHeight - scrollTop - clientHeight > 100);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isSubmitting) {
|
const scrollContainer = ref.current;
|
||||||
scrollToBottom();
|
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_november: 'November',
|
||||||
com_ui_date_december: 'December',
|
com_ui_date_december: 'December',
|
||||||
com_ui_field_required: 'This field is required',
|
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_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_type: 'Unsupported file type for endpoint:',
|
||||||
com_ui_attach_error_openai: 'Cannot attach Assistant files to other endpoints',
|
com_ui_attach_error_openai: 'Cannot attach Assistant files to other endpoints',
|
||||||
|
|
|
||||||
|
|
@ -338,3 +338,27 @@
|
||||||
.shake {
|
.shake {
|
||||||
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both;
|
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",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ariakit/react": "^0.4.11",
|
"@ariakit/react": "^0.4.11",
|
||||||
"@codesandbox/sandpack-react": "^2.18.2",
|
"@codesandbox/sandpack-react": "^2.19.10",
|
||||||
"@dicebear/collection": "^7.0.4",
|
"@dicebear/collection": "^7.0.4",
|
||||||
"@dicebear/core": "^7.0.4",
|
"@dicebear/core": "^7.0.4",
|
||||||
"@headlessui/react": "^2.1.2",
|
"@headlessui/react": "^2.1.2",
|
||||||
|
|
@ -1005,6 +1005,54 @@
|
||||||
"vite-plugin-pwa": "^0.21.1"
|
"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": {
|
"client/node_modules/vite": {
|
||||||
"version": "5.4.14",
|
"version": "5.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz",
|
||||||
|
|
@ -6151,54 +6199,6 @@
|
||||||
"strict-event-emitter": "^0.4.3"
|
"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": {
|
"node_modules/@colors/colors": {
|
||||||
"version": "1.6.0",
|
"version": "1.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz",
|
"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 deleteUser = () => '/api/user/delete';
|
||||||
|
|
||||||
export const messages = (conversationId: string, messageId?: string) =>
|
export const messages = (conversationId: string, messageId?: string) =>
|
||||||
`/api/messages/${conversationId}${messageId ? `/${messageId}` : ''}`;
|
`/api/messages/${conversationId}${messageId != null && messageId ? `/${messageId}` : ''}`;
|
||||||
|
|
||||||
const shareRoot = '/api/share';
|
const shareRoot = '/api/share';
|
||||||
export const shareMessages = (shareId: string) => `${shareRoot}/${shareId}`;
|
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 });
|
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> {
|
export function updateMessageContent(payload: t.TUpdateMessageContent): Promise<unknown> {
|
||||||
const { conversationId, messageId, index, text } = payload;
|
const { conversationId, messageId, index, text } = payload;
|
||||||
if (!conversationId) {
|
if (!conversationId) {
|
||||||
|
|
|
||||||
|
|
@ -315,3 +315,19 @@ export type TDeleteSharedLinkResponse = {
|
||||||
shareId: string;
|
shareId: string;
|
||||||
message: 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