🚀 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:
Danny Avila 2025-01-23 18:19:04 -05:00 committed by GitHub
parent 87383fec27
commit ed57bb4711
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1156 additions and 237 deletions

View file

@ -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 {

View 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,
};

View 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'));
});
});

View file

@ -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 ------------------> */