PluginProbe
Extendify / 3.1.6
Extendify v3.1.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.1.6, at src/Agent/Agent.jsx

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