conversations = new RSVL_Conversations(); } /** The Chats tab's data. Only the open tab gets its chats. */ public function get_tab_data(bool $is_active): array { $settings = RSVL_Settings::all(); $per_page = self::PER_PAGE; $page = isset($_GET['conv_page']) ? max(1, (int) $_GET['conv_page']) : 1; $filter = isset($_GET['conv_filter']) ? sanitize_key(wp_unslash($_GET['conv_filter'])) : 'all'; $filter = array_key_exists($filter, RSVL_Conversations::get_filters()) ? $filter : 'all'; $view = isset($_GET['panel']) && 'settings' === $_GET['panel'] ? 'settings' : 'chats'; // A link to a specific chat can name a page it has since fallen off, as // newer conversations push it down the list — or omit the page number // entirely. Trust the session over conv_page and show the page it is // actually on, so the chat is selected in the list beside it. Everything // downstream (pagination targets, the "showing X-Y" range) derives from // $page, so correcting it here keeps them all consistent. if (! empty($_GET['open_session'])) { $page = $this->conversations->get_session_page( sanitize_text_field(wp_unslash($_GET['open_session'])), $per_page, $filter ); } $data = [ 'is_active' => $is_active, 'is_pro' => resolver_is_pro(), 'view' => $view, 'filter' => $filter, 'filter_options' => $this->get_conversation_filter_options($filter), 'sessions' => [], 'total' => 0, 'range_start' => 0, 'range_end' => 0, 'count_html' => '', 'prev_url' => '', 'next_url' => '', 'accent_color' => $settings['accent_color'], 'retention_days' => (int) ($settings['chat_retention_days'] ?? 0), 'retention_options' => $this->get_retention_options(), ]; if (! $data['is_active']) { return $data; } $sessions = $this->conversations->get_conversation_sessions($per_page, ($page - 1) * $per_page, $filter); $total = $this->conversations->get_conversation_count($filter); $total_pages = $total > 0 ? (int) ceil($total / $per_page) : 0; $data['sessions'] = array_map([__CLASS__, 'format_conversation_list_item'], $sessions); $data['total'] = $total; if (! empty($sessions)) { $data['range_start'] = ($page - 1) * $per_page + 1; $data['range_end'] = $data['range_start'] + count($sessions) - 1; } $data['count_html'] = self::get_chat_count_html($data['range_start'], $data['range_end'], $total); if ($total_pages > 1) { $pagination_args = ['page' => RSVL_Admin::PAGE, 'tab' => 'chats', 'conv_filter' => $filter]; $open_chat_args = ['open_session', 'open_message']; if ($page > 1) { $data['prev_url'] = remove_query_arg($open_chat_args, add_query_arg(array_merge($pagination_args, ['conv_page' => $page - 1]))); } if ($page < $total_pages) { $data['next_url'] = remove_query_arg($open_chat_args, add_query_arg(array_merge($pagination_args, ['conv_page' => $page + 1]))); } } return $data; } /** * AJAX: Get all messages for a conversation session. */ public function ajax_get_conversation() { $session_id = RSVL_Ajax::text('session_id'); if (empty($session_id)) { wp_send_json_error(['message' => __('Missing session ID.', 'resolver-ai-chatbot')]); } $messages = $this->conversations->get_conversation_messages($session_id); $usage = $this->conversations->get_conversation_usage($session_id); $page_url = $this->conversations->get_page_url($session_id); // Derive the date from the first message so the detail header // populates correctly when loading a session from a URL param. $date = ''; if (! empty($messages)) { $date = wp_date(resolver_date_format(), (int) ($messages[0]['ts'] / 1000)); } $has_error = false; foreach ($messages as $message) { if ($message['is_error']) { $has_error = true; break; } } $data = [ 'messages' => $messages, 'model' => $usage['model'], 'tokens' => $usage['total_tokens'], 'date' => $date, 'page_path' => self::get_page_path($page_url), 'message_count' => count($messages), 'is_admin' => $this->conversations->is_admin_session($session_id), 'has_error' => $has_error, ]; /** * Filter an open chat's data for the Chats screen, to add data of its * own for admin scripts (see the rsvl:conversation-* events in admin-chats.js). * * @param array $data As above. * @param string $session_id The chat's session ID. */ wp_send_json_success(apply_filters('resolver_conversation_data', $data, $session_id)); } /** * AJAX: Search conversations by message content. */ public function ajax_search_conversations() { $query = RSVL_Ajax::text('query'); $filter = RSVL_Ajax::enum('filter', array_keys(RSVL_Conversations::get_filters()), 'all'); if ('' === $query) { wp_send_json_success(['html' => '', 'count' => 0, 'total' => 0, 'page' => 1, 'total_pages' => 0]); } $per_page = self::PER_PAGE; $page = RSVL_Ajax::page('page'); $total = $this->conversations->count_search_results($query, $filter); // Clamp to the last page so a stale page number from a previous, wider // query can't land the user on an empty result set. $total_pages = $total > 0 ? (int) ceil($total / $per_page) : 0; if ($total_pages > 0 && $page > $total_pages) { $page = $total_pages; } $sessions = $this->conversations->search_conversations($query, $per_page, ($page - 1) * $per_page, $filter); // Render the rows server-side through the same template the browse list // uses, so search results can't drift away from it again. $html = ''; foreach ($sessions as $session) { $html .= resolver_get_template( 'conversation-list-item', ['session' => self::format_conversation_list_item($session)] ); } $range_start = $total > 0 ? ($page - 1) * $per_page + 1 : 0; $range_end = $total > 0 ? ($page - 1) * $per_page + count($sessions) : 0; wp_send_json_success([ 'html' => $html, 'count' => count($sessions), 'total' => $total, 'page' => $page, 'per_page' => $per_page, 'total_pages' => $total_pages, 'range_start' => $range_start, 'range_end' => $range_end, 'count_html' => self::get_chat_count_html($range_start, $range_end, $total), ]); } /** * AJAX: Delete a conversation session and its messages. */ public function ajax_delete_conversation() { $session_id = RSVL_Ajax::text('session_id'); if ('' === $session_id) { wp_send_json_error(['message' => __('Missing session ID.', 'resolver-ai-chatbot')]); } $this->conversations->delete_conversation($session_id); wp_send_json_success(); } private function get_conversation_filter_options(string $current): array { $labels = RSVL_Conversations::get_filters(); // add_query_arg() builds from the current URL, so anything already in it // is carried over. Two things must not be: the page number, because the // new filter may have fewer pages and land on an empty one; and the open // chat, which may not exist in the filtered list at all. The pagination // links drop the same pair for the same reason. $reset_args = ['conv_page', 'open_session', 'open_message']; $options = []; foreach ($labels as $value => $label) { $options[] = [ 'label' => $label, 'url' => remove_query_arg( $reset_args, add_query_arg(['page' => RSVL_Admin::PAGE, 'tab' => 'chats', 'conv_filter' => $value]) ), 'active' => $value === $current, ]; } return $options; } private function get_retention_options(): array { return [ 0 => __('Keep all chats', 'resolver-ai-chatbot'), 30 => __('Delete after 30 days', 'resolver-ai-chatbot'), 60 => __('Delete after 60 days', 'resolver-ai-chatbot'), 90 => __('Delete after 90 days', 'resolver-ai-chatbot'), 180 => __('Delete after 6 months', 'resolver-ai-chatbot'), 365 => __('Delete after 1 year', 'resolver-ai-chatbot'), 730 => __('Delete after 2 years', 'resolver-ai-chatbot'), ]; } /** Register the AJAX endpoints. */ public static function register(): void { RSVL_Ajax::register(self::ajax_routes(), new self()); } /** * Shape one chat row for the conversation list template. * * Public and static so the search AJAX handler can render its results * through the same formatter and template as the browse list. */ public static function format_conversation_list_item(array $session): array { $item = [ 'session_id' => $session['session_id'], 'preview' => wp_trim_words($session['first_message'], 14, '…'), 'date' => wp_date(resolver_date_format(), strtotime($session['updated_at'])), 'message_count' => (int) $session['message_count'], 'page_path' => self::get_page_path($session['page_url'] ?? ''), 'is_admin' => ! empty($session['is_admin']), 'has_error' => ! empty($session['has_error']), 'has_like' => ! empty($session['has_like']), 'has_dislike' => ! empty($session['has_dislike']), ]; /** * Filter one chat row in the Chats list. * * @param array $item The row, as above. * @param array $session The session, from RSVL_Conversations. */ return (array) apply_filters('resolver_conversation_list_item', $item, $session); } /** * "Showing 1-20 of 113 chats", with the numbers in . Shared by the * Chats list and its search results, so both read the same in every * language. */ public static function get_chat_count_html(int $start, int $end, int $total): string { return sprintf( /* translators: 1: number of the first chat shown, 2: number of the last chat shown, 3: total number of chats. */ _n( 'Showing %1$s-%2$s of %3$s chat', 'Showing %1$s-%2$s of %3$s chats', $total, 'resolver-ai-chatbot' ), number_format_i18n($start), number_format_i18n($end), number_format_i18n($total) ); } /** * URL path of a tracked page, or '' when the session has no page URL. */ private static function get_page_path(string $page_url): string { return '' !== $page_url ? (wp_parse_url($page_url, PHP_URL_PATH) ?: '/') : ''; } /** * The Chats tab's AJAX endpoints, with the guards they require. * * @return array */ private static function ajax_routes(): array { return RSVL_Ajax::admin_routes([ 'resolver_get_conversation' => 'ajax_get_conversation', 'resolver_search_conversations' => 'ajax_search_conversations', 'resolver_delete_conversation' => 'ajax_delete_conversation', ]); } }