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 / full-site-import / Utils / Providers / ChatAIContentProvider.php

ChatAIContentProvider.php in Templately – Elementor & Gutenberg Template Library: 6500+ Free & Pro Ready Templates And Cloud! 3.8.0, at modules/full-site-import/Utils/Providers/ChatAIContentProvider.php

284 lines 12.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\FullSiteImport\Utils\Providers;
4
5 use Templately\Modules\FullSiteImport\Utils\AIContentResolver;
6 use Templately\Modules\FullSiteImport\Utils\AIUtils;
7 use Templately\Utils\Helper;
8
9 /**
10 * Built-in AI content provider for the CHAT-ID generation flow (Phase 2 thin
11 * import — content generated on the backend and pulled by conversation uuid).
12 *
13 * Unlike the classic flow there is no per-page callback: the content lives on
14 * the backend and must be pulled. This provider pulls the ready pages via
15 * `v2/chatbot/generated/{chat}` and writes each to its `.ai.json` using the same
16 * {@see AIUtils::save_template_to_file()} the classic callback uses — so the
17 * wait/merge downstream is byte-for-byte the classic path.
18 *
19 * The wait now happens at the END (inside the Finalizer, through the resolver)
20 * instead of up front: the import starts immediately and each page is pulled
21 * on demand as finalize reaches it, mirroring the classic AI flow.
22 *
23 * A process is owned by this provider when its stored process data carries a
24 * `chat_id` (set when the chat import registers its process). Registered on
25 * {@see AIContentResolver::STAGE_HOOK}.
26 */
27 class ChatAIContentProvider {
28
29 /**
30 * Backend `App\Enums\ChatbotGeneratedReadiness::READY` — generation for the
31 * conversation has finished (PENDING = 0).
32 */
33 const READINESS_READY = 1;
34
35 /**
36 * @param bool $staged True if a previous provider already produced the file.
37 * @param array $context Resolver context (see AIContentResolver::ensure_page_ready).
38 * @return bool
39 */
40 public static function stage( $staged, array $context ) {
41 if ( $staged === true ) {
42 return true;
43 }
44
45 $process_data = $context['process_data'] ?? [];
46 $chat_id = $process_data['chat_id'] ?? '';
47
48 // Not a chat-sourced import — let another provider handle it.
49 if ( empty( $chat_id ) ) {
50 return $staged;
51 }
52
53 $session_id = $context['session_id'] ?? '';
54 $process_id = $context['process_id'] ?? null;
55 $ai_page_ids = $context['ai_page_ids'] ?? [];
56
57 if ( empty( $process_id ) || empty( $session_id ) || empty( $ai_page_ids ) ) {
58 return $staged;
59 }
60
61 // The api_key `chatbot_import_prepare` banked on the process data. It is
62 // REQUIRED here: prepare runs as a REST call authenticated by the browser's
63 // `X-Templately-Apikey` header (API\AIContent overrides _permission_check),
64 // but the Finalizer runs in the admin-ajax/SSE import where no such header
65 // exists and `Options::get('api_key')` can be empty or user-scoped to a
66 // different user. Without it every pull here comes back 401 and the wait
67 // spins to its timeout.
68 $api_key = $process_data['api_key'] ?? '';
69
70 self::pull_generated( $chat_id, $process_id, $session_id, $ai_page_ids, $api_key );
71
72 return AIContentResolver::page_staged( $context ) ? true : $staged;
73 }
74
75 /**
76 * Run a callback with the Templately API Bearer forced to `$api_key`.
77 *
78 * `Helper::make_api_request()` reads `Options::get('api_key')` for the
79 * Authorization header, which is not reliably populated in the import
80 * context — so override it through the same `templately_api_request_params`
81 * filter the request already applies, and always unhook afterwards.
82 *
83 * @param string $api_key Key to authenticate with (no-op when empty).
84 * @param callable $callback Receives no arguments; its return value is returned.
85 * @return mixed
86 */
87 private static function with_api_key( $api_key, callable $callback ) {
88 if ( empty( $api_key ) ) {
89 return $callback();
90 }
91
92 $override = function ( $args ) use ( $api_key ) {
93 $args['headers']['Authorization'] = 'Bearer ' . $api_key;
94 return $args;
95 };
96
97 add_filter( 'templately_api_request_params', $override, 999 );
98 try {
99 return $callback();
100 } finally {
101 remove_filter( 'templately_api_request_params', $override, 999 );
102 }
103 }
104
105 /**
106 * Pull every currently-generated page for a conversation and write each to
107 * its `.ai.json` (plus explicit skip markers), recording credits consumed.
108 *
109 * Idempotent and safe to call on each finalize wait tick: re-writing an
110 * already-present page is harmless, and pages still generating simply have
111 * not appeared yet. This is the chat twin of
112 * {@see AIUtils::poll_for_template()}.
113 *
114 * @param string $chat_id Conversation uuid.
115 * @param string $process_id AI process id (keys processed-pages data).
116 * @param string $session_id Import session id.
117 * @param array $ai_page_ids `type/sub_type => [content_id,...]` map.
118 * @return bool True when the pull succeeded (regardless of how many pages were ready).
119 */
120 public static function pull_generated( $chat_id, $process_id, $session_id, $ai_page_ids, $api_key = '' ): bool {
121 $response = self::with_api_key( $api_key, function () use ( $chat_id ) {
122 return Helper::make_api_get_request( "v2/chatbot/generated/{$chat_id}", [], [ 'Accept' => 'application/json' ], 30 );
123 } );
124
125 if ( is_wp_error( $response ) ) {
126 Helper::log( sprintf( 'chat_provider[%s] pull failed: %s', $chat_id, $response->get_error_message() ), 'ai-import', 'error' );
127 return false;
128 }
129
130 $response_code = wp_remote_retrieve_response_code( $response );
131 if ( $response_code !== 200 ) {
132 Helper::log( sprintf( 'chat_provider[%s] pull HTTP %d', $chat_id, $response_code ), 'ai-import', 'error' );
133 return false;
134 }
135
136 $data = json_decode( wp_remote_retrieve_body( $response ), true );
137
138 // The chatbot API signals application-level failure INSIDE an HTTP 200 as
139 // `{status:'error', message}` — the same contract chatbot_request() in
140 // ai-fsi's REST\AIContent enforces. This call site cannot use that seam
141 // (different module, and the Finalizer context needs the raw-transport
142 // with_api_key override), so the rule is enforced inline: an error body is
143 // a FAILED pull, never "0 pages ready". Without this the error fell
144 // through to the normal wrote=0 info line and the wait ran to its 420s
145 // timeout with nothing in the log naming the actual cause.
146 if ( ! is_array( $data ) || 'error' === ( $data['status'] ?? '' ) ) {
147 $message = is_array( $data ) && ! empty( $data['message'] ) ? $data['message'] : 'invalid response body';
148 Helper::log( sprintf( 'chat_provider[%s] pull rejected: %s', $chat_id, $message ), 'ai-import', 'error' );
149 return false;
150 }
151
152 $generated = ( isset( $data['data'] ) && is_array( $data['data'] ) ) ? $data['data'] : [];
153
154 // Access gate — mirrors the chatbot-import-prepare endpoint. A free user
155 // past their 7-day window must not have content pulled mid-import.
156 if ( isset( $generated['can_import'] ) && ! $generated['can_import'] ) {
157 Helper::log( sprintf( 'chat_provider[%s] can_import=false, skipping pull', $chat_id ), 'ai-import', 'error' );
158 return false;
159 }
160
161 $templates = ( isset( $generated['templates'] ) && is_array( $generated['templates'] ) ) ? $generated['templates'] : [];
162 $skipped = ( isset( $generated['skipped_pages'] ) && is_array( $generated['skipped_pages'] ) ) ? array_map( 'strval', $generated['skipped_pages'] ) : [];
163
164 // Write each newly-generated page to its .ai.json location for the runners.
165 // Pages already on disk are skipped — this runs on every finalize tick and
166 // re-serialising the whole bundle each time is pure waste.
167 $written = 0;
168 foreach ( $templates as $content_id => $template ) {
169 if ( empty( $template ) ) {
170 continue;
171 }
172 if ( self::page_file_exists( $session_id, $content_id, $ai_page_ids ) ) {
173 continue;
174 }
175 // The runners read JSON strings; normalize arrays/objects to a string.
176 $payload = is_string( $template ) ? $template : wp_json_encode( $template );
177 AIUtils::save_template_to_file( $process_id, $session_id, $content_id, $payload, $ai_page_ids, false );
178 $written++;
179 }
180
181 // Backend-skipped pages → explicit `{"isSkipped":true}` markers so finalize
182 // falls back to the pack's default content instead of waiting forever.
183 foreach ( $skipped as $skipped_id ) {
184 if ( array_key_exists( $skipped_id, $templates ) || array_key_exists( (int) $skipped_id, $templates ) ) {
185 continue;
186 }
187 if ( self::page_file_exists( $session_id, $skipped_id, $ai_page_ids ) ) {
188 continue;
189 }
190 AIUtils::save_template_to_file( $process_id, $session_id, $skipped_id, '', $ai_page_ids, true );
191 $written++;
192 }
193
194 $expected = AIUtils::flatten_ai_page_ids( $ai_page_ids );
195 $missing = array_values( array_filter( $expected, function ( $id ) use ( $templates, $skipped ) {
196 return ! array_key_exists( $id, $templates )
197 && ! array_key_exists( (int) $id, $templates )
198 && ! in_array( (string) $id, $skipped, true );
199 } ) );
200
201 // Generation is FINISHED but some expected pages were never delivered and
202 // were never listed in `skipped_pages` either — the backend silently did
203 // not produce them (seen live: a pack's "Product Roadmap" page absent from
204 // a `status: READY` bundle with `skipped_pages: []`).
205 //
206 // Nothing will ever arrive for these, so treat them as skipped and write
207 // the marker now. Without this the Finalizer waits the full 420s on each
208 // one before falling back to the pack's default content anyway — a
209 // multi-minute stall with a frozen progress bar for an identical result.
210 if ( ! empty( $missing ) && self::is_generation_finished( $generated ) ) {
211 foreach ( $missing as $undelivered_id ) {
212 if ( self::page_file_exists( $session_id, $undelivered_id, $ai_page_ids ) ) {
213 continue;
214 }
215 AIUtils::save_template_to_file( $process_id, $session_id, $undelivered_id, '', $ai_page_ids, true );
216 $written++;
217 }
218 Helper::log( sprintf(
219 'chat_provider[%s] generation finished but %d page(s) never delivered (%s) — marked skipped, importing pack defaults',
220 $chat_id, count( $missing ), implode( ',', $missing )
221 ), 'ai-import', 'error' );
222 $missing = [];
223 }
224
225 // Record credits consumed ONLY once generation is genuinely complete.
226 //
227 // `credit_cost` is the pipeline's "generation finished, stop waiting"
228 // signal: its mere presence makes handle_sse_wait_with_timeout() return
229 // true immediately. The backend reports `credits_consumed` incrementally,
230 // so writing it on the first pull would disable waiting for the entire
231 // import and every still-generating page would fall back to pack defaults.
232 $credit_cost = $generated['credits_consumed'] ?? ( $generated['credit_cost'] ?? 0 );
233 if ( empty( $missing ) && $credit_cost > 0 ) {
234 $processed_pages = get_option( 'templately_ai_processed_pages', [] );
235 $processed_pages[ $process_id ] = $processed_pages[ $process_id ] ?? [];
236 $processed_pages[ $process_id ]['credit_cost'] = $credit_cost;
237 update_option( 'templately_ai_processed_pages', $processed_pages, false );
238 }
239
240 Helper::log( sprintf(
241 'chat_provider[%s] pull: wrote=%d ready=%d/%d missing=%d skipped=%d',
242 $chat_id, $written, count( $expected ) - count( $missing ), count( $expected ), count( $missing ), count( $skipped )
243 ), 'ai-import', 'info' );
244
245 return true;
246 }
247
248 /**
249 * Whether the backend considers generation for this conversation FINISHED.
250 *
251 * Mirrors the frontend's `GENERATED_READINESS_READY` (see
252 * AiContentSidebar/AIConversation.js — backend enum
253 * `App\Enums\ChatbotGeneratedReadiness`: PENDING = 0, READY = 1). Once READY,
254 * a page absent from both `templates` and `skipped_pages` is never coming.
255 *
256 * Deliberately strict: an unknown/missing `status` is treated as NOT finished,
257 * so an unrecognised payload makes us keep waiting rather than prematurely
258 * degrade pages to pack default content.
259 *
260 * @param array $generated The bundle's `data` payload.
261 * @return bool
262 */
263 private static function is_generation_finished( array $generated ): bool {
264 return isset( $generated['status'] ) && (int) $generated['status'] === self::READINESS_READY;
265 }
266
267 /**
268 * Whether a page's `.ai.json` is already on disk, so the pull can skip
269 * rewriting it. Thin wrapper over the resolver's staged check.
270 *
271 * @param string $session_id
272 * @param string|int $content_id
273 * @param array $ai_page_ids
274 * @return bool
275 */
276 private static function page_file_exists( $session_id, $content_id, $ai_page_ids ): bool {
277 return AIContentResolver::page_staged( [
278 'session_id' => $session_id,
279 'content_id' => $content_id,
280 'ai_page_ids' => $ai_page_ids,
281 ] );
282 }
283 }
284