mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-22 11:20:15 +01:00
🚀 feat: Artifact Editing & Downloads (#5428)
* refactor: expand container * chore: bump @codesandbox/sandpack-react to latest * WIP: first pass, show editor * feat: implement ArtifactCodeEditor and ArtifactTabs components for enhanced artifact management * refactor: fileKey * refactor: auto scrolling code editor and add messageId to artifact * feat: first pass, editing artifact * feat: first pass, robust artifact replacement * fix: robust artifact replacement & re-render when expected * feat: Download Artifacts * refactor: improve artifact editing UX * fix: layout shift of new download button * fix: enhance missing output checks and logging in StreamRunManager
This commit is contained in:
parent
87383fec27
commit
ed57bb4711
34 changed files with 1156 additions and 237 deletions
|
|
@ -1,6 +1,14 @@
|
|||
const express = require('express');
|
||||
const { ContentTypes } = require('librechat-data-provider');
|
||||
const { saveConvo, saveMessage, getMessages, updateMessage, deleteMessages } = require('~/models');
|
||||
const {
|
||||
saveConvo,
|
||||
saveMessage,
|
||||
getMessage,
|
||||
getMessages,
|
||||
updateMessage,
|
||||
deleteMessages,
|
||||
} = require('~/models');
|
||||
const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/Artifacts/update');
|
||||
const { requireJwtAuth, validateMessageReq } = require('~/server/middleware');
|
||||
const { countTokens } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
|
|
@ -8,6 +16,68 @@ const { logger } = require('~/config');
|
|||
const router = express.Router();
|
||||
router.use(requireJwtAuth);
|
||||
|
||||
router.post('/artifact/:messageId', async (req, res) => {
|
||||
try {
|
||||
const { messageId } = req.params;
|
||||
const { index, original, updated } = req.body;
|
||||
|
||||
if (typeof index !== 'number' || index < 0 || !original || !updated) {
|
||||
return res.status(400).json({ error: 'Invalid request parameters' });
|
||||
}
|
||||
|
||||
const message = await getMessage({ user: req.user.id, messageId });
|
||||
if (!message) {
|
||||
return res.status(404).json({ error: 'Message not found' });
|
||||
}
|
||||
|
||||
const artifacts = findAllArtifacts(message);
|
||||
if (index >= artifacts.length) {
|
||||
return res.status(400).json({ error: 'Artifact index out of bounds' });
|
||||
}
|
||||
|
||||
const targetArtifact = artifacts[index];
|
||||
let updatedText = null;
|
||||
|
||||
if (targetArtifact.source === 'content') {
|
||||
const part = message.content[targetArtifact.partIndex];
|
||||
updatedText = replaceArtifactContent(part.text, targetArtifact, original, updated);
|
||||
if (updatedText) {
|
||||
part.text = updatedText;
|
||||
}
|
||||
} else {
|
||||
updatedText = replaceArtifactContent(message.text, targetArtifact, original, updated);
|
||||
if (updatedText) {
|
||||
message.text = updatedText;
|
||||
}
|
||||
}
|
||||
|
||||
if (!updatedText) {
|
||||
return res.status(400).json({ error: 'Original content not found in target artifact' });
|
||||
}
|
||||
|
||||
const savedMessage = await saveMessage(
|
||||
req,
|
||||
{
|
||||
messageId,
|
||||
conversationId: message.conversationId,
|
||||
text: message.text,
|
||||
content: message.content,
|
||||
user: req.user.id,
|
||||
},
|
||||
{ context: 'POST /api/messages/artifact/:messageId' },
|
||||
);
|
||||
|
||||
res.status(200).json({
|
||||
conversationId: savedMessage.conversationId,
|
||||
content: savedMessage.content,
|
||||
text: savedMessage.text,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error editing artifact:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/* Note: It's necessary to add `validateMessageReq` within route definition for correct params */
|
||||
router.get('/:conversationId', validateMessageReq, async (req, res) => {
|
||||
try {
|
||||
|
|
|
|||
81
api/server/services/Artifacts/update.js
Normal file
81
api/server/services/Artifacts/update.js
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
const ARTIFACT_START = ':::artifact';
|
||||
const ARTIFACT_END = ':::';
|
||||
|
||||
/**
|
||||
* Find all artifact boundaries in the message
|
||||
* @param {TMessage} message
|
||||
* @returns {Array<{start: number, end: number, source: 'content'|'text', partIndex?: number}>}
|
||||
*/
|
||||
const findAllArtifacts = (message) => {
|
||||
const artifacts = [];
|
||||
|
||||
// Check content parts first
|
||||
if (message.content?.length) {
|
||||
message.content.forEach((part, partIndex) => {
|
||||
if (part.type === 'text' && typeof part.text === 'string') {
|
||||
let currentIndex = 0;
|
||||
let start = part.text.indexOf(ARTIFACT_START, currentIndex);
|
||||
|
||||
while (start !== -1) {
|
||||
const end = part.text.indexOf(ARTIFACT_END, start + ARTIFACT_START.length);
|
||||
artifacts.push({
|
||||
start,
|
||||
end: end !== -1 ? end + ARTIFACT_END.length : part.text.length,
|
||||
source: 'content',
|
||||
partIndex,
|
||||
text: part.text,
|
||||
});
|
||||
|
||||
currentIndex = end !== -1 ? end + ARTIFACT_END.length : part.text.length;
|
||||
start = part.text.indexOf(ARTIFACT_START, currentIndex);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Check message.text if no content parts
|
||||
if (!artifacts.length && message.text) {
|
||||
let currentIndex = 0;
|
||||
let start = message.text.indexOf(ARTIFACT_START, currentIndex);
|
||||
|
||||
while (start !== -1) {
|
||||
const end = message.text.indexOf(ARTIFACT_END, start + ARTIFACT_START.length);
|
||||
artifacts.push({
|
||||
start,
|
||||
end: end !== -1 ? end + ARTIFACT_END.length : message.text.length,
|
||||
source: 'text',
|
||||
text: message.text,
|
||||
});
|
||||
|
||||
currentIndex = end !== -1 ? end + ARTIFACT_END.length : message.text.length;
|
||||
start = message.text.indexOf(ARTIFACT_START, currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
};
|
||||
|
||||
const replaceArtifactContent = (originalText, artifact, original, updated) => {
|
||||
const artifactContent = artifact.text.substring(artifact.start, artifact.end);
|
||||
const relativeIndex = artifactContent.indexOf(original);
|
||||
|
||||
if (relativeIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const absoluteIndex = artifact.start + relativeIndex;
|
||||
const endText = originalText.substring(absoluteIndex + original.length);
|
||||
const hasTrailingNewline = endText.startsWith('\n');
|
||||
|
||||
const updatedText =
|
||||
originalText.substring(0, absoluteIndex) + updated + (hasTrailingNewline ? '' : '\n') + endText;
|
||||
|
||||
return updatedText.replace(/\n+(?=```\n:::)/g, '\n');
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
ARTIFACT_START,
|
||||
ARTIFACT_END,
|
||||
findAllArtifacts,
|
||||
replaceArtifactContent,
|
||||
};
|
||||
267
api/server/services/Artifacts/update.spec.js
Normal file
267
api/server/services/Artifacts/update.spec.js
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
const {
|
||||
ARTIFACT_START,
|
||||
ARTIFACT_END,
|
||||
findAllArtifacts,
|
||||
replaceArtifactContent,
|
||||
} = require('./update');
|
||||
|
||||
const createArtifactText = (options = {}) => {
|
||||
const { content = '', wrapCode = true, isClosed = true, prefix = '', suffix = '' } = options;
|
||||
|
||||
const codeBlock = wrapCode ? '```\n' + content + '\n```' : content;
|
||||
const end = isClosed ? `\n${ARTIFACT_END}` : '';
|
||||
|
||||
return `${ARTIFACT_START}${prefix}\n${codeBlock}${end}${suffix}`;
|
||||
};
|
||||
|
||||
describe('findAllArtifacts', () => {
|
||||
test('should return empty array for message with no artifacts', () => {
|
||||
const message = {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'No artifacts here',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(findAllArtifacts(message)).toEqual([]);
|
||||
});
|
||||
|
||||
test('should find artifacts in content parts', () => {
|
||||
const message = {
|
||||
content: [
|
||||
{ type: 'text', text: createArtifactText({ content: 'content1' }) },
|
||||
{ type: 'text', text: createArtifactText({ content: 'content2' }) },
|
||||
],
|
||||
};
|
||||
|
||||
const result = findAllArtifacts(message);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].source).toBe('content');
|
||||
expect(result[1].partIndex).toBe(1);
|
||||
});
|
||||
|
||||
test('should find artifacts in message.text when content is empty', () => {
|
||||
const artifact1 = createArtifactText({ content: 'text1' });
|
||||
const artifact2 = createArtifactText({ content: 'text2' });
|
||||
const message = { text: [artifact1, artifact2].join('\n') };
|
||||
|
||||
const result = findAllArtifacts(message);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].source).toBe('text');
|
||||
});
|
||||
|
||||
test('should handle unclosed artifacts', () => {
|
||||
const message = {
|
||||
text: createArtifactText({ content: 'unclosed', isClosed: false }),
|
||||
};
|
||||
const result = findAllArtifacts(message);
|
||||
expect(result[0].end).toBe(message.text.length);
|
||||
});
|
||||
|
||||
test('should handle multiple artifacts in single part', () => {
|
||||
const artifact1 = createArtifactText({ content: 'first' });
|
||||
const artifact2 = createArtifactText({ content: 'second' });
|
||||
const message = {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: [artifact1, artifact2].join('\n'),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = findAllArtifacts(message);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[1].start).toBeGreaterThan(result[0].end);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceArtifactContent', () => {
|
||||
const createTestArtifact = (content, options) => {
|
||||
const text = createArtifactText({ content, ...options });
|
||||
return {
|
||||
start: 0,
|
||||
end: text.length,
|
||||
text,
|
||||
source: 'text',
|
||||
};
|
||||
};
|
||||
|
||||
test('should replace content within artifact boundaries', () => {
|
||||
const original = 'console.log(\'hello\')';
|
||||
const artifact = createTestArtifact(original);
|
||||
const updated = 'console.log(\'updated\')';
|
||||
|
||||
const result = replaceArtifactContent(artifact.text, artifact, original, updated);
|
||||
expect(result).toContain(updated);
|
||||
expect(result).toMatch(ARTIFACT_START);
|
||||
expect(result).toMatch(ARTIFACT_END);
|
||||
});
|
||||
|
||||
test('should return null when original not found', () => {
|
||||
const artifact = createTestArtifact('function test() {}');
|
||||
const result = replaceArtifactContent(artifact.text, artifact, 'missing', 'updated');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test('should handle dedented content', () => {
|
||||
const original = 'function test() {';
|
||||
const artifact = createTestArtifact(original);
|
||||
const updated = 'function updated() {';
|
||||
|
||||
const result = replaceArtifactContent(artifact.text, artifact, original, updated);
|
||||
expect(result).toContain(updated);
|
||||
});
|
||||
|
||||
test('should preserve text outside artifact', () => {
|
||||
const artifactContent = createArtifactText({ content: 'original' });
|
||||
const fullText = `prefix\n${artifactContent}\nsuffix`;
|
||||
const artifact = createTestArtifact('original', {
|
||||
prefix: 'prefix\n',
|
||||
suffix: '\nsuffix',
|
||||
});
|
||||
|
||||
const result = replaceArtifactContent(fullText, artifact, 'original', 'updated');
|
||||
expect(result).toMatch(/^prefix/);
|
||||
expect(result).toMatch(/suffix$/);
|
||||
});
|
||||
|
||||
test('should handle replacement at artifact boundaries', () => {
|
||||
const original = 'console.log("hello")';
|
||||
const updated = 'console.log("updated")';
|
||||
|
||||
const artifactText = `${ARTIFACT_START}\n${original}\n${ARTIFACT_END}`;
|
||||
const artifact = {
|
||||
start: 0,
|
||||
end: artifactText.length,
|
||||
text: artifactText,
|
||||
source: 'text',
|
||||
};
|
||||
|
||||
const result = replaceArtifactContent(artifactText, artifact, original, updated);
|
||||
|
||||
expect(result).toBe(`${ARTIFACT_START}\n${updated}\n${ARTIFACT_END}`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceArtifactContent with shared text', () => {
|
||||
test('should replace correct artifact when text is shared', () => {
|
||||
const artifactContent = ' hi '; // Preserve exact spacing
|
||||
const sharedText = `LOREM IPSUM
|
||||
|
||||
:::artifact{identifier="calculator" type="application/vnd.react" title="Calculator"}
|
||||
\`\`\`
|
||||
${artifactContent}
|
||||
\`\`\`
|
||||
:::
|
||||
|
||||
LOREM IPSUM
|
||||
|
||||
:::artifact{identifier="calculator2" type="application/vnd.react" title="Calculator"}
|
||||
\`\`\`
|
||||
${artifactContent}
|
||||
\`\`\`
|
||||
:::`;
|
||||
|
||||
const message = { text: sharedText };
|
||||
const artifacts = findAllArtifacts(message);
|
||||
expect(artifacts).toHaveLength(2);
|
||||
|
||||
const targetArtifact = artifacts[1];
|
||||
const updatedContent = ' updated content ';
|
||||
const result = replaceArtifactContent(
|
||||
sharedText,
|
||||
targetArtifact,
|
||||
artifactContent,
|
||||
updatedContent,
|
||||
);
|
||||
|
||||
// Verify exact matches with preserved formatting
|
||||
expect(result).toContain(artifactContent); // First artifact unchanged
|
||||
expect(result).toContain(updatedContent); // Second artifact updated
|
||||
expect(result.indexOf(updatedContent)).toBeGreaterThan(result.indexOf(artifactContent));
|
||||
});
|
||||
|
||||
const codeExample = `
|
||||
function greetPerson(name) {
|
||||
return \`Hello, \${name}! Welcome to JavaScript programming.\`;
|
||||
}
|
||||
|
||||
const personName = "Alice";
|
||||
const greeting = greetPerson(personName);
|
||||
console.log(greeting);`;
|
||||
|
||||
test('should handle random number of artifacts in content array', () => {
|
||||
const numArtifacts = 5; // Fixed number for predictability
|
||||
const targetIndex = 2; // Fixed target for predictability
|
||||
|
||||
// Create content array with multiple parts
|
||||
const contentParts = Array.from({ length: numArtifacts }, (_, i) => ({
|
||||
type: 'text',
|
||||
text: createArtifactText({
|
||||
content: `content-${i}`,
|
||||
wrapCode: true,
|
||||
prefix: i > 0 ? '\n' : '',
|
||||
}),
|
||||
}));
|
||||
|
||||
const message = { content: contentParts };
|
||||
const artifacts = findAllArtifacts(message);
|
||||
expect(artifacts).toHaveLength(numArtifacts);
|
||||
|
||||
const targetArtifact = artifacts[targetIndex];
|
||||
const originalContent = `content-${targetIndex}`;
|
||||
const updatedContent = 'updated-content';
|
||||
|
||||
const result = replaceArtifactContent(
|
||||
contentParts[targetIndex].text,
|
||||
targetArtifact,
|
||||
originalContent,
|
||||
updatedContent,
|
||||
);
|
||||
|
||||
// Verify the specific content was updated
|
||||
expect(result).toContain(updatedContent);
|
||||
expect(result).not.toContain(originalContent);
|
||||
expect(result).toMatch(
|
||||
new RegExp(`${ARTIFACT_START}.*${updatedContent}.*${ARTIFACT_END}`, 's'),
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle artifacts with identical content but different metadata in content array', () => {
|
||||
const contentParts = [
|
||||
{
|
||||
type: 'text',
|
||||
text: createArtifactText({
|
||||
wrapCode: true,
|
||||
content: codeExample,
|
||||
prefix: '{id="1", title="First"}',
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
text: createArtifactText({
|
||||
wrapCode: true,
|
||||
content: codeExample,
|
||||
prefix: '{id="2", title="Second"}',
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
const message = { content: contentParts };
|
||||
const artifacts = findAllArtifacts(message);
|
||||
|
||||
// Target second artifact
|
||||
const targetArtifact = artifacts[1];
|
||||
const result = replaceArtifactContent(
|
||||
contentParts[1].text,
|
||||
targetArtifact,
|
||||
codeExample,
|
||||
'updated content',
|
||||
);
|
||||
console.log(result);
|
||||
expect(result).toMatch(/id="2".*updated content/s);
|
||||
expect(result).toMatch(new RegExp(`${ARTIFACT_START}.*updated content.*${ARTIFACT_END}`, 's'));
|
||||
});
|
||||
});
|
||||
|
|
@ -508,12 +508,30 @@ class StreamRunManager {
|
|||
* @param {RequiredAction[]} actions - The required actions.
|
||||
* @returns {ToolOutput[]} completeOutputs - The complete outputs.
|
||||
*/
|
||||
checkMissingOutputs(tool_outputs, actions) {
|
||||
checkMissingOutputs(tool_outputs = [], actions = []) {
|
||||
const missingOutputs = [];
|
||||
const MISSING_OUTPUT_MESSAGE =
|
||||
'The tool failed to produce an output. The tool may not be currently available or experienced an unhandled error.';
|
||||
const outputIds = new Set();
|
||||
const validatedOutputs = tool_outputs.map((output) => {
|
||||
if (!output) {
|
||||
logger.warn('Tool output is undefined');
|
||||
return;
|
||||
}
|
||||
outputIds.add(output.tool_call_id);
|
||||
if (!output.output) {
|
||||
logger.warn(`Tool output exists but has no output property (ID: ${output.tool_call_id})`);
|
||||
return {
|
||||
...output,
|
||||
output: MISSING_OUTPUT_MESSAGE,
|
||||
};
|
||||
}
|
||||
return output;
|
||||
});
|
||||
|
||||
for (const item of actions) {
|
||||
const { tool, toolCallId, run_id, thread_id } = item;
|
||||
const outputExists = tool_outputs.some((output) => output.tool_call_id === toolCallId);
|
||||
const outputExists = outputIds.has(toolCallId);
|
||||
|
||||
if (!outputExists) {
|
||||
logger.warn(
|
||||
|
|
@ -521,13 +539,12 @@ class StreamRunManager {
|
|||
);
|
||||
missingOutputs.push({
|
||||
tool_call_id: toolCallId,
|
||||
output:
|
||||
'The tool failed to produce an output. The tool may not be currently available or experienced an unhandled error.',
|
||||
output: MISSING_OUTPUT_MESSAGE,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return [...tool_outputs, ...missingOutputs];
|
||||
return [...validatedOutputs, ...missingOutputs];
|
||||
}
|
||||
|
||||
/* <------------------ Run Event handlers ------------------> */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue