PluginProbe
Extendify / trunk
Extendify vtrunk
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
← All changes | src/Agent/Agent.jsx +184 -59 3.1.4 → trunk View file →
@@ -1,11 +1,17 @@
1 1 import {
2 2 callTool,
3 + handleCanvas,
3 4 handleWorkflow,
4 5 pickWorkflow,
5 6 recordAgentActivity,
6 7 } from '@agent/api';
7 8 import { Chat } from '@agent/Chat';
9 +import {
10 + Canvas,
11 + useCanvasAssist,
12 + useCanvasOpen,
13 +} from '@agent/components/Canvas';
8 14 import { ChatInput } from '@agent/components/ChatInput';
9 15 import { ChatMessages } from '@agent/components/ChatMessages';
10 16 import { UsageMessage } from '@agent/components/messages/UsageMessage';
11 17 import { useLockPost } from '@agent/hooks/useLockPost';
@@ -15,8 +21,10 @@
15 21 getClientTools,
16 22 } from '@agent/lib/client-tools';
17 23 import { localPickWorkflow } from '@agent/lib/local-pick';
18 24 import { getRedirectUrl } from '@agent/lib/redirects';
25 +import { doReload } from '@agent/lib/reload';
26 +import { useCanvasStore } from '@agent/state/canvas';
19 27 import { useChatStore } from '@agent/state/chat';
20 28 import { useGlobalStore } from '@agent/state/global';
21 29 import { useStatusStore } from '@agent/state/status';
22 30 import { useSuggestionsStore } from '@agent/state/suggestions';
@@ -21,8 +29,9 @@
21 29 import { useStatusStore } from '@agent/state/status';
22 30 import { useSuggestionsStore } from '@agent/state/suggestions';
23 31 import { useWorkflowStore } from '@agent/state/workflows';
24 32 import { hasRunComponent } from '@agent/workflows/abilities/components/run';
33 +import startOnboardingWorkflow from '@agent/workflows/misc/start-onboarding';
25 34 import { useQuickEditStore } from '@quick-edit/state/store';
26 35 import { digest } from '@shared/api/digest';
27 36 import {
28 37 useCallback,
@@ -50,11 +59,18 @@
50 59 ]);
51 60 return result;
52 61 };
53 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 +
54 70 export const Agent = () => {
55 71 const { addMessage, updateMessage, popMessage, messages } = useChatStore();
56 - const { pushStatus, clearStatuses } = useStatusStore();
72 + const { pushStatus, clearStatuses, leavingPage } = useStatusStore();
57 73 const {
58 74 mergeWorkflowData,
59 75 getWorkflow,
60 76 getWorkflowByExample,
@@ -80,8 +96,11 @@
80 96 return last?.type === 'message' && last.details?.role === 'assistant';
81 97 });
82 98 const [loop, setLoop] = useState(0);
83 99 const workflow = getWorkflow();
100 + const canvasOpen = useCanvasOpen();
101 + const canvasAssist = useCanvasAssist();
102 + const canvasNoticeShown = useRef(false);
84 103 const chatAvailable = useMemo(() => isChatAvailable(), [isChatAvailable]);
85 104 const { addSuggestions, getSuggestions } = useSuggestionsStore();
86 105 // Options render only while their message is last; a reply dismisses them.
87 106 const lastMessage = messages.at(-1);
@@ -91,8 +110,15 @@
91 110 Array.isArray(lastMessage.details?.qaSuggestions)
92 111 ? lastMessage.details.qaSuggestions
93 112 : null;
94 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 +
95 121 const cleanup = useCallback(() => {
96 122 setCanType(true);
97 123 agentWorking.current = false;
98 124 setWaitingOnToolOrUser(false);
@@ -125,13 +151,14 @@
125 151
126 152 const findAgent = useCallback(
127 153 async (options = {}) => {
128 154 pushStatus('calling-agent');
155 + const { signal } = controller;
129 156 const response = await withMinDuration(
130 157 pickWorkflow({
131 158 // A mid-turn drop clears the block after this closure was made.
132 159 workflows: getAvailableWorkflows().map((w) => w.id),
133 - options: { signal: controller.signal, ...options },
160 + options: { signal, ...options },
134 161 }).catch(async (error) => {
135 162 devmode && console.error(error);
136 163 if (error?.response?.status === 429) {
137 164 updateRetryAfter(error?.response?.headers?.get('Retry-After'));
@@ -139,15 +166,9 @@
139 166 pushStatus('credits-exhausted');
140 167 return;
141 168 }
142 169 setCanType(true);
143 - if (error === 'Workflow aborted') {
144 - addMessage('workflow', {
145 - status: 'canceled',
146 - suggestions: getSuggestions(),
147 - });
148 - return;
149 - }
170 + if (error === 'Workflow aborted') return;
150 171
151 172 await new Promise((resolve) => setTimeout(resolve, 1000));
152 173 addMessage('message', {
153 174 role: 'assistant',
@@ -161,9 +182,9 @@
161 182 return;
162 183 }),
163 184 500,
164 185 );
165 - if (!response) return;
186 + if (!response || signal.aborted) return;
166 187
167 188 const { workflow: wf, reply } = response;
168 189 if (wf?.id) setWorkflow(wf);
169 190 if (reply) {
@@ -177,20 +198,106 @@
177 198 pushStatus,
178 199 updateRetryAfter,
179 200 setWorkflow,
180 201 getAvailableWorkflows,
181 - getSuggestions,
182 202 ],
183 203 );
184 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 +
185 275 const handleSubmit = useCallback(
186 - async (message) => {
276 + async (message, { hidden = false } = {}) => {
187 277 // Suggestions reach the agent without the textarea; disabling it isn't enough.
188 278 if (useQuickEditStore.getState().selected) return;
189 279 setWaitingOnToolOrUser(false);
190 280 agentWorking.current = false;
191 - addMessage('message', { role: 'user', content: message });
281 + addMessage('message', { role: 'user', content: message, hidden });
192 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 +
193 300 // Let some phrases auto load workflows
194 301 const bypass = getWorkflowByExample(message);
195 302 if (bypass?.example?.agentResponse) {
196 303 // whenFinishedTool → inline input UI; none → ask for a typed reply.
@@ -218,9 +325,9 @@
218 325
219 326 // Skip the network find-agent when the staged block makes the edit certain.
220 327 const localPick = localPickWorkflow({ block });
221 328 if (localPick) {
222 - await new Promise((resolve) => setTimeout(resolve, 500));
329 + if (await canceledDuring(500)) return;
223 330 setWorkflow(localPick);
224 331 return;
225 332 }
226 333
@@ -228,9 +335,12 @@
228 335 },
229 336 [
230 337 addMessage,
231 338 block,
339 + canvasAssist,
340 + canvasOpen,
232 341 findAgent,
342 + handleCanvasMessage,
233 343 mergeWorkflowData,
234 344 whenFinishedToolProps,
235 345 setWorkflow,
236 346 workflow,
@@ -235,8 +345,9 @@
235 345 setWorkflow,
236 346 workflow,
237 347 workflowData,
238 348 getAvailableWorkflows,
349 + getSuggestions,
239 350 ],
240 351 );
241 352
242 353 // Used to inject a workflow final state
@@ -246,9 +357,9 @@
246 357 if (!agentResponse) return;
247 358 setWorkflow(workflow);
248 359 setCanType(false);
249 360 agentWorking.current = true;
250 - await new Promise((resolve) => setTimeout(resolve, 750));
361 + if (await canceledDuring(750)) return;
251 362 addMessage('message', {
252 363 role: 'assistant',
253 364 content: agentResponse.reply,
254 365 });
@@ -272,9 +383,9 @@
272 383 // Without this the loop calls the backend before the user has typed.
273 384 setWaitingOnToolOrUser(true);
274 385 setCanType(false);
275 386 agentWorking.current = true;
276 - await new Promise((resolve) => setTimeout(resolve, 750));
387 + if (await canceledDuring(750)) return;
277 388 addMessage('message', {
278 389 role: 'assistant',
279 390 content: agentResponse.reply,
280 391 // A workflow example can carry its own suggestions; none still asks.
@@ -292,12 +403,14 @@
292 403 useEffect(() => {
293 404 // Allow external messages to trigger the agent
294 405 const handleMessage = ({ detail }) => {
295 406 if (!detail?.message) return;
296 - handleSubmit(detail.message);
407 + handleSubmit(detail.message, { hidden: detail.hidden });
297 408 };
298 409 // Allow external code to clear the block and workflow
299 410 const handleCleanup = () => {
411 + // cleanup() resets canType and agentWorking, so read them before it runs.
412 + const interrupted = agentWorking.current || !canType;
300 413 controller.abort('Workflow aborted');
301 414 cleanup();
302 415 // Deferred a frame: the input stays disabled until the cancel lands.
303 416 requestAnimationFrame(() =>
@@ -304,9 +417,9 @@
304 417 document.querySelector('#extendify-agent-chat-textarea')?.focus(),
305 418 );
306 419
307 420 // An options panel can outlive its workflow; cancel must still clear it.
308 - if (!workflow?.id && !qaSuggestions) return;
421 + if (!workflow?.id && !qaSuggestions && !interrupted) return;
309 422 setWorkflow(null);
310 423 addMessage('workflow', {
311 424 status: 'canceled',
312 425 agent: workflow?.agent,
@@ -331,10 +444,21 @@
331 444 addMessage,
332 445 workflow,
333 446 qaSuggestions,
334 447 getSuggestions,
448 + canType,
335 449 ]);
336 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 +
337 461 // Handle whenFinished component confirm/cancel
338 462 useEffect(() => {
339 463 const handleConfirm = async ({ detail }) => {
340 464 if (toolWorking.current) return;
@@ -373,13 +497,8 @@
373 497 'no-op': __(
374 498 "That edit came back unchanged, which wasn't expected. Please try rephrasing what you'd like to change.",
375 499 'extendify-local',
376 500 ),
377 - // translators: Shown when the user asked the AI agent to edit a block that lives in a site template (e.g. the header or footer), which the agent cannot save yet.
378 - 'template-part': __(
379 - "That block is part of your site's template, like the header or footer, and I can't make changes there yet.",
380 - 'extendify-local',
381 - ),
382 501 };
383 502 addMessage('message', {
384 503 role: 'assistant',
385 504 content:
@@ -430,22 +549,21 @@
430 549 ),
431 550 });
432 551 }
433 552 setWorkflow(null);
553 + useCanvasStore.getState().endSession();
434 554
435 555 const url = getRedirectUrl(redirectTo, whenFinishedToolProps?.inputs);
436 556 const refreshForAbility = isAbilityTool(id);
437 557 if (url || redirectUrl || shouldRefreshPage || refreshForAbility) {
438 - await new Promise((resolve) => setTimeout(resolve, 1000));
558 + return doReload(url || redirectUrl);
439 559 }
440 - if (url) return window.location.assign(url);
441 - if (redirectUrl) return window.location.assign(redirectUrl);
442 - if (shouldRefreshPage || refreshForAbility)
443 - return window.location.reload();
444 560 cleanup();
445 561 };
446 562 const handleCancel = ({ detail }) => {
447 563 if (toolWorking.current) return;
564 + // Without this a canvas closed mid-turn keeps replying into the chat.
565 + controller.abort('Workflow aborted');
448 566 const { answerId, whenFinishedTool } =
449 567 detail.whenFinishedToolProps?.agentResponse || {};
450 568 addMessage('workflow', {
451 569 status: 'canceled',
@@ -455,8 +573,9 @@
455 573 answerId,
456 574 suggestions: getSuggestions(),
457 575 });
458 576 setWorkflow(null);
577 + useCanvasStore.getState().endSession();
459 578 cleanup();
460 579 };
461 580 const handleRetry = () => {
462 581 popMessage();
@@ -599,9 +718,9 @@
599 718 // translators: Shown while the AI agent deselects a block that can't serve the request.
600 719 __('Removing selected block', 'extendify-local'),
601 720 );
602 721 // findAgent pushes its own status right away; let this one read first.
603 - await new Promise((resolve) => setTimeout(resolve, 2500));
722 + if (await canceledDuring(2500)) return;
604 723 agentWorking.current = false;
605 724 await findAgent();
606 725 return;
607 726 }
@@ -613,9 +732,10 @@
613 732 agentResponse,
614 733 });
615 734 // If static, add it as a message
616 735 const { id, inputs, static: staticC } = agentResponse.whenFinishedTool;
617 - if (staticC) {
736 + // A canvas workflow renders in the canvas and ends on close or submit.
737 + if (staticC && !workflow.whenFinished?.canvas) {
618 738 addMessage('workflow-component', {
619 739 id,
620 740 status: 'completed',
621 741 inputs,
@@ -761,37 +881,42 @@
761 881 // `busy` is true at rest; 429 is blocked, not working.
762 882 const working = !canType && chatAvailable;
763 883
764 884 return (
765 - <Chat busy={busy} working={working}>
766 - <div className="relative z-50 flex h-full flex-col justify-between overflow-auto">
767 - <ChatMessages
768 - redirectComponent={
769 - workflow?.needsRedirect?.() ? workflow.redirectComponent : null
770 - }
771 - />
772 - <div>
773 - <div className="relative flex flex-col px-4 pb-2 pt-2.5 shadow-lg-flipped">
774 - <UsageMessage
775 - onReady={() => {
776 - cleanup();
777 - pushStatus('credits-restored');
778 - }}
779 - />
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>
780 917 </div>
781 - <div className="p-4 pb-2 pt-0">
782 - <ChatInput
783 - disabled={!canType || !chatAvailable || !!qaSuggestions}
784 - handleSubmit={handleSubmit}
785 - />
786 - </div>
787 - <div className="text-pretty px-4 pb-2 text-center text-xss leading-none text-gray-700">
788 - {__(
789 - 'AI Agent can make mistakes. Check changes before saving.',
790 - 'extendify-local',
791 - )}
792 - </div>
793 918 </div>
794 - </div>
795 - </Chat>
919 + </Chat>
920 + </>
796 921 );
797 922 };