PluginProbe
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell / 3.13.1
WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell v3.13.1
3.13.1 3.13.0 3.12.13 3.12.12 3.12.11 3.12.10 3.12.9 3.12.8 3.12.7 3.12.6 3.12.5 3.12.4 3.12.3 3.12.1 3.12.2 3.12.0 3.11.1 3.11.0 3.10.9 3.10.8 3.10.7 3.10.6 2.8.16 2.8.17 2.8.18 All 259 releases
wpfunnels / includes / core / rest-api / Controllers / AiChatController.php

AiChatController.php in WPFunnels – Funnel Builder for WooCommerce with Checkout & One Click Upsell 3.13.1, at includes/core/rest-api/Controllers/AiChatController.php

710 lines 21.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * AiChatController — REST API controller for the WPFunnels AI Copilot.
4 *
5 * Routes:
6 * POST /wpfunnels/v1/ai/conversations — Start conversation + optional first message
7 * GET /wpfunnels/v1/ai/conversations — List my conversations
8 * GET /wpfunnels/v1/ai/conversations/{id} — Conversation detail + history + pending card
9 * PATCH /wpfunnels/v1/ai/conversations/{id} — Rename conversation
10 * DELETE /wpfunnels/v1/ai/conversations/{id} — Delete conversation
11 * POST /wpfunnels/v1/ai/conversations/{id}/messages — Append user message
12 * POST /wpfunnels/v1/ai/conversations/{id}/step — Run ONE agent loop iteration
13 * POST /wpfunnels/v1/ai/conversations/{id}/confirm — Approve/deny pending action
14 * GET /wpfunnels/v1/ai/funnel-preview/{id} — Canvas/flow preview payload
15 * GET /wpfunnels/v1/ai/step-preview/{id} — Step outline preview payload
16 *
17 * @package WPFunnels\Rest\Controllers
18 * @since 3.13.0
19 */
20
21 namespace WPFunnels\Rest\Controllers;
22
23 defined( 'ABSPATH' ) || exit;
24
25 use WP_Error;
26 use WP_REST_Request;
27 use WP_REST_Response;
28 use WP_REST_Server;
29 use WPFunnels\AI\AIInit;
30 use WPFunnels\AI\AgentLoop;
31 use WPFunnels\AI\ConversationStore;
32 use WPFunnels\AI\Settings\AISettings;
33 use WPFunnels\MCP\Tools\PageContentTools;
34
35 /**
36 * Class AiChatController
37 */
38 class AiChatController extends Wpfnl_REST_Controller {
39
40 /**
41 * Endpoint namespace.
42 *
43 * @var string
44 */
45 protected $namespace = 'wpfunnels/v1';
46
47 /**
48 * Route base.
49 *
50 * @var string
51 */
52 protected $rest_base = 'ai';
53
54 /**
55 * Check permissions for AI endpoints.
56 *
57 * @param WP_REST_Request $request Request.
58 * @return bool|WP_Error
59 */
60 public function check_permission( $request ) {
61 if ( ! is_user_logged_in() ) {
62 return new WP_Error(
63 'wpfunnels_rest_cannot_access',
64 __( 'You must be logged in to access the AI assistant.', 'wpfnl' ),
65 [ 'status' => rest_authorization_required_code() ]
66 );
67 }
68
69 if ( ! current_user_can( 'wpf_manage_funnels' ) && ! current_user_can( 'manage_options' ) ) {
70 return new WP_Error(
71 'wpfunnels_rest_cannot_access',
72 __( 'Sorry, you do not have permission to access the AI assistant.', 'wpfnl' ),
73 [ 'status' => rest_authorization_required_code() ]
74 );
75 }
76
77 return true;
78 }
79
80 /**
81 * Register REST routes.
82 *
83 * @return void
84 */
85 public function register_routes() {
86 // Conversations collection: GET (list), POST (create)
87 register_rest_route(
88 $this->namespace,
89 '/' . $this->rest_base . '/conversations',
90 [
91 [
92 'methods' => WP_REST_Server::READABLE,
93 'callback' => [ $this, 'list_conversations' ],
94 'permission_callback' => [ $this, 'check_permission' ],
95 ],
96 [
97 'methods' => WP_REST_Server::CREATABLE,
98 'callback' => [ $this, 'create_conversation' ],
99 'permission_callback' => [ $this, 'check_permission' ],
100 ],
101 ]
102 );
103
104 // Single conversation: GET (detail), PATCH (rename), DELETE (remove)
105 register_rest_route(
106 $this->namespace,
107 '/' . $this->rest_base . '/conversations/(?P<id>\d+)',
108 [
109 [
110 'methods' => WP_REST_Server::READABLE,
111 'callback' => [ $this, 'get_conversation' ],
112 'permission_callback' => [ $this, 'check_permission' ],
113 ],
114 [
115 'methods' => WP_REST_Server::EDITABLE,
116 'callback' => [ $this, 'update_conversation' ],
117 'permission_callback' => [ $this, 'check_permission' ],
118 ],
119 [
120 'methods' => WP_REST_Server::DELETABLE,
121 'callback' => [ $this, 'delete_conversation' ],
122 'permission_callback' => [ $this, 'check_permission' ],
123 ],
124 ]
125 );
126
127 // Messages append: POST /ai/conversations/{id}/messages
128 register_rest_route(
129 $this->namespace,
130 '/' . $this->rest_base . '/conversations/(?P<id>\d+)/messages',
131 [
132 [
133 'methods' => WP_REST_Server::CREATABLE,
134 'callback' => [ $this, 'append_message' ],
135 'permission_callback' => [ $this, 'check_permission' ],
136 ],
137 ]
138 );
139
140 // Agent loop step: POST /ai/conversations/{id}/step
141 register_rest_route(
142 $this->namespace,
143 '/' . $this->rest_base . '/conversations/(?P<id>\d+)/step',
144 [
145 [
146 'methods' => WP_REST_Server::CREATABLE,
147 'callback' => [ $this, 'run_step' ],
148 'permission_callback' => [ $this, 'check_permission' ],
149 ],
150 ]
151 );
152
153 // Action confirmation: POST /ai/conversations/{id}/confirm
154 register_rest_route(
155 $this->namespace,
156 '/' . $this->rest_base . '/conversations/(?P<id>\d+)/confirm',
157 [
158 [
159 'methods' => WP_REST_Server::CREATABLE,
160 'callback' => [ $this, 'confirm_action' ],
161 'permission_callback' => [ $this, 'check_permission' ],
162 ],
163 ]
164 );
165
166 // Canvas preview payload: GET /ai/funnel-preview/{id}
167 register_rest_route(
168 $this->namespace,
169 '/' . $this->rest_base . '/funnel-preview/(?P<id>\d+)',
170 [
171 [
172 'methods' => WP_REST_Server::READABLE,
173 'callback' => [ $this, 'get_funnel_preview' ],
174 'permission_callback' => [ $this, 'check_permission' ],
175 ],
176 ]
177 );
178
179 // Step outline preview payload: GET /ai/step-preview/{id}
180 register_rest_route(
181 $this->namespace,
182 '/' . $this->rest_base . '/step-preview/(?P<id>\d+)',
183 [
184 [
185 'methods' => WP_REST_Server::READABLE,
186 'callback' => [ $this, 'get_step_preview' ],
187 'permission_callback' => [ $this, 'check_permission' ],
188 ],
189 ]
190 );
191
192 // Connection settings for the copilot's Connect modal:
193 // GET|POST /settings/ai
194 register_rest_route(
195 $this->namespace,
196 '/settings/ai',
197 [
198 [
199 'methods' => WP_REST_Server::READABLE,
200 'callback' => [ $this, 'get_ai_settings' ],
201 'permission_callback' => [ $this, 'check_permission' ],
202 ],
203 [
204 'methods' => WP_REST_Server::CREATABLE,
205 'callback' => [ $this, 'save_ai_settings' ],
206 'permission_callback' => [ $this, 'check_permission' ],
207 ],
208 ]
209 );
210 }
211
212 /**
213 * Current connection state. Never returns a decrypted key — only a masked
214 * tail, so the settings screen can show "connected" without handing the
215 * credential back to the browser.
216 *
217 * @param WP_REST_Request $request Request.
218 * @return WP_REST_Response
219 */
220 public function get_ai_settings( $request ) {
221 $state = \WPFunnels\AI\Settings\AISettings::publicState();
222
223 $settings = [
224 'enabled' => (bool) $state['enabled'],
225 'provider' => $state['active_provider'],
226 'custom_instructions' => $state['custom_instructions'],
227 'mcp_enabled' => 'no' !== get_option( '_wpfnl_mcp_enabled', 'yes' ),
228 'mcp_endpoint' => \WPFunnels\MCP\MCPInit::endpointUrl(),
229 'mcp_supported' => \WPFunnels\MCP\MCPInit::abilitiesApiAvailable(),
230 'providers' => $state['providers'],
231 ];
232
233 // Flatten per-provider state into the keys the Connect modal binds to.
234 foreach ( $state['providers'] as $slug => $provider ) {
235 $settings[ $slug . '_connected' ] = (bool) $provider['connected'];
236 $settings[ $slug . '_masked_key' ] = $provider['masked_key'];
237 $settings[ $slug . '_model' ] = $provider['model'];
238 $settings[ $slug . '_key' ] = '';
239 }
240
241 return rest_ensure_response(
242 [
243 'success' => true,
244 'settings' => $settings,
245 ]
246 );
247 }
248
249 /**
250 * Persist connection settings sent by the Connect modal.
251 *
252 * A blank or masked key means "leave the stored credential alone".
253 *
254 * @param WP_REST_Request $request Request.
255 * @return WP_REST_Response
256 */
257 public function save_ai_settings( $request ) {
258 $settings_class = '\WPFunnels\AI\Settings\AISettings';
259 $params = (array) $request->get_json_params();
260 if ( empty( $params ) ) {
261 $params = (array) $request->get_params();
262 }
263
264 $errors = [];
265 $provider = isset( $params['provider'] ) ? sanitize_text_field( (string) $params['provider'] ) : '';
266
267 // Single-purpose action flags (Mail Mint parity) — the dedicated AI
268 // Assistant settings page sends one of these per call. Mutually
269 // exclusive: a request does exactly one thing.
270 if ( ! empty( $params['test_connection'] ) && '' !== $provider ) {
271 return rest_ensure_response( $this->test_ai_connection( $provider ) );
272 }
273
274 if ( ! empty( $params['disconnect'] ) && '' !== $provider ) {
275 call_user_func( [ $settings_class, 'disconnect' ], $provider );
276 } elseif ( isset( $params['api_key'] ) && '' !== $provider ) {
277 $key = trim( (string) $params['api_key'] );
278 $model = isset( $params['model'] ) ? sanitize_text_field( (string) $params['model'] ) : '';
279 if ( '' !== $key && false === strpos( $key, '' ) ) {
280 $result = call_user_func( [ $settings_class, 'connect' ], $provider, sanitize_text_field( $key ), $model );
281 if ( is_wp_error( $result ) ) {
282 $errors[] = $result->get_error_message();
283 }
284 }
285 } elseif ( ! empty( $params['set_active'] ) && '' !== $provider ) {
286 if ( 'wordpress_ai' === $provider && ! call_user_func( [ $settings_class, 'isConnected' ], $provider ) ) {
287 call_user_func( [ $settings_class, 'connect' ], $provider, '' );
288 }
289 if ( ! call_user_func( [ $settings_class, 'setActiveProvider' ], $provider ) ) {
290 $errors[] = sprintf(
291 /* translators: %s: provider slug. */
292 __( 'Add an API key for %s before making it the active provider.', 'wpfnl' ),
293 $provider
294 );
295 }
296 } elseif ( isset( $params['update_model'] ) && '' !== $provider ) {
297 call_user_func( [ $settings_class, 'updateModel' ], $provider, sanitize_text_field( (string) $params['update_model'] ) );
298 }
299
300 if ( array_key_exists( 'set_enabled', $params ) ) {
301 $flag = $params['set_enabled'];
302 call_user_func( [ $settings_class, 'setEnabled' ], is_string( $flag ) ? 'yes' === $flag : (bool) rest_sanitize_boolean( $flag ) );
303 }
304
305 if ( ! empty( $params['save_instructions'] ) && isset( $params['instructions'] ) ) {
306 call_user_func( [ $settings_class, 'saveCustomInstructions' ], sanitize_textarea_field( (string) $params['instructions'] ) );
307 }
308
309 // Legacy combined-payload style — every field arrives in one call.
310 // Still used by the copilot's in-thread Connect modal, so only run
311 // this branch when none of the single-purpose flags above were sent.
312 $is_legacy_payload = empty( $params['disconnect'] ) && ! isset( $params['api_key'] )
313 && empty( $params['set_active'] ) && ! isset( $params['update_model'] );
314
315 if ( $is_legacy_payload ) {
316 foreach ( [ 'anthropic', 'openai', 'gemini' ] as $legacy_provider ) {
317 $key = isset( $params[ $legacy_provider . '_key' ] ) ? trim( (string) $params[ $legacy_provider . '_key' ] ) : '';
318 $model = isset( $params[ $legacy_provider . '_model' ] ) ? sanitize_text_field( (string) $params[ $legacy_provider . '_model' ] ) : '';
319
320 if ( '' !== $key && false === strpos( $key, '' ) ) {
321 $result = call_user_func( [ $settings_class, 'connect' ], $legacy_provider, sanitize_text_field( $key ), $model );
322 if ( is_wp_error( $result ) ) {
323 $errors[] = $result->get_error_message();
324 }
325 } elseif ( '' !== $model && call_user_func( [ $settings_class, 'isConnected' ], $legacy_provider ) ) {
326 call_user_func( [ $settings_class, 'updateModel' ], $legacy_provider, $model );
327 }
328 }
329
330 if ( '' !== $provider ) {
331 if ( 'wordpress_ai' === $provider && ! call_user_func( [ $settings_class, 'isConnected' ], $provider ) ) {
332 call_user_func( [ $settings_class, 'connect' ], $provider, '' );
333 }
334 if ( ! call_user_func( [ $settings_class, 'setActiveProvider' ], $provider ) ) {
335 $errors[] = sprintf(
336 /* translators: %s: provider slug. */
337 __( 'Add an API key for %s before making it the active provider.', 'wpfnl' ),
338 $provider
339 );
340 }
341 }
342
343 if ( isset( $params['custom_instructions'] ) ) {
344 call_user_func( [ $settings_class, 'saveCustomInstructions' ], sanitize_textarea_field( (string) $params['custom_instructions'] ) );
345 }
346
347 if ( array_key_exists( 'enabled', $params ) ) {
348 call_user_func( [ $settings_class, 'setEnabled' ], (bool) rest_sanitize_boolean( $params['enabled'] ) );
349 }
350 }
351
352 if ( array_key_exists( 'mcp_enabled', $params ) ) {
353 update_option( '_wpfnl_mcp_enabled', rest_sanitize_boolean( $params['mcp_enabled'] ) ? 'yes' : 'no' );
354 }
355
356 $response = $this->get_ai_settings( $request );
357 $data = $response->get_data();
358
359 $data['success'] = empty( $errors );
360 if ( ! empty( $errors ) ) {
361 $data['message'] = implode( ' ', $errors );
362 }
363
364 return rest_ensure_response( $data );
365 }
366
367 /**
368 * Live connectivity test against a provider's stored connection.
369 *
370 * Makes one small real request through the same adapter the agent loop
371 * uses, so "it works here" actually means the loop can use it. Never
372 * persists anything.
373 *
374 * @param string $provider Provider slug.
375 * @return array{success: bool, message: string}
376 */
377 private function test_ai_connection( $provider ) {
378 $settings_class = '\WPFunnels\AI\Settings\AISettings';
379
380 if ( 'wordpress_ai' === $provider && ! call_user_func( [ $settings_class, 'isConnected' ], $provider ) ) {
381 call_user_func( [ $settings_class, 'connect' ], $provider, '' );
382 }
383
384 if ( ! call_user_func( [ $settings_class, 'isConnected' ], $provider ) ) {
385 return [
386 'success' => false,
387 'message' => __( 'Save an API key before testing the connection.', 'wpfnl' ),
388 ];
389 }
390
391 $adapter = \WPFunnels\AI\AIInit::makeProvider(
392 $provider,
393 call_user_func( [ $settings_class, 'getApiKey' ], $provider ),
394 call_user_func( [ $settings_class, 'getModel' ], $provider )
395 );
396
397 if ( ! $adapter ) {
398 return [
399 'success' => false,
400 'message' => __( 'Unknown AI provider.', 'wpfnl' ),
401 ];
402 }
403
404 $result = $adapter->chat(
405 'You are a connectivity test. Reply with the single word OK and nothing else.',
406 [
407 [
408 'role' => 'user',
409 'content' => [ 'text' => 'Reply with OK.' ],
410 ],
411 ],
412 []
413 );
414
415 if ( is_wp_error( $result ) ) {
416 return [
417 'success' => false,
418 'message' => $result->get_error_message(),
419 ];
420 }
421
422 return [
423 'success' => true,
424 'message' => __( 'Connection is working.', 'wpfnl' ),
425 ];
426 }
427
428 /**
429 * List conversations for the current user.
430 *
431 * @param WP_REST_Request $request Request.
432 * @return WP_REST_Response
433 */
434 public function list_conversations( $request ) {
435 $user_id = get_current_user_id();
436 $context_type = (string) $request->get_param( 'context_type' );
437 $context_id = (int) $request->get_param( 'context_id' );
438 $limit = max( 1, min( 100, (int) ( $request->get_param( 'limit' ) ?: 50 ) ) );
439 $offset = max( 0, (int) $request->get_param( 'offset' ) );
440
441 $items = ConversationStore::listConversations( $user_id, $context_type, $context_id, $limit, $offset );
442
443 return rest_ensure_response(
444 [
445 'success' => true,
446 'conversations' => $items,
447 ]
448 );
449 }
450
451 /**
452 * Create a conversation and optionally post its first message.
453 *
454 * @param WP_REST_Request $request Request.
455 * @return WP_REST_Response|WP_Error
456 */
457 public function create_conversation( $request ) {
458 $user_id = get_current_user_id();
459 $message = trim( (string) $request->get_param( 'message' ) );
460 $context_type = sanitize_text_field( (string) ( $request->get_param( 'context_type' ) ?: 'dashboard' ) );
461 $context_id = (int) $request->get_param( 'context_id' );
462 $title = trim( (string) $request->get_param( 'title' ) );
463
464 if ( '' === $title ) {
465 $title = '' !== $message ? mb_substr( $message, 0, 40 ) : __( 'New Conversation', 'wpfnl' );
466 }
467
468 $provider_slug = AISettings::getActiveProvider();
469
470 $id = ConversationStore::createConversation( $user_id, $provider_slug, $context_type, $context_id, $title );
471 if ( ! $id ) {
472 return new WP_Error( 'create_failed', __( 'Could not create conversation.', 'wpfnl' ), [ 'status' => 500 ] );
473 }
474
475 if ( '' !== $message ) {
476 ConversationStore::appendMessage(
477 $id,
478 'user',
479 [
480 'text' => $message,
481 'tool_calls' => [],
482 ]
483 );
484 }
485
486 return rest_ensure_response(
487 [
488 'success' => true,
489 'conversation_id' => $id,
490 'title' => $title,
491 'context_type' => $context_type,
492 'context_id' => $context_id,
493 'status' => 'idle',
494 ]
495 );
496 }
497
498 /**
499 * Get full detail for a conversation.
500 *
501 * @param WP_REST_Request $request Request.
502 * @return WP_REST_Response|WP_Error
503 */
504 public function get_conversation( $request ) {
505 $user_id = get_current_user_id();
506 $id = (int) $request->get_param( 'id' );
507
508 $conversation = ConversationStore::getOwnedConversation( $id, $user_id );
509 if ( ! $conversation ) {
510 return new WP_Error( 'not_found', __( 'Conversation not found.', 'wpfnl' ), [ 'status' => 404 ] );
511 }
512
513 $raw_messages = ConversationStore::getMessages( $id );
514 $messages = [];
515
516 foreach ( $raw_messages as $msg ) {
517 $messages[] = [
518 'id' => (int) $msg['id'],
519 'role' => $msg['role'],
520 'content' => ! empty( $msg['content'] ) ? json_decode( $msg['content'], true ) : null,
521 'meta' => ! empty( $msg['meta'] ) ? json_decode( $msg['meta'], true ) : null,
522 'created_at' => $msg['created_at'],
523 ];
524 }
525
526 return rest_ensure_response(
527 [
528 'success' => true,
529 'conversation' => $conversation,
530 'messages' => $messages,
531 'pending' => AgentLoop::pendingForClient( $conversation['pending'] ),
532 ]
533 );
534 }
535
536 /**
537 * Rename a conversation.
538 *
539 * @param WP_REST_Request $request Request.
540 * @return WP_REST_Response|WP_Error
541 */
542 public function update_conversation( $request ) {
543 $user_id = get_current_user_id();
544 $id = (int) $request->get_param( 'id' );
545 $title = sanitize_text_field( (string) $request->get_param( 'title' ) );
546
547 $conversation = ConversationStore::getOwnedConversation( $id, $user_id );
548 if ( ! $conversation ) {
549 return new WP_Error( 'not_found', __( 'Conversation not found.', 'wpfnl' ), [ 'status' => 404 ] );
550 }
551
552 if ( '' === $title ) {
553 return new WP_Error( 'invalid_title', __( 'Title cannot be empty.', 'wpfnl' ), [ 'status' => 400 ] );
554 }
555
556 ConversationStore::updateConversation( $id, [ 'title' => $title ] );
557
558 return rest_ensure_response(
559 [
560 'success' => true,
561 'id' => $id,
562 'title' => $title,
563 ]
564 );
565 }
566
567 /**
568 * Delete a conversation.
569 *
570 * @param WP_REST_Request $request Request.
571 * @return WP_REST_Response|WP_Error
572 */
573 public function delete_conversation( $request ) {
574 $user_id = get_current_user_id();
575 $id = (int) $request->get_param( 'id' );
576
577 $conversation = ConversationStore::getOwnedConversation( $id, $user_id );
578 if ( ! $conversation ) {
579 return new WP_Error( 'not_found', __( 'Conversation not found.', 'wpfnl' ), [ 'status' => 404 ] );
580 }
581
582 ConversationStore::deleteConversation( $id );
583
584 return rest_ensure_response(
585 [
586 'success' => true,
587 'id' => $id,
588 ]
589 );
590 }
591
592 /**
593 * Append a user message to a conversation.
594 *
595 * @param WP_REST_Request $request Request.
596 * @return WP_REST_Response|WP_Error
597 */
598 public function append_message( $request ) {
599 $user_id = get_current_user_id();
600 $id = (int) $request->get_param( 'id' );
601 $text = trim( (string) $request->get_param( 'content' ) );
602
603 $conversation = ConversationStore::getOwnedConversation( $id, $user_id );
604 if ( ! $conversation ) {
605 return new WP_Error( 'not_found', __( 'Conversation not found.', 'wpfnl' ), [ 'status' => 404 ] );
606 }
607
608 if ( '' === $text ) {
609 return new WP_Error( 'empty_message', __( 'Message content cannot be empty.', 'wpfnl' ), [ 'status' => 400 ] );
610 }
611
612 $msg_id = ConversationStore::appendMessage(
613 $id,
614 'user',
615 [
616 'text' => $text,
617 'tool_calls' => [],
618 ]
619 );
620
621 return rest_ensure_response(
622 [
623 'success' => true,
624 'message_id' => $msg_id,
625 ]
626 );
627 }
628
629 /**
630 * Run one agent loop iteration.
631 *
632 * @param WP_REST_Request $request Request.
633 * @return WP_REST_Response|WP_Error
634 */
635 public function run_step( $request ) {
636 $user_id = get_current_user_id();
637 $id = (int) $request->get_param( 'id' );
638
639 $res = AgentLoop::step( $id, $user_id );
640 return rest_ensure_response( $res );
641 }
642
643 /**
644 * Approve or deny a pending confirmation.
645 *
646 * @param WP_REST_Request $request Request.
647 * @return WP_REST_Response|WP_Error
648 */
649 public function confirm_action( $request ) {
650 $user_id = get_current_user_id();
651 $id = (int) $request->get_param( 'id' );
652 $approve = (bool) $request->get_param( 'approve' );
653 $deny_reason = sanitize_text_field( (string) $request->get_param( 'deny_reason' ) );
654
655 $res = AgentLoop::confirm( $id, $user_id, $approve, $deny_reason );
656 return rest_ensure_response( $res );
657 }
658
659 /**
660 * Get canvas/flow preview payload for a funnel.
661 *
662 * @param WP_REST_Request $request Request.
663 * @return WP_REST_Response|WP_Error
664 */
665 public function get_funnel_preview( $request ) {
666 $id = (int) $request->get_param( 'id' );
667 $post = get_post( $id );
668
669 if ( ! $post || WPFNL_FUNNELS_POST_TYPE !== $post->post_type ) {
670 return new WP_Error( 'funnel_not_found', __( 'Funnel not found.', 'wpfnl' ), [ 'status' => 404 ] );
671 }
672
673 $preview = \WPFunnels\MCP\Helpers\MCPHelper::formatFunnel( $post, true );
674 return rest_ensure_response(
675 [
676 'success' => true,
677 'preview' => $preview,
678 ]
679 );
680 }
681
682 /**
683 * Get a rendered step preview payload for the copilot's split-view canvas.
684 *
685 * Outline-based (headline / subheadline / CTA / benefits), not a screenshot —
686 * per the plan, screenshot previews are a later evaluation. Reuses
687 * PageContentTools::getStepOutline() so there is exactly one source of truth
688 * for "what an outline looks like" between the `get-step-outline` ability and
689 * this REST route.
690 *
691 * @param WP_REST_Request $request Request.
692 * @return WP_REST_Response|WP_Error
693 */
694 public function get_step_preview( $request ) {
695 $id = (int) $request->get_param( 'id' );
696
697 $outline = PageContentTools::getStepOutline( [ 'step_id' => $id ] );
698 if ( is_wp_error( $outline ) ) {
699 return $outline;
700 }
701
702 return rest_ensure_response(
703 [
704 'success' => true,
705 'preview' => $outline,
706 ]
707 );
708 }
709 }
710