PluginProbe
Extendify / 3.2.2
Extendify v3.2.2
3.2.2 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 All 128 releases
extendify / src / Agent / Agent.jsx

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

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