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

202 lines
6.8 KiB
TypeScript

/**
* `edit_elements` agent tool
*
* The agent authors guarded RFC 6902 `test`/`add`/`replace` operations against the
* trusted slide canvas. This tool validates the complete batch and converts it
* to the existing host EditIntent vocabulary. The client still owns the live
* document, apply-time revalidation, persistence, and one-step undo.
*/
import { Type, type Static } from 'typebox';
import type { AgentTool } from '@earendil-works/pi-agent-core';
import type { EditIntent } from '@openmaic/editor/core';
import type { PPTElement } from '@openmaic/dsl';
import type { RegenerateActionsDeps, SceneContext } from './regenerate-scene-actions';
import {
buildElementInventory,
elementInventoryFingerprint,
elementInventorySnapshotFingerprint,
type ElementInventoryItem,
} from './edit-elements-gate';
import { mapElementJsonPatchToEditIntents } from './edit-elements-patch';
const ElementPatchOperation = Type.Object(
{
op: Type.Union([Type.Literal('test'), Type.Literal('add'), Type.Literal('replace')]),
path: Type.String({ description: 'RFC 6901 path rooted at /elements.' }),
value: Type.Unknown(),
},
{ additionalProperties: false },
);
export const EditElementsParams = Type.Object({
sceneId: Type.String({
description:
'The id of the SLIDE scene to edit. Use the id of the current scene shown in the system prompt.',
}),
patches: Type.Array(ElementPatchOperation, {
description:
'Guarded JSON Patch operations. Before adding or replacing /elements/N/..., first test /elements/N/id.',
}),
reason: Type.String({
description: 'Short user-facing summary of the requested element edit.',
}),
});
export type EditElementsParams = Static<typeof EditElementsParams>;
export interface EditElementsDetails {
sceneId: string;
intents: EditIntent[] | null;
updateCount: number;
refuseReason?: string;
targetElementTypes?: Record<string, string>;
targetElementFingerprints?: Record<string, string>;
inventoryFingerprint?: string;
}
export type EditElementsDeps = RegenerateActionsDeps;
function targetTypes(
targetIds: readonly string[],
inventory: ElementInventoryItem[],
): Record<string, string> {
const byId = new Map(inventory.map((element) => [element.id, element.type] as const));
return Object.fromEntries(
targetIds.flatMap((id) => {
const type = byId.get(id);
return type ? [[id, type] as const] : [];
}),
);
}
function targetFingerprints(
targetIds: readonly string[],
elements: PPTElement[],
): Record<string, string> {
const byId = new Map(elements.map((element) => [element.id, element] as const));
return Object.fromEntries(
targetIds.flatMap((id) => {
const element = byId.get(id);
return element ? [[id, elementInventoryFingerprint(element)] as const] : [];
}),
);
}
export function makeEditElementsTool(
deps: EditElementsDeps,
): AgentTool<typeof EditElementsParams, EditElementsDetails> {
return {
name: 'edit_elements',
label: 'Edit slide elements',
description:
'Atomically edits existing elements on a SLIDE scene with guarded JSON Patch operations. ' +
'Call read_scene_content first to obtain the indexed element JSON. Before changing any ' +
'/elements/N/... path, test /elements/N/id against the id you just read. Supports existing ' +
'geometry and renderer-visible style properties plus text and shape-label HTML content. ' +
'Supports test, add for optional properties, and replace; it cannot add elements. ' +
'Value contract: geometry/opacity are numbers; colors are CSS color strings; outline is ' +
'{width,style,color}; shadow is {h,v,blur,color}; image filters use unitless numeric strings ' +
'(for example brightness "120" and blur "2"); line width is stroke thickness. Preserve ' +
'existing structured fields and units exactly, and change only requested paths.',
parameters: EditElementsParams,
execute: async (_toolCallId, params) => {
const { sceneId, patches } = params;
const ctxData: SceneContext | undefined = deps.getSceneContext(sceneId);
if (!ctxData) {
return {
content: [
{
type: 'text',
text: `Error: scene context not found for sceneId ${JSON.stringify(String(sceneId).slice(0, 200))}.`,
},
],
details: {
sceneId,
intents: null,
updateCount: 0,
refuseReason: 'scene context not found',
},
isError: true,
};
}
const { content } = ctxData;
if (content.type !== 'slide') {
return {
content: [
{
type: 'text',
text:
'Cannot edit elements on this scene: edit_elements works on SLIDE scenes only. ' +
'For interactive pages use edit_interactive_html; otherwise suggest the canvas.',
},
],
details: {
sceneId,
intents: null,
updateCount: 0,
refuseReason: 'not a slide scene',
},
isError: true,
};
}
const elements = (content.canvas?.elements ?? []) as PPTElement[];
if (elements.length === 0) {
return {
content: [{ type: 'text', text: 'This slide has no elements to edit.' }],
details: {
sceneId,
intents: null,
updateCount: 0,
refuseReason: 'slide has no elements',
},
isError: true,
};
}
const gated = mapElementJsonPatchToEditIntents(patches, elements);
if (!gated.ok) {
return {
content: [
{
type: 'text',
text: `Could not apply the edit: ${gated.reason}. Nothing was changed.`,
},
],
details: {
sceneId,
intents: null,
updateCount: 0,
refuseReason: gated.reason,
},
isError: true,
};
}
const inventory = buildElementInventory(elements);
return {
content: [
{
type: 'text',
text:
`Validated a JSON Patch batch for ${gated.targetIds.length} element(s). ` +
`Do not claim the canvas already changed: the client may still refuse a stale ` +
`slide, a locked target, an active canvas gesture, or a missing Pro edit session.`,
},
],
details: {
sceneId,
intents: gated.intents,
updateCount: gated.targetIds.length,
targetElementTypes: targetTypes(gated.targetIds, inventory),
targetElementFingerprints: targetFingerprints(gated.targetIds, elements),
inventoryFingerprint: elementInventorySnapshotFingerprint(elements),
},
};
},
};
}