admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( self::NONCE_ACTION ), 'thinking' => __( 'Analysing your store data…', 'ai-eshop-optimizer' ), 'error' => __( 'Something went wrong:', 'ai-eshop-optimizer' ), 'clearConf' => __( 'Clear the conversation? This cannot be undone.', 'ai-eshop-optimizer' ), 'unknownError' => __( 'Unknown error.', 'ai-eshop-optimizer' ), 'networkError' => __( 'Network error.', 'ai-eshop-optimizer' ), 'emptyPrompt' => __( 'Your conversation starts here. Ask a question to begin.', 'ai-eshop-optimizer' ), 'pinned' => __( 'Pinned: it is at the top of Try asking.', 'ai-eshop-optimizer' ), 'unpinned' => __( 'Unpinned.', 'ai-eshop-optimizer' ), 'pinsFull' => sprintf( __( 'You can pin up to %d questions. Unpin one first.', 'ai-eshop-optimizer' ), self::MAX_PINS ), 'pinsFailed' => __( 'Your pinned questions were not saved. Try again.', 'ai-eshop-optimizer' ), ) ); wp_add_inline_script( 'aieo-merchant-chat', self::inline_js() ); } public static function is_window_mode() { return ! empty( $_GET[ self::WINDOW_ARG ] ); } public static function window_url() { return add_query_arg( array( 'page' => 'ai-eshop-dmm', 'dmm_tab' => 'insights_chat', self::WINDOW_ARG => '1', ), admin_url( 'admin.php' ) ); } private static function window_css() { return ' html.wp-toolbar { padding-top: 0 !important; } #adminmenumain, #adminmenuback, #adminmenuwrap, #wpadminbar, #wpfooter, .update-nag { display: none !important; } html, body { height: 100%; } /* The window frame never scrolls. The two columns scroll inside it. */ body { overflow: hidden; } #wpwrap, #wpcontent, #wpbody, #wpbody-content { height: 100%; min-height: 0; } #wpcontent, #wpbody-content { margin-left: 0 !important; padding: 0 !important; } #wpbody { padding-top: 0 !important; } #aieo-wpbody-content.aieo-window-mode, .aieo-window-mode .aieo-merchant-wrap, .aieo-window-mode .aieo-modules-panel, .aieo-merchant-chat-wrap { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; height: 100%; margin: 0 !important; max-width: none !important; } .aieo-window-mode .aieo-merchant-wrap, .aieo-window-mode .aieo-modules-panel { padding: 0 !important; border: 0 !important; background: none !important; } .aieo-merchant-chat-wrap { padding: 10px 14px 12px !important; } /* * A TITLE BAR, not a hero. On the page the header is a banner with * room to breathe; in the window every pixel it takes is a pixel of * conversation, and it is the same six words every time. * * The avatar is the reason the first slimming attempt did nothing * visible: setting the h1 to 15px left a 26px emoji inside it, and * the tallest inline box wins the line height. It has to come down * with the text or the bar keeps the height regardless. * * .aieo-dmm-section also puts 24px of padding on this element, at * the same specificity as the rule that styles it, so these need * !important to land whichever sheet happens to load last. */ .aieo-mchat-header { flex: 0 0 auto; margin: 0 0 8px !important; padding: 6px 14px !important; border-radius: 8px !important; } .aieo-mchat-header h1 { font-size: 14px !important; line-height: 1.5 !important; margin: 0 !important; gap: 8px !important; } .aieo-mchat-tagline { display: none !important; } /* The header is position:relative already, being a .aieo-dmm-section. */ .aieo-mchat-header { position: relative; } .aieo-mchat-close { position: absolute; top: 50%; right: 40px; transform: translateY(-50%); background: transparent; border: 0; cursor: pointer; color: #fff; font-size: 22px; line-height: 1; padding: 0 6px; opacity: .75; } .aieo-mchat-close:hover, .aieo-mchat-close:focus { opacity: 1; color: #fff; } /* stretch, not start: the conversation column must fill the height. */ .aieo-mchat-layout { flex: 1 1 auto; min-height: 0; align-items: stretch !important; } .aieo-mchat-sidebar { align-self: stretch !important; min-height: 0; overflow-y: auto; } .aieo-mchat-main { min-height: 0; height: 100%; } /* auto height + flex: the composer sits on the floor of the window. */ .aieo-mchat-transcript { flex: 1 1 auto; min-height: 0; height: auto !important; max-height: none !important; } /* * The window earns its two columns sooner than a browser tab does. * The page breakpoint is 980px because a tab that narrow is usually * a squeezed browser; a window is exactly the size we opened it at, * and at 880-980 there is comfortably room for a list of suggestions * beside the conversation. */ @media (min-width: 820px) { /* Same 32% split as the page, so the two surfaces are the same shape at any size and there is only one ratio to reason about. */ .aieo-mchat-layout { grid-template-columns: 32% minmax(0,1fr) !important; } } '; } public static function user_can_chat() { $cap = (string) apply_filters( 'aieo_merchant_chat_capability', class_exists( 'WooCommerce' ) ? 'manage_woocommerce' : 'manage_options' ); return '' !== $cap && current_user_can( $cap ); } public static function sanitize_context( $raw ) { $raw = strtolower( trim( (string) $raw ) ); return 1 === preg_match( '#^[a-z0-9-]+(/[a-z0-9_-]+)?$#', $raw ) ? $raw : ''; } public static function tool_prefixes() { return (array) apply_filters( 'aieo_merchant_chat_tool_prefixes', array( 'aieo/', 'woocommerce-claude/', 'wc-analytics/', 'rooster/', 'roosterx/', 'rooster-lms/', 'rooster-lrs/', ) ); } public static function tool_allowed( $name, $ability ) { $name = (string) $name; $own = array( 'aieo/', 'woocommerce-claude/', 'wc-analytics/' ); $ok = false; foreach ( $own as $p ) { if ( 0 === strpos( $name, $p ) ) { $ok = true; } } if ( ! $ok ) { $meta = is_object( $ability ) && method_exists( $ability, 'get_meta' ) ? (array) $ability->get_meta() : array(); $ok = ! empty( $meta['annotations']['readonly'] ); if ( ! $ok ) { $schema = is_object( $ability ) && method_exists( $ability, 'get_input_schema' ) ? (array) $ability->get_input_schema() : array(); $modes = isset( $schema['properties']['mode']['enum'] ) ? array_map( 'strval', (array) $schema['properties']['mode']['enum'] ) : array(); $ok = in_array( 'draft', $modes, true ) && in_array( 'apply', $modes, true ); } } return (bool) apply_filters( 'aieo_merchant_chat_tool_allowed', $ok, $name, $ability ); } private static function product_label( $product ) { $labels = array( 'rooster-one' => 'Rooster One', 'rooster-lms' => 'Rooster LMS', 'rooster-lrs' => 'Rooster LRS', 'rooster' => 'Rooster Zero', 'roosterx' => 'Rooster Zero', ); $product = (string) $product; return isset( $labels[ $product ] ) ? $labels[ $product ] : ucfirst( str_replace( '-', ' ', $product ) ); } public static function render_page() { if ( ! self::user_can_chat() ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'ai-eshop-optimizer' ) ); } $aieo_mchat_context = self::sanitize_context( isset( $_GET['context'] ) ? wp_unslash( $_GET['context'] ) : '' ); $user_id = get_current_user_id(); $stored = get_transient( self::TRANSIENT_PREFIX . $user_id ); $history = is_array( $stored ) ? $stored : array(); $has_key = (bool) self::get_anthropic_api_key(); $pinned = self::pinned_questions(); $suggestion_groups = array( array( 'label' => __( 'Store analytics (WooCommerce)', 'ai-eshop-optimizer' ), 'questions' => array( __( 'How did the store do this week?', 'ai-eshop-optimizer' ), __( 'What\'s my revenue this month vs last month?', 'ai-eshop-optimizer' ), __( 'Show me my top products by revenue (last 30 days)', 'ai-eshop-optimizer' ), __( 'What\'s my refund rate and where is refund pressure?', 'ai-eshop-optimizer' ), __( 'New vs returning customer mix this quarter', 'ai-eshop-optimizer' ), __( 'Triage my failed and on-hold orders', 'ai-eshop-optimizer' ), __( 'Which payment methods carry the most paid revenue?', 'ai-eshop-optimizer' ), __( 'Which shipping methods carry the most order value?', 'ai-eshop-optimizer' ), __( 'Country-level revenue concentration', 'ai-eshop-optimizer' ), __( 'Coupon performance — which codes are driving revenue?', 'ai-eshop-optimizer' ), __( 'Tax reconciliation — collected vs refunded', 'ai-eshop-optimizer' ), __( 'Inventory risk — out-of-stock or slow movers', 'ai-eshop-optimizer' ), __( 'Run an AI readiness audit of my product catalog', 'ai-eshop-optimizer' ), __( 'Channel performance — which sources drive sales?', 'ai-eshop-optimizer' ), __( 'Customer lifetime value + cohort retention', 'ai-eshop-optimizer' ), __( 'Average order value trend over the last 90 days', 'ai-eshop-optimizer' ), __( 'Orders breakdown by status (completed / failed / on-hold / refunded)', 'ai-eshop-optimizer' ), __( 'Compare revenue this week vs last week vs same week last year', 'ai-eshop-optimizer' ), __( 'Top product categories by units sold this quarter', 'ai-eshop-optimizer' ), __( 'Cart-add → checkout → completed-order conversion rates', 'ai-eshop-optimizer' ), __( 'Hour-of-day heatmap — when do orders happen?', 'ai-eshop-optimizer' ), __( 'Which products have the highest revenue per visitor?', 'ai-eshop-optimizer' ), __( 'Show me discount usage — how much margin am I giving away?', 'ai-eshop-optimizer' ), ), ), array( 'label' => __( 'Brand portfolio', 'ai-eshop-optimizer' ), 'questions' => array( __( 'Which brands are underperforming this quarter?', 'ai-eshop-optimizer' ), __( 'Tier my brands into top / mid / bottom performers', 'ai-eshop-optimizer' ), __( 'Which brands have no free-gift offer set up?', 'ai-eshop-optimizer' ), __( 'Show me brand revenue concentration — top 5 share of sales', 'ai-eshop-optimizer' ), __( 'Find brands trending up or down vs last quarter', 'ai-eshop-optimizer' ), __( 'Which brands have grown vs declined year-over-year?', 'ai-eshop-optimizer' ), __( 'Newest brands added — are any gaining traction?', 'ai-eshop-optimizer' ), __( 'Brand × category coverage matrix — where are catalog gaps?', 'ai-eshop-optimizer' ), __( 'Which brands have the best engagement-to-conversion ratio?', 'ai-eshop-optimizer' ), __( 'Brand cart-add rate vs revenue share — efficiency winners / losers', 'ai-eshop-optimizer' ), __( 'Which brands lack `pa_need` term coverage?', 'ai-eshop-optimizer' ), __( 'Show me brands with the highest customer repeat rate', 'ai-eshop-optimizer' ), __( 'Which brand pages have the most archive cart-adds?', 'ai-eshop-optimizer' ), ), ), array( 'label' => __( 'Promotions, launches & sales', 'ai-eshop-optimizer' ), 'questions' => array( __( 'Show me products added in the last 30 days — any traction?', 'ai-eshop-optimizer' ), __( 'Newest products by cart-add count this month', 'ai-eshop-optimizer' ), __( 'How are new products performing vs catalog average?', 'ai-eshop-optimizer' ), __( 'Which new products got featured in recommendation rails?', 'ai-eshop-optimizer' ), __( 'Best-performing new products by view-to-cart ratio', 'ai-eshop-optimizer' ), __( 'Which new products have no reviews yet — seed candidates?', 'ai-eshop-optimizer' ), __( 'How effective is my complementary-products picker overall?', 'ai-eshop-optimizer' ), __( 'Which complementary pairings drive the most cart-adds?', 'ai-eshop-optimizer' ), __( 'Compare complementary vs upsell vs cross-sell conversion rates', 'ai-eshop-optimizer' ), __( 'Which source products have no complementary picker configured?', 'ai-eshop-optimizer' ), __( 'Top-selling source products without complementary recommendations', 'ai-eshop-optimizer' ), __( 'Complementary pairs ordered together most often', 'ai-eshop-optimizer' ), __( 'How did my "Offers of the Month" products perform vs non-sale?', 'ai-eshop-optimizer' ), __( 'Which sale-priced products converted best last month?', 'ai-eshop-optimizer' ), __( 'Show me cart-adds during the sale period vs after it ended', 'ai-eshop-optimizer' ), __( 'Is the discount margin justified by the cart-add uplift?', 'ai-eshop-optimizer' ), __( 'Which products are on sale right now and how are they doing?', 'ai-eshop-optimizer' ), __( 'Products that lost momentum after their sale ended', 'ai-eshop-optimizer' ), __( 'Show me sale-period revenue vs full-price revenue per brand', 'ai-eshop-optimizer' ), ), ), array( 'label' => __( 'Customers', 'ai-eshop-optimizer' ), 'questions' => array( __( 'What do my silent customers probably want?', 'ai-eshop-optimizer' ), __( 'Show me the priority distribution across my customer base', 'ai-eshop-optimizer' ), __( 'Find lookalike customers similar to my top spender', 'ai-eshop-optimizer' ), __( 'Who are my top 10 customers by lifetime value?', 'ai-eshop-optimizer' ), __( 'Which customer segments are growing vs shrinking?', 'ai-eshop-optimizer' ), __( 'Show me customers with no purchase in the last 90 days', 'ai-eshop-optimizer' ), __( 'Which customers are most likely to redeem a free-gift offer?', 'ai-eshop-optimizer' ), __( 'What\'s the dominant priority across my F18 responders?', 'ai-eshop-optimizer' ), __( 'Recommend products for my best repeat customer', 'ai-eshop-optimizer' ), __( 'Show me one-time vs repeat customer mix', 'ai-eshop-optimizer' ), __( 'Which customers bought from the most distinct brands?', 'ai-eshop-optimizer' ), sprintf( __( 'Show me abandoned carts > %s in the last 7 days', 'ai-eshop-optimizer' ), html_entity_decode( wp_strip_all_tags( wc_price( 50 ) ) ) ), __( 'Customers at risk of churn (no activity 60+ days)', 'ai-eshop-optimizer' ), __( 'VIP cohort — top 5% of customers by lifetime spend', 'ai-eshop-optimizer' ), __( 'Customers who only buy on discount', 'ai-eshop-optimizer' ), __( 'Find lookalikes of customers who buy a specific brand', 'ai-eshop-optimizer' ), __( 'Average orders per customer by segment', 'ai-eshop-optimizer' ), __( 'Which customers are most active in the last 7 days?', 'ai-eshop-optimizer' ), __( 'Customers with abandoned carts I should email today', 'ai-eshop-optimizer' ), __( 'Spending concentration — how much do my top 10% drive?', 'ai-eshop-optimizer' ), __( 'Show me the customer-priority overlap with brand affinity', 'ai-eshop-optimizer' ), ), ), array( 'label' => __( 'Recommendation rails', 'ai-eshop-optimizer' ), 'questions' => array( __( 'Which recommendation rail converts best?', 'ai-eshop-optimizer' ), __( 'Show me the top converting product pairs', 'ai-eshop-optimizer' ), __( 'How well does the "same-needs" rail perform vs upsells?', 'ai-eshop-optimizer' ), __( 'Which rails have impressions but no cart-adds?', 'ai-eshop-optimizer' ), __( 'What\'s the impression → cart-add ratio per surface?', 'ai-eshop-optimizer' ), __( 'If a visitor scrolls 75%+ and stays 60s+, how likely are they to buy?', 'ai-eshop-optimizer' ), __( 'Which products have the steepest engagement → conversion gradient?', 'ai-eshop-optimizer' ), __( 'Which products appear most often as upsell targets?', 'ai-eshop-optimizer' ), __( 'Top anchor → target product pairs in same_needs', 'ai-eshop-optimizer' ), __( 'Click-through rate per rail surface', 'ai-eshop-optimizer' ), __( 'Rails with high traffic but low conversion — fix candidates', 'ai-eshop-optimizer' ), __( 'Which products show up most often in side-cart recommendations?', 'ai-eshop-optimizer' ), __( 'Compare related vs cross-sells vs upsells per surface', 'ai-eshop-optimizer' ), __( 'Products with the highest sales-per-view ratio (efficient converters)', 'ai-eshop-optimizer' ), __( 'Products with high traffic but low sales (low conversion outliers)', 'ai-eshop-optimizer' ), __( 'Compare 10 sales/10 views vs 10 sales/1000 views — show me both ends', 'ai-eshop-optimizer' ), __( 'Find products underperforming relative to their visit volume', 'ai-eshop-optimizer' ), __( 'Top 10 products by views × bottom 10 by conversion rate', 'ai-eshop-optimizer' ), ), ), array( 'label' => __( 'Attribution & archives', 'ai-eshop-optimizer' ), 'questions' => array( __( 'What sources drive my cart-adds this week?', 'ai-eshop-optimizer' ), __( 'Which need / brand archive pages convert best?', 'ai-eshop-optimizer' ), __( 'Show me dead archive pages (traffic but no cart-adds)', 'ai-eshop-optimizer' ), __( 'Compare same_needs vs related vs recently_viewed surfaces', 'ai-eshop-optimizer' ), __( 'Which "need" archives need promotional pushes?', 'ai-eshop-optimizer' ), __( 'Which sources have high cart-adds AND high engagement?', 'ai-eshop-optimizer' ), __( 'Show me the landing-page → cart-add funnel', 'ai-eshop-optimizer' ), __( 'Which sources have grown month-over-month?', 'ai-eshop-optimizer' ), __( 'Attribution mix for variable products only', 'ai-eshop-optimizer' ), __( 'Top archives by cart-add count', 'ai-eshop-optimizer' ), __( 'Which sticky-bar / side-cart adds outperformed PDP adds?', 'ai-eshop-optimizer' ), __( 'Cart-adds attributed to blog posts vs product pages', 'ai-eshop-optimizer' ), __( 'Show me the journey for the most recent cart-add', 'ai-eshop-optimizer' ), ), ), array( 'label' => __( 'Product needs audit', 'ai-eshop-optimizer' ), 'questions' => array( __( 'Audit my product needs coverage', 'ai-eshop-optimizer' ), __( 'Find products that should carry need terms but don\'t', 'ai-eshop-optimizer' ), __( 'Which need terms have too few products assigned?', 'ai-eshop-optimizer' ), __( 'Find needs with no products at all (empty taxonomy terms)', 'ai-eshop-optimizer' ), __( 'Suggest the right need terms for my newest products', 'ai-eshop-optimizer' ), __( 'Which need terms drive the highest cart-add rate?', 'ai-eshop-optimizer' ), __( 'Top need terms by product count', 'ai-eshop-optimizer' ), __( 'Need-to-need affinity — customers who bought X also bought Y', 'ai-eshop-optimizer' ), __( 'Which products span the most need terms?', 'ai-eshop-optimizer' ), __( 'Need term performance — cart-adds × visitors per need', 'ai-eshop-optimizer' ), __( 'Find duplicate or overlapping need terms', 'ai-eshop-optimizer' ), __( 'Which needs are over-represented (could be split into sub-needs)?', 'ai-eshop-optimizer' ), ), ), array( 'label' => __( 'Star ratings', 'ai-eshop-optimizer' ), 'questions' => array( __( 'Where are my star ratings sparse?', 'ai-eshop-optimizer' ), __( 'Show me my top and bottom rated products', 'ai-eshop-optimizer' ), __( 'Which high-traffic products have zero ratings yet?', 'ai-eshop-optimizer' ), __( 'Which categories have the highest average rating?', 'ai-eshop-optimizer' ), __( 'Find products with 1-2 ratings only (need more votes)', 'ai-eshop-optimizer' ), __( 'Do my higher-rated products convert better?', 'ai-eshop-optimizer' ), __( 'Rating distribution histogram (1, 2, 3, 4, 5 stars)', 'ai-eshop-optimizer' ), __( 'Which brands have the highest avg rating?', 'ai-eshop-optimizer' ), __( 'Recent rating events — last 7 days', 'ai-eshop-optimizer' ), __( 'Products with sudden rating drops', 'ai-eshop-optimizer' ), __( 'Average rating by price tier (cheap vs premium)', 'ai-eshop-optimizer' ), __( 'Which rated products carry the most reviews from repeat customers?', 'ai-eshop-optimizer' ), ), ), array( 'label' => __( 'Newsletters & campaigns', 'ai-eshop-optimizer' ), 'questions' => array( __( 'Which newsletters drove the most cart-adds?', 'ai-eshop-optimizer' ), __( 'Compare newsletter campaign performance over the last 90 days', 'ai-eshop-optimizer' ), __( 'Newsletter open rate × cart-add correlation', 'ai-eshop-optimizer' ), __( 'Which newsletter dispatches had the highest revenue lift?', 'ai-eshop-optimizer' ), __( 'Top-performing email subject lines / preview text', 'ai-eshop-optimizer' ), __( 'Newsletter → product affinity — which products were featured + bought', 'ai-eshop-optimizer' ), __( 'Which stories or story angles performed best in newsletters?', 'ai-eshop-optimizer' ), __( 'Story performance by season', 'ai-eshop-optimizer' ), __( 'Which newsletter sections drive the most clicks?', 'ai-eshop-optimizer' ), __( 'Which need terms appear most in high-performing newsletters?', 'ai-eshop-optimizer' ), __( 'Newsletters that drove cart-adds for products in a specific need', 'ai-eshop-optimizer' ), __( 'Per-need newsletter ROI — which needs respond to email content?', 'ai-eshop-optimizer' ), __( 'Compare newsletter performance by featured need category', 'ai-eshop-optimizer' ), __( 'Subscriber growth — net adds vs unsubscribes by week', 'ai-eshop-optimizer' ), __( 'Which audience segments engage most with newsletter content?', 'ai-eshop-optimizer' ), __( 'Send-time experiments — when does this list convert best?', 'ai-eshop-optimizer' ), __( 'Which lapsed customers re-engaged after a recent newsletter?', 'ai-eshop-optimizer' ), __( 'Newsletter × priority-profile crossover — what do my F18 responders click?', 'ai-eshop-optimizer' ), ), ), array( 'label' => __( 'Content & blog', 'ai-eshop-optimizer' ), 'questions' => array( __( 'Suggest summer blog post ideas based on my product catalog', 'ai-eshop-optimizer' ), __( 'What seasonal content should I publish this month?', 'ai-eshop-optimizer' ), __( 'Generate 5 content angles for my top-selling brand', 'ai-eshop-optimizer' ), __( 'Suggest content topics for the "Ξάνοιγμα" need archive', 'ai-eshop-optimizer' ), __( 'What trending search terms could I write blog posts around?', 'ai-eshop-optimizer' ), __( 'Recommend back-to-school content for my catalogue', 'ai-eshop-optimizer' ), __( 'Draft a holiday gift-guide outline for my top brands', 'ai-eshop-optimizer' ), __( 'Generate a content calendar for the next 3 months', 'ai-eshop-optimizer' ), __( 'Top trending search terms in beauty / haircare this month', 'ai-eshop-optimizer' ), __( 'Draft an FAQ for my top-selling product', 'ai-eshop-optimizer' ), __( 'Repurpose my best-selling brand into a blog post outline', 'ai-eshop-optimizer' ), __( 'Newsletter topic suggestions for my top brand', 'ai-eshop-optimizer' ), __( 'Social media post ideas highlighting my top categories', 'ai-eshop-optimizer' ), __( 'Suggest how-to guide topics for the most popular needs', 'ai-eshop-optimizer' ), __( 'Generate product comparison content for my variable products', 'ai-eshop-optimizer' ), __( 'Show me my top blog posts by recent engagement', 'ai-eshop-optimizer' ), __( 'Which blog topics could drive traffic to my underperforming brands?', 'ai-eshop-optimizer' ), __( 'Which blog posts hold attention longest?', 'ai-eshop-optimizer' ), __( 'Which blog posts drive product cart-adds?', 'ai-eshop-optimizer' ), ), ), array( 'label' => __( 'Search visibility', 'ai-eshop-optimizer' ), 'questions' => array( __( 'Which search queries bring impressions but almost no clicks?', 'ai-eshop-optimizer' ), __( 'Show me striking-distance keywords — where am I sitting just off page one?', 'ai-eshop-optimizer' ), __( 'Which keywords do I already rank for, and in what positions?', 'ai-eshop-optimizer' ), __( 'Find content gaps — what do competitors rank for that I do not?', 'ai-eshop-optimizer' ), __( 'What SEO fixes are waiting for my approval?', 'ai-eshop-optimizer' ), __( 'Apply the safe SEO fixes that do not need my approval', 'ai-eshop-optimizer' ), __( 'Which product images have no alt text?', 'ai-eshop-optimizer' ), __( 'Rewrite the title and meta description for my worst-performing page', 'ai-eshop-optimizer' ), __( 'Undo the last SEO fix you applied', 'ai-eshop-optimizer' ), __( 'Show me my backlink profile and anything worth disavowing', 'ai-eshop-optimizer' ), __( 'Is the keyword data source connected, and how much quota is left?', 'ai-eshop-optimizer' ), ), ), array( 'label' => __( 'Stock and restocking', 'ai-eshop-optimizer' ), 'questions' => array( __( 'What should I reorder next, and when do I run out?', 'ai-eshop-optimizer' ), __( 'Which restocks would unlock the most blocked revenue?', 'ai-eshop-optimizer' ), __( 'Which out-of-stock products are costing me the most right now?', 'ai-eshop-optimizer' ), __( 'What sells in my shops but is missing from the eshop?', 'ai-eshop-optimizer' ), __( 'Show me the stock recovery list ranked by what it is worth', 'ai-eshop-optimizer' ), __( 'Which products are close to a stock-out on current velocity?', 'ai-eshop-optimizer' ), __( 'Which variations are out of stock while the parent still sells?', 'ai-eshop-optimizer' ), __( 'How much demand did I lose to stock-outs last month?', 'ai-eshop-optimizer' ), ), ), array( 'label' => __( 'Checkout and abandoned carts', 'ai-eshop-optimizer' ), 'questions' => array( __( 'Where exactly do people drop out of my checkout?', 'ai-eshop-optimizer' ), __( 'Triage my abandoned carts — which are actually worth chasing?', 'ai-eshop-optimizer' ), __( 'Which abandoned products would justify a targeted offer?', 'ai-eshop-optimizer' ), __( 'What is the total value sitting in abandoned carts right now?', 'ai-eshop-optimizer' ), __( 'Which pages do people leave from most often?', 'ai-eshop-optimizer' ), __( 'Do abandoned carts cluster around a payment or shipping method?', 'ai-eshop-optimizer' ), __( 'Which customers abandoned a cart worth chasing this week?', 'ai-eshop-optimizer' ), ), ), array( 'label' => __( 'Ad spend and what it returned', 'ai-eshop-optimizer' ), 'questions' => array( __( 'How are my campaigns performing against what they cost me?', 'ai-eshop-optimizer' ), __( 'Show multi-touch attribution — which channels assist and which close?', 'ai-eshop-optimizer' ), __( 'What is a completed goal actually worth to me on average?', 'ai-eshop-optimizer' ), __( 'Which landing pages earn their campaign spend?', 'ai-eshop-optimizer' ), __( 'Which channel deserves the credit for first contact?', 'ai-eshop-optimizer' ), __( 'What is on tonight\'s boost slate, and why those products?', 'ai-eshop-optimizer' ), __( 'Where am I spending money that returns nothing?', 'ai-eshop-optimizer' ), __( 'Draft an ad plan for my best-margin brand', 'ai-eshop-optimizer' ), ), ), array( 'label' => __( 'Automations and experiments', 'ai-eshop-optimizer' ), 'questions' => array( __( 'Which automations am I running, and are any of them idle?', 'ai-eshop-optimizer' ), __( 'How is each automation performing?', 'ai-eshop-optimizer' ), __( 'Does my running A/B test have a winner yet?', 'ai-eshop-optimizer' ), __( 'Did that campaign cause the lift, or would it have happened anyway?', 'ai-eshop-optimizer' ), __( 'Explain what this automation does and who it reaches', 'ai-eshop-optimizer' ), __( 'Which automation earns the most per message sent?', 'ai-eshop-optimizer' ), ), ), array( 'label' => __( 'Social posts', 'ai-eshop-optimizer' ), 'questions' => array( __( 'Which products are worth posting about this week?', 'ai-eshop-optimizer' ), __( 'Write me a social post for my strongest candidate', 'ai-eshop-optimizer' ), __( 'How did last month\'s social posts perform?', 'ai-eshop-optimizer' ), __( 'Which posted products actually turned into cart-adds?', 'ai-eshop-optimizer' ), __( 'Draft a week of posts around one brand', 'ai-eshop-optimizer' ), ), ), array( 'label' => __( 'What customers told you', 'ai-eshop-optimizer' ), 'questions' => array( __( 'Summarise what customers have been telling me lately', 'ai-eshop-optimizer' ), __( 'What are the recurring complaints, and how many people raised each?', 'ai-eshop-optimizer' ), __( 'Which form answers split my audience into useful segments?', 'ai-eshop-optimizer' ), __( 'What archetype does my typical buyer fit?', 'ai-eshop-optimizer' ), __( 'Which cancellation reasons come up most often?', 'ai-eshop-optimizer' ), __( 'What do people ask for that I do not sell?', 'ai-eshop-optimizer' ), ), ), array( 'label' => __( 'Find a setting, check a signal', 'ai-eshop-optimizer' ), 'questions' => array( __( 'Where is the setting that controls free shipping?', 'ai-eshop-optimizer' ), __( 'Where do I configure my courier vouchers?', 'ai-eshop-optimizer' ), __( 'Which measurement signals are switched on, and which are silent?', 'ai-eshop-optimizer' ), __( 'Is anything I rely on not collecting data any more?', 'ai-eshop-optimizer' ), __( 'Which content is carrying the most weight right now?', 'ai-eshop-optimizer' ), ), ), array( 'label' => __( 'Cross-skill / overview', 'ai-eshop-optimizer' ), 'questions' => array( __( 'Give me a weekly store health check', 'ai-eshop-optimizer' ), __( 'What are my 3 highest-impact next actions?', 'ai-eshop-optimizer' ), __( 'Summarise everything that changed this week', 'ai-eshop-optimizer' ), __( 'Where is the biggest revenue opportunity I\'m missing?', 'ai-eshop-optimizer' ), __( 'Which products are most often added then removed?', 'ai-eshop-optimizer' ), __( 'Monthly executive summary across all surfaces', 'ai-eshop-optimizer' ), __( 'Year-over-year performance summary', 'ai-eshop-optimizer' ), __( 'What 3 changes would have the biggest revenue impact?', 'ai-eshop-optimizer' ), __( 'Show me everything that\'s broken or underperforming right now', 'ai-eshop-optimizer' ), __( 'Quick wins I can implement in the next 7 days', 'ai-eshop-optimizer' ), __( 'Compare this month against last month — what shifted?', 'ai-eshop-optimizer' ), __( 'Identify cross-functional bottlenecks (catalog × attribution × ratings)', 'ai-eshop-optimizer' ), ), ), ); foreach ( $suggestion_groups as $i => $g ) { $suggestion_groups[ $i ]['product'] = 'rooster-one'; } $suggestion_groups = (array) apply_filters( 'rooster/insights_chat/suggestion_groups', $suggestion_groups, $aieo_mchat_context ); $aieo_mchat_from = '' !== $aieo_mchat_context ? (string) strtok( $aieo_mchat_context, '/' ) : ''; if ( '' !== $aieo_mchat_from ) { $first = array(); $rest = array(); foreach ( $suggestion_groups as $g ) { if ( is_array( $g ) && ! empty( $g['questions'] ) ) { if ( $aieo_mchat_from === (string) ( $g['product'] ?? '' ) ) { $first[] = $g; } else { $rest[] = $g; } } } $suggestion_groups = array_merge( $first, $rest ); } $suggestion_groups = array_values( array_filter( $suggestion_groups, static function ( $g ) { return is_array( $g ) && ! empty( $g['questions'] ) && isset( $g['label'] ); } ) ); $aieo_mchat_default = array_map( array( __CLASS__, 'group_key' ), $suggestion_groups ); $suggestion_groups = self::order_groups( $suggestion_groups, self::group_order() ); $aieo_mchat_window = self::is_window_mode(); self::print_window_opener_script(); if ( $aieo_mchat_window ) { echo ''; } else { self::render_cards( $suggestion_groups, $history, $has_key, $aieo_mchat_context, $pinned, $aieo_mchat_default ); return; } ?>

' . esc_html__( 'Set it in AI Chat → Settings', 'ai-eshop-optimizer' ) . '' ); ?>

'; $aieo_mchat_handoff = \Rooster\Admin\Ui::notice( esc_html__( 'One conversation is kept per person, so this page steps aside while the window is open. Anything you ask there is the same conversation you would have had here.', 'ai-eshop-optimizer' ), 'info', array( 'title' => __( 'Insights Chat is open in its own window.', 'ai-eshop-optimizer' ), 'inline' => true, 'actions' => ' ' . '', ) ); echo $aieo_mchat_handoff; echo ''; $windows = method_exists( '\Rooster\Admin\Ui', 'windows' ); $panel = ! $windows && method_exists( '\Rooster\Admin\Ui', 'panel_state' ); $suggest_card = static function ( array $extra ) use ( $suggestion_groups, $pinned, $default_groups ) { $suggest = array( 'anchor' => 'insights-chat-try-asking', 'icon' => 'lightbulb', 'title' => __( 'Try asking', 'ai-eshop-optimizer' ), 'description' => __( 'Ask anything about your store. Backed by 32 Rooster One read-only analytics tools — brands, needs, customers, recommendations, attribution, ratings, blog engagement, buy-probability.', 'ai-eshop-optimizer' ), 'class' => 'aieo-mchat-card', ) + $extra; list( $open, $close ) = AIEO_DMM_UI::card( $suggest ); ob_start(); echo $open; $aieo_mchat_pins = self::pins_html( $pinned ); if ( '' !== $aieo_mchat_pins ) { echo rooster_admin_card_section_html( __( 'Pinned', 'ai-eshop-optimizer' ) ) . $aieo_mchat_pins; echo rooster_admin_card_section_html( __( 'Suggestions', 'ai-eshop-optimizer' ) ); } echo self::suggestions_html( $suggestion_groups, $pinned, $default_groups ); echo rooster_admin_card_section_html( __( 'Notes', 'ai-eshop-optimizer' ) ); ?> ' . esc_html__( 'Send', 'ai-eshop-optimizer' ) . ' ' . '' . ( $has_key ? esc_html__( 'Analysing your store data…', 'ai-eshop-optimizer' ) : esc_html__( 'Add the Anthropic API key to send.', 'ai-eshop-optimizer' ) ) . ' ' . '' . ' ' . esc_html__( 'Powered by Claude', 'ai-eshop-optimizer' ) . ''; $talk_card = static function ( array $extra ) use ( $footer, $has_key, $history, $context ) { $talk = array( 'anchor' => 'insights-chat-conversation', 'icon' => 'bot', 'title' => __( 'Conversation', 'ai-eshop-optimizer' ), 'description' => __('Ask questions about your store\'s sales, products and customers and get answers drawn from your own data.', 'ai-eshop-optimizer'), 'class' => 'aieo-mchat-card', 'footer' => $footer, ) + $extra; list( $open, $close ) = AIEO_DMM_UI::card( $talk ); ob_start(); echo $open; if ( ! $has_key ) { $aieo_mchat_nokey = \Rooster\Admin\Ui::notice( sprintf( esc_html__( 'Anthropic API key not configured. %s before using Insights Chat.', 'ai-eshop-optimizer' ), '' . esc_html__( 'Set it in AI Chat → Settings', 'ai-eshop-optimizer' ) . '' ), 'warning', array( 'inline' => true ) ); echo $aieo_mchat_nokey; } ?>
'try-asking', 'label' => __( 'Try asking', 'ai-eshop-optimizer' ), 'width' => 'wide', 'html' => static function ( string $tools ) use ( $suggest_card ) { return $suggest_card( array( 'tools' => $tools, 'movable' => false ) ); }, ), array( 'key' => 'conversation', 'label' => __( 'Conversation', 'ai-eshop-optimizer' ), 'main' => true, 'html' => static function ( string $tools ) use ( $talk_card ) { return $talk_card( array( 'tools' => $tools, 'movable' => false ) ); }, ), ) ); } elseif ( $panel ) { echo \Rooster\Admin\Ui::panel_open( self::PANEL, array( 'default' => 'left', 'width' => 'wide' ) ) . $talk_card( array( 'tools' => \Rooster\Admin\Ui::panel_open_button( self::PANEL, __( 'Show the suggestions', 'ai-eshop-optimizer' ) ), 'movable' => false ) ) . \Rooster\Admin\Ui::panel_side( self::PANEL, __( 'Try asking', 'ai-eshop-optimizer' ), array( 'bar' => false ) ) . $suggest_card( array( 'tools' => \Rooster\Admin\Ui::panel_tools( self::PANEL ), 'movable' => false ) ) . \Rooster\Admin\Ui::panel_close(); } else { if ( $band ) { rooster_admin_band_open( array( 'cols' => '1:2' ) ); } echo $suggest_card( array( 'col' => 1 ) ) . $talk_card( array( 'col' => 2 ) ); if ( $band ) { rooster_admin_band_close(); } } echo ''; $aieo_mchat_clear_dialog = \Rooster\Admin\States::confirm( 'aieo-mchat-clear-dialog', 'replace', __( 'Clear this conversation?', 'ai-eshop-optimizer' ), sprintf( esc_html__( 'Messages in this conversation: %s. They are kept for you alone. Once cleared they cannot be brought back.', 'ai-eshop-optimizer' ), '' . esc_html( number_format_i18n( self::visible_message_count( $history ) ) ) . '' ), array( 'confirm' => __( 'Clear conversation', 'ai-eshop-optimizer' ) ) ); echo $aieo_mchat_clear_dialog; } private static function visible_message_count( $history ) { $n = 0; foreach ( is_array( $history ) ? $history : array() as $msg ) { if ( ! is_array( $msg ) || empty( $msg['role'] ) ) { continue; } $content = $msg['content'] ?? ''; if ( 'user' === $msg['role'] && ! is_array( $content ) ) { ++$n; } elseif ( 'assistant' === $msg['role'] ) { $text = ''; if ( is_array( $content ) ) { foreach ( $content as $block ) { if ( isset( $block['type'] ) && 'text' === $block['type'] && isset( $block['text'] ) ) { $text .= $block['text']; } } } else { $text = (string) $content; } if ( '' !== trim( $text ) ) { ++$n; } } } return $n; } public static function pinned_questions() { $pins = get_user_meta( get_current_user_id(), self::PINS_META, true ); return is_array( $pins ) ? array_values( array_filter( array_map( 'strval', $pins ), 'strlen' ) ) : array(); } public static function pin_key( $question ) { return 'q' . substr( md5( (string) $question ), 0, 12 ); } public static function group_key( $group ) { return 'g' . substr( md5( (string) ( $group['product'] ?? '' ) . '|' . (string) ( $group['label'] ?? '' ) ), 0, 12 ); } public static function group_order() { $order = get_user_meta( get_current_user_id(), self::GROUPS_META, true ); return is_array( $order ) ? array_values( array_filter( array_map( 'strval', $order ), 'strlen' ) ) : array(); } private static function order_groups( $groups, $order ) { if ( array() === $order ) { return $groups; } $by = array(); foreach ( $groups as $group ) { $by[ self::group_key( $group ) ] = $group; } $out = array(); foreach ( $order as $key ) { if ( isset( $by[ $key ] ) ) { $out[] = $by[ $key ]; unset( $by[ $key ] ); } } return array_merge( $out, array_values( $by ) ); } private static function suggestions_html( $suggestion_groups, $pinned, $default_keys = array() ) { $keys = array_flip( array_map( array( __CLASS__, 'pin_key' ), $pinned ) ); $library = class_exists( '\Rooster\Admin\Ui' ) && class_exists( '\Rooster\Admin\Fields' ) && method_exists( '\Rooster\Admin\Fields', 'item_grip' ); $rows = ''; foreach ( $suggestion_groups as $group ) { $family = '
' . '' . esc_html( $group['label'] ) . '' . '(' . count( $group['questions'] ) . ')' . '
'; $rows .= $library ? '
  • ' . \Rooster\Admin\Fields::item_grip( sprintf( __( 'Move "%s": drag it, use the arrow keys, or press Enter for the Move menu', 'ai-eshop-optimizer' ), $group['label'] ) ) . $family . '
  • ' : $family; } if ( ! $library ) { return '
    ' . $rows . '
    '; } wp_enqueue_script( 'rooster-admin-board' ); $words = array( 'saved' => __( 'Saved', 'ai-eshop-optimizer' ), 'failed' => __( 'Not saved: %s', 'ai-eshop-optimizer' ), ); $reset = array() !== $default_keys ? \Rooster\Admin\Ui::button( __( 'Reset to default', 'ai-eshop-optimizer' ), array( 'variant' => 'ghost', 'size' => 'sm', 'icon' => 'rotate-ccw', 'attrs' => array( 'data-ra-dialog' => 'aieo-mchat-groups-reset' ), ) ) : ''; $dialog = '' !== $reset ? \Rooster\Admin\Ui::dialog( 'aieo-mchat-groups-reset', __( 'Put the families back in their default order?', 'ai-eshop-optimizer' ), '

    ' . esc_html__( 'Your own order of the families is forgotten. Your pinned questions stay as they are.', 'ai-eshop-optimizer' ) . '

    ', array( 'confirm' => __( 'Reset to default', 'ai-eshop-optimizer' ), 'dialog_attrs' => array( 'data-ra-order-reset' => 'aieo-mchat-groups' ), ) ) : ''; return '
    ' . \Rooster\Admin\Guidance::text( __( 'Move a family by its grip to put the ones you use most on top. The order is kept for you.', 'ai-eshop-optimizer' ), 'guidance', array( 'question' => __('Can I change the order?', 'ai-eshop-optimizer') ) ) . '
      ' . $rows . '
    ' . '
    ' . $reset . '
    ' . '
    ' . $dialog; } private static function pins_html( $pinned ) { if ( ! class_exists( '\Rooster\Admin\Fields' ) || ! method_exists( '\Rooster\Admin\Fields', 'ordered_list' ) ) { return ''; } $rows = array(); foreach ( $pinned as $question ) { $rows[] = self::pin_row( $question ); } $list = \Rooster\Admin\Fields::ordered_list( $rows, array( 'id' => 'aieo-mchat-pins-list', 'label' => __( 'Pinned questions', 'ai-eshop-optimizer' ), 'class' => 'aieo-mchat-pins-list', ) ); $tpl = \Rooster\Admin\Fields::ordered_list( array( self::pin_row( '%%AIEO_Q%%', '%%AIEO_K%%' ) ), array( 'id' => 'aieo-mchat-pins-tpl' ) ); $empty = class_exists( '\Rooster\Admin\States' ) ? \Rooster\Admin\States::state( 'first-use', array( 'title' => __( 'No pinned questions yet', 'ai-eshop-optimizer' ), 'text' => __( 'Pin the questions you ask most with the pin beside each suggestion: they stay here, at the top, in the order you give them.', 'ai-eshop-optimizer' ), 'compact' => true, 'attrs' => array( 'class' => 'aieo-mchat-pins-empty', 'hidden' => array() !== $pinned ? true : null ), ) ) : '

    ' . esc_html__( 'Pin the questions you ask most with the pin beside each suggestion: they stay here, at the top, in the order you give them.', 'ai-eshop-optimizer' ) . '

    '; return '
    ' . $empty . $list . '
    '; } private static function pin_row( $question, $key = null ) { $use = \Rooster\Admin\Ui::button( sprintf( __( 'Put "%s" in the question box', 'ai-eshop-optimizer' ), $question ), array( 'variant' => 'ghost', 'size' => 'sm', 'icon' => 'text-cursor-input', 'icon_only' => true, 'attrs' => array( 'data-aieo-mchat-use' => '' ), ) ); $unpin = \Rooster\Admin\Ui::button( sprintf( __( 'Unpin "%s"', 'ai-eshop-optimizer' ), $question ), array( 'variant' => 'ghost', 'size' => 'sm', 'icon' => 'x', 'icon_only' => true, 'attrs' => array( 'data-aieo-mchat-unpin' => '' ), ) ); return array( 'key' => null !== $key ? $key : self::pin_key( $question ), 'label' => $question, 'action' => $use . $unpin, ); } private static function empty_transcript_html( $as_state ) { $title = __( 'Your conversation starts here. Ask a question to begin.', 'ai-eshop-optimizer' ); if ( $as_state ) { return '
    ' . \Rooster\Admin\States::state( 'first-use', array( 'title' => $title, 'compact' => true ) ) . '
    '; } return '

    ' . esc_html( $title ) . '

    '; } private static function render_transcript( $history, $as_state = false ) { if ( empty( $history ) ) { echo self::empty_transcript_html( $as_state ); return; } foreach ( $history as $msg ) { if ( ! is_array( $msg ) || empty( $msg['role'] ) ) { continue; } $role = $msg['role']; $content = $msg['content'] ?? ''; if ( 'user' === $role ) { if ( is_array( $content ) ) { continue; } $body = (string) $content; } elseif ( 'assistant' === $role ) { $body = ''; if ( is_array( $content ) ) { foreach ( $content as $block ) { if ( isset( $block['type'] ) && 'text' === $block['type'] && isset( $block['text'] ) ) { $body .= $block['text']; } } } else { $body = (string) $content; } if ( '' === trim( $body ) ) { continue; } } else { continue; } self::render_bubble( $role, $body ); } } public static function render_bubble( $role, $body ) { $is_user = ( 'user' === $role ); $avatar = $is_user ? '👤' : '🛍'; $class = $is_user ? 'aieo-mchat-bubble aieo-mchat-bubble--user' : 'aieo-mchat-bubble aieo-mchat-bubble--bot'; $rendered = $is_user ? esc_html( $body ) : self::render_markdown( $body ); ?>
    __( 'Insufficient permissions.', 'ai-eshop-optimizer' ) ), 403 ); } if ( ! self::caller_is_pro() ) { wp_send_json_error( array( 'message' => __( 'The AI assistant is a Pro feature. Upgrade to ask questions about your shop.', 'ai-eshop-optimizer' ), 'upgrade' => 'https://rooster.systems/get-rooster-one/', ), 402 ); } $msg = isset( $_POST['message'] ) ? sanitize_textarea_field( wp_unslash( $_POST['message'] ) ) : ''; if ( '' === $msg ) { wp_send_json_error( array( 'message' => __( 'Empty message.', 'ai-eshop-optimizer' ) ), 400 ); } if ( ! self::get_anthropic_api_key() ) { wp_send_json_error( array( 'message' => __( 'Anthropic API key not configured. Set it in AI Chat → Settings.', 'ai-eshop-optimizer' ) ), 500 ); } $user_id = get_current_user_id(); $transient = self::TRANSIENT_PREFIX . $user_id; $stored = get_transient( $transient ); $history = is_array( $stored ) ? $stored : array(); $history[] = array( 'role' => 'user', 'content' => $msg, ); $context = self::sanitize_context( isset( $_POST['context'] ) ? wp_unslash( $_POST['context'] ) : '' ); $result = self::run_tool_use_loop( $history, self::build_system_prompt( $context ) ); if ( is_wp_error( $result ) ) { wp_send_json_error( array( 'message' => $result->get_error_message() ), 500 ); } $trimmed = array_slice( $result['messages'], -20 ); set_transient( $transient, $trimmed, self::TRANSCRIPT_TTL ); wp_send_json_success( array( 'text' => $result['text'], 'html' => self::render_markdown( $result['text'] ), 'tool_count' => self::count_tool_uses( $result['messages'] ), ) ); } public static function ajax_clear() { check_ajax_referer( self::NONCE_ACTION, 'nonce' ); if ( ! self::user_can_chat() ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'ai-eshop-optimizer' ) ), 403 ); } delete_transient( self::TRANSIENT_PREFIX . get_current_user_id() ); wp_send_json_success(); } public static function ajax_save_pins() { check_ajax_referer( self::NONCE_ACTION, 'nonce' ); if ( ! self::user_can_chat() ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'ai-eshop-optimizer' ) ), 403 ); } $raw = isset( $_POST['pins'] ) && is_array( $_POST['pins'] ) ? wp_unslash( $_POST['pins'] ) : array(); $pins = array(); foreach ( $raw as $question ) { $question = is_string( $question ) ? trim( sanitize_text_field( $question ) ) : ''; if ( '' !== $question && ! in_array( $question, $pins, true ) ) { $pins[] = mb_substr( $question, 0, 300 ); } } $pins = array_slice( $pins, 0, self::MAX_PINS ); update_user_meta( get_current_user_id(), self::PINS_META, $pins ); wp_send_json_success( array( 'pins' => $pins ) ); } public static function ajax_save_group_order() { check_ajax_referer( self::NONCE_ACTION, 'nonce' ); if ( ! self::user_can_chat() ) { wp_send_json_error( array( 'message' => __( 'Insufficient permissions.', 'ai-eshop-optimizer' ) ), 403 ); } if ( ! empty( $_POST['reset'] ) ) { delete_user_meta( get_current_user_id(), self::GROUPS_META ); wp_send_json_success( array( 'order' => array() ) ); } $raw = isset( $_POST['order'] ) && is_array( $_POST['order'] ) ? wp_unslash( $_POST['order'] ) : array(); $order = array(); foreach ( $raw as $key ) { if ( is_string( $key ) && 1 === preg_match( '/^g[0-9a-f]{12}$/', $key ) && ! in_array( $key, $order, true ) ) { $order[] = $key; } } update_user_meta( get_current_user_id(), self::GROUPS_META, array_slice( $order, 0, 200 ) ); wp_send_json_success( array( 'order' => $order ) ); } public static function build_system_prompt( $context = '' ) { $context = self::sanitize_context( $context ); $runs = array(); if ( class_exists( 'WooCommerce' ) ) { $runs[] = 'WooCommerce (a store; the merchant\'s questions below)'; } if ( defined( 'ROOSTER_LMS_VERSION' ) || post_type_exists( 'rooster_course' ) ) { $runs[] = 'Rooster LMS (e-learning: courses, lessons, quizzes, learners, enrolments; its tools are under rooster_lms_ and the LRS\'s under rooster_lrs_)'; } $where = "\n# Where you are\n\n"; $where .= 'This site runs: ' . ( array() === $runs ? 'WordPress' : implode( '; ', $runs ) ) . ".\n"; if ( '' !== $context ) { $where .= 'The operator opened this chat from ' . str_replace( '/', ' › ', $context ) . ": read their questions in that light first.\n"; } $skills = (array) apply_filters( 'rooster/insights_chat/skills', array(), $context ); $extra = ''; foreach ( $skills as $product => $markdown ) { $markdown = trim( (string) $markdown ); if ( '' !== $markdown ) { $extra .= "\n## " . self::product_label( $product ) . " skills\n\n" . $markdown . "\n"; } } $lines = array(); $registry = function_exists( 'wp_get_abilities' ) ? wp_get_abilities() : array(); ksort( $registry ); $prefixes = self::tool_prefixes(); foreach ( $registry as $name => $ability ) { $ok = false; foreach ( $prefixes as $p ) { if ( 0 === strpos( $name, $p ) ) { $ok = true; break; } } if ( ! $ok || ! self::tool_allowed( $name, $ability ) ) { continue; } $anthropic_name = self::to_anthropic_tool_name( $name ); $desc = method_exists( $ability, 'get_description' ) ? $ability->get_description() : ''; if ( '' === trim( (string) $desc ) && method_exists( $ability, 'get_label' ) ) { $desc = $ability->get_label(); } $lines[] = sprintf( '- %s: %s', $anthropic_name, $desc ); } $header = self::SYSTEM_PROMPT_HEADER; if ( '' !== $extra ) { $header = str_replace( "\n## Generic WooCommerce questions", $extra . "\n## Generic WooCommerce questions", $header ); } return $header . "\n" . implode( "\n", $lines ) . $where; } public static function build_tool_list() { $tools = array(); $registry = function_exists( 'wp_get_abilities' ) ? wp_get_abilities() : array(); $prefixes = self::tool_prefixes(); foreach ( $registry as $name => $ability ) { $ok = false; foreach ( $prefixes as $p ) { if ( 0 === strpos( $name, $p ) ) { $ok = true; break; } } if ( ! $ok || ! self::tool_allowed( $name, $ability ) ) { continue; } $anthropic_name = self::to_anthropic_tool_name( $name ); $desc = method_exists( $ability, 'get_description' ) ? $ability->get_description() : ''; if ( '' === trim( (string) $desc ) && method_exists( $ability, 'get_label' ) ) { $desc = $ability->get_label(); } $schema = method_exists( $ability, 'get_input_schema' ) ? (array) $ability->get_input_schema() : array(); if ( empty( $schema ) || ! isset( $schema['type'] ) ) { $schema = array( 'type' => 'object', 'properties' => new stdClass(), 'required' => array(), ); } elseif ( 'object' === $schema['type'] && ( empty( $schema['properties'] ) || ! is_array( $schema['properties'] ) || 0 === count( $schema['properties'] ) ) ) { $schema['properties'] = new stdClass(); } $tools[] = array( 'name' => $anthropic_name, 'description' => (string) $desc, 'input_schema' => $schema, ); } return $tools; } private static function to_anthropic_tool_name( $ability_name ) { return str_replace( array( '/', '-' ), array( '_', '_' ), $ability_name ); } private static function from_anthropic_tool_name( $anthropic_name ) { $registry = function_exists( 'wp_get_abilities' ) ? wp_get_abilities() : array(); foreach ( array_keys( $registry ) as $name ) { if ( self::to_anthropic_tool_name( $name ) === $anthropic_name ) { return $name; } } return null; } private static function run_tool_use_loop( $messages, $system_prompt, $max_iterations = self::MAX_ITERATIONS ) { $tools = self::build_tool_list(); $api_key = self::get_anthropic_api_key(); if ( ! $api_key ) { return new WP_Error( 'no_api_key', __( 'Rooster Content Connector token not configured. Paste it in the Rooster One Pro to enable Insights Chat.', 'ai-eshop-optimizer' ) ); } $model = self::get_model(); $messages_url = self::get_rooster_messages_url(); for ( $i = 0; $i < $max_iterations; $i++ ) { $response = wp_remote_post( $messages_url, array( 'headers' => array( 'X-OM-API-Key' => $api_key, 'content-type' => 'application/json', 'X-OM-Source' => class_exists( 'AIEO_AI_Sources' ) ? AIEO_AI_Sources::CHAT_MERCHANT : 'aieo.chat.merchant', 'X-OM-Site' => preg_replace( '/^www\./', '', strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) ), ), 'body' => wp_json_encode( array( 'model' => $model, 'max_tokens' => 4096, 'system' => $system_prompt, 'tools' => $tools, 'messages' => $messages, ) ), 'timeout' => 90, ) ); if ( is_wp_error( $response ) ) { return $response; } $code = wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 200 !== (int) $code ) { $err = isset( $body['error']['message'] ) ? $body['error']['message'] : wp_remote_retrieve_body( $response ); return new WP_Error( 'anthropic_http_' . $code, sprintf( __( 'Anthropic API returned HTTP %1$d: %2$s', 'ai-eshop-optimizer' ), (int) $code, $err ) ); } if ( ! is_array( $body ) || empty( $body['content'] ) ) { return new WP_Error( 'anthropic_bad_body', __( 'Anthropic returned an empty/invalid response.', 'ai-eshop-optimizer' ) ); } $assistant_content = self::normalise_assistant_content( $body['content'] ); $messages[] = array( 'role' => 'assistant', 'content' => $assistant_content, ); $stop_reason = isset( $body['stop_reason'] ) ? $body['stop_reason'] : ''; if ( 'tool_use' === $stop_reason ) { $tool_results = array(); foreach ( $body['content'] as $block ) { if ( ! is_array( $block ) || empty( $block['type'] ) || 'tool_use' !== $block['type'] ) { continue; } $anthropic_name = isset( $block['name'] ) ? (string) $block['name'] : ''; $ability_name = self::from_anthropic_tool_name( $anthropic_name ); $tool_input = isset( $block['input'] ) ? (array) $block['input'] : array(); if ( ! $ability_name || ! function_exists( 'wp_get_ability' ) ) { $result_str = 'Error: ability ' . $anthropic_name . ' not registered.'; } else { $ability = wp_get_ability( $ability_name ); if ( ! $ability ) { $result_str = 'Error: ability ' . $ability_name . ' not found in registry.'; } else { $result = $ability->execute( $tool_input ); if ( is_wp_error( $result ) ) { $result_str = 'Error: ' . $result->get_error_message(); } else { $result_str = is_string( $result ) ? $result : wp_json_encode( $result ); } } } $tool_results[] = array( 'type' => 'tool_result', 'tool_use_id' => isset( $block['id'] ) ? $block['id'] : '', 'content' => (string) $result_str, ); } if ( empty( $tool_results ) ) { $text = self::extract_text( $body['content'] ); return array( 'text' => $text !== '' ? $text : __( '(no response)', 'ai-eshop-optimizer' ), 'messages' => $messages, ); } $messages[] = array( 'role' => 'user', 'content' => $tool_results, ); continue; } if ( 'end_turn' === $stop_reason || 'stop_sequence' === $stop_reason ) { return array( 'text' => self::extract_text( $body['content'] ), 'messages' => $messages, ); } $text = self::extract_text( $body['content'] ); return array( 'text' => $text !== '' ? $text : sprintf( __( '(response stopped: %s)', 'ai-eshop-optimizer' ), $stop_reason ), 'messages' => $messages, ); } return new WP_Error( 'tool_loop_exhausted', sprintf( __( 'Tool-use loop exceeded %d iterations.', 'ai-eshop-optimizer' ), $max_iterations ) ); } private static function normalise_assistant_content( $content ) { if ( ! is_array( $content ) ) { return array(); } $out = array(); foreach ( $content as $block ) { if ( ! is_array( $block ) ) { continue; } if ( isset( $block['caller'] ) ) { unset( $block['caller'] ); } if ( isset( $block['type'] ) && 'tool_use' === $block['type'] ) { $input = $block['input'] ?? array(); if ( is_array( $input ) && empty( $input ) ) { $block['input'] = new stdClass(); } } $out[] = $block; } return $out; } private static function extract_text( $content ) { $text = ''; if ( ! is_array( $content ) ) { return $text; } foreach ( $content as $block ) { if ( is_array( $block ) && isset( $block['type'] ) && 'text' === $block['type'] && isset( $block['text'] ) ) { $text .= $block['text']; } } return $text; } private static function count_tool_uses( $messages ) { $n = 0; foreach ( $messages as $msg ) { if ( ! is_array( $msg ) || 'assistant' !== ( $msg['role'] ?? '' ) ) { continue; } $content = $msg['content'] ?? array(); if ( ! is_array( $content ) ) { continue; } foreach ( $content as $block ) { if ( is_array( $block ) && isset( $block['type'] ) && 'tool_use' === $block['type'] ) { $n++; } } } return $n; } private static function get_anthropic_api_key() { return (string) get_option( 'aieo_rooster_api_key', '' ); } private static function get_rooster_messages_url() { $base = (string) get_option( 'aieo_rooster_hub_url', 'https://rooster.systems' ); if ( '' === $base ) { $base = 'https://rooster.systems'; } return rtrim( $base, '/' ) . '/wp-json/om-portal/v1/anthropic/messages'; } private static function get_model() { $model = (string) get_option( 'aieo_chat_model', '' ); if ( '' !== $model ) { return $model; } return self::DEFAULT_MODEL; } public static function render_markdown( $markdown ) { $markdown = (string) $markdown; if ( '' === trim( $markdown ) ) { return ''; } $markdown = str_replace( array( "\r\n", "\r" ), "\n", $markdown ); $placeholders = array(); $markdown = preg_replace_callback( '/```([a-zA-Z0-9]*)\n([\s\S]*?)```/', static function ( $m ) use ( &$placeholders ) { $lang = $m[1] !== '' ? ' class="language-' . preg_replace( '/[^a-zA-Z0-9_-]/', '', $m[1] ) . '"' : ''; $html = '
    ' . esc_html( $m[2] ) . '
    '; $key = '%%AIEO_MCHAT_CODE_' . count( $placeholders ) . '%%'; $placeholders[ $key ] = $html; return $key; }, $markdown ); $blocks = preg_split( "/\n{2,}/", $markdown ); $out = array(); foreach ( $blocks as $block ) { $block = rtrim( $block ); if ( '' === $block ) { continue; } if ( preg_match( '/^(#{1,6})\s+(.+)$/m', $block, $hm ) && substr_count( $block, "\n" ) === 0 ) { $level = strlen( $hm[1] ); $out[] = '' . self::inline_md( $hm[2] ) . ''; continue; } if ( preg_match( '/^\s*\|.+\|\s*\n\s*\|[\s:|-]+\|\s*\n/', $block ) ) { $out[] = self::render_md_table( $block ); continue; } if ( preg_match( '/^\s*[-*+]\s+/m', $block ) && self::all_lines_match( $block, '/^\s*[-*+]\s+/' ) ) { $items = array(); foreach ( explode( "\n", $block ) as $line ) { if ( preg_match( '/^\s*[-*+]\s+(.+)$/', $line, $lm ) ) { $items[] = '
  • ' . self::inline_md( $lm[1] ) . '
  • '; } } $out[] = ''; continue; } if ( preg_match( '/^\s*\d+\.\s+/m', $block ) && self::all_lines_match( $block, '/^\s*\d+\.\s+/' ) ) { $items = array(); foreach ( explode( "\n", $block ) as $line ) { if ( preg_match( '/^\s*\d+\.\s+(.+)$/', $line, $lm ) ) { $items[] = '
  • ' . self::inline_md( $lm[1] ) . '
  • '; } } $out[] = '
      ' . implode( '', $items ) . '
    '; continue; } $paragraph = self::inline_md( $block ); $paragraph = nl2br( $paragraph ); $out[] = '

    ' . $paragraph . '

    '; } $html = implode( "\n", $out ); foreach ( $placeholders as $key => $code_html ) { $html = str_replace( $key, $code_html, $html ); } return wp_kses_post( $html ); } private static function all_lines_match( $block, $regex ) { foreach ( explode( "\n", $block ) as $line ) { if ( '' === trim( $line ) ) { continue; } if ( ! preg_match( $regex, $line ) ) { return false; } } return true; } private static function render_md_table( $block ) { $lines = array_values( array_filter( array_map( 'trim', explode( "\n", $block ) ), 'strlen' ) ); if ( count( $lines ) < 2 ) { return '

    ' . self::inline_md( $block ) . '

    '; } $split_row = static function ( $row ) { $row = trim( $row, " \t|" ); return array_map( 'trim', explode( '|', $row ) ); }; $headers = $split_row( $lines[0] ); $body = array_slice( $lines, 2 ); $html = ''; foreach ( $headers as $h ) { $html .= ''; } $html .= ''; foreach ( $body as $row ) { $cells = $split_row( $row ); $html .= ''; foreach ( $cells as $c ) { $html .= ''; } $html .= ''; } $html .= '
    ' . self::inline_md( $h ) . '
    ' . self::inline_md( $c ) . '
    '; return $html; } private static function inline_md( $text ) { $text = esc_html( $text ); $text = preg_replace( '/`([^`]+)`/', '$1', $text ); $text = preg_replace( '/\*\*([^*]+)\*\*/', '$1', $text ); $text = preg_replace( '/__([^_]+)__/', '$1', $text ); $text = preg_replace( '/(?$1', $text ); $text = preg_replace( '/(?$1', $text ); $text = preg_replace_callback( '/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/', static function ( $m ) { return '' . $m[1] . ''; }, $text ); return $text; } private static function inline_css() { return ' /* * Full width. The 1200px cap was inherited from the reading-width * convention the rest of this admin uses, and it is wrong here: a * conversation is not prose you scan, it is a column you watch fill * up beside a list you pick from, and the answers carry tables that * had to scroll inside a box while the screen sat half empty. */ .aieo-merchant-chat-wrap { max-width: none; } /* * Was a purple-to-blue gradient. The chat now wears the assistant * palette on BOTH surfaces — the page and the detached window — * because the button that opens it is near-black with a clay spark * and a purple bar on arrival read as a different product. */ .aieo-mchat-header { margin: 12px 0 20px; padding: 16px 20px; background: ' . self::INK . '; color: #fff; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,.08); } .aieo-mchat-header h1 { color: inherit; margin: 0 0 6px; font-size: 22px; display: flex; align-items: center; gap: 10px; } /* The title takes the colour of its header (admin chrome 0.45.2): white on the banner of the detached window, the text colour of the card on a chrome screen, where the header is a card of the kit (a white title was invisible there). */ /* Was .aieo-mchat-bot-avatar sizing a 26px emoji; the mark is an SVG now. */ .aieo-mchat-header h1 .aieo-spark { flex: 0 0 auto; color: ' . self::SPARK . '; } .aieo-mchat-tagline { margin: 0; opacity: .9; font-size: 13px; } /* * "Try asking" LEFT and FIRST, the conversation right. * * It is also first in the markup, not just first by CSS order, so a * narrow viewport stacks it on top without a second rule and a * screen reader meets it in the same order the eye does. That is the * point of the column: nobody arrives at a chat about their own shop * knowing what it can be asked, and the suggestions are the answer to * that — putting them second made them the thing you found after * giving up. */ /* * 32% for "Try asking", the rest for the conversation. * * A PROPORTION, not the 300px it used to be, because the wrap is no * longer capped: on a wide screen a fixed column shrinks to a sliver * beside the conversation and its two-line group labels start * wrapping to three, while on a laptop the same number is nearly * half the screen. */ .aieo-mchat-layout { display: grid; grid-template-columns: 32% minmax(0,1fr); gap: 20px; align-items: start; } @media (max-width: 980px) { .aieo-mchat-layout { grid-template-columns: 1fr; } } /* * ⚠️ `hidden` DOES NOT WORK ON THESE WITHOUT THIS RULE, and it fails * silently. The browser default `[hidden] { display: none }` is an * attribute selector, so it carries the same specificity as the * class rule above — and at equal specificity an author stylesheet * outranks the user agent one. `display: grid` therefore wins and the * element stays on screen with its hidden attribute set. The same * applies to the hand-off card, whose .aieo-dmm-card sets display:flex. * * The symptom was the card appearing ABOVE a conversation that was * supposed to have stepped aside, which reads as the feature being * broken rather than as a CSS precedence quirk. * * NOTE FOR EDITORS: this whole block is a SINGLE-QUOTED PHP string. * An apostrophe in a comment here is a parse error that takes the * class down with it. Do not write "the browser is" as "browser^s". */ .aieo-mchat-layout[hidden], .aieo-mchat-card[hidden], .aieo-mchat-elsewhere[hidden], .rooster-admin-split[data-ra-panel="insights-chat"][hidden], .rooster-admin-windows[data-ra-windows="insights-chat"][hidden] { display: none !important; } .aieo-mchat-card .aieo-mchat-transcript { padding: 0 0 14px; min-height: 360px; } /* A bounded window (floating from 782px, docked beside the main one from 960px, as the library draws them): the transcript takes the free height and scrolls (the library), so it neither keeps 360px nor stops at 60vh, and the input and Send stay in sight. Narrower, the windows stand in the page and keep both. */ @media (width >= 782px) { .rooster-admin-window.is-floating .aieo-mchat-transcript { min-height: 0; max-height: none; } } @media (width >= 960px) { .rooster-admin-window.is-docked:not(.is-main) .aieo-mchat-transcript { min-height: 0; max-height: none; } } .aieo-mchat-card .aieo-mchat-form { border-top: 0; padding: 0; } .aieo-mchat-main { background: #fff; border: 1px solid #dcdcde; border-radius: 8px; display: flex; flex-direction: column; min-height: 600px; } .aieo-mchat-sidebar { background: #fff; border: 1px solid #dcdcde; border-radius: 8px; padding: 16px; align-self: start; } .aieo-mchat-sidebar h3 { margin: 0 0 8px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: #50575e; } .aieo-mchat-sidebar h3 + h3 { margin-top: 18px; } .aieo-mchat-suggestions, .aieo-mchat-notes { list-style: none; margin: 0 0 8px; padding: 0; } .aieo-mchat-suggestions li { margin-bottom: 6px; } .aieo-mchat-chip { background: #f0f0f1; border: 1px solid #dcdcde; border-radius: 16px; padding: 6px 12px; cursor: pointer; font-size: 12px; text-align: left; width: 100%; } .aieo-mchat-chip:hover { background: #e8e8ea; } /* A suggestion and its pin on one row; the pin is filled while the question is pinned. */ .aieo-mchat-suggestions li.aieo-mchat-suggestion { display: flex; align-items: center; gap: 4px; } .aieo-mchat-suggestion .aieo-mchat-chip { flex: 1 1 auto; min-width: 0; } .aieo-mchat-suggestion .aieo-mchat-pin { flex: 0 0 auto; color: #8c8f94; } .aieo-mchat-suggestion .aieo-mchat-pin:hover, .aieo-mchat-suggestion .aieo-mchat-pin[aria-pressed="true"] { color: #7b3fa5; } .aieo-mchat-pin[aria-pressed="true"] svg { fill: currentColor; } /* The pinned questions: the ordered list of the library; the words of a row put it in the question box as its button does. */ .aieo-mchat-pins { margin: 0 0 12px; } .aieo-mchat-pins .rooster-admin-olist__list:empty { display: none; } .aieo-mchat-pins .rooster-admin-olist__label { cursor: pointer; white-space: normal; font-size: 12px; line-height: 1.35; flex: 1 1 0; } /* One line per pinned question in the narrow panel: grip, the words (wrapping in their own column), the two buttons; the list row of the library wraps, which stacked the three on three lines. */ .aieo-mchat-pins .rooster-admin-olist__list { gap: 4px; } .aieo-mchat-pins .rooster-admin-olist__row { flex-wrap: nowrap; column-gap: 6px; min-height: 0; padding: 3px 4px 3px 6px; } .aieo-mchat-pins .rooster-admin-olist__end { gap: 0; } /* A pinned question fitted to one line (the script shrinks it to its row, as the fit-label of the theme does). */ .aieo-mchat-pins .rooster-admin-olist__label.is-one-line { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /* The families as rows the person moves: the grip of the library beside each family. */ .aieo-mchat-groups-hint { margin: 0 0 8px; font-size: 12px; color: #50575e; } .aieo-mchat-groups-list { list-style: none; margin: 0; padding: 0; } .aieo-mchat-group-row { display: flex; align-items: flex-start; gap: 4px; margin: 0 0 6px; } .aieo-mchat-group-row > .rooster-admin-grip { flex: 0 0 auto; margin-top: 5px; } .aieo-mchat-group-row > .aieo-mchat-suggestion-group { flex: 1 1 auto; min-width: 0; margin-bottom: 0; } .aieo-mchat-groups-foot { display: flex; align-items: center; gap: 8px; min-height: 28px; } .aieo-mchat-groups-status { font-size: 12px; color: #50575e; } .aieo-mchat-groups-status.is-failed { color: #b32d2e; } /* Accordion of suggestion groups — native details/summary, * no JS. Closed by default so the sidebar shows just the * category labels; clicking opens that groups chips. */ .aieo-mchat-suggestion-groups { margin: 0 0 14px; } .aieo-mchat-suggestion-group { border: 1px solid #dcdcde; border-radius: 6px; margin-bottom: 6px; background: #fafafa; } .aieo-mchat-suggestion-group > summary { padding: 8px 12px; font-size: 13px; font-weight: 600; cursor: pointer; list-style: none; display: flex; align-items: center; justify-content: space-between; color: #1d2327; user-select: none; } .aieo-mchat-suggestion-group > summary::-webkit-details-marker { display: none; } .aieo-mchat-suggestion-group > summary::before { content: "▸"; display: inline-block; margin-right: 8px; transition: transform .15s ease; color: #7b3fa5; font-size: 11px; } .aieo-mchat-suggestion-group[open] > summary::before { transform: rotate(90deg); } .aieo-mchat-suggestion-group > summary:hover { background: #f0f0f1; } .aieo-mchat-suggestion-group .aieo-mchat-group-label { flex: 1; } .aieo-mchat-suggestion-group .aieo-mchat-group-count { font-size: 11px; color: #7b3fa5; font-weight: 500; } .aieo-mchat-suggestion-group .aieo-mchat-suggestions { padding: 6px 10px 10px; margin: 0; } .aieo-mchat-suggestion-group .aieo-mchat-chip { font-size: 11px; padding: 5px 10px; } .aieo-mchat-notes li { font-size: 12px; color: #50575e; margin-bottom: 6px; padding-left: 14px; position: relative; } .aieo-mchat-notes li::before { content: "•"; position: absolute; left: 0; color: #7b3fa5; } .aieo-mchat-transcript { flex: 1; padding: 18px; overflow-y: auto; max-height: 60vh; } .aieo-mchat-empty { color: #50575e; text-align: center; padding: 40px 20px; font-style: italic; } .aieo-mchat-empty.is-state { padding: 0; font-style: normal; } .aieo-mchat-bubble { display: flex; gap: 12px; margin-bottom: 16px; align-items: flex-start; } .aieo-mchat-bubble--user { flex-direction: row-reverse; } .aieo-mchat-avatar { width: 32px; height: 32px; flex: 0 0 32px; border-radius: 50%; background: #f0f0f1; display: flex; align-items: center; justify-content: center; font-size: 18px; } .aieo-mchat-bubble--user .aieo-mchat-avatar { background: #dbeafe; } .aieo-mchat-bubble--bot .aieo-mchat-avatar { background: #f3e8ff; } .aieo-mchat-body { background: #f6f7f7; border-radius: 10px; padding: 12px 16px; max-width: 80%; line-height: 1.55; } .aieo-mchat-bubble--user .aieo-mchat-body { background: #2271b1; color: #fff; } .aieo-mchat-bubble--user .aieo-mchat-body a { color: #fff; text-decoration: underline; } .aieo-mchat-body h1, .aieo-mchat-body h2, .aieo-mchat-body h3, .aieo-mchat-body h4 { margin: 12px 0 6px; line-height: 1.3; } .aieo-mchat-body h1 { font-size: 18px; } .aieo-mchat-body h2 { font-size: 16px; } .aieo-mchat-body h3 { font-size: 14px; } .aieo-mchat-body p { margin: 6px 0; } .aieo-mchat-body ul, .aieo-mchat-body ol { margin: 6px 0 6px 22px; } .aieo-mchat-body li { margin: 2px 0; } .aieo-mchat-body code { background: rgba(0,0,0,.06); padding: 1px 5px; border-radius: 3px; font-size: 90%; } .aieo-mchat-body pre { background: #0f172a; color: #f1f5f9; padding: 10px 12px; border-radius: 6px; overflow-x: auto; font-size: 12px; } .aieo-mchat-body pre code { background: transparent; color: inherit; padding: 0; } .aieo-mchat-table { width: 100%; border-collapse: collapse; margin: 8px 0; font-size: 13px; } .aieo-mchat-table th, .aieo-mchat-table td { border: 1px solid #dcdcde; padding: 6px 9px; text-align: left; } .aieo-mchat-table th { background: #f0f0f1; } .aieo-mchat-form { border-top: 1px solid #dcdcde; padding: 14px 18px; } .aieo-mchat-input { width: 100%; resize: vertical; font-size: 14px; padding: 10px; border: 1px solid #c3c4c7; border-radius: 6px; } .aieo-mchat-input:focus { border-color: #7b3fa5; box-shadow: 0 0 0 1px #7b3fa5; outline: 0; } .aieo-mchat-toolbar { display: flex; align-items: center; gap: 10px; margin-top: 10px; flex-wrap: wrap; } .aieo-mchat-footer-note { margin-left: auto; color: #646970; font-size: 12px; } .aieo-mchat-thinking { opacity: .8; font-style: italic; } .aieo-mchat-error { color: #b32d2e; } .aieo-mchat-elsewhere { padding: 16px 18px; margin: 12px 0 0; } .aieo-mchat-elsewhere-title { font-weight: 600; margin: 0 0 6px; } .aieo-mchat-elsewhere-body { margin: 0 0 12px; color: #50575e; max-width: 62ch; } .aieo-dmm-chat-open { display: inline-flex; align-items: center; gap: 8px; } .aieo-dmm-chat-open .aieo-dmm-chat-open-glyph { font-size: 15px; line-height: 1; } '; } private static function window_open_args() { return array( 'name' => 'aieo_insights_chat', 'features' => 'popup=yes,menubar=no,toolbar=no,location=no,status=no,resizable=yes,scrollbars=yes', ); } public static function spark_svg( $size = 16 ) { printf( '', (int) $size ); } public static function print_window_opener_script() { static $printed = false; if ( $printed ) { return; } $printed = true; $args = self::window_open_args(); $data = array( 'url' => self::window_url(), 'pageUrl' => admin_url( 'admin.php?page=ai-eshop-dmm&dmm_tab=insights_chat' ), 'name' => $args['name'], 'features' => $args['features'], 'aliveKey' => 'aieo_mchat_window_alive', 'closeKey' => 'aieo_mchat_window_close', 'staleMs' => 15000, ); ?> "']/g, function (c) { return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]; }); } function appendUserBubble(text) { $transcript.find('.aieo-mchat-empty').remove(); var html = '
    ' + '' + '
    ' + escapeHtml(text) + '
    ' + '
    '; $transcript.append(html); scrollBottom(); } function appendBotBubble(html, extraClass) { $transcript.find('.aieo-mchat-empty').remove(); var cls = 'aieo-mchat-bubble aieo-mchat-bubble--bot' + (extraClass ? ' ' + extraClass : ''); var node = $( '
    ' + '' + '
    ' + '
    ' ); node.find('.aieo-mchat-body').html(html); $transcript.append(node); scrollBottom(); return node; } function scrollBottom() { $transcript.scrollTop($transcript[0].scrollHeight); } $form.on('submit', function (e) { e.preventDefault(); var msg = $.trim($input.val()); if (!msg) return; appendUserBubble(msg); $input.val(''); $send.prop('disabled', true); if ($sendWhy.length) { $send.attr('aria-describedby', 'aieo-mchat-send-reason'); $sendWhy.prop('hidden', false); } var $thinking = appendBotBubble( '' + escapeHtml(AIEOMerchantChat.thinking) + '', 'aieo-mchat-pending' ); $.post(AIEOMerchantChat.ajaxUrl, { action: 'aieo_merchant_chat_send', nonce: AIEOMerchantChat.nonce, message: msg, context: $('#aieo-mchat-context').val() || '' }) .done(function (resp) { $thinking.remove(); if (resp && resp.success && resp.data && resp.data.html) { appendBotBubble(resp.data.html); } else { var err = (resp && resp.data && resp.data.message) || AIEOMerchantChat.unknownError; appendBotBubble('' + escapeHtml(AIEOMerchantChat.error + ' ' + err) + ''); } }) .fail(function (xhr) { $thinking.remove(); var err = (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) || xhr.statusText || AIEOMerchantChat.networkError; appendBotBubble('' + escapeHtml(AIEOMerchantChat.error + ' ' + err) + ''); }) .always(function () { $send.prop('disabled', false); if ($sendWhy.length) { $send.removeAttr('aria-describedby'); $sendWhy.prop('hidden', true); } $input.focus(); }); }); // The dialog's count follows the transcript (the messages Clear would remove). var $clearN = $('#aieo-mchat-clear-dialog-n'); if ($clearN.length && $transcript.length && window.MutationObserver) { new MutationObserver(function () { $clearN.text($transcript.find('.aieo-mchat-body').length.toLocaleString()); }).observe($transcript[0], { childList: true, subtree: true }); } $clear.on('click', function () { // On the page the library's dialog has already asked (the button carries data-ra-confirm); the detached // window has none, so it asks itself. if (!$clear.is('[data-ra-confirm]') && !window.confirm(AIEOMerchantChat.clearConf)) return; $.post(AIEOMerchantChat.ajaxUrl, { action: 'aieo_merchant_chat_clear', nonce: AIEOMerchantChat.nonce }).always(function () { var tpl = document.getElementById('aieo-mchat-tpl-empty'); if (tpl && tpl.content) { $transcript.empty().append(tpl.content.cloneNode(true)); return; } $transcript.html( '

    ' + escapeHtml(AIEOMerchantChat.emptyPrompt) + '

    ' ); }); }); $('.aieo-mchat-chip').on('click', function () { $input.val($(this).data('prompt')).focus(); }); // A request as a real Promise that fails with the server's reason, which a list's footer shows. var post = function (data) { return new Promise(function (resolve, reject) { $.post(AIEOMerchantChat.ajaxUrl, $.extend({ nonce: AIEOMerchantChat.nonce }, data)) .done(function (resp) { if (resp && resp.success) { resolve(resp); return; } reject(new Error((resp && resp.data && resp.data.message) || AIEOMerchantChat.unknownError)); }) .fail(function (xhr) { reject(new Error(xhr.statusText || AIEOMerchantChat.networkError)); }); }); }; // Saves one at a time, a burst (an arrow key held down) as one, each sending the state as it is when its // turn comes: parallel requests can land in any order and leave an older order saved. var serialSaver = function (send) { var chain = Promise.resolve(); var timer = null; var waiting = []; return function () { return new Promise(function (resolve, reject) { waiting.push([resolve, reject]); window.clearTimeout(timer); timer = window.setTimeout(function () { var batch = waiting; waiting = []; chain = chain.then(send).then( function (r) { batch.forEach(function (w) { w[0](r); }); }, function (err) { batch.forEach(function (w) { w[1](err); }); } ); }, 300); }); }; }; // ------------------------------------------------------------------ // Pinned questions, per person (user meta). The pin beside a // suggestion adds it to the list at the top of "Try asking" or takes // it out; the list is the library's ordered list, so its rows move by // their grip (a drag, the arrow keys, the Move menu). Every change is // saved at once, and the list's footer says "Saved", or why not. // ------------------------------------------------------------------ var $pins = $('#aieo-mchat-pins'); if ($pins.length) { var $pinList = $('#aieo-mchat-pins-list'); var $pinRows = $pinList.find('.rooster-admin-olist__list').first(); var $pinStatus = $pinList.find('[data-ra-order-status]').first(); var pinTpl = document.getElementById('aieo-mchat-pin-tpl'); var say = function (text) { if (window.roosterAdmin && window.roosterAdmin.announce) { window.roosterAdmin.announce(text); } }; var savedWord = ''; try { savedWord = JSON.parse($pinList.attr('data-ra-words') || '{}').saved || ''; } catch (e) {} var pinTexts = function () { return $pinRows.children('[data-ra-item]').map(function () { return $(this).find('.rooster-admin-olist__label').text(); }).get(); }; var savePins = serialSaver(function () { return post({ action: 'aieo_merchant_chat_pins', pins: pinTexts() }); }); var syncPins = function () { var keys = {}; $pinRows.children('[data-ra-item]').each(function () { keys[this.getAttribute('data-ra-item')] = true; }); $('[data-aieo-mchat-pin]').each(function () { this.setAttribute('aria-pressed', keys[this.getAttribute('data-aieo-mchat-pin')] ? 'true' : 'false'); }); $pins.find('.aieo-mchat-pins-empty').prop('hidden', $pinRows.children().length > 0); fitPins(); }; // The theme's fit-label technique (rooster-theme assets/js/modules/fit-label.js, the WordPress 7.1 Fit text // pattern, which only shrinks): a pinned question keeps its size when it fits on one line; one that runs // over takes the size that fits its row, measured once; below FIT_MIN it wraps at its own size instead. // Refitted when the panel is docked, floated or resized. var FIT_MIN = 9; // px, as the theme // The words' own width on one line, to the fraction (scrollWidth is whole pixels and never less than the // box, while the ellipsis shows at any fraction over). var textWidth = function (el) { var range = document.createRange(); range.selectNodeContents(el); return range.getBoundingClientRect().width; }; var fitPin = function (label) { label.style.fontSize = ''; label.classList.add('is-one-line'); var room = label.getBoundingClientRect().width; if (!room) { return; } // a folded panel: refitted when it shows var natural = textWidth(label); if (natural <= room) { return; } var base = parseFloat(window.getComputedStyle(label).fontSize); var size = Math.floor((base * room / natural) * 10) / 10; // Text does not scale exactly with its size (kerning, rounding): step down until it truly fits. for (var n = 0; size >= FIT_MIN && n < 8; n++) { label.style.fontSize = size + 'px'; if (textWidth(label) <= room) { return; } size = Math.round((size - 0.2) * 10) / 10; } label.style.fontSize = ''; label.classList.remove('is-one-line'); }; var fitPins = function () { $pinRows.find('.rooster-admin-olist__label').each(function () { fitPin(this); }); }; if (window.ResizeObserver && $pinList.length) { var fitWidth = 0; new window.ResizeObserver(function (entries) { var w = Math.round(entries[0].contentRect.width); if (w !== fitWidth) { fitWidth = w; fitPins(); } }).observe($pinList[0]); } fitPins(); // A pin or an unpin is not a move, so the list does not save it: save here and say so in its footer. var saveChange = function (said) { $pinStatus.removeClass('is-failed').text(''); savePins().then(function () { $pinStatus.text(savedWord); say(said); }, function () { $pinStatus.addClass('is-failed').text(AIEOMerchantChat.pinsFailed); say(AIEOMerchantChat.pinsFailed); }); }; // A move: the list hands over its save, so its footer settles on it. $pinList.on('rooster-admin:order', function (e) { var d = e.originalEvent && e.originalEvent.detail; if (d && 'function' === typeof d.save) { d.save(savePins()); } }); $(document).on('click', '[data-aieo-mchat-pin]', function () { var key = this.getAttribute('data-aieo-mchat-pin'); var $row = $pinRows.children('[data-ra-item="' + key + '"]'); if ($row.length) { $row.remove(); syncPins(); saveChange(AIEOMerchantChat.unpinned); return; } if ($pinRows.children().length >= (parseInt($pins.attr('data-max'), 10) || 40)) { say(AIEOMerchantChat.pinsFull); $pinStatus.addClass('is-failed').text(AIEOMerchantChat.pinsFull); return; } var li = pinTpl && pinTpl.content ? pinTpl.content.querySelector('li') : null; if (!li) { return; } $pinRows.append( li.outerHTML.split('%%AIEO_Q%%').join(escapeHtml(this.getAttribute('data-prompt') || '')) .split('%%AIEO_K%%').join(escapeHtml(key)) ); syncPins(); saveChange(AIEOMerchantChat.pinned); }); $pinRows.on('click', '[data-aieo-mchat-unpin]', function () { var $row = $(this).closest('[data-ra-item]'); var $next = $row.next('[data-ra-item]').length ? $row.next('[data-ra-item]') : $row.prev('[data-ra-item]'); $row.remove(); syncPins(); saveChange(AIEOMerchantChat.unpinned); // Focus stays in the list: the next row's unpin, else the hint. if ($next.length) { $next.find('[data-aieo-mchat-unpin]').trigger('focus'); } }); $pinRows.on('click', '[data-aieo-mchat-use], .rooster-admin-olist__label', function () { $input.val($(this).closest('[data-ra-item]').find('.rooster-admin-olist__label').text()).focus(); }); } // ------------------------------------------------------------------ // The order of the families, per person (user meta). The library moves // the rows (grip: a drag, the arrow keys, the Move menu; Reset to // default asks first); each move hands its save to the list, whose // footer says "Saved", or why not. Reset forgets the order. // ------------------------------------------------------------------ var $groups = $('#aieo-mchat-groups'); var groupsReset = false; // the latest action was Reset (a move after it clears this) var saveGroups = serialSaver(function () { var reset = groupsReset; groupsReset = false; return post({ action: 'aieo_merchant_chat_groups', order: reset ? [] : $groups.find('> ol > [data-ra-item]').map(function () { return this.getAttribute('data-ra-item'); }).get(), reset: reset ? 1 : 0 }); }); $groups.on('rooster-admin:order', function (e) { var d = e.originalEvent && e.originalEvent.detail; if (!d || 'function' !== typeof d.save) { return; } groupsReset = 'reset' === d.reason; d.save(saveGroups()); }); }); })(jQuery); JS; } }