PluginProbe
Extendify / 3.2.3
Extendify v3.2.3
3.2.3 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 All 129 releases
extendify / src / Agent / Agent.jsx

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

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