PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 All 128 releases
← All changes | src/Agent/api.js +197 -59 3.0.4 → 3.2.2 View file →
@@ -1,11 +1,95 @@
1 +import { abilityDescriptors, getAbilities } from '@agent/abilities/abilities';
2 +import { findBlockEl, scopeOf } from '@agent/lib/block-el';
3 +import { buildBlockSchema } from '@agent/lib/block-schema';
4 +import {
5 + classifyBlockEdit,
6 + IGNORED_BLOCKS,
7 +} from '@agent/lib/classify-block-edit';
8 +import { getClientTools } from '@agent/lib/client-tools';
9 +import { isExtendableHeader } from '@agent/lib/extendable-header';
10 +import { INSERTABLE_BLOCK_TYPES } from '@agent/lib/insertable-blocks';
11 +import { fetchLinkablePages } from '@agent/lib/linkable-pages';
12 +import { ensureCoreBlocksRegistered } from '@agent/lib/register-blocks';
13 +import {
14 + buildSubtreeManifest,
15 + buildSubtreeTree,
16 + ownerAncestorFor,
17 +} from '@agent/lib/subtree-manifest';
18 +import { activeCanvasStep } from '@agent/state/canvas';
1 19 import { useChatStore } from '@agent/state/chat';
2 20 import { useGlobalStore } from '@agent/state/global';
3 -import { useWorkflowStore } from '@agent/state/workflows';
4 21 import { tools } from '@agent/workflows/workflows';
5 22 import { AI_HOST } from '@constants';
23 +import { useQuickEditStore } from '@quick-edit/state/store';
24 +import { digest } from '@shared/api/digest';
6 25 import { reqDataBasics } from '@shared/lib/data';
26 +import { getBlockType } from '@wordpress/blocks';
7 27
28 +// page-templates:v1 declares that find-blocks and stage-block reach into parts.
29 +// block-duplicate:v1 declares that update-blocks applies the duplicate op.
30 +export const API_FEATURES = [
31 + 'qa-tool',
32 + 'page-templates:v1',
33 + 'block-duplicate:v1',
34 +];
35 +
36 +const rootFor = (block) =>
37 + block?.id && block?.target
38 + ? findBlockEl(block.id, document, scopeOf(block))
39 + : null;
40 +
41 +// If greater than 5 blocks the agent will narrow the scope
42 +const EAGER_LOAD_MAX = 5;
43 +
44 +// The staged selection's attribute schemas, with the install's palette baked in.
45 +export const blockSchemasFor = async (block) => {
46 + const root = rootFor(block);
47 + const { bucket } = classifyBlockEdit({ block, root });
48 + const owner = ownerAncestorFor(root);
49 + const types = [
50 + ...new Set(
51 + [
52 + ...(bucket === 'single'
53 + ? [block.blockType]
54 + : IGNORED_BLOCKS.has(block?.blockType)
55 + ? []
56 + : buildSubtreeManifest(root).map(({ type }) => type)),
57 + owner?.type,
58 + ].filter(Boolean),
59 + ),
60 + ];
61 + if (!types.length || types.length > EAGER_LOAD_MAX) return [];
62 + await ensureCoreBlocksRegistered();
63 + return types
64 + .map((type) => ({ type, schema: buildBlockSchema(getBlockType(type)) }))
65 + .filter(({ schema }) => schema);
66 +};
67 +
68 +// Without the site's addresses the model links a named page to "#".
69 +const withLinkablePages = async (data) => {
70 + if (!data.blockSchemas?.some(({ type }) => type === 'core/navigation-link')) {
71 + return data;
72 + }
73 + const pages = await fetchLinkablePages();
74 + return {
75 + ...data,
76 + linkablePages: pages.map(({ title, url }) => ({ title, url })),
77 + };
78 +};
79 +
80 +// Only consumed for a multi-block edit; inert for single/combo.
81 +export const subtreeManifestFor = (block) => {
82 + const root = rootFor(block);
83 + const owner = ownerAncestorFor(root);
84 + const manifest = buildSubtreeManifest(root);
85 + // buildSubtreeManifest treats the first entry as the selection's baseline.
86 + return owner ? [...manifest, owner] : manifest;
87 +};
88 +
89 +// Selection hierarchy for the backend's positional edits.
90 +export const subtreeTreeFor = (block) => buildSubtreeTree(rootFor(block));
91 +
8 92 const extra = () => {
9 93 const { x, y, width, height } = useGlobalStore.getState();
10 94 return {
11 95 userAgent: window?.navigator?.userAgent,
@@ -29,11 +113,13 @@
29 113 const { failedWorkflows, context } = window.extAgentData;
30 114 const failed = failedWorkflows ?? new Set();
31 115 const filteredWorkflows = workflows.filter((wf) => !failed.has(wf.id));
32 116
33 - const { workflowHistory: pastWorkflows, block } = useWorkflowStore.getState();
117 + const block = useQuickEditStore.getState().agentBlock;
34 118
35 - const messages = useChatStore.getState().getMessagesForAI();
119 + const messages = useChatStore
120 + .getState()
121 + .getCurrentMessages({ includeTools: false });
36 122 const lastAssistantMessage = useChatStore
37 123 .getState()
38 124 .getLastAssistantMessage();
39 125
@@ -43,19 +129,22 @@
43 129 signal: options?.signal,
44 130 body: JSON.stringify({
45 131 ...reqDataBasics,
46 132 workflows: filteredWorkflows,
47 - previousAgentName: pastWorkflows.at(0)?.agentName,
48 133 previousWorkflow: {
134 + workflowId: lastAssistantMessage?.details?.workflowId,
135 + language: lastAssistantMessage?.details?.language,
49 136 lastMessage: lastAssistantMessage?.details?.content,
50 137 sessionId: lastAssistantMessage?.details?.sessionId,
51 - ...pastWorkflows?.at(0),
52 138 },
53 139 context,
54 140 agentContext: window.extAgentData.agentContext,
141 + wpAbilities: window.extAgentData.wpAbilities ?? [],
142 + clientTools: getClientTools(),
55 143 messages: messages.slice(-5),
56 144 hasBlock: Boolean(block), // todo: remove this
57 145 blockDetails: block,
146 + features: API_FEATURES,
58 147 ...options,
59 148 extra: extra(),
60 149 }),
61 150 });
@@ -61,13 +150,10 @@
61 150 });
62 151
63 152 if (!response.ok) {
64 153 digest({
65 - caller: 'pick-workflow',
66 - error: {
67 - name: response.statusText,
68 - messages: response.statusMessage,
69 - },
154 + error: response,
155 + details: { source: 'agent', caller: 'pick-workflow' },
70 156 });
71 157 const error = new Error('Bad response from server');
72 158 error.response = response;
73 159 throw error;
@@ -75,9 +161,25 @@
75 161 return await response.json();
76 162 };
77 163
78 164 export const handleWorkflow = async ({ workflow, workflowData, options }) => {
79 - const messages = useChatStore.getState().getMessagesForAI();
165 + const { getCurrentMessages, getMessagesFor } = useChatStore.getState();
166 + const block = useQuickEditStore.getState().agentBlock;
167 + const isBlockPatching =
168 + workflow?.id === 'block-patching' ||
169 + (workflow?.id === 'select-block' && Boolean(block));
170 + // Spent once the manifest flows: patching never reads this workflow's inputs.
171 + const { blockSearch: _spent, ...carried } = workflowData ?? {};
172 + // Schemas: pinned up front when the selection is small enough; otherwise
173 + // empty until get-block-schemas fetches them into the same field.
174 + const data = isBlockPatching
175 + ? await withLinkablePages({
176 + ...carried,
177 + blockSchemas: workflowData?.blockSchemas?.length
178 + ? workflowData.blockSchemas
179 + : await blockSchemasFor(block),
180 + })
181 + : workflowData;
80 182 const response = await fetch(`${AI_HOST}/api/agent/handle-workflow`, {
81 183 method: 'POST',
82 184 headers: { 'Content-Type': 'application/json' },
83 185 signal: options?.signal,
@@ -83,13 +185,27 @@
83 185 signal: options?.signal,
84 186 body: JSON.stringify({
85 187 ...reqDataBasics,
86 188 workflow,
87 - workflowData,
88 - messages: messages,
189 + workflowData: data,
190 + messages: getCurrentMessages(),
191 + previousMessages: getMessagesFor(workflow?.id),
89 192 context: window.extAgentData.context,
90 193 agentContext: window.extAgentData.agentContext,
194 + wpAbilities: window.extAgentData.wpAbilities ?? [],
195 + clientTools: getClientTools(),
196 + // The manifest is the backend's block-patching signal — never send it
197 + // for other workflows or they'd be routed into the patching handler.
198 + blockManifest: isBlockPatching ? subtreeManifestFor(block) : [],
199 + blockTree: isBlockPatching ? subtreeTreeFor(block) : [],
200 + // The plugin owns the add catalog so old fielded builds are never
201 + // offered a type they can't build.
202 + insertableBlockTypes: isBlockPatching ? INSERTABLE_BLOCK_TYPES : [],
203 + // The patcher writes dead CSS for these switches and claims success.
204 + isExtendableHeader:
205 + isBlockPatching && block?.template === 'header' && isExtendableHeader(),
91 206 retry: options?.retry || false,
207 + features: API_FEATURES,
92 208 extra: extra(),
93 209 }),
94 210 });
95 211
@@ -96,8 +212,37 @@
96 212 if (!response.ok) throw new Error('Bad response from server');
97 213 return await response.json();
98 214 };
99 215
216 +export const handleCanvas = async ({
217 + toolId,
218 + sessionId,
219 + abilities,
220 + options,
221 +}) => {
222 + const { getCurrentMessages, getMessagesFor } = useChatStore.getState();
223 + const { values } = activeCanvasStep();
224 + const response = await fetch(`${AI_HOST}/api/agent/handle-canvas`, {
225 + method: 'POST',
226 + headers: { 'Content-Type': 'application/json' },
227 + signal: options?.signal,
228 + body: JSON.stringify({
229 + ...reqDataBasics,
230 + toolId,
231 + values,
232 + abilities: abilityDescriptors(getAbilities(abilities)),
233 + messages: getCurrentMessages(),
234 + previousMessages: getMessagesFor(toolId),
235 + context: window.extAgentData.context,
236 + sessionId,
237 + extra: extra(),
238 + }),
239 + });
240 +
241 + if (!response.ok) throw new Error('Bad response from server');
242 + return await response.json();
243 +};
244 +
100 245 export const rateAnswer = ({ answerId, rating }) =>
101 246 fetch(`${AI_HOST}/api/agent/rate-workflow`, {
102 247 method: 'POST',
103 248 headers: { 'Content-Type': 'application/json' },
@@ -103,60 +248,53 @@
103 248 headers: { 'Content-Type': 'application/json' },
104 249 body: JSON.stringify({ answerId, rating }),
105 250 }).catch((error) =>
106 251 digest({
107 - caller: 'rateAnswer',
108 - error,
109 - extra: { answerId, rating },
252 + error: error,
253 + details: { source: 'agent', caller: 'rateAnswer', answerId, rating },
110 254 }),
111 255 );
112 256
113 -export const callTool = async ({ tool, inputs }) => {
114 - if (!tools[tool]) throw new Error(`Tool ${tool} not found`);
115 - return await tools[tool](inputs);
257 +// An error with no message serializes to {}, which the model reads as an empty success.
258 +const reasonFor = async (error) => {
259 + // An unparsed apiFetch rejects with the Response, whose body holds the reason.
260 + if (typeof error?.json === 'function') {
261 + const body = await error.json().catch(() => null);
262 + return body?.message ?? `The site answered ${error.status}.`;
263 + }
264 +
265 + return (
266 + error?.error ?? error?.message ?? 'The ability failed without saying why.'
267 + );
116 268 };
117 269
118 -export const digest = ({ error, sessionId, caller, additional = {} }) => {
119 - if (Boolean(reqDataBasics?.devbuild) === true) return;
270 +// A throw would reach the model outside the tool's slot, reading as no result.
271 +const runAbility = async (ability, inputs) => {
272 + try {
273 + return await ability.execute(inputs);
274 + } catch (error) {
275 + return { error: await reasonFor(error) };
276 + }
277 +};
120 278
121 - const errorMessage = () => {
122 - if (error.response?.statusText) {
123 - return (
124 - error.response?.statusText || error.response.message || 'Unknown error'
125 - );
126 - }
127 - return typeof error === 'string'
128 - ? error
129 - : error?.message || 'Unknown error';
130 - };
131 -
132 - const errorData = {
133 - message: errorMessage(),
134 - name: error?.name,
135 - };
136 -
137 - return fetch(`${AI_HOST}/api/agent/digest`, {
138 - method: 'POST',
139 - keepalive: true,
140 - headers: { 'Content-Type': 'application/json' },
141 - body: JSON.stringify({
142 - ...reqDataBasics,
143 - phpVersion: window.extSharedData?.phpVersion,
144 - sessionId,
145 - error: errorData,
146 - browser: {
147 - userAgent: window.navigator?.userAgent,
148 - vendor: window.navigator?.vendor,
149 - platform: window.navigator?.platform,
150 - width: window.innerWidth,
151 - height: window.innerHeight,
152 - touchSupport: 'ontouchstart' in window || navigator.maxTouchPoints > 0,
153 - },
154 - caller,
155 - ...additional,
156 - extra: extra(),
157 - }),
158 - }).catch(() => {});
279 +export const callTool = async ({ tool, inputs, abilities = [] }) => {
280 + if (tools[tool]) return await tools[tool](inputs);
281 + // Ours wins a name collision, so a duplicate WP ability can't change what runs.
282 + const ours =
283 + getAbilities(abilities).find(({ name }) => name === tool) ??
284 + getAbilities([tool])[0];
285 + if (ours) return { [tool]: await runAbility(ours, inputs) };
286 + // Ability tools are named after the ability and have no file; the generic
287 + // runner executes them. Key the result to its slot so the loop sees it filled.
288 + const isAbility = (window.extAgentData?.wpAbilities ?? []).some((category) =>
289 + category.abilities?.some((ability) => ability.name === tool),
290 + );
291 + if (isAbility) {
292 + return {
293 + [tool]: await tools['execute-ability']({ ability: tool, input: inputs }),
294 + };
295 + }
296 + throw new Error(`Tool ${tool} not found`);
159 297 };
160 298
161 299 export const recordAgentActivity = ({ action, sessionId, value = {} }) => {
162 300 return fetch(`${AI_HOST}/api/agent/activities`, {