import { useEditor } from '@craftjs/core'; import { usePages } from '../state/PageContext'; import { SitesmithResponse, SerializedTreeNode, SitesmithPatchOp } from '../types/sitesmith'; import { sanitizeAiTree, buildNodeTree } from './craft-tree'; // Re-exported for existing callers/tests that import sanitizeAiTree/ // buildNodeTree from this module — the implementation lives in craft-tree.ts // alongside the shared flattener: `templates/apply-template.ts` needs the // exact same tree-materialization logic to load template header/footer/page // content (see the doc comment on `buildNodeTree` in craft-tree.ts). export { sanitizeAiTree, buildNodeTree }; /** * update_props may never touch these keys, regardless of what the AI sends. * `node_id` is the stable identifier other ops (and findNodeIdByAiNodeId) * rely on to re-target a node in later turns of the conversation — letting * the AI clobber it would silently orphan future patches. */ const PROTECTED_UPDATE_PROPS_KEYS = new Set(['node_id']); /** * Find the Craft.js node id that corresponds to an AI node_id value. * Checks `data.props.node_id` first, then falls back to raw id equality. */ export function findNodeIdByAiNodeId(query: any, aiNodeId: string): string | null { const all = query.getNodes() as Record; for (const [id, n] of Object.entries(all)) { if (n.data?.props?.node_id === aiNodeId) return id; if (id === aiNodeId) return id; } return null; } /** Exported for unit tests */ export const __test = { findNodeIdByAiNodeId, buildNodeTree, applyPatch }; /** * React hook that returns an `apply` function. * Call `apply(response)` after a successful Sitesmith API call to materialize * the AI's instructions into the editor. */ export function useApplyAiResponse() { const { actions, query } = useEditor(); const pages = usePages(); /** * @param targetNodeId If set and the AI returned a section-scoped replace * instead of a patch, treat the first returned tree as a replacement for * this node (the user said "edit this block" — they don't want a new * section appended at the bottom). */ return async function apply( resp: SitesmithResponse, targetNodeId?: string, ): Promise<{ ok: boolean; message?: string }> { // 'ask' type = AI wants clarification, nothing to apply if (resp.type === 'ask') return { ok: true }; if (resp.type === 'replace') { if (resp.scope === 'site') { pages.replaceAllPages(resp.pages.map((p) => ({ name: p.name, tree: p.tree }))); if (resp.header) pages.setHeader(resp.header.tree); if (resp.footer) pages.setFooter(resp.footer.tree); return { ok: true }; } if (resp.scope === 'page') { pages.replaceCurrentPage(resp.pages[0]); return { ok: true }; } if (resp.scope === 'section') { // When targeted at a specific node, the AI's tree replaces that node // in place (vs appending a fresh section at the end of ROOT). if (targetNodeId && resp.pages.length > 0) { try { const nodeTree = buildNodeTree(query, resp.pages[0].tree); const parent: string = query.node(targetNodeId).get().data.parent ?? 'ROOT'; const siblings: string[] = query.node(parent).childNodes(); const index = siblings.indexOf(targetNodeId); actions.delete(targetNodeId); actions.addNodeTree(nodeTree, parent, index); return { ok: true }; } catch (e) { console.warn('sitesmith: targeted section replace failed, falling back to append', e); } } // Insert each provided tree as a new node tree appended to ROOT for (const p of resp.pages) { try { const nodeTree = buildNodeTree(query, p.tree); actions.addNodeTree(nodeTree, 'ROOT'); } catch (e) { console.warn('sitesmith: failed to add section tree', e); } } return { ok: true }; } } if (resp.type === 'patch') { return applyPatch(actions, query, resp.ops); } return { ok: false, message: 'Unknown response type' }; }; } function applyPatch( actions: any, query: any, ops: SitesmithPatchOp[], ): { ok: boolean; message?: string } { for (const op of ops) { const id = findNodeIdByAiNodeId(query, op.node_id); if (!id) { console.warn('sitesmith patch: node_id not found, skipping op:', op.node_id, op.op); continue; } switch (op.op) { case 'update_props': { const patchProps = op.props; if (!patchProps || typeof patchProps !== 'object' || Array.isArray(patchProps)) { console.warn('sitesmith patch: update_props with invalid props payload, skipping', op.node_id); break; } actions.setProp(id, (p: any) => { for (const [key, value] of Object.entries(patchProps)) { if (PROTECTED_UPDATE_PROPS_KEYS.has(key)) { console.warn(`sitesmith patch: refusing to overwrite protected prop "${key}"`); continue; } // Merge `style` shallowly instead of blindly replacing it — the // AI usually only means to change one or two style keys, and a // blind Object.assign would clobber every other style the user // (or a previous AI turn) had already set. if (key === 'style') { if (value && typeof value === 'object' && !Array.isArray(value)) { const existingStyle = p.style && typeof p.style === 'object' ? p.style : {}; p.style = { ...existingStyle, ...(value as Record) }; } else { // `style: null` (or any other non-object) is not a valid // style patch — ignore it rather than clobbering the whole // style object with null/undefined/a stray primitive. console.warn('sitesmith patch: update_props ignoring non-object "style" value', value); } } else { p[key] = value; } } }); break; } case 'replace_node': { try { const nodeTree = buildNodeTree(query, op.tree); const parent: string = query.node(id).get().data.parent ?? 'ROOT'; const siblings: string[] = query.node(parent).childNodes(); const index = siblings.indexOf(id); actions.delete(id); actions.addNodeTree(nodeTree, parent, index); } catch (e) { console.warn('sitesmith patch: replace_node failed', e); } break; } case 'insert_after': case 'insert_before': { try { const nodeTree = buildNodeTree(query, op.tree); const parent: string = query.node(id).get().data.parent ?? 'ROOT'; const siblings: string[] = query.node(parent).childNodes(); const index = siblings.indexOf(id); const at = op.op === 'insert_after' ? index + 1 : index; actions.addNodeTree(nodeTree, parent, at); } catch (e) { console.warn(`sitesmith patch: ${op.op} failed`, e); } break; } case 'delete_node': try { actions.delete(id); } catch (e) { console.warn('sitesmith patch: delete_node failed', e); } break; default: // Every SitesmithPatchOp variant is handled above, so `op` is typed // `never` here at compile time — but the AI response is untrusted // JSON (see useSitesmith.ts's `j: SendResult = await r.json()`), so // a malformed/unrecognized `op` field can still reach this branch at // runtime. Log the raw value rather than assuming a shape. console.warn('sitesmith patch: unknown op', op); } } return { ok: true }; }