Skip to content

Commit 58f31fb

Browse files
danny-avilajustinmdickey
authored andcommitted
🚀 feat: Artifact Editing & Downloads (danny-avila#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
1 parent 670b088 commit 58f31fb

34 files changed

+1156
-237
lines changed

api/server/routes/messages.js

+71-1
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,83 @@
11
const express = require('express');
22
const { ContentTypes } = require('librechat-data-provider');
3-
const { saveConvo, saveMessage, getMessages, updateMessage, deleteMessages } = require('~/models');
3+
const {
4+
saveConvo,
5+
saveMessage,
6+
getMessage,
7+
getMessages,
8+
updateMessage,
9+
deleteMessages,
10+
} = require('~/models');
11+
const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/Artifacts/update');
412
const { requireJwtAuth, validateMessageReq } = require('~/server/middleware');
513
const { countTokens } = require('~/server/utils');
614
const { logger } = require('~/config');
715

816
const router = express.Router();
917
router.use(requireJwtAuth);
1018

19+
router.post('/artifact/:messageId', async (req, res) => {
20+
try {
21+
const { messageId } = req.params;
22+
const { index, original, updated } = req.body;
23+
24+
if (typeof index !== 'number' || index < 0 || !original || !updated) {
25+
return res.status(400).json({ error: 'Invalid request parameters' });
26+
}
27+
28+
const message = await getMessage({ user: req.user.id, messageId });
29+
if (!message) {
30+
return res.status(404).json({ error: 'Message not found' });
31+
}
32+
33+
const artifacts = findAllArtifacts(message);
34+
if (index >= artifacts.length) {
35+
return res.status(400).json({ error: 'Artifact index out of bounds' });
36+
}
37+
38+
const targetArtifact = artifacts[index];
39+
let updatedText = null;
40+
41+
if (targetArtifact.source === 'content') {
42+
const part = message.content[targetArtifact.partIndex];
43+
updatedText = replaceArtifactContent(part.text, targetArtifact, original, updated);
44+
if (updatedText) {
45+
part.text = updatedText;
46+
}
47+
} else {
48+
updatedText = replaceArtifactContent(message.text, targetArtifact, original, updated);
49+
if (updatedText) {
50+
message.text = updatedText;
51+
}
52+
}
53+
54+
if (!updatedText) {
55+
return res.status(400).json({ error: 'Original content not found in target artifact' });
56+
}
57+
58+
const savedMessage = await saveMessage(
59+
req,
60+
{
61+
messageId,
62+
conversationId: message.conversationId,
63+
text: message.text,
64+
content: message.content,
65+
user: req.user.id,
66+
},
67+
{ context: 'POST /api/messages/artifact/:messageId' },
68+
);
69+
70+
res.status(200).json({
71+
conversationId: savedMessage.conversationId,
72+
content: savedMessage.content,
73+
text: savedMessage.text,
74+
});
75+
} catch (error) {
76+
logger.error('Error editing artifact:', error);
77+
res.status(500).json({ error: 'Internal server error' });
78+
}
79+
});
80+
1181
/* Note: It's necessary to add `validateMessageReq` within route definition for correct params */
1282
router.get('/:conversationId', validateMessageReq, async (req, res) => {
1383
try {
+81
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
const ARTIFACT_START = ':::artifact';
2+
const ARTIFACT_END = ':::';
3+
4+
/**
5+
* Find all artifact boundaries in the message
6+
* @param {TMessage} message
7+
* @returns {Array<{start: number, end: number, source: 'content'|'text', partIndex?: number}>}
8+
*/
9+
const findAllArtifacts = (message) => {
10+
const artifacts = [];
11+
12+
// Check content parts first
13+
if (message.content?.length) {
14+
message.content.forEach((part, partIndex) => {
15+
if (part.type === 'text' && typeof part.text === 'string') {
16+
let currentIndex = 0;
17+
let start = part.text.indexOf(ARTIFACT_START, currentIndex);
18+
19+
while (start !== -1) {
20+
const end = part.text.indexOf(ARTIFACT_END, start + ARTIFACT_START.length);
21+
artifacts.push({
22+
start,
23+
end: end !== -1 ? end + ARTIFACT_END.length : part.text.length,
24+
source: 'content',
25+
partIndex,
26+
text: part.text,
27+
});
28+
29+
currentIndex = end !== -1 ? end + ARTIFACT_END.length : part.text.length;
30+
start = part.text.indexOf(ARTIFACT_START, currentIndex);
31+
}
32+
}
33+
});
34+
}
35+
36+
// Check message.text if no content parts
37+
if (!artifacts.length && message.text) {
38+
let currentIndex = 0;
39+
let start = message.text.indexOf(ARTIFACT_START, currentIndex);
40+
41+
while (start !== -1) {
42+
const end = message.text.indexOf(ARTIFACT_END, start + ARTIFACT_START.length);
43+
artifacts.push({
44+
start,
45+
end: end !== -1 ? end + ARTIFACT_END.length : message.text.length,
46+
source: 'text',
47+
text: message.text,
48+
});
49+
50+
currentIndex = end !== -1 ? end + ARTIFACT_END.length : message.text.length;
51+
start = message.text.indexOf(ARTIFACT_START, currentIndex);
52+
}
53+
}
54+
55+
return artifacts;
56+
};
57+
58+
const replaceArtifactContent = (originalText, artifact, original, updated) => {
59+
const artifactContent = artifact.text.substring(artifact.start, artifact.end);
60+
const relativeIndex = artifactContent.indexOf(original);
61+
62+
if (relativeIndex === -1) {
63+
return null;
64+
}
65+
66+
const absoluteIndex = artifact.start + relativeIndex;
67+
const endText = originalText.substring(absoluteIndex + original.length);
68+
const hasTrailingNewline = endText.startsWith('\n');
69+
70+
const updatedText =
71+
originalText.substring(0, absoluteIndex) + updated + (hasTrailingNewline ? '' : '\n') + endText;
72+
73+
return updatedText.replace(/\n+(?=```\n:::)/g, '\n');
74+
};
75+
76+
module.exports = {
77+
ARTIFACT_START,
78+
ARTIFACT_END,
79+
findAllArtifacts,
80+
replaceArtifactContent,
81+
};

0 commit comments

Comments
 (0)