UNPKG

@ai-stack/payloadcms

Version:

<p align="center"> <img alt="Payload AI Plugin" src="assets/payload-ai-intro.gif" width="100%" /> </p>

147 lines (146 loc) 6.14 kB
import { BLOCK_PLACEHOLDER_PREFIX, BLOCK_PLACEHOLDER_SUFFIX } from './lexicalToHTML.js'; const nodeTextContent = (node)=>{ // Broadened from paragraph-only: the model may echo the marker back wrapped in a different // block type (e.g. a heading) despite the prompt instruction, so match on any node that // exposes plain-text children. if (!node || !Array.isArray(node.children)) { return null; } return node.children.filter((child)=>child?.type === 'text').map((child)=>typeof child.text === 'string' ? child.text : '').join('').trim(); }; const findMarkerIndex = (nextChildren, markerIndex)=>{ const marker = `${BLOCK_PLACEHOLDER_PREFIX}${markerIndex}${BLOCK_PLACEHOLDER_SUFFIX}`; return nextChildren.findIndex((node)=>nodeTextContent(node) === marker); }; /** * The generation JSON schema has no entry for Payload's custom `block` node type * (BlocksFeature), and the caller always fully replaces `root.children` with the model's * output - so any custom blocks in the document would otherwise be silently dropped by every * Compose/Translate/Expand/etc. action. Since the model was never given a schema to reproduce * these nodes, the only way to keep them is to reinsert them ourselves before committing the * new state. * * Counterpart of the placeholder substitution in `lexicalToHTML.ts`: each preserved block is * rendered as a `"[[[BLOCK_<n>]]]"` marker paragraph in the model's prompt context (see * `buildBlockPlaceholderInstruction` in `endpoints/index.ts` for the accompanying instruction to * echo it back unchanged). If the model cooperated, that marker is found here in the generated * content and swapped for the real block at that exact position. If the model altered, * translated, or dropped the marker (not guaranteed, since it's only a prompt instruction, not a * schema constraint), position falls back to an approximation proportional to the block's * original relative index, since the regenerated content no longer has a 1:1 correspondence * with the original paragraph/heading count. Only top-level `root.children` are handled - blocks * nested inside other node types are out of scope for this pass. */ export const reinsertPreservedBlocks = (currentRoot, nextChildren)=>{ const currentChildren = currentRoot?.children; if (!Array.isArray(currentChildren)) { return nextChildren; } const originalLength = currentChildren.length; const preservedBlocks = currentChildren.map((child, index)=>({ child, index })).filter(({ child })=>child?.type === 'block'); if (preservedBlocks.length === 0) { return nextChildren; } const merged = [ ...nextChildren ]; const unmatched = []; preservedBlocks.forEach(({ child }, markerIndex)=>{ const matchIndex = findMarkerIndex(merged, markerIndex); if (matchIndex === -1) { unmatched.push(child); return; } merged.splice(matchIndex, 1, child); }); for (const child of unmatched){ const originalIndex = currentChildren.indexOf(child); const relativePosition = originalLength > 1 ? originalIndex / (originalLength - 1) : 0; const targetIndex = Math.max(0, Math.min(merged.length, Math.round(relativePosition * merged.length))); merged.splice(targetIndex, 0, child); } return merged; }; const normalizeRoot = (root)=>{ if (!Array.isArray(root.children)) { return null; } return { ...root, type: 'root', children: root.children, direction: root.direction ?? null, format: typeof root.format === 'string' ? root.format : '', indent: typeof root.indent === 'number' ? root.indent : 0, version: typeof root.version === 'number' ? root.version : 1 }; }; export const normalizeLexicalState = (state)=>{ const parsedState = typeof state === 'string' ? (()=>{ try { return JSON.parse(state); } catch { return null; } })() : state; if (!parsedState || typeof parsedState !== 'object' || !('root' in parsedState)) { return null; } const root = parsedState.root; if (!root || typeof root !== 'object') { return null; } const normalizedRoot = normalizeRoot(root); if (!normalizedRoot) { return null; } return { ...parsedState, root: normalizedRoot }; }; export const setSafeLexicalState = (state, editorInstance, options = {})=>{ const { logErrors = true, originalRoot = null } = options; if (!editorInstance) { if (logErrors) { console.error('Error setting editor state: missing Lexical editor instance', { state }); } return false; } const normalizedState = normalizeLexicalState(state); if (!normalizedState) { if (logErrors) { console.error('Error setting editor state: invalid Lexical state shape', { state }); } return false; } try { // Prefer the caller-provided pre-generation snapshot (see useGenerate.ts) over the live // editor state, which may already have been mutated by an earlier call in this same // streaming/generation cycle - see comment on `reinsertPreservedBlocks` for why that would // otherwise compound. const currentRoot = originalRoot ?? editorInstance.getEditorState().toJSON()?.root; normalizedState.root.children = reinsertPreservedBlocks(currentRoot, normalizedState.root.children); const editorState = editorInstance.parseEditorState(normalizedState); if (editorState.isEmpty()) { return false; } editorInstance.setEditorState(editorState); return true; } catch (error) { if (logErrors) { console.error('Error setting editor state: ', { error, state: normalizedState }); } return false; } }; //# sourceMappingURL=setSafeLexicalState.js.map