PluginProbe
Extendify / 3.1.4
Extendify v3.1.4
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 0.7.0 All 126 releases
extendify / src / Agent / Agent.jsx

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

798 lines 25.3 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 { useLockPost } from '@agent/hooks/useLockPost';
12 import { isAbilityTool, isAbilityWorkflow } from '@agent/lib/abilities';
13 import {
14 getClientToolFallbackReply,
15 getClientTools,
16 } from '@agent/lib/client-tools';
17 import { localPickWorkflow } from '@agent/lib/local-pick';
18 import { getRedirectUrl } from '@agent/lib/redirects';
19 import { useChatStore } from '@agent/state/chat';
20 import { useGlobalStore } from '@agent/state/global';
21 import { useStatusStore } from '@agent/state/status';
22 import { useSuggestionsStore } from '@agent/state/suggestions';
23 import { useWorkflowStore } from '@agent/state/workflows';
24 import { hasRunComponent } from '@agent/workflows/abilities/components/run';
25 import { useQuickEditStore } from '@quick-edit/state/store';
26 import { digest } from '@shared/api/digest';
27 import {
28 useCallback,
29 useEffect,
30 useMemo,
31 useRef,
32 useState,
33 } from '@wordpress/element';
34 import { __, _n, sprintf } from '@wordpress/i18n';
35
36 const devmode = window.extSharedData.devbuild;
37 // Logged with tool errors: the banner sends users here and support needs to
38 // know which site the console they paste came from.
39 const { siteId } = window.extSharedData;
40 // Used to abort when wf canceled - reset in cleanup()
41 let controller = new AbortController();
42 const { postId } = window?.extAgentData?.context || {};
43
44 // Floor a resolution so its result doesn't re-render over the still-animating
45 // scroll-to-top, which leaves the chat scrolled to the wrong place.
46 const withMinDuration = async (promise, ms) => {
47 const [result] = await Promise.all([
48 promise,
49 new Promise((resolve) => setTimeout(resolve, ms)),
50 ]);
51 return result;
52 };
53
54 export const Agent = () => {
55 const { addMessage, updateMessage, popMessage, messages } = useChatStore();
56 const { pushStatus, clearStatuses } = useStatusStore();
57 const {
58 mergeWorkflowData,
59 getWorkflow,
60 getWorkflowByExample,
61 workflowData,
62 setWorkflow,
63 setWhenFinishedToolProps,
64 whenFinishedToolProps,
65 getAvailableWorkflows,
66 requireBlock,
67 } = useWorkflowStore();
68 const block = useQuickEditStore((s) => s.agentBlock);
69 const setBlock = useQuickEditStore((s) => s.setAgentBlock);
70 const { open, setOpen, updateRetryAfter, isChatAvailable } = useGlobalStore();
71 useLockPost({ postId, enabled: !!open });
72 const [canType, setCanType] = useState(true);
73 const agentWorking = useRef(false);
74 const toolWorking = useRef(false);
75 const retrying = useRef(false);
76 // Starting false would re-run the agent's last turn on every reload.
77 const [waitingOnToolOrUser, setWaitingOnToolOrUser] = useState(() => {
78 const last = useChatStore.getState().messages.at(-1);
79 if (last?.type === 'tool') return !('result' in (last.details ?? {}));
80 return last?.type === 'message' && last.details?.role === 'assistant';
81 });
82 const [loop, setLoop] = useState(0);
83 const workflow = getWorkflow();
84 const chatAvailable = useMemo(() => isChatAvailable(), [isChatAvailable]);
85 const { addSuggestions, getSuggestions } = useSuggestionsStore();
86 // Options render only while their message is last; a reply dismisses them.
87 const lastMessage = messages.at(-1);
88 const qaSuggestions =
89 lastMessage?.type === 'message' &&
90 lastMessage.details?.role === 'assistant' &&
91 Array.isArray(lastMessage.details?.qaSuggestions)
92 ? lastMessage.details.qaSuggestions
93 : null;
94
95 const cleanup = useCallback(() => {
96 setCanType(true);
97 agentWorking.current = false;
98 setWaitingOnToolOrUser(false);
99 controller = new AbortController();
100 const { agentBlock, setCommittedSelection } = useQuickEditStore.getState();
101 agentBlock && setBlock(null);
102 // A class or pin left up freezes Quick Edit hover site-wide.
103 document
104 .querySelector('.wp-site-blocks')
105 ?.classList.remove('extendify-agent-working', 'extendify-agent-busy');
106 setCommittedSelection(null);
107 clearStatuses();
108 window.dispatchEvent(new Event('extendify-agent:remove-block-highlight'));
109 }, [setBlock, clearStatuses]);
110
111 useEffect(() => {
112 const handle = ({ detail }) => {
113 if (!detail?.id) return;
114 updateMessage(detail.id, { result: detail.result });
115 setWaitingOnToolOrUser(false);
116 agentWorking.current = false;
117 // The main loop parks on a staged tool; leaving one strands the run.
118 setWhenFinishedToolProps(null);
119 setLoop((prev) => prev + 1);
120 };
121 window.addEventListener('extendify-agent:client-tool-done', handle);
122 return () =>
123 window.removeEventListener('extendify-agent:client-tool-done', handle);
124 }, [updateMessage, setWhenFinishedToolProps]);
125
126 const findAgent = useCallback(
127 async (options = {}) => {
128 pushStatus('calling-agent');
129 const response = await withMinDuration(
130 pickWorkflow({
131 // A mid-turn drop clears the block after this closure was made.
132 workflows: getAvailableWorkflows().map((w) => w.id),
133 options: { signal: controller.signal, ...options },
134 }).catch(async (error) => {
135 devmode && console.error(error);
136 if (error?.response?.status === 429) {
137 updateRetryAfter(error?.response?.headers?.get('Retry-After'));
138 setCanType(false);
139 pushStatus('credits-exhausted');
140 return;
141 }
142 setCanType(true);
143 if (error === 'Workflow aborted') {
144 addMessage('workflow', {
145 status: 'canceled',
146 suggestions: getSuggestions(),
147 });
148 return;
149 }
150
151 await new Promise((resolve) => setTimeout(resolve, 1000));
152 addMessage('message', {
153 role: 'assistant',
154 // translators: This message is shown when the AI agent fails to find a suitable workflow.
155 content: __(
156 'Something went wrong while trying to start this request. Please try again.',
157 'extendify-local',
158 ),
159 error: true,
160 });
161 return;
162 }),
163 500,
164 );
165 if (!response) return;
166
167 const { workflow: wf, reply } = response;
168 if (wf?.id) setWorkflow(wf);
169 if (reply) {
170 const data = { role: 'assistant', content: reply, agent: wf?.agent };
171 addMessage('message', data);
172 }
173 if (!wf?.id) setCanType(true);
174 },
175 [
176 addMessage,
177 pushStatus,
178 updateRetryAfter,
179 setWorkflow,
180 getAvailableWorkflows,
181 getSuggestions,
182 ],
183 );
184
185 const handleSubmit = useCallback(
186 async (message) => {
187 // Suggestions reach the agent without the textarea; disabling it isn't enough.
188 if (useQuickEditStore.getState().selected) return;
189 setWaitingOnToolOrUser(false);
190 agentWorking.current = false;
191 addMessage('message', { role: 'user', content: message });
192
193 // Let some phrases auto load workflows
194 const bypass = getWorkflowByExample(message);
195 if (bypass?.example?.agentResponse) {
196 // whenFinishedTool → inline input UI; none → ask for a typed reply.
197 return bypass.example.agentResponse.whenFinishedTool
198 ? handleBypass(bypass)
199 : handleInstantAsk(bypass);
200 }
201
202 setCanType(false);
203 // If they typed while waiting on a redirect, reset the workflow
204 const redirect = workflow?.needsRedirect?.();
205 // If they typed while an active whenFinished, reset the workflow
206 const inWhenFinished = whenFinishedToolProps?.id;
207 const removingWorkflow = redirect || inWhenFinished;
208 if (removingWorkflow) setWorkflow(null);
209
210 // They are in the middle of a workflow back and forth
211 if (workflow && !removingWorkflow) {
212 // Clone the workflow to let the effect handle it
213 const wfData = workflowData || {};
214 setWorkflow({ ...workflow });
215 mergeWorkflowData(wfData);
216 return;
217 }
218
219 // Skip the network find-agent when the staged block makes the edit certain.
220 const localPick = localPickWorkflow({ block });
221 if (localPick) {
222 await new Promise((resolve) => setTimeout(resolve, 500));
223 setWorkflow(localPick);
224 return;
225 }
226
227 await findAgent().catch((e) => devmode && console.error(e));
228 },
229 [
230 addMessage,
231 block,
232 findAgent,
233 mergeWorkflowData,
234 whenFinishedToolProps,
235 setWorkflow,
236 workflow,
237 workflowData,
238 getAvailableWorkflows,
239 ],
240 );
241
242 // Used to inject a workflow final state
243 const handleBypass = useCallback(async (workflow) => {
244 const agentResponse = workflow.example?.agentResponse;
245 cleanup();
246 if (!agentResponse) return;
247 setWorkflow(workflow);
248 setCanType(false);
249 agentWorking.current = true;
250 await new Promise((resolve) => setTimeout(resolve, 750));
251 addMessage('message', {
252 role: 'assistant',
253 content: agentResponse.reply,
254 });
255 setWhenFinishedToolProps({
256 ...agentResponse?.whenFinishedTool,
257 agentResponse,
258 });
259 recordAgentActivity({
260 sessionId: workflow?.sessionId,
261 action: 'workflow_tool_bypass',
262 value: { workflow: workflow?.id },
263 });
264 }, []);
265
266 // Ask the user, then let the normal loop handle their typed reply.
267 const handleInstantAsk = useCallback(async (workflow) => {
268 const agentResponse = workflow.example?.agentResponse;
269 cleanup();
270 if (!agentResponse) return;
271 setWorkflow(workflow);
272 // Without this the loop calls the backend before the user has typed.
273 setWaitingOnToolOrUser(true);
274 setCanType(false);
275 agentWorking.current = true;
276 await new Promise((resolve) => setTimeout(resolve, 750));
277 addMessage('message', {
278 role: 'assistant',
279 content: agentResponse.reply,
280 // A workflow example can carry its own suggestions; none still asks.
281 qaSuggestions: agentResponse.qaSuggestions ?? [],
282 });
283 agentWorking.current = false;
284 setCanType(true);
285 recordAgentActivity({
286 sessionId: workflow?.sessionId,
287 action: 'workflow_instant_ask',
288 value: { workflow: workflow?.id },
289 });
290 }, []);
291
292 useEffect(() => {
293 // Allow external messages to trigger the agent
294 const handleMessage = ({ detail }) => {
295 if (!detail?.message) return;
296 handleSubmit(detail.message);
297 };
298 // Allow external code to clear the block and workflow
299 const handleCleanup = () => {
300 controller.abort('Workflow aborted');
301 cleanup();
302 // Deferred a frame: the input stays disabled until the cancel lands.
303 requestAnimationFrame(() =>
304 document.querySelector('#extendify-agent-chat-textarea')?.focus(),
305 );
306
307 // An options panel can outlive its workflow; cancel must still clear it.
308 if (!workflow?.id && !qaSuggestions) return;
309 setWorkflow(null);
310 addMessage('workflow', {
311 status: 'canceled',
312 agent: workflow?.agent,
313 workflowId: workflow?.id,
314 suggestions: getSuggestions(),
315 });
316 return;
317 };
318 window.addEventListener('extendify-agent:cancel-workflow', handleCleanup);
319 window.addEventListener('extendify-agent:chat-submit', handleMessage);
320 return () => {
321 window.removeEventListener(
322 'extendify-agent:cancel-workflow',
323 handleCleanup,
324 );
325 window.removeEventListener('extendify-agent:chat-submit', handleMessage);
326 };
327 }, [
328 handleSubmit,
329 cleanup,
330 setWorkflow,
331 addMessage,
332 workflow,
333 qaSuggestions,
334 getSuggestions,
335 ]);
336
337 // Handle whenFinished component confirm/cancel
338 useEffect(() => {
339 const handleConfirm = async ({ detail }) => {
340 if (toolWorking.current) return;
341 setWhenFinishedToolProps(null);
342 toolWorking.current = true;
343 const { data, whenFinishedToolProps, shouldRefreshPage, redirectUrl } =
344 detail ?? {};
345 const { whenFinishedTool, answerId, redirectTo } =
346 whenFinishedToolProps?.agentResponse || {};
347 const { id, labels } = whenFinishedTool || {};
348 // Staged unanswered so its own component can show the run in progress.
349 const runMessageId = id ? addMessage('tool', { id, inputs: data }) : null;
350 if (!hasRunComponent(id)) pushStatus('workflow-tool-processing');
351 // Not all workflows have a tool at the end (e.g. tours)
352 const toolResponse = await callTool?.({ tool: id, inputs: data }).catch(
353 (error) => {
354 const { sessionId } = workflow || {};
355 digest({
356 error,
357 details: {
358 source: 'agent',
359 caller: `when-finished: ${id}`,
360 sessionId,
361 },
362 });
363 console.error(`Extendify agent tool error: ${id}`, { siteId, error });
364 return { error: { message: error?.message, code: error?.code } };
365 },
366 );
367 toolWorking.current = false;
368 if (runMessageId) updateMessage(runMessageId, { result: toolResponse });
369 if (toolResponse?.refused) {
370 await new Promise((resolve) => setTimeout(resolve, 1000));
371 const refusalMessages = {
372 // translators: Shown when the AI agent's edit produced no change to the block, which is unexpected.
373 'no-op': __(
374 "That edit came back unchanged, which wasn't expected. Please try rephrasing what you'd like to change.",
375 'extendify-local',
376 ),
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 };
383 addMessage('message', {
384 role: 'assistant',
385 content:
386 refusalMessages[toolResponse.reason] ??
387 // translators: Shown when the AI agent could not safely apply an edit to the selected block.
388 __(
389 "I couldn't safely apply that edit to the selected block. Please re-select the block and try again.",
390 'extendify-local',
391 ),
392 error: true,
393 });
394 setWorkflow(null);
395 cleanup();
396 return;
397 }
398 // Only loop back on error, so the model can recover. A clean
399 // whenFinished tool means the workflow is done.
400 if (toolResponse?.error) {
401 setWaitingOnToolOrUser(false);
402 agentWorking.current = false;
403 setLoop((prev) => prev + 1);
404 return;
405 }
406
407 addSuggestions(whenFinishedToolProps.agentResponse?.recommendations);
408 addMessage('workflow', {
409 status: 'completed',
410 label: labels?.confirm,
411 agent: workflow.agent,
412 workflowId: workflow.id,
413 answerId,
414 suggestions: getSuggestions(),
415 });
416 // A chat message persists, so the next turn's model knows the save was partial.
417 const refusedCount = toolResponse?.refusedOperations?.length;
418 if (refusedCount) {
419 addMessage('message', {
420 role: 'assistant',
421 content: sprintf(
422 // translators: %d is how many of the requested edits were not applied.
423 _n(
424 "Heads up — %d of those changes couldn't be applied. If something still looks the same, ask me to redo just that part.",
425 "Heads up — %d of those changes couldn't be applied. If something still looks the same, ask me to redo just those parts.",
426 refusedCount,
427 'extendify-local',
428 ),
429 refusedCount,
430 ),
431 });
432 }
433 setWorkflow(null);
434
435 const url = getRedirectUrl(redirectTo, whenFinishedToolProps?.inputs);
436 const refreshForAbility = isAbilityTool(id);
437 if (url || redirectUrl || shouldRefreshPage || refreshForAbility) {
438 await new Promise((resolve) => setTimeout(resolve, 1000));
439 }
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 cleanup();
445 };
446 const handleCancel = ({ detail }) => {
447 if (toolWorking.current) return;
448 const { answerId, whenFinishedTool } =
449 detail.whenFinishedToolProps?.agentResponse || {};
450 addMessage('workflow', {
451 status: 'canceled',
452 label: whenFinishedTool?.labels?.cancel,
453 agent: workflow.agent,
454 workflowId: workflow.id,
455 answerId,
456 suggestions: getSuggestions(),
457 });
458 setWorkflow(null);
459 cleanup();
460 };
461 const handleRetry = () => {
462 popMessage();
463 setWaitingOnToolOrUser(false);
464 agentWorking.current = false;
465 retrying.current = true;
466 setLoop((prev) => prev + 1); // Trigger next loop
467 };
468 window.addEventListener('extendify-agent:workflow-confirm', handleConfirm);
469 window.addEventListener('extendify-agent:workflow-cancel', handleCancel);
470 window.addEventListener('extendify-agent:workflow-retry', handleRetry);
471 return () => {
472 window.removeEventListener(
473 'extendify-agent:workflow-confirm',
474 handleConfirm,
475 );
476 window.removeEventListener(
477 'extendify-agent:workflow-cancel',
478 handleCancel,
479 );
480 window.removeEventListener('extendify-agent:workflow-retry', handleRetry);
481 };
482 }, [
483 addMessage,
484 pushStatus,
485 popMessage,
486 cleanup,
487 setWorkflow,
488 workflow,
489 getSuggestions,
490 addSuggestions,
491 ]);
492
493 useEffect(() => {
494 const handleClose = () => setOpen(false);
495 const handleOpen = () => setOpen(true);
496 window.addEventListener('extendify-agent:close', handleClose);
497 window.addEventListener('extendify-agent:open', handleOpen);
498 return () => {
499 window.removeEventListener('extendify-agent:close', handleClose);
500 window.removeEventListener('extendify-agent:open', handleOpen);
501 };
502 }, [setOpen]);
503
504 // Closing the sidebar dismisses any latent block selection. The X-close
505 // indicator (in DOMHighlighter) only renders while the sidebar is open,
506 // so leaving `block` set after close would let Quick Edit's hover-bar
507 // gate fire on a selection the user can no longer see or clear.
508 useEffect(() => {
509 if (open) return;
510 if (block) setBlock(null);
511 }, [open, block, setBlock]);
512
513 useEffect(() => {
514 if (waitingOnToolOrUser || !open || !workflow?.id) return;
515 // Some workflows require they dont change pages
516 const theyMoved = workflow?.startingPage !== window.location.href;
517 // Requires a block to be selected
518 const blockMissing = !block && workflow?.requires?.includes('block');
519 const cancelWorkflow =
520 (workflow?.cancelOnPageChange && theyMoved) || blockMissing;
521 if (cancelWorkflow) {
522 addMessage('workflow', {
523 status: 'canceled',
524 agent: workflow.agent,
525 workflowId: workflow.id,
526 suggestions: getSuggestions(),
527 });
528 setWorkflow(null);
529 cleanup();
530 return;
531 }
532 // A component is running
533 if (whenFinishedToolProps?.id) return;
534 // They must be on a page where they can do work
535 if (workflow?.needsRedirect?.()) {
536 cleanup();
537 return;
538 }
539 (async () => {
540 if (agentWorking.current) return; // Prevent multiple calls
541 if (toolWorking.current) return;
542 setCanType(false);
543 agentWorking.current = true;
544 pushStatus('agent-working');
545 const agentResponse = await handleWorkflow({
546 workflow,
547 workflowData,
548 options: { signal: controller.signal, retry: retrying.current },
549 }).catch((error) => {
550 // handleCleanup already added the canceled message
551 if (error === 'Workflow aborted') return;
552 const { sessionId } = workflow || {};
553 digest({
554 error,
555 details: { source: 'agent', caller: `handle-workflow`, sessionId },
556 });
557 devmode && console.error(error);
558 return { error: error.message };
559 });
560 if (retrying.current) retrying.current = false;
561 if (!agentResponse) return;
562 const { answerId, sessionId } = agentResponse;
563 if (!open) return;
564 if (agentResponse.error) {
565 // mutate the window to add failed tools rather than keep state
566 window.extAgentData.failedWorkflows =
567 window.extAgentData.failedWorkflows || new Set();
568 window.extAgentData.failedWorkflows.add(workflow.id);
569 throw new Error(`Error handling workflow: ${agentResponse.error}`);
570 }
571 // The ai sent back some text to show to the user
572 const reply =
573 agentResponse.reply ??
574 (agentResponse.tool
575 ? getClientToolFallbackReply(agentResponse.tool.id)
576 : null);
577 if (reply) {
578 addMessage('message', {
579 role: 'assistant',
580 content: reply,
581 followup: !!agentResponse.tool,
582 pageSuggestion: agentResponse.pageSuggestion,
583 qaSuggestions: agentResponse.qaSuggestions,
584 agent: workflow.agent,
585 sessionId: workflow?.sessionId,
586 workflowId: workflow?.id,
587 language: workflow?.language,
588 });
589 }
590 // Set when the request needs something no block edit can do.
591 if (agentResponse.dropSelection) {
592 setBlock(null);
593 window.dispatchEvent(
594 new Event('extendify-agent:remove-block-highlight'),
595 );
596 setWorkflow(null);
597 pushStatus(
598 'tool-started',
599 // translators: Shown while the AI agent deselects a block that can't serve the request.
600 __('Removing selected block', 'extendify-local'),
601 );
602 // findAgent pushes its own status right away; let this one read first.
603 await new Promise((resolve) => setTimeout(resolve, 2500));
604 agentWorking.current = false;
605 await findAgent();
606 return;
607 }
608 // This is at the end of the workflow
609 // and we are about to execute the final tool
610 if (agentResponse.whenFinishedTool?.id) {
611 setWhenFinishedToolProps({
612 ...agentResponse.whenFinishedTool,
613 agentResponse,
614 });
615 // If static, add it as a message
616 const { id, inputs, static: staticC } = agentResponse.whenFinishedTool;
617 if (staticC) {
618 addMessage('workflow-component', {
619 id,
620 status: 'completed',
621 inputs,
622 workflowId: workflow.id,
623 });
624 addSuggestions(agentResponse.recommendations);
625 setWorkflow(null);
626 addMessage('workflow', {
627 status: 'completed',
628 agent: workflow.agent,
629 workflowId: workflow.id,
630 answerId,
631 suggestions: getSuggestions(),
632 });
633 cleanup();
634 }
635 return;
636 }
637 // If we're done, it means the AI has the answer
638 if (agentResponse.status !== 'in-progress') {
639 const { recommendations, status } = agentResponse;
640 const isCompleted = status === 'completed';
641 if (recommendations) addSuggestions(recommendations);
642 setWorkflow(null);
643 cleanup();
644 addMessage('workflow', {
645 status: isCompleted ? 'completed' : 'canceled',
646 agent: workflow.agent,
647 workflowId: workflow.id,
648 answerId,
649 suggestions: getSuggestions(),
650 });
651 return;
652 }
653 if (sessionId && sessionId !== workflow.sessionId) {
654 // Session ID changed, update the workflow
655 setWorkflow({ ...workflow, sessionId });
656 }
657 // These inputs are filled out by the AI
658 mergeWorkflowData(agentResponse.inputs);
659 // Agent needs more info from a
660 if (agentResponse.tool) {
661 const { id, inputs, labels } = agentResponse.tool;
662 // A client tool is answered by in-chat UI, so the turn ends here.
663 if (getClientTools().includes(id)) {
664 addMessage('tool', { id, inputs });
665 // Clearing agentWorking here fires a second turn: the wait flag
666 // has not committed yet.
667 setWaitingOnToolOrUser(true);
668 setCanType(false);
669 return;
670 }
671 pushStatus('tool-started', labels?.started);
672 const toolData = await Promise.all([
673 callTool({ tool: id, inputs }),
674 new Promise((resolve) => setTimeout(resolve, 3000)),
675 ])
676 .then(([data]) => data)
677 .catch((error) => {
678 const { sessionId } = workflow || {};
679 digest({
680 error,
681 details: {
682 source: 'agent',
683 caller: `in-progress: ${id}`,
684 sessionId,
685 },
686 });
687 console.error(`Extendify agent tool error: ${id}`, {
688 siteId,
689 error,
690 });
691 // Don't throw; the loop hands the error to the model.
692 return { error: { message: error?.message, code: error?.code } };
693 });
694 // do-when-finished spreads first-class workflowData into the tool.
695 if (!toolData?.error && !isAbilityWorkflow(workflow.id)) {
696 mergeWorkflowData(toolData);
697 }
698 if (toolData?.stagedBlockIds?.length) requireBlock();
699 addMessage('tool', {
700 id,
701 inputs,
702 result: toolData,
703 label: labels?.confirm,
704 });
705 setWaitingOnToolOrUser(false);
706 agentWorking.current = false;
707 setLoop((prev) => prev + 1); // Trigger next loop
708 return;
709 }
710 setCanType(true);
711 setWaitingOnToolOrUser(true);
712 })().catch(async (error) => {
713 const { sessionId } = workflow || {};
714 digest({
715 error,
716 details: { source: 'agent', caller: 'main-loop', sessionId },
717 });
718 devmode && console.error(error);
719 setWorkflow(null);
720 cleanup();
721 await new Promise((resolve) => setTimeout(resolve, 1000));
722 addMessage('message', {
723 role: 'assistant',
724 // translators: This message is shown when the AI agent encounters a general error.
725 content: __(
726 "Sorry, something went wrong. I tried but wasn't able to do this request. Please try again.",
727 'extendify-local',
728 ),
729 error: true,
730 });
731 });
732 }, [
733 loop,
734 cleanup,
735 open,
736 workflow,
737 workflowData,
738 addMessage,
739 pushStatus,
740 setWorkflow,
741 agentWorking,
742 waitingOnToolOrUser,
743 mergeWorkflowData,
744 requireBlock,
745 canType,
746 whenFinishedToolProps,
747 setWhenFinishedToolProps,
748 block,
749 setBlock,
750 findAgent,
751 addSuggestions,
752 getSuggestions,
753 ]);
754
755 useEffect(() => {
756 if (!canType) return;
757 document.querySelector('#extendify-agent-chat-textarea')?.focus();
758 }, [canType]);
759
760 const busy = !canType || !chatAvailable || workflow?.id;
761 // `busy` is true at rest; 429 is blocked, not working.
762 const working = !canType && chatAvailable;
763
764 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 />
780 </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 </div>
794 </div>
795 </Chat>
796 );
797 };
798