PluginProbe
Extendify / 3.2.1
Extendify v3.2.1
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 0.6.0 All 127 releases
← All changes | src/Agent/Agent.jsx +184 -59 3.1.43.2.1 View file →
@@ -1,11 +1,17 @@
1 1 import {
2 2 callTool,
3 + handleCanvas,
3 4 handleWorkflow,
4 5 pickWorkflow,
5 6 recordAgentActivity,
6 7 } from '@agent/api';
7 8 import { Chat } from '@agent/Chat';
9 +import {
10 + Canvas,
11 + useCanvasAssist,
12 + useCanvasOpen,
13 +} from '@agent/components/Canvas';
8 14 import { ChatInput } from '@agent/components/ChatInput';
9 15 import { ChatMessages } from '@agent/components/ChatMessages';
10 16 import { UsageMessage } from '@agent/components/messages/UsageMessage';
11 17 import { useLockPost } from '@agent/hooks/useLockPost';
@@ -15,8 +21,10 @@
15 21 getClientTools,
16 22 } from '@agent/lib/client-tools';
17 23 import { localPickWorkflow } from '@agent/lib/local-pick';
18 24 import { getRedirectUrl } from '@agent/lib/redirects';
25 +import { doReload } from '@agent/lib/reload';
26 +import { useCanvasStore } from '@agent/state/canvas';
19 27 import { useChatStore } from '@agent/state/chat';
20 28 import { useGlobalStore } from '@agent/state/global';
21 29 import { useStatusStore } from '@agent/state/status';
22 30 import { useSuggestionsStore } from '@agent/state/suggestions';
@@ -21,8 +29,9 @@
21 29 import { useStatusStore } from '@agent/state/status';
22 30 import { useSuggestionsStore } from '@agent/state/suggestions';
23 31 import { useWorkflowStore } from '@agent/state/workflows';
24 32 import { hasRunComponent } from '@agent/workflows/abilities/components/run';
33 +import startOnboardingWorkflow from '@agent/workflows/misc/start-onboarding';
25 34 import { useQuickEditStore } from '@quick-edit/state/store';
26 35 import { digest } from '@shared/api/digest';
27 36 import {
28 37 useCallback,
@@ -50,11 +59,18 @@
50 59 ]);
51 60 return result;
52 61 };
53 62
63 +// cleanup() swaps in a fresh controller; a signal read after the wait is never aborted.
64 +const canceledDuring = async (ms) => {
65 + const { signal } = controller;
66 + await new Promise((resolve) => setTimeout(resolve, ms));
67 + return signal.aborted;
68 +};
69 +
54 70 export const Agent = () => {
55 71 const { addMessage, updateMessage, popMessage, messages } = useChatStore();
56 - const { pushStatus, clearStatuses } = useStatusStore();
72 + const { pushStatus, clearStatuses, leavingPage } = useStatusStore();
57 73 const {
58 74 mergeWorkflowData,
59 75 getWorkflow,
60 76 getWorkflowByExample,
@@ -80,8 +96,11 @@
80 96 return last?.type === 'message' && last.details?.role === 'assistant';
81 97 });
82 98 const [loop, setLoop] = useState(0);
83 99 const workflow = getWorkflow();
100 + const canvasOpen = useCanvasOpen();
101 + const canvasAssist = useCanvasAssist();
102 + const canvasNoticeShown = useRef(false);
84 103 const chatAvailable = useMemo(() => isChatAvailable(), [isChatAvailable]);
85 104 const { addSuggestions, getSuggestions } = useSuggestionsStore();
86 105 // Options render only while their message is last; a reply dismisses them.
87 106 const lastMessage = messages.at(-1);
@@ -91,8 +110,15 @@
91 110 Array.isArray(lastMessage.details?.qaSuggestions)
92 111 ? lastMessage.details.qaSuggestions
93 112 : null;
94 113
114 + // Without this the input stays disabled for as long as the canvas is open.
115 + useEffect(() => {
116 + if (canvasOpen && canvasAssist) setCanType(true);
117 + // A workflow reached by example carries no sessionId to key this on.
118 + if (!canvasOpen) canvasNoticeShown.current = false;
119 + }, [canvasOpen, canvasAssist]);
120 +
95 121 const cleanup = useCallback(() => {
96 122 setCanType(true);
97 123 agentWorking.current = false;
98 124 setWaitingOnToolOrUser(false);
@@ -125,13 +151,14 @@
125 151
126 152 const findAgent = useCallback(
127 153 async (options = {}) => {
128 154 pushStatus('calling-agent');
155 + const { signal } = controller;
129 156 const response = await withMinDuration(
130 157 pickWorkflow({
131 158 // A mid-turn drop clears the block after this closure was made.
132 159 workflows: getAvailableWorkflows().map((w) => w.id),
133 - options: { signal: controller.signal, ...options },
160 + options: { signal, ...options },
134 161 }).catch(async (error) => {
135 162 devmode && console.error(error);
136 163 if (error?.response?.status === 429) {
137 164 updateRetryAfter(error?.response?.headers?.get('Retry-After'));
@@ -139,15 +166,9 @@
139 166 pushStatus('credits-exhausted');
140 167 return;
141 168 }
142 169 setCanType(true);
143 - if (error === 'Workflow aborted') {
144 - addMessage('workflow', {
145 - status: 'canceled',
146 - suggestions: getSuggestions(),
147 - });
148 - return;
149 - }
170 + if (error === 'Workflow aborted') return;
150 171
151 172 await new Promise((resolve) => setTimeout(resolve, 1000));
152 173 addMessage('message', {
153 174 role: 'assistant',
@@ -161,9 +182,9 @@
161 182 return;
162 183 }),
163 184 500,
164 185 );
165 - if (!response) return;
186 + if (!response || signal.aborted) return;
166 187
167 188 const { workflow: wf, reply } = response;
168 189 if (wf?.id) setWorkflow(wf);
169 190 if (reply) {
@@ -177,20 +198,106 @@
177 198 pushStatus,
178 199 updateRetryAfter,
179 200 setWorkflow,
180 201 getAvailableWorkflows,
181 - getSuggestions,
182 202 ],
183 203 );
184 204
205 + // The backend caps tool runs; nothing here bounds the loop.
206 + const handleCanvasMessage = useCallback(async () => {
207 + setCanType(false);
208 + // cleanup() replaces the controller, so a cancel is lost between passes.
209 + const { signal } = controller;
210 + while (!signal.aborted) {
211 + pushStatus('agent-working');
212 + const response = await handleCanvas({
213 + toolId: whenFinishedToolProps?.id,
214 + sessionId: workflow?.sessionId,
215 + abilities: workflow?.abilities,
216 + options: { signal },
217 + }).catch((error) => {
218 + if (error === 'Workflow aborted') return null;
219 + const { sessionId } = workflow || {};
220 + digest({
221 + error,
222 + details: { source: 'agent', caller: 'handle-canvas', sessionId },
223 + });
224 + devmode && console.error(error);
225 + return { error: error.message };
226 + });
227 + // A request that settled before the abort still resolves with a reply.
228 + if (!response || signal.aborted) break;
229 + if (response.error) {
230 + addMessage('message', {
231 + role: 'assistant',
232 + // translators: Shown when the AI agent could not answer a question about the form it has open on screen.
233 + content: __(
234 + 'Sorry, something went wrong. Please try asking again.',
235 + 'extendify-local',
236 + ),
237 + error: true,
238 + });
239 + break;
240 + }
241 + if (response.reply) {
242 + addMessage('message', {
243 + role: 'assistant',
244 + content: response.reply,
245 + followup: !!response.tool,
246 + agent: workflow?.agent,
247 + });
248 + }
249 + // The model is never told what is on screen around the canvas.
250 + if (response.cannotHelp && !canvasNoticeShown.current) {
251 + canvasNoticeShown.current = true;
252 + addMessage('canvas-notice', {});
253 + }
254 + if (!response.tool) break;
255 + const { id, inputs, labels } = response.tool;
256 + pushStatus('tool-started', labels?.started);
257 + const result = await callTool({
258 + tool: id,
259 + inputs,
260 + abilities: workflow?.abilities,
261 + }).catch((error) => {
262 + const { sessionId } = workflow || {};
263 + digest({
264 + error,
265 + details: { source: 'agent', caller: `canvas: ${id}`, sessionId },
266 + });
267 + console.error(`Extendify agent tool error: ${id}`, { siteId, error });
268 + return { error: { message: error?.message, code: error?.code } };
269 + });
270 + addMessage('tool', { id, inputs, result, label: labels?.confirm });
271 + }
272 + setCanType(true);
273 + }, [addMessage, pushStatus, whenFinishedToolProps, workflow]);
274 +
185 275 const handleSubmit = useCallback(
186 - async (message) => {
276 + async (message, { hidden = false } = {}) => {
187 277 // Suggestions reach the agent without the textarea; disabling it isn't enough.
188 278 if (useQuickEditStore.getState().selected) return;
189 279 setWaitingOnToolOrUser(false);
190 280 agentWorking.current = false;
191 - addMessage('message', { role: 'user', content: message });
281 + addMessage('message', { role: 'user', content: message, hidden });
192 282
283 + // Without this a typed message would drop the workflow and close the canvas.
284 + if (canvasOpen && canvasAssist) return handleCanvasMessage();
285 +
286 + // A staged tool the user walked away from still owes its receipt.
287 + if (workflow?.id && whenFinishedToolProps?.id) {
288 + const { answerId, whenFinishedTool } =
289 + whenFinishedToolProps.agentResponse || {};
290 + addMessage('workflow', {
291 + status: 'canceled',
292 + label: whenFinishedTool?.labels?.cancel,
293 + agent: workflow.agent,
294 + workflowId: workflow.id,
295 + answerId,
296 + suggestions: getSuggestions(),
297 + });
298 + }
299 +
193 300 // Let some phrases auto load workflows
194 301 const bypass = getWorkflowByExample(message);
195 302 if (bypass?.example?.agentResponse) {
196 303 // whenFinishedTool → inline input UI; none → ask for a typed reply.
@@ -218,9 +325,9 @@
218 325
219 326 // Skip the network find-agent when the staged block makes the edit certain.
220 327 const localPick = localPickWorkflow({ block });
221 328 if (localPick) {
222 - await new Promise((resolve) => setTimeout(resolve, 500));
329 + if (await canceledDuring(500)) return;
223 330 setWorkflow(localPick);
224 331 return;
225 332 }
226 333
@@ -228,9 +335,12 @@
228 335 },
229 336 [
230 337 addMessage,
231 338 block,
339 + canvasAssist,
340 + canvasOpen,
232 341 findAgent,
342 + handleCanvasMessage,
233 343 mergeWorkflowData,
234 344 whenFinishedToolProps,
235 345 setWorkflow,
236 346 workflow,
@@ -235,8 +345,9 @@
235 345 setWorkflow,
236 346 workflow,
237 347 workflowData,
238 348 getAvailableWorkflows,
349 + getSuggestions,
239 350 ],
240 351 );
241 352
242 353 // Used to inject a workflow final state
@@ -246,9 +357,9 @@
246 357 if (!agentResponse) return;
247 358 setWorkflow(workflow);
248 359 setCanType(false);
249 360 agentWorking.current = true;
250 - await new Promise((resolve) => setTimeout(resolve, 750));
361 + if (await canceledDuring(750)) return;
251 362 addMessage('message', {
252 363 role: 'assistant',
253 364 content: agentResponse.reply,
254 365 });
@@ -272,9 +383,9 @@
272 383 // Without this the loop calls the backend before the user has typed.
273 384 setWaitingOnToolOrUser(true);
274 385 setCanType(false);
275 386 agentWorking.current = true;
276 - await new Promise((resolve) => setTimeout(resolve, 750));
387 + if (await canceledDuring(750)) return;
277 388 addMessage('message', {
278 389 role: 'assistant',
279 390 content: agentResponse.reply,
280 391 // A workflow example can carry its own suggestions; none still asks.
@@ -292,12 +403,14 @@
292 403 useEffect(() => {
293 404 // Allow external messages to trigger the agent
294 405 const handleMessage = ({ detail }) => {
295 406 if (!detail?.message) return;
296 - handleSubmit(detail.message);
407 + handleSubmit(detail.message, { hidden: detail.hidden });
297 408 };
298 409 // Allow external code to clear the block and workflow
299 410 const handleCleanup = () => {
411 + // cleanup() resets canType and agentWorking, so read them before it runs.
412 + const interrupted = agentWorking.current || !canType;
300 413 controller.abort('Workflow aborted');
301 414 cleanup();
302 415 // Deferred a frame: the input stays disabled until the cancel lands.
303 416 requestAnimationFrame(() =>
@@ -304,9 +417,9 @@
304 417 document.querySelector('#extendify-agent-chat-textarea')?.focus(),
305 418 );
306 419
307 420 // An options panel can outlive its workflow; cancel must still clear it.
308 - if (!workflow?.id && !qaSuggestions) return;
421 + if (!workflow?.id && !qaSuggestions && !interrupted) return;
309 422 setWorkflow(null);
310 423 addMessage('workflow', {
311 424 status: 'canceled',
312 425 agent: workflow?.agent,
@@ -331,10 +444,21 @@
331 444 addMessage,
332 445 workflow,
333 446 qaSuggestions,
334 447 getSuggestions,
448 + canType,
335 449 ]);
336 450
451 + // Dispatching before chat-submit has a listener drops the workflow.
452 + useEffect(() => {
453 + if (!startOnboardingWorkflow.available()) return;
454 + window.dispatchEvent(
455 + new CustomEvent('extendify-agent:chat-submit', {
456 + detail: { message: startOnboardingWorkflow.example.text, hidden: true },
457 + }),
458 + );
459 + }, []);
460 +
337 461 // Handle whenFinished component confirm/cancel
338 462 useEffect(() => {
339 463 const handleConfirm = async ({ detail }) => {
340 464 if (toolWorking.current) return;
@@ -373,13 +497,8 @@
373 497 'no-op': __(
374 498 "That edit came back unchanged, which wasn't expected. Please try rephrasing what you'd like to change.",
375 499 'extendify-local',
376 500 ),
377 - // translators: Shown when the user asked the AI agent to edit a block that lives in a site template (e.g. the header or footer), which the agent cannot save yet.
378 - 'template-part': __(
379 - "That block is part of your site's template, like the header or footer, and I can't make changes there yet.",
380 - 'extendify-local',
381 - ),
382 501 };
383 502 addMessage('message', {
384 503 role: 'assistant',
385 504 content:
@@ -430,22 +549,21 @@
430 549 ),
431 550 });
432 551 }
433 552 setWorkflow(null);
553 + useCanvasStore.getState().endSession();
434 554
435 555 const url = getRedirectUrl(redirectTo, whenFinishedToolProps?.inputs);
436 556 const refreshForAbility = isAbilityTool(id);
437 557 if (url || redirectUrl || shouldRefreshPage || refreshForAbility) {
438 - await new Promise((resolve) => setTimeout(resolve, 1000));
558 + return doReload(url || redirectUrl);
439 559 }
440 - if (url) return window.location.assign(url);
441 - if (redirectUrl) return window.location.assign(redirectUrl);
442 - if (shouldRefreshPage || refreshForAbility)
443 - return window.location.reload();
444 560 cleanup();
445 561 };
446 562 const handleCancel = ({ detail }) => {
447 563 if (toolWorking.current) return;
564 + // Without this a canvas closed mid-turn keeps replying into the chat.
565 + controller.abort('Workflow aborted');
448 566 const { answerId, whenFinishedTool } =
449 567 detail.whenFinishedToolProps?.agentResponse || {};
450 568 addMessage('workflow', {
451 569 status: 'canceled',
@@ -455,8 +573,9 @@
455 573 answerId,
456 574 suggestions: getSuggestions(),
457 575 });
458 576 setWorkflow(null);
577 + useCanvasStore.getState().endSession();
459 578 cleanup();
460 579 };
461 580 const handleRetry = () => {
462 581 popMessage();
@@ -599,9 +718,9 @@
599 718 // translators: Shown while the AI agent deselects a block that can't serve the request.
600 719 __('Removing selected block', 'extendify-local'),
601 720 );
602 721 // findAgent pushes its own status right away; let this one read first.
603 - await new Promise((resolve) => setTimeout(resolve, 2500));
722 + if (await canceledDuring(2500)) return;
604 723 agentWorking.current = false;
605 724 await findAgent();
606 725 return;
607 726 }
@@ -613,9 +732,10 @@
613 732 agentResponse,
614 733 });
615 734 // If static, add it as a message
616 735 const { id, inputs, static: staticC } = agentResponse.whenFinishedTool;
617 - if (staticC) {
736 + // A canvas workflow renders in the canvas and ends on close or submit.
737 + if (staticC && !workflow.whenFinished?.canvas) {
618 738 addMessage('workflow-component', {
619 739 id,
620 740 status: 'completed',
621 741 inputs,
@@ -761,37 +881,42 @@
761 881 // `busy` is true at rest; 429 is blocked, not working.
762 882 const working = !canType && chatAvailable;
763 883
764 884 return (
765 - <Chat busy={busy} working={working}>
766 - <div className="relative z-50 flex h-full flex-col justify-between overflow-auto">
767 - <ChatMessages
768 - redirectComponent={
769 - workflow?.needsRedirect?.() ? workflow.redirectComponent : null
770 - }
771 - />
772 - <div>
773 - <div className="relative flex flex-col px-4 pb-2 pt-2.5 shadow-lg-flipped">
774 - <UsageMessage
775 - onReady={() => {
776 - cleanup();
777 - pushStatus('credits-restored');
778 - }}
779 - />
885 + <>
886 + <Canvas />
887 + <Chat busy={busy} working={working}>
888 + <div className="relative z-50 flex h-full flex-col justify-between overflow-auto">
889 + <ChatMessages
890 + redirectComponent={
891 + workflow?.needsRedirect?.() ? workflow.redirectComponent : null
892 + }
893 + />
894 + <div>
895 + <div className="relative flex flex-col px-4 pb-2 pt-2.5 shadow-lg-flipped">
896 + <UsageMessage
897 + onReady={() => {
898 + cleanup();
899 + pushStatus('credits-restored');
900 + }}
901 + />
902 + </div>
903 + <div className="p-4 pb-2 pt-0">
904 + <ChatInput
905 + disabled={
906 + !canType || !chatAvailable || !!qaSuggestions || leavingPage
907 + }
908 + handleSubmit={handleSubmit}
909 + />
910 + </div>
911 + <div className="text-pretty px-4 pb-2 text-center text-xss leading-none text-gray-700">
912 + {__(
913 + 'AI Agent can make mistakes. Check changes before saving.',
914 + 'extendify-local',
915 + )}
916 + </div>
780 917 </div>
781 - <div className="p-4 pb-2 pt-0">
782 - <ChatInput
783 - disabled={!canType || !chatAvailable || !!qaSuggestions}
784 - handleSubmit={handleSubmit}
785 - />
786 - </div>
787 - <div className="text-pretty px-4 pb-2 text-center text-xss leading-none text-gray-700">
788 - {__(
789 - 'AI Agent can make mistakes. Check changes before saving.',
790 - 'extendify-local',
791 - )}
792 - </div>
793 918 </div>
794 - </div>
795 - </Chat>
919 + </Chat>
920 + </>
796 921 );
797 922 };