PluginProbe
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! / 3.8.0
Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! v3.8.0
3.8.0 3.7.5 3.7.4 3.7.3 3.7.2 1-final 3.7.1 3.7.0 3.6.8 3.6.7 3.6.6 3.6.5 3.6.4 3.6.3 3.6.2 3.6.1 3.0.3 3.0.4 3.0.5 3.0.6 3.0.7 3.0.8 3.0.9 3.1.0 3.1.1 All 112 releases
templately / modules / ai-editor / REST / AIEditor.php

AIEditor.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! 3.8.0, at modules/ai-editor/REST/AIEditor.php

1,017 lines 38.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace Templately\Modules\AiEditor\REST;
4
5 use Templately\API\API;
6 use Templately\Utils\Database;
7 use Templately\Utils\Helper;
8 use Templately\Utils\Response\FatalGuard;
9 use WP_REST_Request;
10
11 /**
12 * AI Editor chat proxy (041) — templately/v1/ai-editor/*.
13 *
14 * The plugin never calls an AI provider (FR-020): every conversation turn is
15 * proxied to the Templately app (v2/ai-editor/chat, Bearer via Helper). This
16 * controller owns permissions, sanitization of the turn payload, the
17 * developer-mode mock (research R11), and the pending-turn poll route
18 * (analyze P1). Change application happens client-side through the editor's
19 * native APIs — never here.
20 */
21 class AIEditor extends API {
22
23 const CLOUD_ENDPOINT = 'v2/ai-editor/chat';
24
25 /** Cloud contract major version this plugin speaks (contract §cloud). */
26 const CONTRACT_VERSION = 1;
27
28 public function register_routes() {
29 $this->post( 'ai-editor/chat', [ $this, 'chat' ] );
30 $this->get( 'ai-editor/chat/(?P<process_id>[a-zA-Z0-9_\-]+)', [ $this, 'poll_turn' ] );
31 // Shared AI credit balance (research R10) — same upstream as the
32 // logo-generation route, which stays untouched.
33 $this->get( 'ai/available-credits', [ $this, 'available_credits' ] );
34 }
35
36 /**
37 * GET /ai/available-credits — the user's AI credit balance (FR-023).
38 */
39 public function available_credits() {
40 if ( $this->is_mock_mode() ) {
41 return $this->success( [ 'success' => true, 'available_credit' => 150 ] );
42 }
43
44 $response = Helper::make_api_get_request( 'v2/ai/available-credits' );
45
46 if ( is_wp_error( $response ) ) {
47 return $this->success( self::ai_envelope( 'remote_failed', $response->get_error_message() ) );
48 }
49
50 $response = json_decode( wp_remote_retrieve_body( $response ), true );
51
52 // A missing field must NOT read as a zero balance — coercing an unknown
53 // upstream response to 0 would wrongly trip the client's pre-turn gate
54 // (FR-025) and block every send. Unknown ⇒ error; the client keeps
55 // credits null (display empty, no gate) until the balance is real.
56 if ( ! is_array( $response ) || ! isset( $response['available_credit'] ) ) {
57 return $this->success( self::ai_envelope( 'remote_failed', __( 'Credit balance is unavailable right now.', 'templately' ) ) );
58 }
59
60 return $this->success( [
61 'success' => true,
62 'available_credit' => (int) $response['available_credit'],
63 ] );
64 }
65
66 /**
67 * Signed-in Templately account AND WordPress edit permission (FR-002,
68 * Clarification 2026-07-06 Q5). The base class already gates on a valid
69 * api_key via parent::permission_check(); we add the per-post capability.
70 *
71 * @return bool|\WP_Error
72 */
73 public function permission_check( WP_REST_Request $request ) {
74 $permission = parent::permission_check( $request );
75 if ( $permission !== true ) {
76 return $permission;
77 }
78
79 $post_id = absint( $request->get_param( 'post_id' ) );
80 if ( $post_id > 0 ) {
81 if ( ! current_user_can( 'edit_post', $post_id ) ) {
82 return $this->error( 'forbidden', __( 'You are not allowed to edit this content.', 'templately' ), 'ai-editor/chat', 403 );
83 }
84 return true;
85 }
86
87 // The poll route carries no post_id — require general editing capability.
88 return current_user_can( 'edit_posts' )
89 ? true
90 : $this->error( 'forbidden', __( 'You are not allowed to use the AI editor.', 'templately' ), 'ai-editor/chat', 403 );
91 }
92
93 /**
94 * POST /ai-editor/chat — one conversation turn.
95 */
96 public function chat() {
97 // A turn spends most of its life inside one long cloud call, which puts it
98 // in reach of PHP's own limits (execution time, memory). Without this, a
99 // fatal answers with WordPress's HTML "critical error" page and the chat
100 // panel renders that markup as the assistant's reply. Armed, the same
101 // fatal comes back as the 043 envelope the client already understands.
102 FatalGuard::arm( 'ai-editor/chat' );
103
104 $payload = $this->parse_chat_request();
105 if ( is_wp_error( $payload ) ) {
106 return $payload;
107 }
108
109 // Confirm-first resolution for a pending site-wide proposal (FR-014):
110 // applied entirely plugin-side — the held content never round-trips.
111 if ( ! empty( $payload['confirm'] ) ) {
112 return $this->success( $this->handle_confirmation( $payload['confirm'] ) );
113 }
114
115 // @header/@footer resolve to the ENABLED Templately template (FR-008).
116 $site_target = null;
117 $handle = $payload['target']['handle'] ?? '';
118 if ( in_array( $handle, [ 'header', 'footer' ], true ) ) {
119 $site_target = $this->resolve_site_template( $handle );
120 if ( ! $site_target ) {
121 // No template enabled → say so, point to Theme Builder, spend nothing.
122 return $this->success( [
123 'success' => true,
124 'type' => 'message',
125 'conversation_id' => $payload['conversation_id'],
126 'code' => 'no_template_enabled',
127 'reply' => sprintf(
128 /* translators: %s: "header" or "footer" */
129 __( 'No Templately %s is enabled on this site yet. Enable one under Templately → Theme Builder first, then I can edit it.', 'templately' ),
130 $handle === 'header' ? __( 'header', 'templately' ) : __( 'footer', 'templately' )
131 ),
132 ] );
133 }
134 $payload['target']['template_id'] = $site_target['id'];
135 $payload['target']['settings'] = [
136 'platform' => $site_target['platform'],
137 'content' => $site_target['content'],
138 ];
139 }
140
141 if ( $this->is_mock_mode() ) {
142 return $this->success( $this->mock_response( $payload, $site_target ) );
143 }
144
145 $payload['contract'] = self::CONTRACT_VERSION;
146
147 // Attribute-bank handshake (docs/ai-editor/ai-editor-bank-plugin-instructions.md):
148 // stateless per-turn version report — the server answers with
149 // schema_coverage, which lets the client omit block_schemas.
150 $payload['plugin_versions'] = self::plugin_versions();
151
152 // Gutenberg global settings (docs/ai-editor/ai-editor-plugin-globals.md): the raw
153 // eb_global_styles option, decoded — EB global colors/gradients and
154 // typography presets. Proxy-injected verbatim; the app resolves what a
155 // global:<preset> pointer means from it. Absent until the user first
156 // customizes globals (EB creates the option lazily).
157 if ( 'gutenberg' === $payload['platform'] ) {
158 $global_settings = self::eb_global_settings();
159 if ( $global_settings ) {
160 $payload['global_settings'] = $global_settings;
161 }
162 }
163
164 // Dev diagnostic: dump the EXACT minified JSON we send to the app
165 // (WP_DEBUG_LOG-gated via Helper::log; opt out with the filter). No
166 // credentials here — auth is a header added later in
167 // Helper::make_api_request, never in the body.
168 if ( apply_filters( 'templately_ai_editor_log_payload', true ) ) {
169 $json = wp_json_encode( $payload );
170 Helper::log( 'bytes=' . strlen( $json ) . ' body=' . $json, 'ai-editor-request' );
171 }
172
173 $response = Helper::make_api_post_request( self::CLOUD_ENDPOINT, $payload, [], 60 );
174
175 return $this->relay_cloud_response( $response );
176 }
177
178 /**
179 * GET /ai-editor/chat/{process_id} — pending-turn poll (analyze P1).
180 * Returns the standard envelope while in flight; the terminal turn once done.
181 */
182 public function poll_turn() {
183 $process_id = $this->get_param( 'process_id', '', 'sanitize_key' );
184 if ( empty( $process_id ) ) {
185 return $this->success( self::ai_envelope( 'invalid_process', __( 'Missing process id.', 'templately' ) ) );
186 }
187
188 if ( $this->is_mock_mode() ) {
189 // The mock never defers, so a poll can only mean an unknown process.
190 return $this->success( self::ai_envelope( 'invalid_process', __( 'Unknown process.', 'templately' ) ) );
191 }
192
193 $response = Helper::make_api_get_request( self::CLOUD_ENDPOINT . '/' . $process_id );
194
195 return $this->relay_cloud_response( $response );
196 }
197
198 /**
199 * Extract + sanitize + validate the turn payload (033 DTO pattern).
200 * The nested target/sections/confirm/context payloads use `null` sanitizers
201 * with manual structural validation — sanitize_text_field would corrupt them.
202 *
203 * @return array|\WP_Error
204 */
205 /**
206 * Installed editor-family versions for the server-side attribute bank.
207 * Sent on EVERY turn (stateless — no handshake, no session): the server
208 * uses it both to answer `schema_coverage` and to version-mark organic
209 * `block_schemas` ingestion. Constants only — exact and free; slugs per
210 * the bank contract, `wordpress` for core blocks. Unknown/missing
211 * families are simply absent (the server treats them as not covered).
212 */
213 private static function plugin_versions() {
214 $versions = [ 'wordpress' => substr( (string) get_bloginfo( 'version' ), 0, 32 ) ];
215 $constants = [
216 'essential-blocks' => 'ESSENTIAL_BLOCKS_VERSION',
217 'essential-blocks-pro' => 'ESSENTIAL_BLOCKS_PRO_VERSION',
218 'elementor' => 'ELEMENTOR_VERSION',
219 'elementor-pro' => 'ELEMENTOR_PRO_VERSION',
220 'essential-addons-for-elementor-lite' => 'EAEL_PLUGIN_VERSION',
221 'essential-addons-elementor' => 'EAEL_PRO_PLUGIN_VERSION',
222 ];
223 foreach ( $constants as $slug => $constant ) {
224 if ( defined( $constant ) && is_scalar( constant( $constant ) ) ) {
225 $versions[ $slug ] = substr( (string) constant( $constant ), 0, 32 );
226 }
227 }
228 return $versions;
229 }
230
231 /**
232 * The eb_global_styles option, decoded for the wire. EB stores each group
233 * (global_colors, gradient_colors, global_typography, …) as a JSON-encoded
234 * STRING inside the option array — decode each so the app receives real
235 * structures. Groups capped and sanitized as bounded scalar trees.
236 *
237 * @return array|null
238 */
239 private static function eb_global_settings() {
240 $option = get_option( 'eb_global_styles' );
241 if ( ! is_array( $option ) || ! $option ) {
242 return null;
243 }
244 $clean = [];
245 foreach ( array_slice( $option, 0, 20, true ) as $group => $value ) {
246 if ( ! is_string( $group ) ) {
247 continue;
248 }
249 $decoded = is_string( $value ) ? json_decode( wp_unslash( $value ), true ) : $value;
250 if ( ! is_array( $decoded ) || ! $decoded ) {
251 continue;
252 }
253 $clean_group = self::sanitize_scalar_tree( $decoded, 4 );
254 if ( is_array( $clean_group ) && $clean_group ) {
255 $clean[ sanitize_key( $group ) ] = $clean_group;
256 }
257 }
258 return $clean ? $clean : null;
259 }
260
261 private function parse_chat_request() {
262 $message = $this->get_param( 'message', '', 'sanitize_textarea_field' );
263 if ( empty( trim( (string) $message ) ) ) {
264 return $this->error( 'invalid_request', __( 'Message is required.', 'templately' ), 'ai-editor/chat', 400 );
265 }
266
267 $platform = $this->get_param( 'platform', '', 'sanitize_key' );
268 if ( ! in_array( $platform, [ 'gutenberg', 'elementor' ], true ) ) {
269 return $this->error( 'invalid_request', __( 'Invalid platform.', 'templately' ), 'ai-editor/chat', 400 );
270 }
271
272 $post_id = $this->get_param( 'post_id', 0, 'absint' );
273 if ( $post_id <= 0 ) {
274 return $this->error( 'invalid_request', __( 'Invalid post.', 'templately' ), 'ai-editor/chat', 400 );
275 }
276
277 $payload = [
278 'message' => $message,
279 'platform' => $platform,
280 'post_id' => $post_id,
281 'conversation_id' => $this->get_param( 'conversation_id', null, 'sanitize_key' ),
282 ];
283
284 $target = $this->get_param( 'target', null, null );
285 if ( $target !== null && $target !== '' ) {
286 $target = $this->validate_target( $target );
287 if ( is_wp_error( $target ) ) {
288 return $target;
289 }
290 $payload['target'] = $target;
291 }
292
293 $sections = $this->get_param( 'sections', [], null );
294 $sections = $this->validate_sections( $sections );
295 if ( is_wp_error( $sections ) ) {
296 return $sections;
297 }
298 $payload['sections'] = $sections;
299
300 $confirm = $this->get_param( 'confirm', null, null );
301 if ( is_array( $confirm ) && isset( $confirm['proposal_id'] ) ) {
302 $payload['confirm'] = [
303 'proposal_id' => sanitize_key( (string) $confirm['proposal_id'] ),
304 'accepted' => ! empty( $confirm['accepted'] ),
305 ];
306 }
307
308 $context = $this->get_param( 'context', null, null );
309 if ( is_array( $context ) ) {
310 $clean_context = [];
311 if ( ! empty( $context['rejection_reason'] ) ) {
312 $clean_context['rejection_reason'] = sanitize_text_field( (string) $context['rejection_reason'] );
313 }
314 // Over-cap untargeted turns carry labels-only sections — the app must
315 // know the settings were omitted (answer questions normally; ask for an
316 // @mention only when the intent is an edit).
317 if ( ! empty( $context['sections_truncated'] ) ) {
318 $clean_context['sections_truncated'] = true;
319 }
320 // Untargeted follow-ups carry the last explicitly targeted section
321 // (or site handle) so the app can resolve "it"/"that section".
322 if ( ! empty( $context['previous_target'] ) && is_array( $context['previous_target'] ) ) {
323 $prev_id = $context['previous_target']['id'] ?? '';
324 $prev_handle = $context['previous_target']['handle'] ?? '';
325 if ( is_string( $prev_id ) && $prev_id !== '' ) {
326 $clean_context['previous_target'] = [
327 'id' => sanitize_text_field( $prev_id ),
328 'handle' => is_string( $prev_handle ) ? sanitize_text_field( $prev_handle ) : '',
329 ];
330 }
331 }
332 if ( $clean_context ) {
333 $payload['context'] = $clean_context;
334 }
335 }
336
337 // block_schemas: map of block/element type => [available base attribute
338 // key names], so the model can discover off-by-default controls without
339 // per-instance default repetition. Plain identifiers — sanitize each.
340 $block_schemas = $this->get_param( 'block_schemas', null, null );
341 if ( is_array( $block_schemas ) ) {
342 $clean_schemas = [];
343 foreach ( $block_schemas as $type => $keys ) {
344 if ( ! is_string( $type ) || ! is_array( $keys ) ) {
345 continue;
346 }
347 $clean_keys = array_values( array_filter( array_map(
348 function ( $k ) {
349 return is_string( $k ) ? sanitize_text_field( $k ) : null;
350 },
351 $keys
352 ) ) );
353 if ( $clean_keys ) {
354 $clean_schemas[ sanitize_text_field( $type ) ] = $clean_keys;
355 }
356 }
357 if ( $clean_schemas ) {
358 $payload['block_schemas'] = $clean_schemas;
359 }
360 }
361
362 // schema_enums: the block_schemas companion — type => key => [allowed
363 // values] for closed-set controls (Elementor choose/select). Key names
364 // alone leave the model guessing a vocabulary Elementor is not
365 // self-consistent about ("right" is valid on an image widget's `align`,
366 // wrong on a form's `button_align`). Same nesting depth everywhere;
367 // plain identifiers, sanitized per leaf. Note '' IS a legal option
368 // ("Default"), so empty leaves are kept — only non-strings are dropped.
369 $schema_enums = $this->get_param( 'schema_enums', null, null );
370 if ( is_array( $schema_enums ) ) {
371 $clean_enums = [];
372 foreach ( $schema_enums as $type => $keys ) {
373 if ( ! is_string( $type ) || ! is_array( $keys ) ) {
374 continue;
375 }
376 $clean_keys = [];
377 foreach ( $keys as $key => $values ) {
378 if ( ! is_string( $key ) || ! is_array( $values ) ) {
379 continue;
380 }
381 $clean_values = [];
382 foreach ( $values as $value ) {
383 if ( is_string( $value ) ) {
384 $clean_values[] = sanitize_text_field( $value );
385 } elseif ( is_int( $value ) || is_float( $value ) ) {
386 // Numerically-keyed option maps arrive as ints.
387 $clean_values[] = (string) $value;
388 }
389 }
390 if ( $clean_values ) {
391 $clean_keys[ sanitize_text_field( $key ) ] = $clean_values;
392 }
393 }
394 if ( $clean_keys ) {
395 $clean_enums[ sanitize_text_field( $type ) ] = $clean_keys;
396 }
397 }
398 if ( $clean_enums ) {
399 $payload['schema_enums'] = $clean_enums;
400 }
401 }
402
403 // kit_globals: design-token refs bound via __globals__ in the payload,
404 // resolved to their effective kit values (colors => "#hex" string,
405 // typography => the token's settings object). Lets the model reason
406 // about bound keys — a relative edit needs the token's actual value.
407 $kit_globals = $this->get_param( 'kit_globals', null, null );
408 if ( is_array( $kit_globals ) ) {
409 $clean_globals = [];
410 foreach ( array_slice( $kit_globals, 0, 100, true ) as $ref => $value ) {
411 if ( ! is_string( $ref ) || ! preg_match( '#^globals/[a-z_]+\?id=[\w-]+$#', $ref ) ) {
412 continue;
413 }
414 $clean_value = self::sanitize_scalar_tree( $value, 4 );
415 if ( $clean_value !== null ) {
416 $clean_globals[ $ref ] = $clean_value;
417 }
418 }
419 if ( $clean_globals ) {
420 $payload['kit_globals'] = $clean_globals;
421 }
422 }
423
424 return $payload;
425 }
426
427 /**
428 * Bounded-depth sanitizer for token values: scalars pass (strings through
429 * sanitize_text_field), arrays/objects recurse, anything else drops.
430 *
431 * @param mixed $value
432 * @param int $depth
433 * @return mixed|null
434 */
435 private static function sanitize_scalar_tree( $value, $depth ) {
436 if ( is_string( $value ) ) {
437 return sanitize_text_field( $value );
438 }
439 if ( is_int( $value ) || is_float( $value ) || is_bool( $value ) ) {
440 return $value;
441 }
442 if ( is_array( $value ) && $depth > 0 ) {
443 $clean = [];
444 foreach ( $value as $k => $v ) {
445 $cv = self::sanitize_scalar_tree( $v, $depth - 1 );
446 if ( $cv !== null ) {
447 $clean[ is_string( $k ) ? sanitize_text_field( $k ) : $k ] = $cv;
448 }
449 }
450 return $clean;
451 }
452 return null;
453 }
454
455 /**
456 * @param mixed $target
457 * @return array|\WP_Error
458 */
459 private function validate_target( $target ) {
460 if ( ! is_array( $target ) || empty( $target['id'] ) || ! is_string( $target['id'] ) ) {
461 return $this->error( 'invalid_request', __( 'Invalid target.', 'templately' ), 'ai-editor/chat', 400 );
462 }
463 $clean = [
464 'id' => sanitize_text_field( $target['id'] ),
465 'handle' => isset( $target['handle'] ) && is_string( $target['handle'] ) ? sanitize_key( $target['handle'] ) : '',
466 ];
467 if ( isset( $target['settings'] ) ) {
468 if ( ! is_array( $target['settings'] ) ) {
469 return $this->error( 'invalid_request', __( 'Invalid target settings.', 'templately' ), 'ai-editor/chat', 400 );
470 }
471 // Structural validation only — settings are the element's own values
472 // and must reach the cloud byte-faithful (base64/HTML included).
473 $clean['settings'] = $target['settings'];
474 }
475 return $clean;
476 }
477
478 /**
479 * @param mixed $sections
480 * @return array|\WP_Error
481 */
482 private function validate_sections( $sections ) {
483 if ( ! is_array( $sections ) ) {
484 return $this->error( 'invalid_request', __( 'Invalid sections.', 'templately' ), 'ai-editor/chat', 400 );
485 }
486 $clean = [];
487 foreach ( $sections as $section ) {
488 if ( ! is_array( $section ) || empty( $section['id'] ) || ! is_string( $section['id'] ) ) {
489 return $this->error( 'invalid_request', __( 'Invalid section entry.', 'templately' ), 'ai-editor/chat', 400 );
490 }
491 $entry = [
492 'id' => sanitize_text_field( $section['id'] ),
493 'handle' => isset( $section['handle'] ) && is_string( $section['handle'] ) ? sanitize_key( $section['handle'] ) : '',
494 'label' => isset( $section['label'] ) && is_string( $section['label'] ) ? sanitize_text_field( $section['label'] ) : '',
495 'kind' => isset( $section['kind'] ) && is_string( $section['kind'] ) ? sanitize_text_field( $section['kind'] ) : '',
496 ];
497 if ( isset( $section['settings'] ) && is_array( $section['settings'] ) ) {
498 $entry['settings'] = $section['settings'];
499 }
500 $clean[] = $entry;
501 }
502 return $clean;
503 }
504
505 /**
506 * Resolve the ENABLED Templately template for a location via the theme
507 * builder's condition system (research R9).
508 *
509 * @return array{id:int,platform:string,content:mixed}|null
510 */
511 private function resolve_site_template( string $location ) {
512 $theme_builder = function_exists( 'templately' ) ? templately()->theme_builder : null;
513 if ( ! $theme_builder || empty( $theme_builder::$conditions_manager ) ) {
514 return null;
515 }
516
517 $templates = $theme_builder::$conditions_manager->get_templates_by_location( $location );
518 if ( empty( $templates ) ) {
519 return null;
520 }
521
522 $template_id = (int) array_key_first( $templates );
523 $platform = get_post_meta( $template_id, '_templately_template_platform', true );
524 $platform = $platform === 'elementor' ? 'elementor' : 'gutenberg';
525
526 if ( $platform === 'elementor' ) {
527 $data = get_post_meta( $template_id, '_elementor_data', true );
528 $content = is_string( $data ) ? json_decode( $data, true ) : $data;
529 } else {
530 $post = get_post( $template_id );
531 $content = $post ? $post->post_content : '';
532 }
533
534 return [ 'id' => $template_id, 'platform' => $platform, 'content' => $content ];
535 }
536
537 /**
538 * Store a pending site-wide proposal (1h TTL). The FULL replacement content
539 * is held server-side; the client only sees the proposal id + description
540 * (U1 spike outcome: content replacement, not per-element patches —
541 * Gutenberg templates via wp_update_post, Elementor via _elementor_data).
542 *
543 * @param array $proposal { proposal_id, location, template_id, platform, content }
544 */
545 private function store_proposal( array $proposal ) {
546 Database::set_transient( 'ai_editor_proposal_' . $proposal['proposal_id'], $proposal, HOUR_IN_SECONDS );
547 }
548
549 /**
550 * Apply or discard a held proposal (FR-014 — after the in-chat confirm).
551 *
552 * @param array $confirm { proposal_id, accepted }
553 * @return array response payload
554 */
555 private function handle_confirmation( array $confirm ) {
556 $key = 'ai_editor_proposal_' . $confirm['proposal_id'];
557 $proposal = Database::get_transient( $key );
558
559 if ( empty( $confirm['accepted'] ) ) {
560 Database::set_transient( $key, false, 1 );
561 return [
562 'success' => true,
563 'type' => 'message',
564 'reply' => __( 'Okay — I left your site-wide template unchanged.', 'templately' ),
565 ];
566 }
567
568 if ( ! is_array( $proposal ) || empty( $proposal['template_id'] ) ) {
569 return self::ai_envelope( 'invalid_process', __( 'That proposal has expired. Ask me again to make the change.', 'templately' ) );
570 }
571
572 $template_id = (int) $proposal['template_id'];
573
574 if ( $proposal['platform'] === 'elementor' ) {
575 update_post_meta( $template_id, '_elementor_data', wp_slash( wp_json_encode( $proposal['content'] ) ) );
576 if ( class_exists( '\Elementor\Plugin' ) ) {
577 // Stale generated CSS would keep rendering the old design.
578 \Elementor\Plugin::$instance->files_manager->clear_cache();
579 }
580 } else {
581 $updated = wp_update_post( [
582 'ID' => $template_id,
583 'post_content' => wp_slash( (string) $proposal['content'] ),
584 ], true );
585 if ( is_wp_error( $updated ) ) {
586 return self::ai_envelope( 'internal_error', $updated->get_error_message() );
587 }
588 }
589
590 Database::set_transient( $key, false, 1 );
591
592 return [
593 'success' => true,
594 'type' => 'message',
595 'site_wide' => true,
596 'template_id' => $template_id,
597 'reply' => sprintf(
598 /* translators: %s: "header" or "footer" */
599 __( 'Done — your site-wide %s was updated. This affects every page (reload the editor to see it here); it isn\'t covered by this page\'s undo.', 'templately' ),
600 $proposal['location'] === 'footer' ? __( 'footer', 'templately' ) : __( 'header', 'templately' )
601 ),
602 ];
603 }
604
605 /**
606 * Normalize a cloud response into our REST response, guarding the contract
607 * version (defensive, cheap — contract §cloud).
608 *
609 * @param mixed $response
610 */
611 private function relay_cloud_response( $response ) {
612 if ( is_wp_error( $response ) ) {
613 return $this->success( self::ai_envelope( 'remote_failed', $response->get_error_message() ) );
614 }
615
616 // Helper::make_api_*_request returns the RAW wp_remote array — the cloud
617 // payload is the JSON body (which may be an envelope error on non-2xx).
618 $raw_body = wp_remote_retrieve_body( $response );
619 $status = (int) wp_remote_retrieve_response_code( $response );
620
621 // Dev diagnostic: the EXACT server response, paired with the
622 // "ai-editor-request" dump above (same gate) so a turn's in/out sit
623 // together in debug.log.
624 if ( apply_filters( 'templately_ai_editor_log_payload', true ) ) {
625 Helper::log( 'status=' . $status . ' bytes=' . strlen( $raw_body ) . ' body=' . $raw_body, 'ai-editor-response' );
626 }
627
628 /*
629 * A non-2xx upstream is a FAILURE, whatever its body happens to contain.
630 *
631 * Without this check a 405/500/502 sails straight through: the body is
632 * still valid JSON and still decodes to an array, so it was handed to
633 * `success()` and the browser received an upstream error dressed as a
634 * successful turn. Three things went wrong as a result — the panel showed
635 * a permanently blank assistant bubble (there is no `reply` key to read),
636 * a Laravel stack trace with absolute server paths crossed the wire, and
637 * RestEnvelope tripped over the shape and emitted PHP warnings on every
638 * turn. Observed live as a 502 during an editor probe run, and earlier as
639 * a 405 when the cloud route briefly stopped accepting POST.
640 */
641 if ( $status < 200 || $status >= 300 ) {
642 Helper::log( 'upstream ' . $status . ': ' . substr( $raw_body, 0, 500 ), 'ai-editor-response', 'error' );
643
644 return $this->success( self::ai_envelope(
645 'remote_failed',
646 __( 'The Templately AI service is unavailable right now. Please try again in a moment.', 'templately' )
647 ) );
648 }
649
650 $response = json_decode( $raw_body, true );
651
652 if ( ! is_array( $response ) ) {
653 return $this->success( self::ai_envelope( 'internal_error', __( 'Unexpected response from the Templately app.', 'templately' ) ) );
654 }
655
656 if ( isset( $response['contract'] ) && (int) $response['contract'] > self::CONTRACT_VERSION ) {
657 return $this->success( self::ai_envelope( 'remote_failed', __( 'This version of Templately is too old for the AI editor service. Please update the plugin.', 'templately' ) ) );
658 }
659
660 // A cloud confirmation_request carries the full proposed content — hold
661 // it server-side and strip it from what the browser sees (FR-014).
662 // Store only COMPLETE proposals: an empty content applied on confirm
663 // would blank the site-wide template.
664 if ( ( $response['type'] ?? '' ) === 'confirmation_request' && ! empty( $response['proposal']['proposal_id'] ) ) {
665 $template_id = absint( $response['proposal']['template_id'] ?? 0 );
666 $content = $response['proposal']['content'] ?? null;
667 if ( $template_id > 0 && ! empty( $content ) ) {
668 $this->store_proposal( [
669 'proposal_id' => sanitize_key( (string) $response['proposal']['proposal_id'] ),
670 'location' => ( $response['proposal']['location'] ?? '' ) === 'footer' ? 'footer' : 'header',
671 'template_id' => $template_id,
672 'platform' => ( $response['proposal']['platform'] ?? '' ) === 'elementor' ? 'elementor' : 'gutenberg',
673 'content' => $content,
674 ] );
675 }
676 unset( $response['proposal']['content'] );
677 }
678
679 return $this->success( $response );
680 }
681
682 /**
683 * Developer-mode mock (research R11) — canned, deterministic turns so the
684 * whole plugin-side flow (validation, apply, undo, E2E) runs without the
685 * cloud endpoint. Never active in production.
686 */
687 private function is_mock_mode(): bool {
688 $dev_mode = defined( 'TEMPLATELY_DEVELOPER_MODE' ) && TEMPLATELY_DEVELOPER_MODE;
689 return (bool) apply_filters( 'templately_ai_editor_mock', $dev_mode );
690 }
691
692 /**
693 * @param array $payload the sanitized chat payload
694 * @param array|null $site_target resolved header/footer template (when targeted)
695 */
696 private function mock_response( array $payload, $site_target = null ): array {
697 $message = strtolower( $payload['message'] );
698 $base = [
699 'success' => true,
700 'conversation_id' => 'mock-conversation',
701 'contract' => self::CONTRACT_VERSION,
702 ];
703
704 // Site-wide target → confirm-first proposal (FR-014). The mock's "edit"
705 // appends a deterministic CTA paragraph to Gutenberg-platform templates.
706 if ( $site_target ) {
707 $proposal_id = 'mock-' . substr( md5( $site_target['id'] . wp_json_encode( $site_target['content'] ) ), 0, 12 );
708 $content = $site_target['content'];
709 if ( $site_target['platform'] === 'gutenberg' ) {
710 $content .= "\n<!-- wp:paragraph --><p>Get started</p><!-- /wp:paragraph -->";
711 }
712 $this->store_proposal( [
713 'proposal_id' => $proposal_id,
714 'location' => $payload['target']['handle'],
715 'template_id' => $site_target['id'],
716 'platform' => $site_target['platform'],
717 'content' => $content,
718 ] );
719 return $base + [
720 'type' => 'confirmation_request',
721 'reply' => sprintf(
722 /* translators: %s: "header" or "footer" */
723 __( 'This updates your site-wide %s (every page, not just this one). Apply it?', 'templately' ),
724 $payload['target']['handle']
725 ),
726 'proposal' => [
727 'proposal_id' => $proposal_id,
728 'location' => $payload['target']['handle'],
729 'template_id' => $site_target['id'],
730 ],
731 'credits' => [ 'cost' => 1, 'remaining' => 149 ],
732 ];
733 }
734
735 if ( strpos( $message, 'credits' ) !== false ) {
736 // insufficient_credits is a cloud-domain code outside the ai_envelope
737 // taxonomy — same shape, built directly (contract §errors).
738 return [
739 'success' => false,
740 'terminal' => true,
741 'code' => 'insufficient_credits',
742 'message' => __( "You're out of credits. Top up to keep editing with AI.", 'templately' ),
743 ];
744 }
745
746 if ( strpos( $message, 'malformed' ) !== false ) {
747 // Deliberately stale target — exercises the client reject path (FR-012).
748 return $base + [
749 'type' => 'edit',
750 'reply' => __( 'Applying a change…', 'templately' ),
751 'changes' => [ [ 'id' => 'mock-nonexistent-element', 'attributes' => [ 'content' => 'x' ] ] ],
752 'credits' => [ 'cost' => 1, 'remaining' => 149 ],
753 ];
754 }
755
756 if ( preg_match( '/\badd\b|\bneed\b.*\b(section|block)\b/', $message ) ) {
757 // Real-shaped dependencies so /dependencies/check enriches them with
758 // is_active: a free one (installed on most stacks → ✓) + a Pro one
759 // (absent → "Skipped (Pro)"). Exercises the install→import checklist
760 // without forcing a real install in E2E.
761 $deps = [
762 [
763 'id' => 1,
764 'name' => 'Essential Blocks',
765 'plugin_file' => 'essential-blocks/essential-blocks.php',
766 'plugin_original_slug' => 'essential-blocks',
767 'is_pro' => false,
768 'link' => 'https://wordpress.org/plugins/essential-blocks/',
769 ],
770 [
771 'id' => 2,
772 'name' => 'Essential Blocks Pro',
773 'plugin_file' => 'essential-blocks-pro/essential-blocks-pro.php',
774 'plugin_original_slug' => 'essential-blocks-pro',
775 'is_pro' => true,
776 'link' => 'https://essential-blocks.com/upgrade/',
777 ],
778 ];
779
780 // insert.anchor_id is an id we "sent" this turn — the pinned/selected
781 // target when there is one, else null (the user hasn't said where; §4).
782 $anchor_id = $payload['target']['id'] ?? null;
783 $insert = $anchor_id ? [ 'anchor_id' => $anchor_id, 'placement' => 'after' ] : null;
784
785 return $base + [
786 'type' => 'library_results',
787 'reply' => __( 'Here are some matching sections from the Templately library:', 'templately' ),
788 'insert' => $insert,
789 'items' => [
790 [ 'id' => 990001, 'name' => 'Testimonials 01', 'type' => 'block', 'price' => 0, 'is_pro' => false, 'thumbnail' => 'https://placehold.co/300x200', 'preview_url' => 'https://placehold.co/1200x800', 'dependencies' => $deps ],
791 [ 'id' => 990002, 'name' => 'Testimonials 02 (Pro)', 'type' => 'block', 'price' => 39, 'is_pro' => true, 'thumbnail' => 'https://placehold.co/300x200', 'preview_url' => 'https://placehold.co/1200x800', 'buy_url' => 'https://templately.com/#pricing', 'dependencies' => [] ],
792 [ 'id' => 990003, 'name' => 'FAQ 01', 'type' => 'block', 'price' => 0, 'is_pro' => false, 'thumbnail' => 'https://placehold.co/300x200', 'dependencies' => [] ],
793 ],
794 'credits' => [ 'cost' => 1, 'remaining' => 149 ],
795 ];
796 }
797
798 // `set <key> to "<value>"` → a change for the first submitted element that
799 // carries <key>. Drives the T039 coverage matrices: the E2E picks a REAL
800 // attribute per block/widget type and asserts the full pipeline
801 // (serialize → validate → apply → undo) without per-type mock knowledge.
802 if ( preg_match( '/set ([a-zA-Z0-9_]+) to "([^"]*)"/', $payload['message'], $m ) ) {
803 $element_id = $this->mock_find_element_with_key( $payload, $m[1] );
804 if ( $element_id ) {
805 return $base + [
806 'type' => 'edit',
807 'reply' => __( 'Done — I updated it.', 'templately' ),
808 'changes' => [ [ 'id' => $element_id, 'attributes' => [ $m[1] => $m[2] ] ] ],
809 'credits' => [ 'cost' => 1, 'remaining' => 149 ],
810 ];
811 }
812 return $base + [
813 'type' => 'message',
814 'reply' => __( "I couldn't find that setting on the targeted section.", 'templately' ),
815 ];
816 }
817
818 // "reorder"/"move" → a STRUCTURAL move (contracts/move-operation.md).
819 // Emits a moves-only turn: reordering is not an attribute write, and the
820 // client must accept a change-set with no `changes` at all.
821 if ( strpos( $message, 'reorder' ) !== false || strpos( $message, 'move ' ) !== false ) {
822 $ids = $this->mock_movable_children( $payload );
823 if ( count( $ids ) >= 2 ) {
824 return $base + [
825 'type' => 'edit',
826 'reply' => __( 'Moved it into place.', 'templately' ),
827 // Last child to the front — one move, not a two-write swap.
828 'moves' => [ [ 'id' => end( $ids ), 'index' => 0 ] ],
829 'credits' => [ 'cost' => 1, 'remaining' => 149 ],
830 ];
831 }
832 return $base + [
833 'type' => 'message',
834 'reply' => __( 'There is nothing to reorder in that section.', 'templately' ),
835 ];
836 }
837
838 // "rewrite" → a MULTI-element change-set (drives the single-undo E2E: one
839 // undo must revert every patched field at once — research R3).
840 $limit = strpos( $message, 'rewrite' ) !== false ? 2 : 1;
841 $editables = $this->mock_find_editables( $payload, $limit );
842 if ( ! empty( $editables ) ) {
843 $targeted = ! empty( $payload['target'] );
844 $changes = [];
845 foreach ( $editables as $i => $editable ) {
846 $changes[] = [
847 'id' => $editable['id'],
848 'attributes' => [
849 $editable['key'] => $i === 0
850 ? __( 'Shorter, punchier headline', 'templately' )
851 : __( 'Rewritten supporting copy.', 'templately' ),
852 ],
853 ];
854 }
855 return $base + [
856 'type' => 'edit',
857 'reply' => __( 'Done — I updated the text.', 'templately' ),
858 'changes' => $changes,
859 'credits' => [ 'cost' => $targeted ? 1 : 2, 'remaining' => $targeted ? 149 : 148 ],
860 ];
861 }
862
863 return $base + [
864 'type' => 'message',
865 'reply' => __( 'Which section do you mean? Try @-mentioning one.', 'templately' ),
866 'credits' => [ 'cost' => 1, 'remaining' => 149 ],
867 ];
868 }
869
870 /**
871 * Find up to $limit string text-ish attributes in the submitted settings so
872 * the mock's change-set survives real client-side validation.
873 *
874 * @return array<int, array{id:string,key:string}>
875 */
876 private function mock_find_editables( array $payload, int $limit = 1 ): array {
877 $pools = [];
878 if ( ! empty( $payload['target']['settings'] ) ) {
879 $pools[] = $payload['target']['settings'];
880 }
881 foreach ( $payload['sections'] as $section ) {
882 if ( ! empty( $section['settings'] ) ) {
883 $pools[] = $section['settings'];
884 }
885 }
886
887 $found = [];
888 foreach ( $pools as $pool ) {
889 // Gutenberg roots at `blocks`, Elementor at `elements`.
890 $blocks = $pool['blocks'] ?? $pool['elements'] ?? ( is_array( $pool ) ? $pool : [] );
891 $this->mock_scan_blocks( $blocks, $limit, $found );
892 if ( count( $found ) >= $limit ) {
893 break;
894 }
895 }
896 return $found;
897 }
898
899 /**
900 * Ids of the deepest sibling group in the submitted settings — the set a
901 * reorder can actually act on. Walks to the first node with 2+ children so
902 * the mock moves real siblings (a move between different parents would be a
903 * different, unrelated assertion).
904 *
905 * @return array<int, string>
906 */
907 private function mock_movable_children( array $payload ): array {
908 $pool = $payload['target']['settings'] ?? null;
909 if ( ! $pool && ! empty( $payload['sections'][0]['settings'] ) ) {
910 $pool = $payload['sections'][0]['settings'];
911 }
912 if ( ! is_array( $pool ) ) {
913 return [];
914 }
915 $nodes = $pool['blocks'] ?? $pool['elements'] ?? [];
916 while ( is_array( $nodes ) ) {
917 $ids = [];
918 foreach ( $nodes as $node ) {
919 $id = $node['id'] ?? $node['clientId'] ?? null;
920 if ( is_string( $id ) && '' !== $id ) {
921 $ids[] = $id;
922 }
923 }
924 if ( count( $ids ) >= 2 ) {
925 return $ids;
926 }
927 $first = $nodes[0] ?? null;
928 if ( ! is_array( $first ) ) {
929 return $ids;
930 }
931 $nodes = $first['elements'] ?? $first['innerBlocks'] ?? null;
932 }
933 return [];
934 }
935
936 /**
937 * First submitted element (target pool first) carrying $key in its
938 * attributes/settings — the T039 matrix locator.
939 *
940 * @return string|null element id
941 */
942 private function mock_find_element_with_key( array $payload, string $key ) {
943 $pools = [];
944 if ( ! empty( $payload['target']['settings'] ) ) {
945 $pools[] = $payload['target']['settings'];
946 }
947 foreach ( $payload['sections'] as $section ) {
948 if ( ! empty( $section['settings'] ) ) {
949 $pools[] = $section['settings'];
950 }
951 }
952 foreach ( $pools as $pool ) {
953 $blocks = $pool['blocks'] ?? $pool['elements'] ?? ( is_array( $pool ) ? $pool : [] );
954 $found = $this->mock_scan_for_key( $blocks, $key );
955 if ( $found ) {
956 return $found;
957 }
958 }
959 return null;
960 }
961
962 /**
963 * @param mixed $blocks
964 * @return string|null
965 */
966 private function mock_scan_for_key( $blocks, string $key ) {
967 if ( ! is_array( $blocks ) ) {
968 return null;
969 }
970 foreach ( $blocks as $block ) {
971 if ( ! is_array( $block ) ) {
972 continue;
973 }
974 $id = $block['clientId'] ?? $block['id'] ?? null;
975 $attributes = $block['attributes'] ?? $block['settings'] ?? [];
976 if ( $id && is_array( $attributes ) && array_key_exists( $key, $attributes ) ) {
977 return (string) $id;
978 }
979 $found = $this->mock_scan_for_key( $block['innerBlocks'] ?? $block['elements'] ?? null, $key );
980 if ( $found ) {
981 return $found;
982 }
983 }
984 return null;
985 }
986
987 /**
988 * @param mixed $blocks
989 * @param int $limit
990 * @param array $found accumulator of {id, key} (unique per element)
991 */
992 private function mock_scan_blocks( $blocks, int $limit, array &$found ) {
993 if ( ! is_array( $blocks ) || count( $found ) >= $limit ) {
994 return;
995 }
996 foreach ( $blocks as $block ) {
997 if ( count( $found ) >= $limit ) {
998 return;
999 }
1000 if ( ! is_array( $block ) ) {
1001 continue;
1002 }
1003 $id = $block['clientId'] ?? $block['id'] ?? null;
1004 $attributes = $block['attributes'] ?? $block['settings'] ?? [];
1005 if ( $id && is_array( $attributes ) ) {
1006 foreach ( [ 'content', 'text', 'title', 'label', 'editor' ] as $key ) {
1007 if ( isset( $attributes[ $key ] ) && is_string( $attributes[ $key ] ) ) {
1008 $found[] = [ 'id' => (string) $id, 'key' => $key ];
1009 break;
1010 }
1011 }
1012 }
1013 $this->mock_scan_blocks( $block['innerBlocks'] ?? $block['elements'] ?? null, $limit, $found );
1014 }
1015 }
1016 }
1017