$panel ) { $normalized = self::normalize( (string) $id, $panel ); if ( null !== $normalized ) { $list[ (string) $id ] = $normalized; } } } self::$panels = $list; return self::$panels; } /** * Get only the panels whose plugin is installed and active. * * @return array */ public static function get_active_panels(): array { return array_filter( self::get_panels(), fn( array $panel ): bool => (bool) call_user_func( $panel['available'] ) ); } /** * Get one panel by id. * * @param string $id Panel id. * @return array|null */ public static function get_panel( string $id ): ?array { $panels = self::get_panels(); return $panels[ $id ] ?? null; } /** * The fixed schema for the global widget settings. * * These belong to the core plugin, not to any integration, because the site * shows a single chat widget. * * @return array */ public static function get_widget_schema(): array { return [ 'botName' => [ 'type' => 'text', 'label' => __( 'Assistant Name', 'aisentic' ), 'help' => __( 'Displayed name in the chat widget header.', 'aisentic' ), 'default' => 'AI Assistant', ], 'headline' => [ 'type' => 'text', 'label' => __( 'Welcome Headline', 'aisentic' ), 'help' => __( 'Shown as the first message when a visitor opens the chat.', 'aisentic' ), 'default' => 'How can I help you today?', ], // Empty defaults so an untouched field keeps following the site language; // the widget fills in the translated text. 'greeting' => [ 'type' => 'text', 'label' => __( 'Greeting', 'aisentic' ), 'help' => __( 'The big first line in the chat and the pop-up. Leave empty to use the default, translated for your site language.', 'aisentic' ), 'default' => '', 'placeholder' => __( 'Hi there,', 'aisentic' ), // Two lines in the header on a phone. 'maxChars' => 50, ], 'inputPlaceholder' => [ 'type' => 'text', 'label' => __( 'Message Box Hint', 'aisentic' ), 'help' => __( 'Grey text in the empty message box. Leave empty to use the default, translated for your site language.', 'aisentic' ), 'default' => '', 'placeholder' => __( 'Ask me anything…', 'aisentic' ), // Two lines in the message box on a phone. 'maxChars' => 70, ], // A media library attachment id. Localize turns it into the thumbnail link. 'avatarId' => [ 'type' => 'image', 'label' => __( 'Assistant Avatar', 'aisentic' ), 'help' => __( 'A square image shown beside the name, like your logo. Empty shows the robot icon.', 'aisentic' ), 'default' => 0, ], 'brandColor' => [ 'type' => 'color', 'label' => __( 'Brand Color', 'aisentic' ), 'help' => __( 'Accent color for the chat widget. Empty uses the default violet.', 'aisentic' ), 'default' => '', ], 'position' => [ 'type' => 'select', 'label' => __( 'Widget Position', 'aisentic' ), 'help' => __( 'Which corner the chat launcher sits in.', 'aisentic' ), 'options' => [ 'bottom-right' => __( 'Bottom Right', 'aisentic' ), 'bottom-left' => __( 'Bottom Left', 'aisentic' ), ], 'default' => 'bottom-right', ], 'rateLimitPerHour' => [ 'type' => 'number', 'label' => __( 'Rate Limit (per hour)', 'aisentic' ), 'help' => __( 'Max messages per visitor per hour. Prevents abuse of your API quota.', 'aisentic' ), 'min' => 1, 'default' => 20, ], 'maxToolCallsPerTurn' => [ 'type' => 'number', 'label' => __( 'Max Tool Calls', 'aisentic' ), 'help' => __( 'Max AI tool calls per conversation turn. Higher values use more tokens.', 'aisentic' ), 'min' => 1, 'default' => 3, ], ]; } /** * Turn one raw panel declaration into the canonical shape. * * @param string $id Panel id. * @param mixed $panel Raw declaration from the filter. * @return array|null Null when the declaration is unusable. */ private static function normalize( string $id, $panel ): ?array { if ( '' === $id || ! is_array( $panel ) ) { return null; } if ( empty( $panel['fields'] ) || ! is_array( $panel['fields'] ) ) { return null; } $fields = [ 'enabled' => [ 'type' => 'toggle', 'label' => __( 'Enabled', 'aisentic' ), 'help' => '', 'default' => false, ], ]; foreach ( $panel['fields'] as $key => $field ) { $key = (string) $key; // The registry owns `enabled`; a panel may not redefine it. if ( 'enabled' === $key || ! is_array( $field ) || empty( $field['type'] ) ) { continue; } $field['type'] = (string) $field['type']; $field['label'] = isset( $field['label'] ) ? (string) $field['label'] : $key; $field['help'] = isset( $field['help'] ) ? (string) $field['help'] : ''; if ( ! array_key_exists( 'default', $field ) ) { $field['default'] = null; } $fields[ $key ] = $field; } $available = isset( $panel['available'] ) && is_callable( $panel['available'] ) ? $panel['available'] : fn(): bool => true; return [ 'id' => $id, 'label' => isset( $panel['label'] ) ? (string) $panel['label'] : $id, 'icon' => isset( $panel['icon'] ) ? (string) $panel['icon'] : 'plug', 'slot' => isset( $panel['slot'] ) ? (string) $panel['slot'] : '', // "Read more" on the panel's card. Only a web link, never a script. 'docs' => isset( $panel['docs'] ) && preg_match( '#^https?://#i', (string) $panel['docs'] ) ? esc_url_raw( (string) $panel['docs'] ) : '', 'available' => $available, 'fields' => $fields, 'topic' => self::normalize_topic( $panel['topic'] ?? null ), ]; } /** * The storefront topic card a panel declares, in a safe shape. * * @param mixed $topic Raw `topic` declaration. * @return array|null Null when there is no usable label. */ private static function normalize_topic( $topic ): ?array { if ( ! is_array( $topic ) || ! is_string( $topic['label'] ?? null ) || '' === trim( $topic['label'] ) ) { return null; } $icon = (string) ( $topic['icon'] ?? '' ); return [ 'label' => trim( $topic['label'] ), 'description' => is_string( $topic['description'] ?? null ) ? trim( $topic['description'] ) : '', 'icon' => in_array( $icon, self::TOPIC_ICONS, true ) ? $icon : 'chat', 'matches_page' => isset( $topic['matches_page'] ) && is_callable( $topic['matches_page'] ) ? $topic['matches_page'] : null, ]; } }