PluginProbe
Extendify / 3.1.5
Extendify v3.1.5
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.5, at src/Agent/Agent.jsx

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