mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-20 17:13:44 +00:00
🪡 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
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:
parent
3544cd972a
commit
0d981b08d8
6 changed files with 588 additions and 160 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
1
packages/api/src/artifacts/index.ts
Normal file
1
packages/api/src/artifacts/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './update';
|
||||
296
packages/api/src/artifacts/update.ts
Normal file
296
packages/api/src/artifacts/update.ts
Normal 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');
|
||||
};
|
||||
|
|
@ -1,4 +1,6 @@
|
|||
export * from './app';
|
||||
/* Artifacts */
|
||||
export * from './artifacts';
|
||||
/* Admin */
|
||||
export * from './admin';
|
||||
export * from './cdn';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue