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 +112 -17 3.1.4 → 3.2.2 View file →
@@ -1,4 +1,6 @@
1 +import { abilityDescriptors, getAbilities } from '@agent/abilities/abilities';
2 +import { findBlockEl, scopeOf } from '@agent/lib/block-el';
1 3 import { buildBlockSchema } from '@agent/lib/block-schema';
2 4 import {
3 5 classifyBlockEdit,
4 6 IGNORED_BLOCKS,
@@ -3,14 +5,18 @@
3 5 classifyBlockEdit,
4 6 IGNORED_BLOCKS,
5 7 } from '@agent/lib/classify-block-edit';
6 8 import { getClientTools } from '@agent/lib/client-tools';
9 +import { isExtendableHeader } from '@agent/lib/extendable-header';
7 10 import { INSERTABLE_BLOCK_TYPES } from '@agent/lib/insertable-blocks';
11 +import { fetchLinkablePages } from '@agent/lib/linkable-pages';
8 12 import { ensureCoreBlocksRegistered } from '@agent/lib/register-blocks';
9 13 import {
10 14 buildSubtreeManifest,
11 15 buildSubtreeTree,
16 + ownerAncestorFor,
12 17 } from '@agent/lib/subtree-manifest';
18 +import { activeCanvasStep } from '@agent/state/canvas';
13 19 import { useChatStore } from '@agent/state/chat';
14 20 import { useGlobalStore } from '@agent/state/global';
15 21 import { tools } from '@agent/workflows/workflows';
16 22 import { AI_HOST } from '@constants';
@@ -18,11 +24,19 @@
18 24 import { digest } from '@shared/api/digest';
19 25 import { reqDataBasics } from '@shared/lib/data';
20 26 import { getBlockType } from '@wordpress/blocks';
21 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 +
22 36 const rootFor = (block) =>
23 37 block?.id && block?.target
24 - ? document.querySelector(`[${block.target}="${block.id}"]`)
38 + ? findBlockEl(block.id, document, scopeOf(block))
25 39 : null;
26 40
27 41 // If greater than 5 blocks the agent will narrow the scope
28 42 const EAGER_LOAD_MAX = 5;
@@ -30,14 +44,21 @@
30 44 // The staged selection's attribute schemas, with the install's palette baked in.
31 45 export const blockSchemasFor = async (block) => {
32 46 const root = rootFor(block);
33 47 const { bucket } = classifyBlockEdit({ block, root });
34 - const types =
35 - bucket === 'single'
36 - ? [block.blockType]
37 - : IGNORED_BLOCKS.has(block?.blockType)
38 - ? []
39 - : [...new Set(buildSubtreeManifest(root).map(({ type }) => type))];
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 + ];
40 61 if (!types.length || types.length > EAGER_LOAD_MAX) return [];
41 62 await ensureCoreBlocksRegistered();
42 63 return types
43 64 .map((type) => ({ type, schema: buildBlockSchema(getBlockType(type)) }))
@@ -43,11 +64,28 @@
43 64 .map((type) => ({ type, schema: buildBlockSchema(getBlockType(type)) }))
44 65 .filter(({ schema }) => schema);
45 66 };
46 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 +
47 80 // Only consumed for a multi-block edit; inert for single/combo.
48 -export const subtreeManifestFor = (block) =>
49 - buildSubtreeManifest(rootFor(block));
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 +};
50 88
51 89 // Selection hierarchy for the backend's positional edits.
52 90 export const subtreeTreeFor = (block) => buildSubtreeTree(rootFor(block));
53 91
@@ -104,8 +142,9 @@
104 142 clientTools: getClientTools(),
105 143 messages: messages.slice(-5),
106 144 hasBlock: Boolean(block), // todo: remove this
107 145 blockDetails: block,
146 + features: API_FEATURES,
108 147 ...options,
109 148 extra: extra(),
110 149 }),
111 150 });
@@ -111,12 +150,9 @@
111 150 });
112 151
113 152 if (!response.ok) {
114 153 digest({
115 - error: {
116 - name: response.statusText,
117 - messages: response.statusMessage,
118 - },
154 + error: response,
119 155 details: { source: 'agent', caller: 'pick-workflow' },
120 156 });
121 157 const error = new Error('Bad response from server');
122 158 error.response = response;
@@ -135,14 +171,14 @@
135 171 const { blockSearch: _spent, ...carried } = workflowData ?? {};
136 172 // Schemas: pinned up front when the selection is small enough; otherwise
137 173 // empty until get-block-schemas fetches them into the same field.
138 174 const data = isBlockPatching
139 - ? {
175 + ? await withLinkablePages({
140 176 ...carried,
141 177 blockSchemas: workflowData?.blockSchemas?.length
142 178 ? workflowData.blockSchemas
143 179 : await blockSchemasFor(block),
144 - }
180 + })
145 181 : workflowData;
146 182 const response = await fetch(`${AI_HOST}/api/agent/handle-workflow`, {
147 183 method: 'POST',
148 184 headers: { 'Content-Type': 'application/json' },
@@ -163,10 +199,13 @@
163 199 blockTree: isBlockPatching ? subtreeTreeFor(block) : [],
164 200 // The plugin owns the add catalog so old fielded builds are never
165 201 // offered a type they can't build.
166 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(),
167 206 retry: options?.retry || false,
168 - features: ['qa-tool'],
207 + features: API_FEATURES,
169 208 extra: extra(),
170 209 }),
171 210 });
172 211
@@ -173,8 +212,37 @@
173 212 if (!response.ok) throw new Error('Bad response from server');
174 213 return await response.json();
175 214 };
176 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 +
177 245 export const rateAnswer = ({ answerId, rating }) =>
178 246 fetch(`${AI_HOST}/api/agent/rate-workflow`, {
179 247 method: 'POST',
180 248 headers: { 'Content-Type': 'application/json' },
@@ -185,10 +253,37 @@
185 253 details: { source: 'agent', caller: 'rateAnswer', answerId, rating },
186 254 }),
187 255 );
188 256
189 -export const callTool = async ({ 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 + );
268 +};
269 +
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 +};
278 +
279 +export const callTool = async ({ tool, inputs, abilities = [] }) => {
190 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) };
191 286 // Ability tools are named after the ability and have no file; the generic
192 287 // runner executes them. Key the result to its slot so the loop sees it filled.
193 288 const isAbility = (window.extAgentData?.wpAbilities ?? []).some((category) =>
194 289 category.abilities?.some((ability) => ability.name === tool),