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 | app/Agent/Admin.php +195 -91 3.0.5 → trunk View file →
@@ -9,16 +9,20 @@
9 9 defined('ABSPATH') || die('No direct access.');
10 10
11 11 use Extendify\Agent\Controllers\ChatHistoryController;
12 12 use Extendify\Agent\Controllers\TourController;
13 -use Extendify\Agent\Controllers\WorkflowHistoryController;
14 13 use Extendify\Config;
14 +use Extendify\Constants;
15 15 use Extendify\Shared\Services\Escaper;
16 +use Extendify\Shared\Services\HttpClient;
16 17 use Extendify\Agent\TagBlocks;
17 18 use Extendify\Agent\TagTemplateParts;
19 +use Extendify\Agent\WooProductImages;
20 +use Extendify\Agent\AbilitiesDiscovery;
18 21 use Extendify\Agent\Controllers\SiteNavigationController;
19 22 use Extendify\PartnerData;
20 23 use Extendify\Shared\DataProvider\ProductsData;
24 +use Extendify\SiteVisibility;
21 25
22 26 /**
23 27 * This class handles any file loading for the admin area.
24 28 */
@@ -33,9 +37,8 @@
33 37 {
34 38 \add_action('admin_enqueue_scripts', [$this, 'loadScriptsAndStyles']);
35 39 \add_action('wp_enqueue_scripts', [$this, 'loadScriptsAndStyles']);
36 40 ChatHistoryController::init();
37 - WorkflowHistoryController::init();
38 41
39 42 // Tag blocks so we can identify them later
40 43 TagBlocks::init();
41 44 TagTemplateParts::init();
@@ -41,11 +44,31 @@
41 44 TagTemplateParts::init();
42 45
43 46 // Add the site navigation ids to the navigation blocks
44 47 SiteNavigationController::init();
48 +
49 + Skeleton::init();
50 +
51 + WooProductImages::init();
52 +
53 + \add_action('extendify_agent_suggestions_refresh', [$this, 'refreshSuggestions']);
45 54 }
46 55
47 56 /**
57 + * Where the agent panel mounts — docked-left only on the onboarding front end.
58 + *
59 + * @return string
60 + */
61 + public static function agentPosition()
62 + {
63 + $agentOnboarding = PartnerData::setting('useAgentOnboarding') ||
64 + Config::preview('agent-onboarding') ||
65 + constant('EXTENDIFY_DEVMODE');
66 +
67 + return $agentOnboarding && !is_admin() ? 'docked-left' : 'floating';
68 + }
69 +
70 + /**
48 71 * Adds various JS scripts and styles
49 72 *
50 73 * @return void
51 74 */
@@ -50,8 +73,14 @@
50 73 * @return void
51 74 */
52 75 public function loadScriptsAndStyles()
53 76 {
77 + // The Customizer preview iframe is a front-end render, so this fires
78 + // there too — but the Agent only belongs on the live, top-level page.
79 + if (is_customize_preview()) {
80 + return;
81 + }
82 +
54 83 $version = constant('EXTENDIFY_DEVMODE') ? uniqid() : Config::$version;
55 84 $scriptAssetPath = EXTENDIFY_PATH . 'public/build/' . Config::$assetManifest['extendify-agent.php'];
56 85 $fallback = [
57 86 'dependencies' => [],
@@ -90,14 +119,13 @@
90 119 (bool) use_block_editor_for_post($this->getCurrentPostId()) :
91 120 false,
92 121 'isOnEditorOrFSE' => $this->isGutenbergOrFse(),
93 122 'activePlugins' => array_values(\get_option('active_plugins', [])),
94 - // Whether the user is using the vibes experience or not.
95 - 'isUsingVibes' => (bool) file_exists(EXTENDIFY_PATH . 'src/Launch/_data/block-style-variations.json') &&
96 - version_compare(wp_get_theme("extendable")->get('Version'), '2.0.32', '>='),
123 + 'isUsingVibes' => version_compare((string) wp_get_theme('extendable')->get('Version'), '2.0.32', '>='),
97 124 'siteTitle' => \esc_attr(\get_bloginfo('name')),
98 125 'siteDescription' => \esc_attr(\get_bloginfo('description')),
99 126 'themePresets' => $this->getThemePresets(),
127 + 'presetSlugs' => $this->getPresetSlugs(),
100 128 ];
101 129 $recommendations = ProductsData::get() ?? [];
102 130 $pluginRecommendations = array_filter($recommendations, function ($item) {
103 131 return in_array('ai-agent', $item['slots'] ?? [], true) && $item['ctaType'] === 'plugin';
@@ -112,8 +140,10 @@
112 140 }, $pluginRecommendations));
113 141 $agentContext = [
114 142 'availableAdminPages' => get_option('_transient_extendify_admin_pages_menu', []),
115 143 'pluginRecommendations' => $mappedPluginRecommendations,
144 + 'sitePublished' => SiteVisibility::isPublished(),
145 + 'comingSoonEnabled' => (bool) PartnerData::setting('useComingSoon'),
116 146 ];
117 147 $abilities = [
118 148 'canEditPost' => (bool) \current_user_can('edit_post', \get_queried_object_id()),
119 149 // TODO: this may be true for a user, while they still can't edit every post
@@ -136,24 +166,33 @@
136 166 Config::$slug . '-agent-scripts',
137 167 'window.extAgentData = ' . \wp_json_encode([
138 168 // phpcs:ignore WordPress.Security.NonceVerification.Recommended
139 169 'startOnboarding' => isset($_GET['extendify-launch-success']) && $agentOnboarding,
140 - 'agentPosition' => $agentOnboarding && !is_admin() ? 'docked-left' : 'floating',
170 + 'agentPosition' => self::agentPosition(),
141 171 // Add context about where they are
142 172 'context' => $context,
143 - // Context that the Agent might need when returning a response,
144 - // but not for handling the workflow.
173 + // Material a workflow may want; each declares the keys it reads.
145 174 'agentContext' => $agentContext,
146 175 // List of abilities the AI can perform for this user.
147 176 // For example, we could check whether their theme has variations.
148 177 'abilities' => $abilities,
178 + // Registered WordPress Abilities (6.9+) this user may run.
179 + 'wpAbilities' => AbilitiesDiscovery::discover(),
149 180 // List of suggestions the AI can make for this user.
150 181 // For example, we could check whether they need to set up a specific plugin.
151 182 'suggestions' => $this->getSuggestions(),
183 + 'domainsSuggestionSettings' => [
184 + 'showPrimary' => (bool) PartnerData::setting('showPrimaryDomainRecommendationAgent'),
185 + 'showSecondary' => (bool) PartnerData::setting('showSecondaryDomainRecommendationAgent'),
186 + 'stagingSites' => PartnerData::setting('stagingSites'),
187 + 'searchUrl' => PartnerData::setting('domainSearchURL'),
188 + ],
152 189 'chatHistory' => ChatHistoryController::getChatHistory(),
153 - 'workflowHistory' => WorkflowHistoryController::getWorkflowHistory(),
154 190 'userData' => [
155 191 'tourData' => \wp_json_encode(TourController::get()->get_data()),
192 + 'domainsRecommendationsActivities' => \wp_json_encode(
193 + \get_option('extendify_domains_recommendations_activities', null)
194 + ),
156 195 ],
157 196 ]),
158 197 'before'
159 198 );
@@ -258,8 +297,85 @@
258 297 'colorPairs' => $colorPairs,
259 298 ];
260 299 }
261 300
301 + /**
302 + * Preset slugs per design-token family across every origin. Feeds the Agent's
303 + * block-attribute schema so the model picks named tokens, not raw CSS.
304 + *
305 + * @return array<string,string[]>
306 + */
307 + private function getPresetSlugs()
308 + {
309 + if (!function_exists('wp_get_global_settings')) {
310 + return [];
311 + }
312 +
313 + $color = \wp_get_global_settings(['color']);
314 + $typography = \wp_get_global_settings(['typography']);
315 +
316 + return [
317 + 'color' => $this->originSlugs($color, 'palette', 'defaultPalette'),
318 + 'colorValues' => $this->originValues($color, 'palette', 'defaultPalette', 'color'),
319 + 'gradient' => $this->originSlugs($color, 'gradients', 'defaultGradients'),
320 + 'fontSize' => $this->originSlugs($typography, 'fontSizes', 'defaultFontSizes'),
321 + 'fontFamily' => $this->originSlugs($typography, 'fontFamilies', 'defaultFontFamilies'),
322 + ];
323 + }
324 +
325 + /**
326 + * Slug => value, so a colour written as its own hex finds the token naming it.
327 + *
328 + * @return array<string, string>
329 + */
330 + private function originValues($node, $listKey, $defaultKey, $valueKey)
331 + {
332 + $list = $node[$listKey] ?? [];
333 +
334 + $origins = ['custom', 'theme'];
335 + if (($node[$defaultKey] ?? true) !== false) {
336 + $origins[] = 'default';
337 + }
338 +
339 + $values = [];
340 + foreach ($origins as $origin) {
341 + foreach ($list[$origin] ?? [] as $item) {
342 + if (isset($item['slug'], $item[$valueKey])) {
343 + $values[(string) $item['slug']] = (string) $item[$valueKey];
344 + }
345 + }
346 + }
347 +
348 + return $values;
349 + }
350 +
351 + /**
352 + * Preset slugs from a settings node's origin buckets; drops the `default`
353 + * origin when the theme opted the family out.
354 + *
355 + * @return string[]
356 + */
357 + private function originSlugs($node, $listKey, $defaultKey)
358 + {
359 + $list = $node[$listKey] ?? [];
360 +
361 + $origins = ['custom', 'theme'];
362 + if (($node[$defaultKey] ?? true) !== false) {
363 + $origins[] = 'default';
364 + }
365 +
366 + $slugs = [];
367 + foreach ($origins as $origin) {
368 + foreach ($list[$origin] ?? [] as $item) {
369 + if (isset($item['slug'])) {
370 + $slugs[] = (string) $item['slug'];
371 + }
372 + }
373 + }
374 +
375 + return array_values(array_unique($slugs));
376 + }
377 +
262 378 private static function extractColorSlug(string $value)
263 379 {
264 380 if (preg_match('/var\(--wp--preset--color--([^)]+)\)/', $value, $m)) {
265 381 return $m[1];
@@ -366,90 +482,78 @@
366 482 * @return array
367 483 */
368 484 private function getSuggestions()
369 485 {
370 - return [
371 - [
372 - 'id' => 'publish-this-page',
373 - 'icon' => 'published',
374 - 'message' => __('Publish this page', 'extendify-local'),
375 - 'workflowId' => 'update-post-status',
376 - 'source' => 'plugin',
377 - 'available' => ['context' => ['postStatus' => 'draft']],
378 - ],
379 - [
380 - 'id' => 'unpublish-this-page',
381 - 'icon' => 'drafts',
382 - 'message' => __('Unpublish this page', 'extendify-local'),
383 - 'workflowId' => 'update-post-status',
384 - 'source' => 'plugin',
385 - 'available' => ['context' => ['postStatus' => 'publish', 'isFrontPage' => false]],
386 - ],
387 - [
388 - 'id' => 'change-site-title',
389 - 'icon' => 'edit',
390 - 'message' => __('Change website title', 'extendify-local'),
391 - 'workflowId' => 'edit-wp-setting',
392 - 'source' => 'plugin',
393 - 'available' => ['abilities' => ['canEditSettings']],
394 - ],
395 - [
396 - 'id' => 'change-theme-colors',
397 - 'icon' => 'styles',
398 - 'message' => __('Change website colors', 'extendify-local'),
399 - 'workflowId' => 'change-theme-variation',
400 - 'source' => 'plugin',
401 - 'available' => ['context' => ['hasThemeVariations']],
402 - ],
403 - [
404 - 'id' => 'change-theme-fonts',
405 - 'icon' => 'typography',
406 - 'message' => __('Change website fonts', 'extendify-local'),
407 - 'workflowId' => 'change-theme-fonts-variation',
408 - 'source' => 'plugin',
409 - 'available' => ['context' => ['hasThemeVariations']],
410 - ],
411 - [
412 - 'id' => 'change-site-style',
413 - 'icon' => 'styles',
414 - 'message' => __('Change website style', 'extendify-local'),
415 - 'workflowId' => 'change-site-vibes',
416 - 'source' => 'plugin',
417 - 'available' => ['abilities' => ['canEditThemes'], 'context' => ['isUsingVibes']],
418 - ],
419 - [
420 - 'id' => 'edit-text-on-this-page',
421 - 'icon' => 'edit',
422 - 'message' => __('Edit text on this page', 'extendify-local'),
423 - 'workflowId' => 'edit-post-strings',
424 - 'source' => 'plugin',
425 - 'available' => ['abilities' => ['canEditPost'], 'context' => ['postId', 'usingBlockEditor']],
426 - ],
427 - [
428 - 'id' => 'change-website-animation',
429 - 'icon' => 'swatch',
430 - 'message' => __('Change website animation', 'extendify-local'),
431 - 'workflowId' => 'change-animation',
432 - 'source' => 'plugin',
433 - 'available' => ['abilities' => ['canEditSettings']],
434 - ],
435 - [
436 - 'id' => 'change-website-logo',
437 - 'icon' => 'siteLogo',
438 - 'message' => __('Change website logo', 'extendify-local'),
439 - 'workflowId' => 'update-logo',
440 - 'source' => 'plugin',
441 - 'available' => ['abilities' => ['canEditSettings', 'canUploadMedia']],
442 - ],
443 - [
444 - 'id' => 'change-website-browser-icon',
445 - 'icon' => 'siteLogo',
446 - 'message' => __('Change website browser icon', 'extendify-local'),
447 - 'workflowId' => 'update-site-icon',
448 - 'source' => 'plugin',
449 - 'available' => ['abilities' => ['canEditSettings', 'canUploadMedia']],
450 - ],
451 - ];
486 + $locale = \get_locale();
487 + $cached = \get_option('extendify_agent_suggestions_' . $locale);
488 +
489 + if (!is_array($cached) || !isset($cached['fetchedAt'])) {
490 + return $this->refreshSuggestions($locale) ?? [];
491 + }
492 +
493 + $age = time() - $cached['fetchedAt'];
494 + if ($age > DAY_IN_SECONDS) {
495 + if (!\wp_next_scheduled('extendify_agent_suggestions_refresh', [$locale])) {
496 + \wp_schedule_single_event(time(), 'extendify_agent_suggestions_refresh', [$locale]);
497 + if (\is_admin()) {
498 + \spawn_cron();
499 + }
500 + }
501 + }
502 +
503 + return $cached['data'] ?? [];
504 + }
505 +
506 + /**
507 + * Fetch suggestions from the API and persist them.
508 + * Called synchronously on cold start and via wp-cron when cache is stale.
509 + *
510 + * @param string $locale - Locale to fetch (cron may run in a different site locale).
511 + * @return array|null
512 + */
513 + public function refreshSuggestions($locale)
514 + {
515 + // When the refresh runs via wp-cron, the active locale may differ from the cached entry's locale.
516 + // Switch so HttpClient sends the matching wp_language and the response lands in the right cache key.
517 + $needSwitch = $locale !== \get_locale();
518 + if ($needSwitch) {
519 + \switch_to_locale($locale);
520 + }
521 +
522 + $response = HttpClient::post(
523 + Constants::AI_HOST . '/api/agent/suggestions',
524 + [],
525 + null,
526 + true
527 + );
528 +
529 + if ($needSwitch) {
530 + \restore_previous_locale();
531 + }
532 +
533 + $optionKey = 'extendify_agent_suggestions_' . $locale;
534 +
535 + if ($response['code'] !== 200) {
536 + // Back off: stamp the cache as fetched ~23h ago so we retry in ~1h instead of every request.
537 + $cached = \get_option($optionKey);
538 + \update_option(
539 + $optionKey,
540 + [
541 + 'data' => is_array($cached) ? ($cached['data'] ?? []) : [],
542 + 'fetchedAt' => time() - (DAY_IN_SECONDS - HOUR_IN_SECONDS),
543 + ],
544 + false
545 + );
546 + return null;
547 + }
548 +
549 + $suggestions = $response['response']['suggestions'] ?? [];
550 + \update_option(
551 + $optionKey,
552 + ['data' => $suggestions, 'fetchedAt' => time()],
553 + false
554 + );
555 + return $suggestions;
452 556 }
453 557
454 558 /**
455 559 * Check if the user is in the Gutenberg or FSE editor.