Files
openmaic/OpenMAIC/lib/agent/client/apply-edit-elements.ts
2026-08-16 14:58:47 +08:00

343 lines
12 KiB
TypeScript

/**
* Host adapter: apply L1 EditIntents from `edit_elements` through the existing
* slide edit session as ONE editor transaction.
*
* Apply-time revalidation: the gate ran against a turn-start inventory; before
* writing we re-check ids/locks/groups against live content and refuse the
* whole batch if anything drifted (never partial apply).
*/
import {
applyEditorTransaction,
createEditorTransaction,
type EditIntent,
type EditorOperation,
} from '@openmaic/editor/core';
import type { PPTElement, PPTShapeElement } from '@openmaic/dsl';
import { SHAPE_PATH_FORMULAS } from '@/configs/shapes';
import { useSlideEditSession } from '@/components/edit/surfaces/slide/slide-edit-session';
import type { SlideContent } from '@/lib/types/stage';
import { editElementsOutcome } from '@/lib/agent/client/edit-elements-result';
import {
elementInventorySnapshotFingerprint,
revalidateIntentsAgainstElements,
SHAPE_TEXT_CHROME_PROPS,
} from '@/lib/agent/tools/edit-elements-gate';
export interface EditElementsApplyDetails {
sceneId?: string;
intents?: EditIntent[] | null;
updateCount?: number;
/** Element types captured when the server-side gate accepted the batch. */
targetElementTypes?: Record<string, string>;
/** Mutable element state captured before the model call, keyed by target id. */
targetElementFingerprints?: Record<string, string>;
/** Full prompt-visible inventory captured before the model call. */
inventoryFingerprint?: string;
/** Present when the tool or host refused; retained for agent history and diagnostics. */
refuseReason?: string;
}
export type ApplyEditElementsResult = { ok: true } | { ok: false; reason: string };
const MERGED_STYLE_PROPS = new Set(['outline', 'shadow', 'filters']);
function mergedElementPatch(
element: PPTElement,
props: Partial<PPTElement>,
): Record<string, unknown> {
const patch = { ...(props as Record<string, unknown>) };
const current = element as unknown as Record<string, unknown>;
for (const property of MERGED_STYLE_PROPS) {
const existing = current[property];
const update = patch[property];
if (
existing &&
typeof existing === 'object' &&
!Array.isArray(existing) &&
update &&
typeof update === 'object' &&
!Array.isArray(update)
) {
patch[property] = {
...(existing as Record<string, unknown>),
...(update as Record<string, unknown>),
};
}
}
return patch;
}
/** Compile the agent's L1 vocabulary to editor operations without mutating content. */
function compileAgentEditOperations(
content: SlideContent,
intents: EditIntent[],
): EditorOperation[] {
let working = content;
const compiled: EditorOperation[] = [];
const append = (operations: EditorOperation[]) => {
if (operations.length === 0) return;
working = applyEditorTransaction(
working,
createEditorTransaction({ origin: 'system', history: 'neutral', operations }),
);
compiled.push(...operations);
};
const compileUpdate = (element: PPTElement, props: Partial<PPTElement>) => {
const patch = mergedElementPatch(element, props);
if (element.type === 'shape') {
const shape = element as PPTShapeElement;
const textPatch: Record<string, unknown> = {};
for (const key of Object.keys(patch)) {
if (SHAPE_TEXT_CHROME_PROPS.has(key)) {
textPatch[key] = patch[key];
delete patch[key];
} else if (key === 'vAlign') {
textPatch.align = patch[key];
delete patch[key];
}
}
const operations: EditorOperation[] = [];
if ('gradient' in patch) {
operations.push({
type: 'element.removeProps',
elementId: shape.id,
propNames: ['pattern'],
});
} else if ('fill' in patch) {
operations.push({
type: 'element.removeProps',
elementId: shape.id,
propNames: ['pattern', 'gradient'],
});
}
if (('width' in patch || 'height' in patch) && shape.pathFormula) {
const formula = SHAPE_PATH_FORMULAS[shape.pathFormula];
if (formula) {
const width = typeof patch.width === 'number' ? patch.width : shape.width;
const height = typeof patch.height === 'number' ? patch.height : shape.height;
patch.viewBox = [width, height];
patch.path = formula.formula(
width,
height,
formula.editable ? (shape.keypoints ?? formula.defaultValue) : undefined,
);
}
}
if (Object.keys(textPatch).length > 0) {
patch.text = { ...shape.text, ...textPatch };
}
if (Object.keys(patch).length > 0) {
operations.push({
type: 'element.update',
elementId: shape.id,
patch: patch as Partial<PPTElement>,
});
}
append(operations);
return;
}
if (element.type === 'table' && typeof patch.height === 'number') {
const nextHeight = patch.height;
if (element.data.length > 0) {
patch.cellMinHeight = Math.max(
36,
element.cellMinHeight + (nextHeight - element.height) / element.data.length,
);
}
if (element.rowHeights?.length && element.height > 0) {
const scale = nextHeight / element.height;
patch.rowHeights = element.rowHeights.map((height) => height * scale);
}
}
append([
{ type: 'element.update', elementId: element.id, patch: patch as Partial<PPTElement> },
]);
};
for (const intent of intents) {
const elements = working.canvas.elements;
if (intent.type === 'element.update') {
const element = elements.find((candidate) => candidate.id === intent.id);
if (element) compileUpdate(element, intent.props);
} else if (intent.type === 'element.updateMany') {
for (const update of intent.updates) {
const element = working.canvas.elements.find((candidate) => candidate.id === update.id);
if (element) compileUpdate(element, update.props);
}
} else if (intent.type === 'element.removeProps') {
if (elements.some((element) => element.id === intent.id)) {
append([{ type: 'element.removeProps', elementId: intent.id, propNames: intent.props }]);
}
} else if (intent.type === 'text.updateContent') {
const element = elements.find((candidate) => candidate.id === intent.id);
if (intent.target === 'text' && element?.type === 'text') {
append([{ type: 'text.updateContent', elementId: element.id, content: intent.content }]);
} else if (intent.target === 'shape' && element?.type === 'shape') {
append([
{ type: 'shape.updateTextContent', elementId: element.id, content: intent.content },
]);
}
}
}
return compiled;
}
/**
* Apply validated intents for a scene. Returns ok/reason.
* Requires an open slide edit session for that scene (one undo via commitContent).
* No silent stage-store fallback — that path had no undo and violated the contract.
*/
export function applyEditElementsIntents(
sceneId: string,
intents: EditIntent[],
targetElementTypes?: Record<string, string>,
targetElementFingerprints?: Record<string, string>,
inventoryFingerprint?: string,
): ApplyEditElementsResult {
if (!intents.length) return { ok: false, reason: 'no element updates proposed' };
const pendingReplacementProps = new Map<string, Set<string>>();
for (const intent of intents) {
if (intent.type === 'element.removeProps') {
if (
intent.props.length === 0 ||
new Set(intent.props).size !== intent.props.length ||
intent.props.some((prop) => !MERGED_STYLE_PROPS.has(prop))
) {
return { ok: false, reason: 'invalid structured-property replace marker' };
}
let pending = pendingReplacementProps.get(intent.id);
if (!pending) {
pending = new Set();
pendingReplacementProps.set(intent.id, pending);
}
if (intent.props.some((prop) => pending.has(prop))) {
return { ok: false, reason: 'invalid structured-property replace marker' };
}
for (const prop of intent.props) pending.add(prop);
continue;
}
const updates =
intent.type === 'element.update'
? [{ id: intent.id, props: intent.props }]
: intent.type === 'element.updateMany'
? intent.updates
: [];
for (const update of updates) {
const pending = pendingReplacementProps.get(update.id);
if (!pending) continue;
for (const prop of Object.keys(update.props)) {
if (!pending.has(prop)) continue;
const value = (update.props as Record<string, unknown>)[prop];
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return { ok: false, reason: 'invalid structured-property replace marker' };
}
pending.delete(prop);
}
if (pending.size === 0) pendingReplacementProps.delete(update.id);
}
}
if (pendingReplacementProps.size > 0) {
return { ok: false, reason: 'invalid structured-property replace marker' };
}
const session = useSlideEditSession.getState();
if (session.sceneId !== sceneId || !session.history) {
return {
ok: false,
reason: 'no open edit session for this scene; open Pro mode on the target slide first',
};
}
if (session.gestureActive) {
return { ok: false, reason: 'a canvas gesture is still in progress' };
}
const present = session.history.present;
if (present.type !== 'slide') {
return { ok: false, reason: 'edit session content is not a slide' };
}
if (
inventoryFingerprint &&
elementInventorySnapshotFingerprint(present.canvas.elements as PPTElement[]) !==
inventoryFingerprint
) {
return { ok: false, reason: 'slide elements changed while the edit was being prepared' };
}
const recheck = revalidateIntentsAgainstElements(
present.canvas.elements as PPTElement[],
intents,
targetElementTypes,
targetElementFingerprints,
);
if (!recheck.ok) return { ok: false, reason: recheck.reason };
const byId = new Map((present.canvas.elements as PPTElement[]).map((el) => [el.id, el] as const));
for (const intent of intents) {
if (intent.type !== 'text.updateContent') continue;
const element = byId.get(intent.id);
if (intent.target === 'text' && element?.type !== 'text') {
return { ok: false, reason: `element ${JSON.stringify(intent.id)} is not a text element` };
}
if (intent.target === 'shape' && (element?.type !== 'shape' || !element.text)) {
return { ok: false, reason: `shape ${JSON.stringify(intent.id)} has no text label to edit` };
}
}
// Refuse fabricating shape.text when the shape has no label (no content authoring).
for (const intent of intents) {
const updates =
intent.type === 'element.update'
? [{ id: intent.id, props: intent.props as Record<string, unknown> }]
: intent.type === 'element.updateMany'
? intent.updates.map((u) => ({
id: u.id,
props: u.props as Record<string, unknown>,
}))
: [];
for (const u of updates) {
const el = byId.get(u.id);
if (!el || el.type !== 'shape') continue;
const touchesText = Object.keys(u.props).some(
(k) => SHAPE_TEXT_CHROME_PROPS.has(k) || k === 'vAlign',
);
if (touchesText && !(el as { text?: unknown }).text) {
return {
ok: false,
reason: `shape ${JSON.stringify(u.id)} has no text label to style`,
};
}
}
}
const operations = compileAgentEditOperations(present, intents);
if (operations.length === 0) {
return { ok: false, reason: 'nothing changed (targets missing after revalidation)' };
}
const transaction = createEditorTransaction({ origin: 'agent', history: 'record', operations });
const next = applyEditorTransaction(present, transaction);
if (next === present) {
return { ok: false, reason: 'nothing changed (targets missing after revalidation)' };
}
session.applyTransaction(transaction);
return { ok: true };
}
/** True when tool details carry applyable edit_elements intents. */
export function hasEditElementsIntents(
details: EditElementsApplyDetails | null | undefined,
): details is EditElementsApplyDetails & { sceneId: string; intents: EditIntent[] } {
return (
!!details && typeof details.sceneId === 'string' && editElementsOutcome(details) === 'applied'
);
}