PluginProbe
Extendify / 3.1.1
Extendify v3.1.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
extendify / src / Agent / Agent.jsx

Agent.jsx in Extendify 3.1.1, at src/Agent/Agent.jsx

594 lines 18.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import {
2 callTool,
3 handleWorkflow,
4 pickWorkflow,
5 recordAgentActivity,
6 } from '@agent/api';
7 import { Chat } from '@agent/Chat';
8 import { ChatInput } from '@agent/components/ChatInput';
9 import { ChatMessages } from '@agent/components/ChatMessages';
10 import { UsageMessage } from '@agent/components/messages/UsageMessage';
11 import { PageDocument } from '@agent/components/PageDocument';
12 import { useLockPost } from '@agent/hooks/useLockPost';
13 import { getRedirectUrl } from '@agent/lib/redirects';
14 import { useChatStore } from '@agent/state/chat';
15 import { useGlobalStore } from '@agent/state/global';
16 import { useSuggestionsStore } from '@agent/state/suggestions';
17 import { useWorkflowStore } from '@agent/state/workflows';
18 import { useQuickEditStore } from '@quick-edit/state/store';
19 import { digest } from '@shared/api/digest';
20 import {
21 useCallback,
22 useEffect,
23 useMemo,
24 useRef,
25 useState,
26 } from '@wordpress/element';
27 import { __ } from '@wordpress/i18n';
28
29 const devmode = window.extSharedData.devbuild;
30 // Used to abort when wf canceled - reset in cleanup()
31 let controller = new AbortController();
32 const { postId } = window?.extAgentData?.context || {};
33
34 export const Agent = () => {
35 const { addMessage, popMessage } = useChatStore();
36 const {
37 mergeWorkflowData,
38 getWorkflow,
39 getWorkflowByExample,
40 workflowData,
41 setWorkflow,
42 setWhenFinishedToolProps,
43 whenFinishedToolProps,
44 getAvailableWorkflows,
45 } = useWorkflowStore();
46 const block = useQuickEditStore((s) => s.agentBlock);
47 const setBlock = useQuickEditStore((s) => s.setAgentBlock);
48 const workflowIds = getAvailableWorkflows().map((w) => w.id);
49 const { open, setOpen, updateRetryAfter, isChatAvailable } = useGlobalStore();
50 useLockPost({ postId, enabled: !!open });
51 const [canType, setCanType] = useState(true);
52 const agentWorking = useRef(false);
53 const toolWorking = useRef(false);
54 const retrying = useRef(false);
55 const [waitingOnToolOrUser, setWaitingOnToolOrUser] = useState(false);
56 const [loop, setLoop] = useState(0);
57 const workflow = getWorkflow();
58 const chatAvailable = useMemo(() => isChatAvailable(), [isChatAvailable]);
59 const { addSuggestions, getSuggestions } = useSuggestionsStore();
60
61 const cleanup = useCallback(() => {
62 setCanType(true);
63 agentWorking.current = false;
64 setWaitingOnToolOrUser(false);
65 controller = new AbortController();
66 block && setBlock(null);
67 window.dispatchEvent(new Event('extendify-agent:remove-block-highlight'));
68 // scrollIntoView below walks up and scrolls the page itself,
69 // fighting useLayoutShift's scroll restore when closing.
70 if (!useGlobalStore.getState().open) return;
71 const c = Array.from(
72 document.querySelectorAll(
73 '#extendify-agent-chat-scroll-area div:last-child',
74 ),
75 )?.at(-1);
76 c?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
77 c?.scrollBy({ top: -5, behavior: 'smooth' });
78 }, [setBlock, block]);
79
80 const findAgent = useCallback(
81 async (options = {}) => {
82 addMessage('status', { type: 'calling-agent' });
83 const response = await pickWorkflow({
84 workflows: workflowIds,
85 options: { signal: controller.signal, ...options },
86 }).catch(async (error) => {
87 devmode && console.error(error);
88 if (error?.response?.status === 429) {
89 updateRetryAfter(error?.response?.headers?.get('Retry-After'));
90 setCanType(false);
91 addMessage('status', { type: 'credits-exhausted' });
92 return;
93 }
94 setCanType(true);
95 if (error === 'Workflow aborted') {
96 addMessage('status', { type: 'workflow-canceled' });
97 return;
98 }
99
100 await new Promise((resolve) => setTimeout(resolve, 1000));
101 addMessage('message', {
102 role: 'assistant',
103 // translators: This message is shown when the AI agent fails to find a suitable workflow.
104 content: __(
105 'Something went wrong while trying to start this request. Please try again.',
106 'extendify-local',
107 ),
108 error: true,
109 });
110 return;
111 });
112 if (!response) return;
113
114 const { workflow: wf, reply } = response;
115 if (wf?.id) setWorkflow(wf);
116 if (reply) {
117 const data = { role: 'assistant', content: reply, agent: wf?.agent };
118 addMessage('message', data);
119 }
120 if (!wf?.id) setCanType(true);
121 },
122 [addMessage, updateRetryAfter, setWorkflow, workflowIds],
123 );
124
125 const handleSubmit = useCallback(
126 async (message) => {
127 // Save any in-flight QE canvas edits before the agent runs so
128 // the user doesn't lose their work to a workflow that touches
129 // the same block. No-op when no QE canvas is mounted.
130 window.dispatchEvent(
131 new CustomEvent('extendify-quick-edit:agent-submit'),
132 );
133 setWaitingOnToolOrUser(false);
134 agentWorking.current = false;
135 addMessage('message', { role: 'user', content: message });
136
137 // Let some phrases auto load workflows
138 const bypass = getWorkflowByExample(message);
139 if (bypass?.example?.agentResponse) return handleBypass(bypass);
140
141 setCanType(false);
142 // If they typed while waiting on a redirect, reset the workflow
143 const redirect = workflow?.needsRedirect?.();
144 // If they typed while an active whenFinished, reset the workflow
145 const inWhenFinished = whenFinishedToolProps?.id;
146 const removingWorkflow = redirect || inWhenFinished;
147 if (removingWorkflow) setWorkflow(null);
148
149 // They are in the middle of a workflow back and forth
150 if (workflow && !removingWorkflow) {
151 // Clone the workflow to let the effect handle it
152 const wfData = workflowData || {};
153 setWorkflow({ ...workflow });
154 mergeWorkflowData(wfData);
155 return;
156 }
157
158 await findAgent().catch((e) => devmode && console.error(e));
159 },
160 [
161 addMessage,
162 findAgent,
163 mergeWorkflowData,
164 whenFinishedToolProps,
165 setWorkflow,
166 workflow,
167 workflowData,
168 getAvailableWorkflows,
169 ],
170 );
171
172 // Used to inject a workflow final state
173 const handleBypass = useCallback(async (workflow) => {
174 const agentResponse = workflow.example?.agentResponse;
175 cleanup();
176 if (!agentResponse) return;
177 setWorkflow(workflow);
178 setCanType(false);
179 agentWorking.current = true;
180 await new Promise((resolve) => setTimeout(resolve, 750));
181 addMessage('message', {
182 role: 'assistant',
183 content: agentResponse.reply,
184 });
185 setWhenFinishedToolProps({
186 ...agentResponse?.whenFinishedTool,
187 agentResponse,
188 });
189 recordAgentActivity({
190 sessionId: workflow?.sessionId,
191 action: 'workflow_tool_bypass',
192 value: { workflow: workflow?.id },
193 });
194 }, []);
195
196 useEffect(() => {
197 // Allow external messages to trigger the agent
198 const handleMessage = ({ detail }) => {
199 if (!detail?.message) return;
200 handleSubmit(detail.message);
201 };
202 // Allow external code to clear the block and workflow
203 const handleCleanup = () => {
204 controller.abort('Workflow aborted');
205 cleanup();
206
207 if (!workflow?.id) return;
208 setWorkflow(null);
209 addMessage('status', { type: 'workflow-canceled' });
210 return;
211 };
212 window.addEventListener('extendify-agent:cancel-workflow', handleCleanup);
213 window.addEventListener('extendify-agent:chat-submit', handleMessage);
214 return () => {
215 window.removeEventListener(
216 'extendify-agent:cancel-workflow',
217 handleCleanup,
218 );
219 window.removeEventListener('extendify-agent:chat-submit', handleMessage);
220 };
221 }, [handleSubmit, cleanup, setWorkflow, addMessage, workflow]);
222
223 // Handle whenFinished component confirm/cancel
224 useEffect(() => {
225 const handleConfirm = async ({ detail }) => {
226 if (toolWorking.current) return;
227 setWhenFinishedToolProps(null);
228 addMessage('status', { type: 'workflow-tool-processing' });
229 toolWorking.current = true;
230 const { data, whenFinishedToolProps, shouldRefreshPage, redirectUrl } =
231 detail ?? {};
232 const { whenFinishedTool, answerId, redirectTo } =
233 whenFinishedToolProps?.agentResponse || {};
234 const { id, labels } = whenFinishedTool || {};
235 // Not all workflows have a tool at the end (e.g. tours)
236 const toolResponse = await callTool?.({ tool: id, inputs: data }).catch(
237 (error) => {
238 const { sessionId } = workflow || {};
239 digest({
240 error,
241 details: {
242 source: 'agent',
243 caller: `when-finished: ${id}`,
244 sessionId,
245 },
246 });
247 devmode && console.error(error);
248 return { error: error.message };
249 },
250 );
251 toolWorking.current = false;
252 if (toolResponse?.error) {
253 await new Promise((resolve) => setTimeout(resolve, 1000));
254 addMessage('message', {
255 role: 'assistant',
256 // translators: This message is shown when the AI agent fails to confirm an action.
257 content: __(
258 'Sorry, something went wrong attempting to call the tool. Please try again.',
259 'extendify-local',
260 ),
261 error: true,
262 });
263 setWorkflow(null);
264 cleanup();
265 return;
266 }
267 addMessage('status', {
268 label: labels?.confirm,
269 type: 'workflow-tool-completed',
270 });
271 addSuggestions(whenFinishedToolProps.agentResponse?.recommendations);
272 addMessage('workflow', {
273 status: 'completed',
274 agent: workflow.agent,
275 answerId,
276 suggestions: getSuggestions(),
277 });
278 setWorkflow(null);
279
280 const url = getRedirectUrl(redirectTo, whenFinishedToolProps?.inputs);
281
282 if (url || redirectUrl || shouldRefreshPage) {
283 await new Promise((resolve) => setTimeout(resolve, 1000));
284 }
285
286 if (url) return window.location.assign(url);
287 if (redirectUrl) return window.location.assign(redirectUrl);
288 if (shouldRefreshPage) return window.location.reload();
289 // Clean up if not redirecting
290 cleanup();
291 };
292 const handleCancel = ({ detail }) => {
293 if (toolWorking.current) return;
294 const { answerId, whenFinishedTool } =
295 detail.whenFinishedToolProps?.agentResponse || {};
296 addMessage('status', {
297 type: 'workflow-canceled',
298 label: whenFinishedTool?.labels?.cancel,
299 });
300 addMessage('workflow', {
301 status: 'canceled',
302 agent: workflow.agent,
303 answerId,
304 suggestions: getSuggestions(),
305 });
306 setWorkflow(null);
307 cleanup();
308 };
309 const handleRetry = () => {
310 popMessage();
311 setWaitingOnToolOrUser(false);
312 agentWorking.current = false;
313 retrying.current = true;
314 setLoop((prev) => prev + 1); // Trigger next loop
315 };
316 window.addEventListener('extendify-agent:workflow-confirm', handleConfirm);
317 window.addEventListener('extendify-agent:workflow-cancel', handleCancel);
318 window.addEventListener('extendify-agent:workflow-retry', handleRetry);
319 return () => {
320 window.removeEventListener(
321 'extendify-agent:workflow-confirm',
322 handleConfirm,
323 );
324 window.removeEventListener(
325 'extendify-agent:workflow-cancel',
326 handleCancel,
327 );
328 window.removeEventListener('extendify-agent:workflow-retry', handleRetry);
329 };
330 }, [
331 addMessage,
332 popMessage,
333 cleanup,
334 setWorkflow,
335 workflow,
336 getSuggestions,
337 addSuggestions,
338 ]);
339
340 useEffect(() => {
341 const handleClose = () => setOpen(false);
342 const handleOpen = () => setOpen(true);
343 window.addEventListener('extendify-agent:close', handleClose);
344 window.addEventListener('extendify-agent:open', handleOpen);
345 return () => {
346 window.removeEventListener('extendify-agent:close', handleClose);
347 window.removeEventListener('extendify-agent:open', handleOpen);
348 };
349 }, [setOpen]);
350
351 // Closing the sidebar dismisses any latent block selection. The X-close
352 // indicator (in DOMHighlighter) only renders while the sidebar is open,
353 // so leaving `block` set after close would let Quick Edit's hover-bar
354 // gate fire on a selection the user can no longer see or clear.
355 useEffect(() => {
356 if (open) return;
357 if (block) setBlock(null);
358 }, [open, block, setBlock]);
359
360 useEffect(() => {
361 if (waitingOnToolOrUser || !open || !workflow?.id) return;
362 // Some workflows require they dont change pages
363 const theyMoved = workflow?.startingPage !== window.location.href;
364 // Requires a block to be selected
365 const blockMissing = !block && workflow?.requires?.includes('block');
366 const cancelWorkflow =
367 (workflow?.cancelOnPageChange && theyMoved) || blockMissing;
368 if (cancelWorkflow) {
369 addMessage('workflow', {
370 status: 'canceled',
371 agent: workflow.agent,
372 suggestions: getSuggestions(),
373 });
374 setWorkflow(null);
375 cleanup();
376 return;
377 }
378 // A component is running
379 if (whenFinishedToolProps?.id) return;
380 // They must be on a page where they can do work
381 if (workflow?.needsRedirect?.()) {
382 cleanup();
383 return;
384 }
385 (async () => {
386 if (agentWorking.current) return; // Prevent multiple calls
387 if (toolWorking.current) return;
388 setCanType(false);
389 agentWorking.current = true;
390 addMessage('status', { type: 'agent-working' });
391 const agentResponse = await handleWorkflow({
392 workflow,
393 workflowData,
394 options: { signal: controller.signal, retry: retrying.current },
395 }).catch((error) => {
396 if (error === 'Workflow aborted') {
397 addMessage('status', { type: 'workflow-canceled' });
398 setWorkflow(null);
399 cleanup();
400 return;
401 }
402 const { sessionId } = workflow || {};
403 digest({
404 error,
405 details: { source: 'agent', caller: `handle-workflow`, sessionId },
406 });
407 devmode && console.error(error);
408 return { error: error.message };
409 });
410 if (retrying.current) retrying.current = false;
411 if (!agentResponse) return;
412 const { answerId, sessionId } = agentResponse;
413 if (!open) return;
414 if (agentResponse.error) {
415 // mutate the window to add failed tools rather than keep state
416 window.extAgentData.failedWorkflows =
417 window.extAgentData.failedWorkflows || new Set();
418 window.extAgentData.failedWorkflows.add(workflow.id);
419 throw new Error(`Error handling workflow: ${agentResponse.error}`);
420 }
421 // The ai sent back some text to show to the user
422 if (agentResponse.reply) {
423 addMessage('message', {
424 role: 'assistant',
425 content: agentResponse.reply,
426 followup: !!agentResponse.tool,
427 pageSuggestion: agentResponse.pageSuggestion,
428 agent: workflow.agent,
429 sessionId: workflow?.sessionId,
430 workflowId: workflow?.id,
431 language: workflow?.language,
432 });
433 }
434 // This is at the end of the workflow
435 // and we are about to execute the final tool
436 if (agentResponse.whenFinishedTool?.id) {
437 setWhenFinishedToolProps({
438 ...agentResponse.whenFinishedTool,
439 agentResponse,
440 });
441 // If static, add it as a message
442 const { id, inputs, static: staticC } = agentResponse.whenFinishedTool;
443 if (staticC) {
444 addMessage('workflow-component', { id, status: 'completed', inputs });
445 addSuggestions(agentResponse.recommendations);
446 setWorkflow(null);
447 addMessage('workflow', {
448 status: 'completed',
449 agent: workflow.agent,
450 answerId,
451 suggestions: getSuggestions(),
452 });
453 cleanup();
454 }
455 return;
456 }
457 // If we're done, it means the AI has the answer
458 if (agentResponse.status !== 'in-progress') {
459 const { recommendations, status } = agentResponse;
460 const isCompleted = status === 'completed';
461 if (recommendations) addSuggestions(recommendations);
462 setWorkflow(null);
463 cleanup();
464 addMessage('workflow', {
465 status: isCompleted ? 'completed' : 'canceled',
466 agent: workflow.agent,
467 answerId,
468 suggestions: getSuggestions(),
469 });
470 return;
471 }
472 if (sessionId && sessionId !== workflow.sessionId) {
473 // Session ID changed, update the workflow
474 setWorkflow({ ...workflow, sessionId });
475 }
476 // These inputs are filled out by the AI
477 mergeWorkflowData(agentResponse.inputs);
478 // Agent needs more info from a
479 if (agentResponse.tool) {
480 const { id, inputs, labels } = agentResponse.tool;
481 addMessage('status', { label: labels?.started, type: 'tool-started' });
482 const toolData = await Promise.all([
483 callTool({ tool: id, inputs }),
484 new Promise((resolve) => setTimeout(resolve, 3000)),
485 ])
486 .then(([data]) => data)
487 .catch((error) => {
488 const { sessionId } = workflow || {};
489 digest({
490 error,
491 details: {
492 source: 'agent',
493 caller: `in-progress: ${id}`,
494 sessionId,
495 },
496 });
497 devmode && console.error(error);
498 throw error;
499 });
500 addMessage('status', {
501 label: labels?.confirm,
502 type: 'tool-completed',
503 });
504 await new Promise((resolve) => setTimeout(resolve, 1000));
505 mergeWorkflowData(toolData);
506 setWaitingOnToolOrUser(false);
507 agentWorking.current = false;
508 setLoop((prev) => prev + 1); // Trigger next loop
509 return;
510 }
511 setCanType(true);
512 setWaitingOnToolOrUser(true);
513 })().catch(async (error) => {
514 const { sessionId } = workflow || {};
515 digest({
516 error,
517 details: { source: 'agent', caller: 'main-loop', sessionId },
518 });
519 devmode && console.error(error);
520 setWorkflow(null);
521 cleanup();
522 await new Promise((resolve) => setTimeout(resolve, 1000));
523 addMessage('message', {
524 role: 'assistant',
525 // translators: This message is shown when the AI agent encounters a general error.
526 content: __(
527 "Sorry, something went wrong. I tried but wasn't able to do this request. Please try again.",
528 'extendify-local',
529 ),
530 error: true,
531 });
532 });
533 }, [
534 loop,
535 cleanup,
536 open,
537 workflow,
538 workflowData,
539 addMessage,
540 setWorkflow,
541 agentWorking,
542 waitingOnToolOrUser,
543 mergeWorkflowData,
544 canType,
545 whenFinishedToolProps,
546 setWhenFinishedToolProps,
547 block,
548 addSuggestions,
549 getSuggestions,
550 ]);
551
552 useEffect(() => {
553 if (!canType) return;
554 document.querySelector('#extendify-agent-chat-textarea')?.focus();
555 }, [canType]);
556
557 const busy = !canType || !chatAvailable || workflow?.id;
558
559 return (
560 <Chat busy={busy}>
561 <div className="relative z-50 flex h-full flex-col justify-between overflow-auto">
562 <ChatMessages
563 redirectComponent={
564 workflow?.needsRedirect?.() ? workflow.redirectComponent : null
565 }
566 />
567 <div>
568 <div className="relative flex flex-col px-4 pb-2 pt-2.5 shadow-lg-flipped">
569 {block ? <PageDocument busy={busy} blockId={block.id} /> : null}
570 <UsageMessage
571 onReady={() => {
572 cleanup();
573 addMessage('status', { type: 'credits-restored' });
574 }}
575 />
576 </div>
577 <div className="p-4 pb-2 pt-0">
578 <ChatInput
579 disabled={!canType || !chatAvailable}
580 handleSubmit={handleSubmit}
581 />
582 </div>
583 <div className="text-pretty px-4 pb-2 text-center text-xss leading-none text-gray-700">
584 {__(
585 'AI Agent can make mistakes. Check changes before saving.',
586 'extendify-local',
587 )}
588 </div>
589 </div>
590 </div>
591 </Chat>
592 );
593 };
594