2026-05-23 14:20:51 -07:00
|
|
|
import { useEditor } from '@craftjs/core';
|
|
|
|
|
import { usePages } from '../state/PageContext';
|
2026-07-12 15:30:56 -07:00
|
|
|
import { SitesmithResponse, SerializedTreeNode, SitesmithPatchOp } from '../types/sitesmith';
|
2026-07-13 06:19:26 -07:00
|
|
|
import { sanitizeAiTree, buildNodeTree } from './craft-tree';
|
2026-05-23 14:20:51 -07:00
|
|
|
|
2026-07-13 06:19:26 -07:00
|
|
|
// 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 };
|
2026-05-24 17:22:40 -07:00
|
|
|
|
2026-07-12 12:37:25 -07:00
|
|
|
/**
|
|
|
|
|
* 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<string>(['node_id']);
|
|
|
|
|
|
2026-05-23 14:20:51 -07:00
|
|
|
/**
|
|
|
|
|
* 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<string, any>;
|
|
|
|
|
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 */
|
2026-07-12 12:37:25 -07:00
|
|
|
export const __test = { findNodeIdByAiNodeId, buildNodeTree, applyPatch };
|
2026-05-23 14:20:51 -07:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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();
|
|
|
|
|
|
2026-05-25 12:43:28 -07:00
|
|
|
/**
|
|
|
|
|
* @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 }> {
|
2026-05-23 14:20:51 -07:00
|
|
|
// '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') {
|
2026-05-25 12:43:28 -07:00
|
|
|
// 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-23 14:20:51 -07:00
|
|
|
// 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,
|
2026-07-12 15:30:56 -07:00
|
|
|
ops: SitesmithPatchOp[],
|
2026-05-23 14:20:51 -07:00
|
|
|
): { 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) {
|
2026-07-12 12:37:25 -07:00
|
|
|
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.
|
2026-07-12 14:05:59 -07:00
|
|
|
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<string, unknown>) };
|
|
|
|
|
} 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);
|
|
|
|
|
}
|
2026-07-12 12:37:25 -07:00
|
|
|
} else {
|
|
|
|
|
p[key] = value;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-05-23 14:20:51 -07:00
|
|
|
break;
|
2026-07-12 12:37:25 -07:00
|
|
|
}
|
2026-05-23 14:20:51 -07:00
|
|
|
|
|
|
|
|
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:
|
2026-07-12 15:30:56 -07:00
|
|
|
// 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);
|
2026-05-23 14:20:51 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return { ok: true };
|
|
|
|
|
}
|