'dashicons-admin-post', 'page' => 'dashicons-admin-page', 'rsvl-pdf' => 'dashicons-pdf', 'rsvl-note' => 'dashicons-edit', 'product' => 'dashicons-products', ]; /** Post types listed first in training tables and filters. */ private const POST_TYPE_SORT_PRIORITY = ['page' => 0, 'post' => 1]; /** Period filter values for the analytics-style tabs, in display order. */ private const CALENDAR_PERIODS = ['this_month', 'last_month', '7', '30', '60', '90']; /** @var array Plugin settings merged with defaults. */ private $settings; /** @var RSVL_Conversations */ private $conversations; /** @var string */ private $active_tab; public function __construct() { $this->settings = RSVL_Settings::all(); $this->conversations = new RSVL_Conversations(); $this->active_tab = isset($_GET['tab']) ? sanitize_key(wp_unslash($_GET['tab'])) : 'dashboard'; } /** * Render the full settings page. */ public function render(): void { resolver_template('settings-page', [ 'active_tab' => $this->active_tab, 'is_pro' => resolver_is_pro(), 'nav_tabs' => RSVL_Admin::tabs(), 'show_save_button' => ! in_array($this->active_tab, ['dashboard', 'analytics', 'help'], true) && ! ('training' === $this->active_tab && in_array(RSVL_Training_Admin::get_active_panel(), ['custom-fields', 'product-fields'], true)), 'token_usage_percent' => RSVL_Token_Budget::usage_percent(), 'token_usage_url' => self::get_settings_tab_url('token-usage'), 'onboarding' => $this->get_onboarding_data(), 'tabs' => [ 'dashboard' => $this->filter_tab_data('dashboard', $this->get_dashboard_data()), 'model' => $this->filter_tab_data('model', (new RSVL_Model_Admin())->get_tab_data('model' === $this->active_tab)), 'training' => $this->filter_tab_data('training', (new RSVL_Training_Admin())->get_tab_data('training' === $this->active_tab)), 'appearance' => $this->filter_tab_data('appearance', $this->get_appearance_data()), 'visibility' => $this->filter_tab_data('visibility', (new RSVL_Visibility_Admin())->get_tab_data('visibility' === $this->active_tab)), 'chats' => $this->filter_tab_data('chats', (new RSVL_Chats_Admin())->get_tab_data('chats' === $this->active_tab)), 'analytics' => $this->filter_tab_data('analytics', $this->get_analytics_data()), 'token_usage' => $this->filter_tab_data('token_usage', $this->get_token_usage_data()), ], ]); } /** * Public so the dev-tools plugin can report the real onboarding state — * which steps are outstanding — instead of keeping its own copy of the * completion rules. */ public function get_onboarding_data(): array { if (get_option('resolver_onboarding_dismissed', false)) { return ['dismissed' => true, 'all_done' => false]; } $defaults = RSVL_Settings::defaults(); $provider = $this->settings['chat_provider'] ?? ''; $connected = '' !== $provider && self::is_provider_connected($provider); $base_url = 'https://resolverchatbot.com/knowledgebase/'; $instructions_changed = ( ($this->settings['prompt_identity'] ?? '') !== $defaults['prompt_identity'] || ($this->settings['prompt_rules'] ?? '') !== $defaults['prompt_rules'] || ($this->settings['prompt_tone'] ?? '') !== $defaults['prompt_tone'] || ($this->settings['prompt_fallback'] ?? '') !== $defaults['prompt_fallback'] ); $styles_changed = ( ($this->settings['accent_color'] ?? '') !== $defaults['accent_color'] || ($this->settings['bot_name'] ?? '') !== $defaults['bot_name'] || ($this->settings['greeting_message'] ?? '') !== $defaults['greeting_message'] ); $steps = [ [ 'key' => 'install', 'title' => __('Install Resolver', 'resolver-ai-chatbot'), 'desc' => __('Resolver has been installed and activated on your site!', 'resolver-ai-chatbot'), 'done' => true, 'tab' => '', 'docs_url' => '', ], [ 'key' => 'connect', 'title' => __('Connect an AI platform', 'resolver-ai-chatbot'), 'desc' => __('Connecting to an AI platform requires entering an API key.', 'resolver-ai-chatbot'), 'done' => $connected, 'tab' => 'model', 'docs_url' => $base_url . 'getting-started/connect-ai-platforms/', ], [ 'key' => 'model', 'title' => __('Choose an AI model', 'resolver-ai-chatbot'), 'desc' => __('The AI model is the source of intelligence for your chatbot\'s replies.', 'resolver-ai-chatbot'), 'done' => '' !== ($this->settings['chat_model'] ?? ''), 'tab' => 'model', 'docs_url' => $base_url . 'getting-started/choose-ai-model/', ], [ 'key' => 'embedding', 'title' => __('Pick a training platform', 'resolver-ai-chatbot'), 'desc' => __('The training platform helps your chatbot find relevant content from your site.', 'resolver-ai-chatbot'), 'done' => '' !== ($this->settings['embedding_provider'] ?? ''), 'tab' => 'model', 'docs_url' => $base_url . 'common-questions/what-is-embedding-provider/', ], [ 'key' => 'train', 'title' => __('Train your chatbot', 'resolver-ai-chatbot'), 'desc' => __('Select the pages you want to train your chatbot with, so it can provide relevant answers to your visitors.', 'resolver-ai-chatbot'), 'done' => RSVL_Index::has_trained_content(), 'tab' => 'training', 'docs_url' => $base_url . 'training/train-chatbot/', ], [ 'key' => 'instructions', 'title' => __('Edit the instructions', 'resolver-ai-chatbot'), 'desc' => __('Give your chatbot a persona, set rules, and define its tone of voice.', 'resolver-ai-chatbot'), 'done' => $instructions_changed, 'tab' => 'training&panel=instructions', 'docs_url' => $base_url . 'training/edit-chatbot-instructions/', ], [ 'key' => 'styles', 'title' => __('Customize your chatbot', 'resolver-ai-chatbot'), 'desc' => __('Change the accent color, bot name, and greeting message to match your brand.', 'resolver-ai-chatbot'), 'done' => $styles_changed, 'tab' => 'appearance', 'docs_url' => $base_url . 'getting-started/customize-chatbot/', ], [ 'key' => 'launch', 'title' => __('Launch your new chatbot', 'resolver-ai-chatbot'), 'desc' => __('Choose where the chatbot should and shouldn\'t display, then publish it.', 'resolver-ai-chatbot'), 'done' => 'none' !== ($this->settings['display_mode'] ?? 'none'), 'tab' => 'visibility', 'docs_url' => $base_url . 'getting-started/edit-visibility/', ], ]; $done_count = count(array_filter($steps, function ($s) { return $s['done']; })); return [ 'dismissed' => false, 'steps' => $steps, 'done_count' => $done_count, 'total' => count($steps), 'all_done' => $done_count === count($steps), // Whether the floating checklist was closed, which admin.js keeps // in a cookie, so the page is sent with it already closed. 'collapsed' => isset($_COOKIE['rsvl_onboarding_collapsed']) && '1' === sanitize_text_field(wp_unslash($_COOKIE['rsvl_onboarding_collapsed'])), ]; } /** * Let other code add to a tab's data before it's rendered. Pro fills in * its training panels, analytics and dashboard charts this way. */ private function filter_tab_data(string $tab, array $data): array { /** * Filter the data a settings tab is rendered with. * * @param array $data Tab data; 'is_active' says whether it's the open tab. * @param RSVL_Settings_Page $page The settings page, for its helpers. */ return (array) apply_filters("resolver_{$tab}_tab_data", $data, $this); } private function get_current_chat_provider(): string { return $this->settings['chat_provider'] ?? ''; } /* --------------------------------------------------------------------- * Dashboard tab * ------------------------------------------------------------------- */ private function get_dashboard_data(): array { $provider_id = $this->get_current_chat_provider(); $provider = RSVL_Provider::get_provider($provider_id); $models = $provider ? $provider->get_available_models() : []; $chat_model = $this->settings['chat_model'] ?? ''; $embedding_provider = self::get_current_embedding_provider(); $data = [ 'is_active' => 'dashboard' === $this->active_tab, 'is_pro' => resolver_is_pro(), 'provider_name' => $provider ? $provider->get_name() : ($provider_id ? ucfirst($provider_id) : '—'), 'model_label' => $models[ $chat_model ] ?? ($chat_model ?: '—'), 'model_connected' => '' !== $chat_model, 'provider_connected' => self::is_provider_connected($provider_id), 'embedding_model_label' => $this->get_provider_name($embedding_provider) ?: '—', 'embedding_connected' => self::is_provider_connected($embedding_provider), 'conversations_tab_url' => self::get_settings_tab_url('chats'), 'training_tab_url' => self::get_settings_tab_url('training'), 'token_usage_tab_url' => self::get_settings_tab_url('token-usage'), 'help_tab_url' => self::get_settings_tab_url('help'), 'total_conversations' => 0, 'trained_page_count' => 0, 'tokens_used_30d' => 0, 'recent_conversations' => [], 'recently_trained_pages' => [], 'chart' => ['labels' => [], 'embedding' => [], 'input' => [], 'output' => []], 'show_token_chart' => false, ]; if (! $data['is_active']) { return $data; } $stats = RSVL_Index::get_stats(); $token_series = $this->build_daily_token_series(...self::get_calendar_period_range('30')); $data['total_conversations'] = $this->conversations->get_conversation_count('all'); $data['trained_page_count'] = (int) $stats['total_posts']; $data['tokens_used_30d'] = (int) array_sum($token_series['input']) + (int) array_sum($token_series['output']); $data['recent_conversations'] = $this->get_recent_conversation_summaries(4); $data['recently_trained_pages'] = $this->get_recently_trained_pages(4); $data['chart'] = $token_series; $data['show_token_chart'] = (bool) (array_sum($token_series['input']) + array_sum($token_series['output']) + array_sum($token_series['embedding'])); return $data; } /** A provider's name, or '' for none. */ private function get_provider_name(string $provider_id): string { $provider = RSVL_Provider::get_provider($provider_id); return $provider ? $provider->get_name() : ''; } /** * Latest conversations, formatted for a dashboard list section. */ private function get_recent_conversation_summaries(int $limit): array { $summaries = []; foreach ($this->conversations->get_conversation_sessions($limit, 0) as $session) { $preview = wp_trim_words($session['first_message'], 12, '…'); $summaries[] = [ // Rendered as data-session on the row. The pages list that // shares this template has no session and simply omits it. 'session_id' => $session['session_id'], 'url' => RSVL_Admin::tab_url('chats', ['open_session' => $session['session_id']]), 'preview' => $preview ?: __('(no message)', 'resolver-ai-chatbot'), 'external' => false, 'meta' => [ ['text' => wp_date(get_option('date_format'), strtotime($session['updated_at'])), 'class' => ''], ['text' => wp_date(get_option('time_format'), strtotime($session['updated_at'])), 'class' => ''], ], ]; } return $summaries; } /** * Most recently trained posts, formatted for a dashboard list section. */ private function get_recently_trained_pages(int $limit): array { $pages = []; foreach (RSVL_Index::get_recent_posts($limit) as ['post' => $post, 'indexed_at' => $indexed_at]) { $pages[] = [ // Rendered as data-post-id on the row. The chats list that // shares this template has no post and simply omits it. 'post_id' => (int) $post->ID, 'url' => get_edit_post_link($post->ID) ?: (string) get_permalink($post->ID), 'preview' => get_the_title($post) ?: __('(untitled)', 'resolver-ai-chatbot'), 'external' => true, 'meta' => [ ['text' => wp_date(get_option('date_format'), strtotime($indexed_at)), 'class' => ''], ['text' => wp_date(get_option('time_format'), strtotime($indexed_at)), 'class' => ''], ], ]; } return $pages; } /* --------------------------------------------------------------------- * Appearance tab * ------------------------------------------------------------------- */ private function get_appearance_data(): array { $is_active = 'appearance' === $this->active_tab; return [ 'is_active' => $is_active, 'is_pro' => resolver_is_pro(), 'model_configured' => $is_active && $this->settings['chat_model'] !== '' && self::is_provider_connected($this->settings['chat_provider']), 'has_training_data' => $is_active && RSVL_Index::has_sources(), 'model_tab_url' => self::get_settings_tab_url('model'), 'training_tab_url' => self::get_settings_tab_url('training'), 'accent_color' => $this->settings['accent_color'], 'bot_name' => $this->settings['bot_name'], 'greeting_message' => $this->settings['greeting_message'], 'position' => $this->settings['position'] ?? 'bottom-right', 'show_attribution' => ! empty($this->settings['show_attribution']), ]; } /* --------------------------------------------------------------------- * Analytics tab * ------------------------------------------------------------------- */ /** * The Analytics tab only shows the Pro upgrade notice here; Pro replaces * its template and fills in the data. */ private function get_analytics_data(): array { return [ 'is_active' => 'analytics' === $this->active_tab, 'is_pro' => resolver_is_pro(), ]; } /* --------------------------------------------------------------------- * Token Usage tab * ------------------------------------------------------------------- */ private function get_token_usage_data(): array { $period = self::get_calendar_period('tu_period', 'this_month'); $limit = (int) ($this->settings['monthly_token_limit'] ?? 0); $data = [ 'is_active' => 'token-usage' === $this->active_tab, 'period_filters' => self::build_calendar_period_filters('token-usage', 'tu_period', $period), 'limit' => $limit, 'limit_preset' => in_array($limit, [0, 100000, 500000, 1000000, 2000000, 5000000], true) ? (string) $limit : 'custom', 'limit_presets' => $this->get_token_limit_presets(), 'portal' => $this->get_provider_usage_portal(), 'alert_enabled' => ! empty($this->settings['token_alert_enabled']), 'alert_email' => $this->settings['token_alert_email'] !== '' ? $this->settings['token_alert_email'] : get_option('admin_email'), 'chart' => ['labels' => [], 'embedding' => [], 'input' => [], 'output' => []], 'has_chart_data' => false, ]; if (! $data['is_active']) { return $data; } list($since, $until) = self::get_calendar_period_range($period); $data['chart'] = $this->build_daily_token_series($since, $until); $data['has_chart_data'] = array_sum($data['chart']['embedding']) > 0 || array_sum($data['chart']['input']) > 0 || array_sum($data['chart']['output']) > 0; return $data; } /** * Daily chat and indexing token totals over an inclusive date range, with * missing days filled with zeros so the chart has a continuous axis. */ private function build_daily_token_series(string $since, string $until): array { $by_day = []; foreach (RSVL_Analytics::get_daily_token_usage($since, $until) as $row) { $by_day[ $row['day'] ]['input'] = (int) $row['prompt_tokens']; $by_day[ $row['day'] ]['output'] = (int) $row['completion_tokens']; } foreach (RSVL_Analytics::get_daily_indexing_token_usage($since, $until) as $row) { $by_day[ $row['day'] ]['embedding'] = (int) $row['total_tokens']; } $days = resolver_daily_rows($since, $until, $by_day, ['embedding' => 0, 'input' => 0, 'output' => 0]); return [ 'labels' => array_map('resolver_day_label', array_column($days, 'date')), 'embedding' => array_column($days, 'embedding'), 'input' => array_column($days, 'input'), 'output' => array_column($days, 'output'), ]; } private function get_token_limit_presets(): array { return [ '0' => __('No Limit', 'resolver-ai-chatbot'), '100000' => __('100K', 'resolver-ai-chatbot'), '500000' => __('500K', 'resolver-ai-chatbot'), '1000000' => __('1M', 'resolver-ai-chatbot'), '2000000' => __('2M', 'resolver-ai-chatbot'), '5000000' => __('5M', 'resolver-ai-chatbot'), 'custom' => __('Custom', 'resolver-ai-chatbot'), ]; } /** * Billing/usage dashboard link for the current chat provider. */ private function get_provider_usage_portal(): array { $provider = RSVL_Provider::get_provider($this->get_current_chat_provider()); return $provider ? $provider->get_usage_portal() : []; } /* --------------------------------------------------------------------- * Shared helpers * ------------------------------------------------------------------- */ public static function is_provider_connected(string $provider): bool { $provider = RSVL_Provider::get_provider($provider); return $provider && $provider->is_connected(); } public static function get_current_embedding_provider(): string { return (string) resolver_get_setting('embedding_provider'); } /** Whether the embedding provider is connected, which training and retraining need. */ public static function is_embedding_connected(): bool { return self::is_provider_connected(self::get_current_embedding_provider()); } public static function get_settings_tab_url(string $tab): string { return add_query_arg(['page' => RSVL_Admin::PAGE, 'tab' => $tab]); } /** * Read a validated calendar period from a query parameter, defaulting to $default. */ public static function get_calendar_period(string $param, string $default): string { $value = isset($_GET[ $param ]) ? sanitize_text_field(wp_unslash($_GET[ $param ])) : ''; return in_array($value, self::CALENDAR_PERIODS, true) ? $value : $default; } /** * Resolve a calendar period to an inclusive [since, until] range of the * site's calendar days (Y-m-d). Queries turn these into UTC bounds with * resolver_utc_bounds(). */ public static function get_calendar_period_range(string $period): array { // Every boundary below is derived from today's date, so a caller that // needs deterministic windows — an automated test asserting on "This // Month" — can pin the whole calendar through resolver_current_time, // rather than reimplementing the month arithmetic somewhere else. The // arithmetic itself is on dates alone, so it's done in UTC, which has // no daylight saving days to trip over. $today = resolver_today(); $month_start = substr($today, 0, 8) . '01'; switch ($period) { case 'this_month': return [$month_start, $today]; case 'last_month': $last_month_end = gmdate('Y-m-d', strtotime($month_start . ' UTC') - DAY_IN_SECONDS); return [substr($last_month_end, 0, 8) . '01', $last_month_end]; default: $days = (int) $period; return [gmdate('Y-m-d', strtotime($today . ' UTC') - ($days - 1) * DAY_IN_SECONDS), $today]; } } /** * Period filter buttons (This Month/Last Month/7d/30d/60d/90d) for the analytics-style tabs. */ public static function build_calendar_period_filters(string $tab, string $param, string $current): array { $labels = [ 'this_month' => __('This Month', 'resolver-ai-chatbot'), 'last_month' => __('Last Month', 'resolver-ai-chatbot'), ]; $query_base = ['page' => RSVL_Admin::PAGE, 'tab' => $tab]; $panel = isset($_GET['panel']) ? sanitize_key($_GET['panel']) : ''; if ('' !== $panel) { $query_base['panel'] = $panel; } $filters = []; foreach (self::CALENDAR_PERIODS as $value) { $filters[] = [ /* translators: %s: number of days. Short label for a period filter button, e.g. "30d" for the last 30 days. */ 'label' => $labels[ $value ] ?? sprintf(__('%sd', 'resolver-ai-chatbot'), number_format_i18n((int) $value)), 'url' => add_query_arg(array_merge($query_base, [$param => $value])), 'active' => $value === $current, ]; } return $filters; } public static function get_post_type_icon(string $type_slug): array { if (isset(self::POST_TYPE_ICONS[ $type_slug ])) { return ['type' => 'dashicon', 'value' => self::POST_TYPE_ICONS[ $type_slug ]]; } $post_type = get_post_type_object($type_slug); $icon = $post_type->menu_icon ?? ''; if ('' === $icon || null === $icon) { return ['type' => 'dashicon', 'value' => 'dashicons-admin-generic']; } if (str_starts_with($icon, 'dashicons-')) { return ['type' => 'dashicon', 'value' => $icon]; } return ['type' => 'url', 'value' => $icon]; } /** * Public post types eligible for training and visibility settings. */ public static function get_public_post_types(): array { $post_types = get_post_types(['public' => true], 'objects'); unset($post_types['attachment']); return $post_types; } /** * Sort comparator: pages first, posts second, everything else alphabetical. */ public static function compare_post_type_priority(string $a, string $b): int { $priority_a = self::POST_TYPE_SORT_PRIORITY[ $a ] ?? 99; $priority_b = self::POST_TYPE_SORT_PRIORITY[ $b ] ?? 99; return $priority_a !== $priority_b ? $priority_a - $priority_b : strcmp($a, $b); } }