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';