PluginProbe
Extendify / 3.0.6
Extendify v3.0.6
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.0.6, at src/Agent/Agent.jsx

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