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

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