PluginProbe
Extendify / 3.1.3
Extendify v3.1.3
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.3, at src/Agent/Agent.jsx

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