🪡 fix: Artifact Edit Saves (#13358)
Some checks are pending
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Waiting to run
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Waiting to run
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Blocked by required conditions

* fix artifact edit saves

* style format artifact updater

* fix artifact save review findings

* move artifact updater to api package

* fix artifact updater type check

* fix artifact edit review findings

* style format artifact editor guard

* fix artifact parser fallback scope

* fix artifact fallback overwrite
This commit is contained in:
Danny Avila 2026-05-27 22:03:42 -07:00 • committed by GitHub
parent 3544cd972a
commit 0d981b08d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 588 additions and 160 deletions

View file

@ -1,130 +1,9 @@
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);
// Find boundaries between ARTIFACT_START and ARTIFACT_END
const contentStart = artifactContent.indexOf('\n', artifactContent.indexOf(ARTIFACT_START)) + 1;
let contentEnd = artifactContent.lastIndexOf(ARTIFACT_END);
// Special case: if contentEnd is 0, it means the only ::: found is at the start of :::artifact
// This indicates an incomplete artifact (no closing :::)
// We need to check that it's exactly at position 0 (the beginning of artifactContent)
if (contentEnd === 0 && artifactContent.indexOf(ARTIFACT_START) === 0) {
contentEnd = artifactContent.length;
}
if (contentStart === -1 || contentEnd === -1) {
return null;
}
// Check if there are code blocks - handle both ```\n and ```lang\n formats
let codeBlockStart = artifactContent.indexOf('```', contentStart);
const codeBlockEnd = artifactContent.lastIndexOf('\n```', contentEnd);
// If we found opening backticks, find the actual newline (skipping any language identifier)
if (codeBlockStart !== -1) {
const newlineAfterBackticks = artifactContent.indexOf('\n', codeBlockStart);
if (newlineAfterBackticks !== -1 && newlineAfterBackticks < contentEnd) {
codeBlockStart = newlineAfterBackticks;
} else {
codeBlockStart = -1;
}
}
// Determine where to look for the original content
let searchStart, searchEnd;
if (codeBlockStart !== -1) {
// Code block starts - searchStart is right after the newline following ```[lang]
searchStart = codeBlockStart + 1; // after the newline
if (codeBlockEnd !== -1 && codeBlockEnd > codeBlockStart) {
// Code block has proper ending
searchEnd = codeBlockEnd;
} else {
// No closing backticks found or they're before the opening (shouldn't happen)
// This might be an incomplete artifact - search to contentEnd
searchEnd = contentEnd;
}
} else {
// No code blocks at all
searchStart = contentStart;
searchEnd = contentEnd;
}
const innerContent = artifactContent.substring(searchStart, searchEnd);
// Remove trailing newline from original for comparison
const originalTrimmed = original.replace(/\n$/, '');
const relativeIndex = innerContent.indexOf(originalTrimmed);
if (relativeIndex === -1) {
return null;
}
const absoluteIndex = artifact.start + searchStart + relativeIndex;
const endText = originalText.substring(absoluteIndex + originalTrimmed.length);
const hasTrailingNewline = endText.startsWith('\n');
const updatedText =
originalText.substring(0, absoluteIndex) + updated + (hasTrailingNewline ? '' : '\n') + endText;
return updatedText.replace(/\n+(?=```\n:::)/g, '\n');
};
const {
ARTIFACT_START,
ARTIFACT_END,
findAllArtifacts,
replaceArtifactContent,
} = require('@librechat/api');
module.exports = {
ARTIFACT_START,

View file

@ -75,6 +75,85 @@ describe('findAllArtifacts', () => {
expect(result).toHaveLength(2);
expect(result[1].start).toBeGreaterThan(result[0].end);
});
test('should ignore artifact close markers inside fenced content', () => {
const content = 'before\n:::\nafter';
const artifactText = `${ARTIFACT_START}{identifier="markdown" type="text/markdown" title="Markdown"}
\`\`\`markdown
${content}
\`\`\`
${ARTIFACT_END}`;
const message = { text: `${artifactText}\ntrailer` };
const result = findAllArtifacts(message);
expect(result).toHaveLength(1);
expect(result[0].end).toBe(artifactText.length);
});
test('should allow trailing text after an artifact close marker', () => {
const artifactText = `${ARTIFACT_START}{identifier="plain" type="text/plain" title="Plain"}
content
${ARTIFACT_END}Thanks for reading`;
const message = { text: `${artifactText}\n${createArtifactText({ content: 'next' })}` };
const result = findAllArtifacts(message);
expect(result).toHaveLength(2);
expect(message.text.slice(result[0].end, result[0].end + 18)).toBe('Thanks for reading');
});
test('should not end an unclosed artifact at an internal marker in a closed fence', () => {
const artifactText = `${ARTIFACT_START}{identifier="markdown" type="text/markdown" title="Markdown"}
\`\`\`markdown
before
:::
after
\`\`\`
trailer`;
const message = { text: artifactText };
const result = findAllArtifacts(message);
expect(result).toHaveLength(1);
expect(result[0].end).toBe(artifactText.length);
});
test('should keep artifact start markers inside fenced content', () => {
const artifactText = `${ARTIFACT_START}{identifier="markdown" type="text/markdown" title="Markdown"}
\`\`\`markdown
before
:::
:::artifact{identifier="sample" type="text/plain" title="Sample"}
after
\`\`\`
${ARTIFACT_END}`;
const message = { text: artifactText };
const result = findAllArtifacts(message);
expect(result).toHaveLength(1);
expect(result[0].end).toBe(artifactText.length);
});
test('should preserve the first fallback close in an unclosed fence', () => {
const firstArtifact = `${ARTIFACT_START}{identifier="first" type="text/html" title="First"}
\`\`\`html
<div>first</div>
${ARTIFACT_END}`;
const secondArtifact = createArtifactText({
content: '<div>second</div>',
wrapCode: false,
prefix: '{identifier="second" type="text/html" title="Second"}',
});
const message = { text: `${firstArtifact}\n${secondArtifact}` };
const result = findAllArtifacts(message);
expect(result).toHaveLength(2);
expect(result[0].end).toBe(firstArtifact.length);
expect(result[1].start).toBe(firstArtifact.length + 1);
});
});
describe('replaceArtifactContent', () => {
@ -143,6 +222,49 @@ describe('replaceArtifactContent', () => {
expect(result).toBe(`${ARTIFACT_START}\n${updated}\n${ARTIFACT_END}`);
});
test('should replace markdown artifacts with internal code fences', () => {
const original = `# Notes
\`\`\`js
console.log('inside');
\`\`\`
Done`;
const updated = original.replace('Done', 'Updated');
const artifactText = `${ARTIFACT_START}{identifier="notes" type="text/markdown" title="Notes"}
${original}
${ARTIFACT_END}`;
const message = { text: artifactText };
const artifacts = findAllArtifacts(message);
const result = replaceArtifactContent(artifactText, artifacts[0], original, updated);
expect(result).not.toBeNull();
expect(result).toContain('Updated');
expect(result).toContain("console.log('inside');");
});
test('should replace unclosed artifacts with internal markers in fenced content', () => {
const original = `before
\`\`\`markdown
inside
:::
still inside
\`\`\`
after`;
const artifactText = `${ARTIFACT_START}{identifier="notes" type="text/markdown" title="Notes"}
${original}`;
const message = { text: artifactText };
const artifacts = findAllArtifacts(message);
const updated = original.replace('after', 'updated');
const result = replaceArtifactContent(artifactText, artifacts[0], original, updated);
expect(result).not.toBeNull();
expect(result).toContain('updated');
expect(result).toContain(':::');
});
});
describe('replaceArtifactContent with shared text', () => {
@ -375,6 +497,26 @@ ${original}`;
expect(result).toContain('UPDATED');
});
test('should handle complete artifact marker with unclosed wrapping code block', () => {
const original = '# Markdown document\n\n```js\nconsole.log("missing closing fence");';
const artifactText = `${ARTIFACT_START}{identifier="doc" type="text/markdown" title="Doc"}
\`\`\`markdown
${original}
${ARTIFACT_END}`;
const message = { text: artifactText };
const artifacts = findAllArtifacts(message);
expect(artifacts).toHaveLength(1);
expect(artifacts[0].end).toBe(artifactText.length);
const updated = original.replace('Markdown document', 'Updated document');
const result = replaceArtifactContent(artifactText, artifacts[0], original, updated);
expect(result).not.toBeNull();
expect(result).toContain('Updated document');
expect(result).toContain(ARTIFACT_END);
});
test('should handle incomplete artifacts without code blocks', () => {
const original = 'Some plain text content';
const incompleteArtifact = `${ARTIFACT_START}{id="test"}\n${original}`;

View file

@ -49,6 +49,23 @@ const TYPE_MAP: Record<string, string> = {
'application/vnd.mermaid': 'markdown',
};
type ArtifactEditTarget = {
artifactId: string;
messageId: string;
index: number;
};
type PendingUpdate = ArtifactEditTarget & {
code: string;
original: string;
};
type ArtifactMutationVars = {
messageId: string;
index: number;
updated: string;
};
function getMonacoLanguage(type?: string, language?: string): string {
if (language && LANG_MAP[language]) {
return LANG_MAP[language];
@ -56,6 +73,30 @@ function getMonacoLanguage(type?: string, language?: string): string {
return TYPE_MAP[type ?? ''] ?? 'plaintext';
}
function getArtifactEditTarget(artifact: Artifact): ArtifactEditTarget | null {
if (artifact.index == null) {
return null;
}
return {
artifactId: artifact.id,
messageId: artifact.messageId ?? '',
index: artifact.index,
};
}
function isSameArtifactTarget(left: ArtifactEditTarget, right: ArtifactEditTarget): boolean {
return (
left.artifactId === right.artifactId &&
left.messageId === right.messageId &&
left.index === right.index
);
}
function isSameMutationTarget(target: ArtifactEditTarget, vars: ArtifactMutationVars): boolean {
return target.messageId === vars.messageId && target.index === vars.index;
}
export const ArtifactCodeEditor = function ArtifactCodeEditor({
artifact,
monacoRef,
@ -70,25 +111,68 @@ export const ArtifactCodeEditor = function ArtifactCodeEditor({
const { setCurrentCode } = useCodeState();
const [currentUpdate, setCurrentUpdate] = useState<string | null>(null);
const { isMutating, setIsMutating } = useMutationState();
const editArtifact = useEditArtifact({
onMutate: (vars) => {
setIsMutating(true);
setCurrentUpdate(vars.updated);
},
onSuccess: () => {
setIsMutating(false);
setCurrentUpdate(null);
},
onError: () => {
setIsMutating(false);
},
});
const artifactRef = useRef(artifact);
const isMutatingRef = useRef(isMutating);
const currentUpdateRef = useRef(currentUpdate);
const editArtifactRef = useRef(editArtifact);
const setCurrentCodeRef = useRef(setCurrentCode);
const pendingUpdateRef = useRef<PendingUpdate | null>(null);
const runMutationRef = useRef<(code: string, original?: string) => void>(() => {});
const editArtifact = useEditArtifact({
onMutate: (vars) => {
isMutatingRef.current = true;
currentUpdateRef.current = vars.updated;
setIsMutating(true);
setCurrentUpdate(vars.updated);
},
onSuccess: (_data, vars) => {
isMutatingRef.current = false;
currentUpdateRef.current = null;
setIsMutating(false);
setCurrentUpdate(null);
const pending = pendingUpdateRef.current;
pendingUpdateRef.current = null;
const currentTarget = getArtifactEditTarget(artifactRef.current);
if (
pending == null ||
currentTarget == null ||
!isSameArtifactTarget(pending, currentTarget)
) {
return;
}
const original = isSameMutationTarget(pending, vars) ? vars.updated : pending.original;
if (pending.code.trim() !== original.trim()) {
setCurrentCodeRef.current(pending.code);
runMutationRef.current(pending.code, original);
}
},
onError: () => {
const pending = pendingUpdateRef.current;
pendingUpdateRef.current = null;
isMutatingRef.current = false;
currentUpdateRef.current = null;
setIsMutating(false);
setCurrentUpdate(null);
const currentTarget = getArtifactEditTarget(artifactRef.current);
if (
pending == null ||
currentTarget == null ||
!isSameArtifactTarget(pending, currentTarget)
) {
return;
}
if (pending.code.trim() !== pending.original.trim()) {
setCurrentCodeRef.current(pending.code);
runMutationRef.current(pending.code, pending.original);
}
},
});
const editArtifactRef = useRef(editArtifact);
const prevContentRef = useRef(artifact.content ?? '');
const prevArtifactId = useRef(artifact.id);
const prevReadOnly = useRef(readOnly);
@ -99,28 +183,51 @@ export const ArtifactCodeEditor = function ArtifactCodeEditor({
editArtifactRef.current = editArtifact;
setCurrentCodeRef.current = setCurrentCode;
const runMutation = useCallback(
(code: string, originalOverride?: string) => {
const art = artifactRef.current;
const target = getArtifactEditTarget(art);
if (readOnly || target == null) {
return;
}
const original = originalOverride ?? art.content ?? '';
if (isMutatingRef.current) {
pendingUpdateRef.current = {
...target,
code,
original,
};
return;
}
const isNotOriginal = code.trim() !== original.trim();
const isNotRepeated =
currentUpdateRef.current == null ? true : code.trim() !== currentUpdateRef.current.trim();
if (!isNotOriginal || !isNotRepeated) {
return;
}
setCurrentCodeRef.current(code);
editArtifactRef.current.mutate({
index: target.index,
messageId: target.messageId,
original,
updated: code,
});
},
[readOnly],
);
runMutationRef.current = runMutation;
const debouncedMutation = useMemo(
() =>
debounce((code: string) => {
if (readOnly || isMutatingRef.current || artifactRef.current.index == null) {
return;
}
const art = artifactRef.current;
const isNotOriginal = art.content != null && code.trim() !== art.content.trim();
const isNotRepeated =
currentUpdateRef.current == null ? true : code.trim() !== currentUpdateRef.current.trim();
if (art.content != null && isNotOriginal && isNotRepeated && art.index != null) {
setCurrentCodeRef.current(code);
editArtifactRef.current.mutate({
index: art.index,
messageId: art.messageId ?? '',
original: art.content,
updated: code,
});
}
runMutationRef.current(code);
}, 500),
[readOnly],
[],
);
useEffect(() => {
@ -176,6 +283,7 @@ export const ArtifactCodeEditor = function ArtifactCodeEditor({
return;
}
prevArtifactId.current = artifact.id;
pendingUpdateRef.current = null;
prevContentRef.current = artifact.content ?? '';
const ed = monacoRef.current;
if (ed && artifact.content != null) {

View file

@ -0,0 +1 @@
export * from './update';

View file

@ -0,0 +1,296 @@
export const ARTIFACT_START = ':::artifact';
export const ARTIFACT_END = ':::';
type TextPart = {
type?: string;
text?: string;
};
type ArtifactMessage = {
content?: TextPart[];
text?: string;
};
export type ArtifactBoundary = {
start: number;
end: number;
source: 'content' | 'text';
partIndex?: number;
text: string;
};
type ArtifactCloseRange = {
start: number;
end: number;
};
type CodeFence = {
marker: string;
length: number;
};
type SearchRange = {
searchStart: number;
searchEnd: number;
};
const getLineEnd = (text: string, start: number): number => {
const index = text.indexOf('\n', start);
return index === -1 ? text.length : index;
};
const getNextLineStart = (text: string, lineEnd: number): number =>
lineEnd >= text.length ? text.length : lineEnd + 1;
const getCloseRange = (
text: string,
lineStart: number,
lineEnd: number,
): ArtifactCloseRange | null => {
const line = text.slice(lineStart, lineEnd);
const contentStart = line.search(/\S/);
if (contentStart === -1) {
return null;
}
const markerStart = lineStart + contentStart;
const markerText = text.slice(markerStart);
if (!markerText.startsWith(ARTIFACT_END) || markerText.startsWith(ARTIFACT_START)) {
return null;
}
return {
start: markerStart,
end: markerStart + ARTIFACT_END.length,
};
};
const getCodeFence = (line: string): CodeFence | null => {
const match = line.trimStart().match(/^(`{3,}|~{3,})/);
if (!match) {
return null;
}
return {
marker: match[1][0],
length: match[1].length,
};
};
const isClosingCodeFence = (line: string, openingFence: CodeFence): boolean => {
const closePattern = new RegExp(`^\\${openingFence.marker}{${openingFence.length},}\\s*$`);
return closePattern.test(line.trim());
};
const findArtifactClose = (text: string, start: number): ArtifactCloseRange | null => {
const openingLineEnd = getLineEnd(text, start);
let currentIndex = getNextLineStart(text, openingLineEnd);
let codeFence: CodeFence | null = null;
let fallbackClose: ArtifactCloseRange | null = null;
while (currentIndex < text.length) {
const lineEnd = getLineEnd(text, currentIndex);
const line = text.slice(currentIndex, lineEnd);
const closeRange = getCloseRange(text, currentIndex, lineEnd);
if (closeRange) {
if (!codeFence) {
return closeRange;
}
fallbackClose = fallbackClose ?? closeRange;
}
const fence = getCodeFence(line);
if (fence && !codeFence) {
codeFence = fence;
} else if (codeFence && isClosingCodeFence(line, codeFence)) {
codeFence = null;
fallbackClose = null;
}
currentIndex = getNextLineStart(text, lineEnd);
}
return codeFence ? fallbackClose : null;
};
const findArtifactEnd = (text: string, start: number): number => {
return findArtifactClose(text, start)?.end ?? text.length;
};
const getOpeningCodeFence = (
text: string,
contentStart: number,
contentEnd: number,
): (CodeFence & { contentStart: number }) | null => {
const content = text.slice(contentStart, contentEnd);
const firstContentMatch = content.match(/\S/);
if (!firstContentMatch) {
return null;
}
const fenceStart = contentStart + (firstContentMatch.index ?? 0);
const lineEnd = getLineEnd(text, fenceStart);
const line = text.slice(fenceStart, lineEnd);
const fence = getCodeFence(line);
if (!fence || !line.trimStart().startsWith(fence.marker.repeat(fence.length))) {
return null;
}
return {
...fence,
contentStart: getNextLineStart(text, lineEnd),
};
};
const findClosingCodeFenceStart = (
text: string,
start: number,
end: number,
openingFence: CodeFence,
): number => {
let currentIndex = start;
while (currentIndex < end) {
const lineEnd = getLineEnd(text, currentIndex);
const line = text.slice(currentIndex, lineEnd);
if (isClosingCodeFence(line, openingFence)) {
return currentIndex;
}
currentIndex = getNextLineStart(text, lineEnd);
}
return -1;
};
const getSearchRange = (artifactContent: string): SearchRange | null => {
const openingLineEnd = getLineEnd(artifactContent, artifactContent.indexOf(ARTIFACT_START));
if (openingLineEnd >= artifactContent.length) {
return null;
}
const contentStart = getNextLineStart(artifactContent, openingLineEnd);
const artifactClose = findArtifactClose(artifactContent, 0);
const contentEnd = artifactClose?.start ?? artifactContent.length;
const openingFence = getOpeningCodeFence(artifactContent, contentStart, contentEnd);
if (!openingFence) {
return { searchStart: contentStart, searchEnd: contentEnd };
}
const closingFenceStart = findClosingCodeFenceStart(
artifactContent,
openingFence.contentStart,
contentEnd,
openingFence,
);
if (closingFenceStart === -1) {
return { searchStart: openingFence.contentStart, searchEnd: contentEnd };
}
const closingLineEnd = getLineEnd(artifactContent, closingFenceStart);
const trailingContent = artifactContent.slice(closingLineEnd, contentEnd);
if (trailingContent.trim().length > 0) {
return { searchStart: contentStart, searchEnd: contentEnd };
}
const searchEnd =
artifactContent[closingFenceStart - 1] === '\n' ? closingFenceStart - 1 : closingFenceStart;
return { searchStart: openingFence.contentStart, searchEnd };
};
const replaceRange = (
originalText: string,
start: number,
end: number,
updated: string,
): string => {
const endText = originalText.substring(end);
const separator = endText.startsWith('\n') || updated.endsWith('\n') ? '' : '\n';
return originalText.substring(0, start) + updated + separator + endText;
};
export const findAllArtifacts = (message: ArtifactMessage): ArtifactBoundary[] => {
const artifacts: ArtifactBoundary[] = [];
if (message.content?.length) {
message.content.forEach((part, partIndex) => {
if (part.type !== 'text' || typeof part.text !== 'string') {
return;
}
let currentIndex = 0;
let start = part.text.indexOf(ARTIFACT_START, currentIndex);
while (start !== -1) {
const end = findArtifactEnd(part.text, start);
artifacts.push({
start,
end,
source: 'content',
partIndex,
text: part.text,
});
currentIndex = end;
start = part.text.indexOf(ARTIFACT_START, currentIndex);
}
});
}
if (!artifacts.length && message.text) {
let currentIndex = 0;
let start = message.text.indexOf(ARTIFACT_START, currentIndex);
while (start !== -1) {
const end = findArtifactEnd(message.text, start);
artifacts.push({
start,
end,
source: 'text',
text: message.text,
});
currentIndex = end;
start = message.text.indexOf(ARTIFACT_START, currentIndex);
}
}
return artifacts;
};
export const replaceArtifactContent = (
originalText: string,
artifact: ArtifactBoundary,
original: string,
updated: string,
): string | null => {
const artifactContent = artifact.text.substring(artifact.start, artifact.end);
const range = getSearchRange(artifactContent);
if (!range) {
return null;
}
const { searchStart, searchEnd } = range;
const innerContent = artifactContent.substring(searchStart, searchEnd);
const originalTrimmed = original.replace(/\n$/, '');
const relativeIndex =
originalTrimmed === '' && innerContent.trim().length > 0
? -1
: innerContent.indexOf(originalTrimmed);
if (relativeIndex === -1) {
return null;
}
const absoluteIndex = artifact.start + searchStart + relativeIndex;
return replaceRange(
originalText,
absoluteIndex,
absoluteIndex + originalTrimmed.length,
updated,
).replace(/\n+(?=```\n:::)/g, '\n');
};

View file

@ -1,4 +1,6 @@
export * from './app';
/* Artifacts */
export * from './artifacts';
/* Admin */
export * from './admin';
export * from './cdn';