diff --git a/api/server/services/Artifacts/update.js b/api/server/services/Artifacts/update.js
index be1644b11c..11831c96e8 100644
--- a/api/server/services/Artifacts/update.js
+++ b/api/server/services/Artifacts/update.js
@@ -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,
diff --git a/api/server/services/Artifacts/update.spec.js b/api/server/services/Artifacts/update.spec.js
index 39a4f02863..59cd1325a9 100644
--- a/api/server/services/Artifacts/update.spec.js
+++ b/api/server/services/Artifacts/update.spec.js
@@ -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
+
first
+${ARTIFACT_END}`;
+ const secondArtifact = createArtifactText({
+ content: 'second
',
+ 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}`;
diff --git a/client/src/components/Artifacts/ArtifactCodeEditor.tsx b/client/src/components/Artifacts/ArtifactCodeEditor.tsx
index d03397821d..a349f30eda 100644
--- a/client/src/components/Artifacts/ArtifactCodeEditor.tsx
+++ b/client/src/components/Artifacts/ArtifactCodeEditor.tsx
@@ -49,6 +49,23 @@ const TYPE_MAP: Record = {
'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(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(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) {
diff --git a/packages/api/src/artifacts/index.ts b/packages/api/src/artifacts/index.ts
new file mode 100644
index 0000000000..c37c258c7c
--- /dev/null
+++ b/packages/api/src/artifacts/index.ts
@@ -0,0 +1 @@
+export * from './update';
diff --git a/packages/api/src/artifacts/update.ts b/packages/api/src/artifacts/update.ts
new file mode 100644
index 0000000000..b91e6b0fee
--- /dev/null
+++ b/packages/api/src/artifacts/update.ts
@@ -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');
+};
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index 338e86d7cc..11459f07dc 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -1,4 +1,6 @@
export * from './app';
+/* Artifacts */
+export * from './artifacts';
/* Admin */
export * from './admin';
export * from './cdn';