tables->ai_logs ) ) { return false; } $defaults = [ 'action_type' => '', 'userid' => get_current_user_id(), 'user_type' => self::USER_TYPE_USER, 'credits_used' => 0, 'status' => self::STATUS_SUCCESS, 'content_type' => null, 'content_id' => null, 'forumid' => null, 'topicid' => null, 'request_summary' => null, 'response_summary' => null, 'error_message' => null, 'duration_ms' => 0, 'ip_address' => $this->get_client_ip(), 'extra_data' => null, 'created' => current_time( 'mysql', true ), // UTC for timezone conversion ]; $data = wp_parse_args( $data, $defaults ); // Validate required field if ( empty( $data['action_type'] ) ) { return false; } // Determine user type if not explicitly set if ( $data['user_type'] === self::USER_TYPE_USER ) { if ( $data['userid'] == 0 ) { $data['user_type'] = self::USER_TYPE_GUEST; } if ( defined( 'DOING_CRON' ) && DOING_CRON ) { $data['user_type'] = self::USER_TYPE_CRON; } } // Encode extra_data if array if ( is_array( $data['extra_data'] ) ) { $data['extra_data'] = wp_json_encode( $data['extra_data'] ); } // Truncate long strings if ( $data['request_summary'] && strlen( $data['request_summary'] ) > 500 ) { $data['request_summary'] = substr( $data['request_summary'], 0, 497 ) . '...'; } if ( $data['response_summary'] && strlen( $data['response_summary'] ) > 500 ) { $data['response_summary'] = substr( $data['response_summary'], 0, 497 ) . '...'; } if ( $data['error_message'] && strlen( $data['error_message'] ) > 1000 ) { $data['error_message'] = substr( $data['error_message'], 0, 997 ) . '...'; } $result = $wpdb->insert( WPF()->tables->ai_logs, [ 'action_type' => $data['action_type'], 'userid' => $data['userid'], 'user_type' => $data['user_type'], 'credits_used' => $data['credits_used'], 'status' => $data['status'], 'content_type' => $data['content_type'], 'content_id' => $data['content_id'], 'forumid' => $data['forumid'], 'topicid' => $data['topicid'], 'request_summary' => $data['request_summary'], 'response_summary' => $data['response_summary'], 'error_message' => $data['error_message'], 'duration_ms' => $data['duration_ms'], 'ip_address' => $data['ip_address'], 'extra_data' => $data['extra_data'], 'created' => $data['created'], ], [ '%s', '%d', '%s', '%d', '%s', '%s', '%d', '%d', '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s' ] ); if ( $result === false ) { return false; } $this->schedule_cleanup(); return $wpdb->insert_id; } /** * Get logs with filtering * * @param array $args Query arguments * * @return array */ public function get_logs( $args = [] ) { global $wpdb; $defaults = [ 'action_type' => '', 'date_filter' => 'all', 'status' => '', 'user_type' => '', 'search' => '', 'limit' => 50, 'offset' => 0, 'orderby' => 'created', 'order' => 'DESC', ]; $args = wp_parse_args( $args, $defaults ); $table = WPF()->tables->ai_logs; $where = [ '1=1' ]; $prepare_values = []; // Action type filter if ( ! empty( $args['action_type'] ) ) { $where[] = 'action_type = %s'; $prepare_values[] = $args['action_type']; } // Status filter if ( ! empty( $args['status'] ) ) { $where[] = 'status = %s'; $prepare_values[] = $args['status']; } // User type filter if ( ! empty( $args['user_type'] ) ) { $where[] = 'user_type = %s'; $prepare_values[] = $args['user_type']; } // Date filter if ( ! empty( $args['date_filter'] ) && $args['date_filter'] !== 'all' ) { $date_sql = $this->get_date_filter_sql( $args['date_filter'] ); if ( $date_sql ) { $where[] = $date_sql; } } // Search if ( ! empty( $args['search'] ) ) { $where[] = '(request_summary LIKE %s OR response_summary LIKE %s OR error_message LIKE %s)'; $search_term = '%' . $wpdb->esc_like( $args['search'] ) . '%'; $prepare_values[] = $search_term; $prepare_values[] = $search_term; $prepare_values[] = $search_term; } $where_clause = implode( ' AND ', $where ); // Sanitize order $allowed_orderby = [ 'id', 'created', 'action_type', 'status', 'credits_used', 'duration_ms' ]; $orderby = in_array( $args['orderby'], $allowed_orderby, true ) ? $args['orderby'] : 'created'; $order = strtoupper( $args['order'] ) === 'ASC' ? 'ASC' : 'DESC'; $sql = "SELECT * FROM `{$table}` WHERE {$where_clause} ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d"; $prepare_values[] = intval( $args['limit'] ); $prepare_values[] = intval( $args['offset'] ); if ( ! empty( $prepare_values ) ) { $sql = $wpdb->prepare( $sql, $prepare_values ); } $logs = $wpdb->get_results( $sql, ARRAY_A ); // Enrich logs with user display names if ( $logs ) { $logs = $this->enrich_logs_with_user_data( $logs ); } return $logs ?: []; } /** * Get total count for pagination * * @param array $args Query arguments * * @return int */ public function get_logs_count( $args = [] ) { global $wpdb; $table = WPF()->tables->ai_logs; $where = [ '1=1' ]; $prepare_values = []; if ( ! empty( $args['action_type'] ) ) { $where[] = 'action_type = %s'; $prepare_values[] = $args['action_type']; } if ( ! empty( $args['status'] ) ) { $where[] = 'status = %s'; $prepare_values[] = $args['status']; } if ( ! empty( $args['user_type'] ) ) { $where[] = 'user_type = %s'; $prepare_values[] = $args['user_type']; } if ( ! empty( $args['date_filter'] ) && $args['date_filter'] !== 'all' ) { $date_sql = $this->get_date_filter_sql( $args['date_filter'] ); if ( $date_sql ) { $where[] = $date_sql; } } if ( ! empty( $args['search'] ) ) { $where[] = '(request_summary LIKE %s OR response_summary LIKE %s OR error_message LIKE %s)'; $search_term = '%' . $wpdb->esc_like( $args['search'] ) . '%'; $prepare_values[] = $search_term; $prepare_values[] = $search_term; $prepare_values[] = $search_term; } $where_clause = implode( ' AND ', $where ); $sql = "SELECT COUNT(*) FROM `{$table}` WHERE {$where_clause}"; if ( ! empty( $prepare_values ) ) { $sql = $wpdb->prepare( $sql, $prepare_values ); } return (int) $wpdb->get_var( $sql ); } /** * Get a single log by ID * * @param int $id Log ID * * @return array|null */ public function get_log( $id ) { global $wpdb; $sql = $wpdb->prepare( "SELECT * FROM `" . WPF()->tables->ai_logs . "` WHERE id = %d", $id ); $log = $wpdb->get_row( $sql, ARRAY_A ); if ( $log ) { $logs = $this->enrich_logs_with_user_data( [ $log ] ); $log = $logs[0]; } return $log; } /** * Delete specific logs * * @param array $ids Log IDs * * @return int|false Number of deleted rows or false on error */ public function delete_logs( $ids ) { global $wpdb; if ( empty( $ids ) || ! is_array( $ids ) ) { return false; } $ids = array_map( 'intval', $ids ); $ids = array_filter( $ids ); $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); return $wpdb->query( $wpdb->prepare( "DELETE FROM `" . WPF()->tables->ai_logs . "` WHERE id IN ({$placeholders})", $ids ) ); } /** * Empty all logs * * @return int|false Number of deleted rows or false on error */ public function empty_all_logs() { global $wpdb; return $wpdb->query( "TRUNCATE TABLE `" . WPF()->tables->ai_logs . "`" ); } /** * Cleanup old logs (called by cron) * * @param int $days Delete logs older than this many days (default 90) * * @return int Number of deleted rows */ public function cleanup_old_logs( $days = 90 ) { global $wpdb; $days = apply_filters( 'wpforo_ai_logs_retention_days', $days ); $result = $wpdb->query( $wpdb->prepare( "DELETE FROM `" . WPF()->tables->ai_logs . "` WHERE created < DATE_SUB(NOW(), INTERVAL %d DAY)", $days ) ); return $result !== false ? $result : 0; } /** * Cron callback for log cleanup */ public function cron_cleanup_old_logs() { $days = $this->get_cleanup_days(); // If 0, auto-cleanup is disabled if ( $days <= 0 ) { return; } $deleted = $this->cleanup_old_logs( $days ); if ( $deleted > 0 ) { \wpforo_ai_log( 'info', "Cron cleanup: deleted {$deleted} logs older than {$days} days", 'Logs' ); } } /** * Get cleanup days setting (board-specific) * * @return int Number of days (0 = keep forever) */ public function get_cleanup_days() { return (int) wpforo_get_option( 'ai_logs_cleanup_days', 90 ); } /** * Save cleanup days setting (board-specific) * * @param int $days Number of days (0 = keep forever) * * @return bool Success */ public function save_cleanup_days( $days ) { $days = max( 0, min( 365, intval( $days ) ) ); return wpforo_update_option( 'ai_logs_cleanup_days', $days ); } /** * Get per page setting (board-specific) * * @return int Number of logs per page */ public function get_per_page() { return (int) wpforo_get_option( 'ai_logs_per_page', 50 ); } /** * Save per page setting (board-specific) * * @param int $per_page Number of logs per page * * @return bool Success */ public function save_per_page( $per_page ) { $allowed = [ 25, 50, 100, 200 ]; $per_page = in_array( $per_page, $allowed, true ) ? $per_page : 50; return wpforo_update_option( 'ai_logs_per_page', $per_page ); } /** * Schedule cleanup cron if not already scheduled */ public function schedule_cleanup() { if ( ! wp_next_scheduled( 'wpforo_ai_logs_cleanup' ) ) { wp_schedule_event( strtotime( 'tomorrow 5:00am' ), 'daily', 'wpforo_ai_logs_cleanup' ); } } /** * Unschedule cleanup cron */ public function unschedule_cleanup() { $timestamp = wp_next_scheduled( 'wpforo_ai_logs_cleanup' ); if ( $timestamp ) { wp_unschedule_event( $timestamp, 'wpforo_ai_logs_cleanup' ); } } // ========================================================================= // AJAX HANDLERS // ========================================================================= /** * AJAX: Get logs with filtering */ public function ajax_get_logs() { $this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); $this->switch_board_context(); // Accept both 'date_filter' and 'date_range' parameter names (JS sends date_range) $date_filter = $this->get_post_param( 'date_filter', '' ) ?: $this->get_post_param( 'date_range', 'all' ); $page = $this->get_post_param( 'page', 1, 'int' ); $per_page = $this->get_post_param( 'per_page', 50, 'int' ); $args = [ 'action_type' => $this->get_post_param( 'action_type', '' ), 'date_filter' => $date_filter, 'status' => $this->get_post_param( 'status', '' ), 'user_type' => $this->get_post_param( 'user_type', '' ), 'search' => $this->get_post_param( 'search', '' ), 'limit' => $per_page, 'offset' => ( $page - 1 ) * $per_page, 'orderby' => $this->get_post_param( 'orderby', 'created' ), 'order' => $this->get_post_param( 'order', 'DESC' ), ]; $logs = $this->get_logs( $args ); $total = $this->get_logs_count( $args ); // Enrich logs with user data $logs = $this->enrich_logs_with_user_data( $logs ); // Render HTML for table rows $html = $this->render_logs_table_html( $logs ); $this->send_success( [ 'html' => $html, 'logs' => $logs, 'total' => $total, 'page' => $page, 'per_page' => $per_page, 'total_pages' => ceil( $total / $per_page ), ] ); } /** * AJAX: Delete selected logs */ public function ajax_delete_logs() { $this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); $this->switch_board_context(); // Accept both 'log_ids' and 'ids' parameter names $ids = $this->get_post_param( 'log_ids', [], 'array_int' ); if ( empty( $ids ) ) { $ids = $this->get_post_param( 'ids', [], 'array_int' ); } if ( empty( $ids ) ) { $this->send_error( __( 'No logs selected', 'wpforo' ), 400 ); } $deleted = $this->delete_logs( $ids ); if ( $deleted === false ) { $this->send_error( __( 'Failed to delete logs', 'wpforo' ), 500 ); } $this->send_success( [ 'message' => sprintf( __( '%d log(s) deleted', 'wpforo' ), $deleted ), 'deleted' => $deleted, ] ); } /** * AJAX: Empty all logs */ public function ajax_empty_all_logs() { $this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); $this->switch_board_context(); $result = $this->empty_all_logs(); if ( $result === false ) { $this->send_error( __( 'Failed to empty logs', 'wpforo' ), 500 ); } $this->send_success( [ 'message' => __( 'All logs have been deleted', 'wpforo' ), ] ); } /** * AJAX: Get single log detail */ public function ajax_get_log_detail() { $this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); $this->switch_board_context(); // Accept both 'id' and 'log_id' parameter names $id = $this->get_post_param( 'log_id', 0, 'int' ); if ( ! $id ) { $id = $this->get_post_param( 'id', 0, 'int' ); } if ( ! $id ) { $this->send_error( __( 'Invalid log ID', 'wpforo' ), 400 ); } $log = $this->get_log( $id ); if ( ! $log ) { $this->send_error( __( 'Log not found', 'wpforo' ), 404 ); } // Enrich with user data $logs = $this->enrich_logs_with_user_data( [ $log ] ); $log = $logs[0]; // Decode extra_data for display $extra_data_decoded = null; if ( ! empty( $log['extra_data'] ) ) { $extra_data_decoded = json_decode( $log['extra_data'], true ); } // Build HTML for modal $html = $this->render_log_detail_html( $log, $extra_data_decoded ); $this->send_success( [ 'html' => $html, 'log' => $log ] ); } /** * AJAX: Save cleanup days setting */ public function ajax_save_cleanup_days() { $this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); $this->switch_board_context(); $days = $this->get_post_param( 'days', 90, 'int' ); if ( $days < 0 || $days > 365 ) { $this->send_error( __( 'Days must be between 0 and 365', 'wpforo' ), 400 ); } $result = $this->save_cleanup_days( $days ); if ( ! $result ) { $this->send_error( __( 'Failed to save setting', 'wpforo' ), 500 ); } if ( $days > 0 ) { $message = sprintf( __( 'Logs older than %d days will be automatically deleted', 'wpforo' ), $days ); } else { $message = __( 'Auto-cleanup disabled. Logs will be kept forever.', 'wpforo' ); } $this->send_success( [ 'message' => $message, 'days' => $days ] ); } /** * AJAX: Save per page setting */ public function ajax_save_per_page() { $this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); $this->switch_board_context(); $per_page = $this->get_post_param( 'per_page', 50, 'int' ); $allowed = [ 25, 50, 100, 200 ]; if ( ! in_array( $per_page, $allowed, true ) ) { $this->send_error( __( 'Invalid value', 'wpforo' ), 400 ); } $result = $this->save_per_page( $per_page ); if ( ! $result ) { $this->send_error( __( 'Failed to save setting', 'wpforo' ), 500 ); } $this->send_success( [ 'message' => __( 'Setting saved', 'wpforo' ), 'per_page' => $per_page, ] ); } /** * AJAX: Get AI Chatbot messages (displayed as logs) */ public function ajax_get_chat_messages() { $this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); $this->switch_board_context(); // Accept both 'date_filter' and 'date_range' parameter names $date_filter = $this->get_post_param( 'date_filter', '' ) ?: $this->get_post_param( 'date_range', 'all' ); $page = $this->get_post_param( 'page', 1, 'int' ); $per_page = $this->get_post_param( 'per_page', 50, 'int' ); $args = [ 'date_filter' => $date_filter, 'status' => $this->get_post_param( 'status', '' ), 'user_type' => $this->get_post_param( 'user_type', '' ), 'search' => $this->get_post_param( 'search', '' ), 'limit' => $per_page, 'offset' => ( $page - 1 ) * $per_page, ]; $messages = $this->get_chat_messages( $args ); $total = $this->get_chat_messages_count( $args ); // Render HTML for table rows $html = $this->render_chat_messages_table_html( $messages ); $this->send_success( [ 'html' => $html, 'messages' => $messages, 'total' => $total, 'page' => $page, 'per_page' => $per_page, 'total_pages' => ceil( $total / $per_page ), ] ); } /** * AJAX: Get single chat message detail */ public function ajax_get_chat_message_detail() { $this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); $this->switch_board_context(); // Accept both 'message_id' and 'id' parameter names $message_id = $this->get_post_param( 'message_id', 0, 'int' ); if ( ! $message_id ) { $message_id = $this->get_post_param( 'id', 0, 'int' ); } if ( ! $message_id ) { $this->send_error( __( 'Invalid message ID', 'wpforo' ), 400 ); } $message = $this->get_chat_message( $message_id ); if ( ! $message ) { $this->send_error( __( 'Message not found', 'wpforo' ), 404 ); } // Build HTML for modal $html = $this->render_chat_message_detail_html( $message ); $this->send_success( [ 'html' => $html, 'message' => $message ] ); } /** * Get chat messages with filtering * * @param array $args Query arguments * * @return array */ public function get_chat_messages( $args = [] ) { global $wpdb; $defaults = [ 'date_filter' => 'all', 'status' => '', 'user_type' => '', 'search' => '', 'limit' => 50, 'offset' => 0, ]; $args = wp_parse_args( $args, $defaults ); $messages_table = WPF()->tables->ai_chat_messages; $conversations_table = WPF()->tables->ai_chat_conversations; $where = [ '1=1' ]; $prepare_values = []; // Date filter if ( ! empty( $args['date_filter'] ) && $args['date_filter'] !== 'all' ) { $date_sql = $this->get_chat_date_filter_sql( $args['date_filter'] ); if ( $date_sql ) { $where[] = $date_sql; } } // Status filter - for chat messages, we interpret this as role // 'success' = assistant messages (responses), 'error' would be messages with no credits, etc. // Since chat messages don't have status, we skip this filter for now // User type filter - filter by conversation owner type if ( ! empty( $args['user_type'] ) ) { if ( $args['user_type'] === 'guest' ) { $where[] = 'c.userid = 0'; } elseif ( $args['user_type'] === 'user' ) { $where[] = 'c.userid > 0'; } // 'cron' and 'system' don't apply to chat messages } // Search in message content or conversation title if ( ! empty( $args['search'] ) ) { $where[] = '(m.content LIKE %s OR c.title LIKE %s)'; $search_term = '%' . $wpdb->esc_like( $args['search'] ) . '%'; $prepare_values[] = $search_term; $prepare_values[] = $search_term; } $where_clause = implode( ' AND ', $where ); $sql = "SELECT m.*, c.title as conversation_title, c.userid FROM `{$messages_table}` m LEFT JOIN `{$conversations_table}` c ON m.conversation_id = c.conversation_id WHERE {$where_clause} ORDER BY m.created_at DESC LIMIT %d OFFSET %d"; $prepare_values[] = intval( $args['limit'] ); $prepare_values[] = intval( $args['offset'] ); if ( ! empty( $prepare_values ) ) { $sql = $wpdb->prepare( $sql, $prepare_values ); } $messages = $wpdb->get_results( $sql, ARRAY_A ); // Enrich with user data if ( $messages ) { $messages = $this->enrich_chat_messages_with_user_data( $messages ); } return $messages ?: []; } /** * Get total count of chat messages for pagination * * @param array $args Query arguments * * @return int */ public function get_chat_messages_count( $args = [] ) { global $wpdb; $messages_table = WPF()->tables->ai_chat_messages; $conversations_table = WPF()->tables->ai_chat_conversations; $where = [ '1=1' ]; $prepare_values = []; if ( ! empty( $args['date_filter'] ) && $args['date_filter'] !== 'all' ) { $date_sql = $this->get_chat_date_filter_sql( $args['date_filter'] ); if ( $date_sql ) { $where[] = $date_sql; } } if ( ! empty( $args['user_type'] ) ) { if ( $args['user_type'] === 'guest' ) { $where[] = 'c.userid = 0'; } elseif ( $args['user_type'] === 'user' ) { $where[] = 'c.userid > 0'; } } if ( ! empty( $args['search'] ) ) { $where[] = '(m.content LIKE %s OR c.title LIKE %s)'; $search_term = '%' . $wpdb->esc_like( $args['search'] ) . '%'; $prepare_values[] = $search_term; $prepare_values[] = $search_term; } $where_clause = implode( ' AND ', $where ); $sql = "SELECT COUNT(*) FROM `{$messages_table}` m LEFT JOIN `{$conversations_table}` c ON m.conversation_id = c.conversation_id WHERE {$where_clause}"; if ( ! empty( $prepare_values ) ) { $sql = $wpdb->prepare( $sql, $prepare_values ); } return (int) $wpdb->get_var( $sql ); } /** * Get a single chat message by ID * * @param int $message_id Message ID * * @return array|null */ public function get_chat_message( $message_id ) { global $wpdb; $messages_table = WPF()->tables->ai_chat_messages; $conversations_table = WPF()->tables->ai_chat_conversations; $sql = $wpdb->prepare( "SELECT m.*, c.title as conversation_title, c.userid, c.running_summary, c.message_count, c.total_credits FROM `{$messages_table}` m LEFT JOIN `{$conversations_table}` c ON m.conversation_id = c.conversation_id WHERE m.message_id = %d", $message_id ); $message = $wpdb->get_row( $sql, ARRAY_A ); if ( $message ) { $messages = $this->enrich_chat_messages_with_user_data( [ $message ] ); $message = $messages[0]; } return $message; } /** * Get date filter SQL for chat messages * * @param string $filter Date filter key * * @return string SQL condition */ private function get_chat_date_filter_sql( $filter ) { switch ( $filter ) { case 'last_hour': return 'm.created_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)'; case 'last_day': return 'm.created_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)'; case 'last_week': return 'm.created_at >= DATE_SUB(NOW(), INTERVAL 1 WEEK)'; case 'last_month': return 'm.created_at >= DATE_SUB(NOW(), INTERVAL 1 MONTH)'; default: return ''; } } /** * Enrich chat messages with user display names * * @param array $messages Array of chat messages * * @return array Enriched messages */ private function enrich_chat_messages_with_user_data( $messages ) { return $this->enrich_items_with_user_data( $messages, 'userid', [ 'user_type_field' => 'user_type', 'output_field' => 'user_display', 'use_type_labels' => false, // Chat messages only have user or guest ] ); } /** * Render chat messages as log table rows HTML * * @param array $messages Array of chat messages * * @return string HTML content for table tbody */ private function render_chat_messages_table_html( $messages ) { if ( empty( $messages ) ) { ob_start(); ?>
render_chat_message_row( $message ); } return ob_get_clean(); } /** * Render a single chat message as a log table row * * @param array $message Chat message data */ private function render_chat_message_row( $message ) { $message_id = intval( $message['message_id'] ); $role = sanitize_text_field( $message['role'] ); $user_type = sanitize_text_field( $message['user_type'] ?? 'user' ); $credits = intval( $message['credits_spent'] ?? 0 ); $created = $message['created_at']; // Format date in user's timezone $date_display = self::format_datetime( $created, 'M j, Y' ); $time_display = self::format_datetime( $created, 'g:i a' ); // Get display values $user_display = isset( $message['user_display'] ) ? $message['user_display'] : __( 'Unknown', 'wpforo' ); // Build summary: conversation title + truncated message $conv_title = ! empty( $message['conversation_title'] ) ? $message['conversation_title'] : __( 'Conversation', 'wpforo' ); $msg_preview = wp_trim_words( wp_strip_all_tags( $message['content'] ), 12 ); $summary = '' . esc_html( $conv_title ) . ': ' . esc_html( $msg_preview ); // Role badge - user or assistant $role_label = $role === 'assistant' ? __( 'AI Response', 'wpforo' ) : __( 'User Message', 'wpforo' ); $role_class = $role === 'assistant' ? 'wpforo-ai-chat-role-assistant' : 'wpforo-ai-chat-role-user'; // Status based on role and credits $status_label = $role === 'assistant' ? __( 'Response', 'wpforo' ) : __( 'Query', 'wpforo' ); $status_class = $role === 'assistant' ? 'wpforo-ai-log-status-success' : 'wpforo-ai-log-status-cached'; ?> 0 ) : ?> - format_chat_message_content( $message['content'] ); ob_start(); ?>
0 ) : ?>
0 ) : ?>
0 ) : ?>
    0 ) { $url = WPF()->post->get_url( $postid ); } // Get title from post if not provided if ( empty( $title ) && $postid > 0 ) { $post = WPF()->post->get_post( $postid ); if ( $post ) { $title = ! empty( $post['title'] ) ? $post['title'] : sprintf( __( 'Post #%d', 'wpforo' ), $postid ); } } // Fallback title if ( empty( $title ) ) { $title = $postid > 0 ? sprintf( __( 'Post #%d', 'wpforo' ), $postid ) : __( 'Source', 'wpforo' ); } ?>
'inline', 'class' => 'wpforo-ai-post-link', ] ); // Convert markdown to HTML (admin mode with strikethrough support) return AIMarkdown::to_html( $content, AIMarkdown::MODE_ADMIN ); } /** * Convert basic markdown to HTML * * Delegates to AIMarkdown for consistent markdown conversion. * * @param string $text Markdown text * * @return string HTML */ private function markdown_to_html( $text ) { return AIMarkdown::to_html( $text, AIMarkdown::MODE_ADMIN ); } /** * Render log detail HTML for modal * * @param array $log Log data * @param array|null $extra_data_decoded Decoded extra data * * @return string HTML content */ private function render_log_detail_html( $log, $extra_data_decoded = null ) { $action_label = self::get_action_label( $log['action_type'] ); $status_label = self::get_status_label( $log['status'] ); $status_class = self::get_status_class( $log['status'] ); $user_display = $log['user_display'] ?? $this->get_user_type_display_label( $log['user_type'] ); ob_start(); ?>
()
0 ? number_format( $log['credits_used'] ) : '-'; ?>
0 ? number_format( $log['duration_ms'] ) . 'ms' : '-'; ?>
render_log_row( $log ); } return ob_get_clean(); } /** * Render a single log table row * * @param array $log Log data (enriched with user_display) */ private function render_log_row( $log ) { $log_id = intval( $log['id'] ); $action_type = sanitize_text_field( $log['action_type'] ); $status = sanitize_text_field( $log['status'] ); $user_type = sanitize_text_field( $log['user_type'] ); $credits = intval( $log['credits_used'] ); $duration = intval( $log['duration_ms'] ); $created = $log['created']; // Format date in user's timezone (database stores UTC) $date_display = self::format_datetime( $created, 'M j, Y' ); $time_display = self::format_datetime( $created, 'g:i a' ); // Get display values $action_label = self::get_action_label( $action_type ); $status_label = self::get_status_label( $status ); $status_class = self::get_status_class( $status ); $user_display = isset( $log['user_display'] ) ? $log['user_display'] : __( 'Unknown', 'wpforo' ); // Build summary $summary = ''; if ( ! empty( $log['request_summary'] ) ) { $summary = esc_html( wp_trim_words( $log['request_summary'], 15 ) ); } elseif ( ! empty( $log['response_summary'] ) ) { $summary = esc_html( wp_trim_words( $log['response_summary'], 15 ) ); } elseif ( ! empty( $log['error_message'] ) ) { $summary = '' . esc_html( wp_trim_words( $log['error_message'], 15 ) ) . ''; } ?> 0 ) : ?> - 0 ) : ?> (ms) = DATE_SUB(NOW(), INTERVAL 1 HOUR)'; case 'last_day': return 'created >= DATE_SUB(NOW(), INTERVAL 1 DAY)'; case 'last_week': return 'created >= DATE_SUB(NOW(), INTERVAL 1 WEEK)'; case 'last_month': return 'created >= DATE_SUB(NOW(), INTERVAL 1 MONTH)'; default: return ''; } } /** * Enrich logs with user display names * * @param array $logs Array of log entries * * @return array Enriched logs */ private function enrich_logs_with_user_data( $logs ) { return $this->enrich_items_with_user_data( $logs, 'userid', [ 'user_type_field' => 'user_type', 'output_field' => 'user_display', 'use_type_labels' => true, // Logs can have cron, system types ] ); } /** * Get client IP address * * @return string|null */ private function get_client_ip() { $ip_keys = [ 'HTTP_CF_CONNECTING_IP', 'HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', 'HTTP_X_CLUSTER_CLIENT_IP', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 'REMOTE_ADDR', ]; foreach ( $ip_keys as $key ) { if ( ! empty( $_SERVER[ $key ] ) ) { $ip = sanitize_text_field( $_SERVER[ $key ] ); // Handle comma-separated list (X-Forwarded-For) if ( strpos( $ip, ',' ) !== false ) { $ip = trim( explode( ',', $ip )[0] ); } if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) { return $ip; } } } return null; } /** * Get action type display label * * @param string $action_type Action type constant * * @return string Display label */ public static function get_action_label( $action_type ) { $labels = [ self::ACTION_SEMANTIC_SEARCH => __( 'Semantic Search', 'wpforo' ), self::ACTION_PUBLIC_SEARCH => __( 'Public Search', 'wpforo' ), self::ACTION_TRANSLATION => __( 'Translation', 'wpforo' ), self::ACTION_TOPIC_SUMMARY => __( 'Topic Summary', 'wpforo' ), self::ACTION_TOPIC_SUGGESTIONS => __( 'Topic Suggestions', 'wpforo' ), self::ACTION_BOT_REPLY => __( 'Bot Reply', 'wpforo' ), self::ACTION_SUGGEST_REPLY => __( 'Suggest Reply', 'wpforo' ), self::ACTION_ANALYTICS_INSIGHTS => __( 'Analytics Insights', 'wpforo' ), self::ACTION_CONTENT_INDEXING => __( 'Content Indexing', 'wpforo' ), self::ACTION_KNOWLEDGE_INDEXING => __( 'Knowledge Indexing', 'wpforo' ), self::ACTION_BATCH_EMBEDDING => __( 'Batch Embedding', 'wpforo' ), self::ACTION_QUEUE_PROCESSING => __( 'Queue Processing', 'wpforo' ), self::ACTION_SPAM_DETECTION => __( 'Spam Detection', 'wpforo' ), self::ACTION_MODERATION => __( 'Moderation', 'wpforo' ), self::ACTION_TASK_EXECUTION => __( 'Task Execution', 'wpforo' ), self::ACTION_CHATBOT => __( 'AI Chatbot', 'wpforo' ), ]; return $labels[ $action_type ] ?? ucwords( str_replace( '_', ' ', $action_type ) ); } /** * Get all action types for filter dropdown * * Note: AI Chatbot is excluded - chat messages are viewed separately via the "AI ChatBot Messages" button * * @return array */ public static function get_action_types() { return [ self::ACTION_SEMANTIC_SEARCH => __( 'Semantic Search', 'wpforo' ), self::ACTION_PUBLIC_SEARCH => __( 'Public Search', 'wpforo' ), self::ACTION_TRANSLATION => __( 'Translation', 'wpforo' ), self::ACTION_TOPIC_SUMMARY => __( 'Topic Summary', 'wpforo' ), self::ACTION_TOPIC_SUGGESTIONS => __( 'Topic Suggestions', 'wpforo' ), self::ACTION_BOT_REPLY => __( 'Bot Reply', 'wpforo' ), self::ACTION_SUGGEST_REPLY => __( 'Suggest Reply', 'wpforo' ), self::ACTION_ANALYTICS_INSIGHTS => __( 'Analytics Insights', 'wpforo' ), self::ACTION_CONTENT_INDEXING => __( 'Content Indexing', 'wpforo' ), self::ACTION_KNOWLEDGE_INDEXING => __( 'Knowledge Indexing', 'wpforo' ), self::ACTION_SPAM_DETECTION => __( 'Spam Detection', 'wpforo' ), self::ACTION_MODERATION => __( 'Moderation', 'wpforo' ), self::ACTION_TASK_EXECUTION => __( 'Task Execution', 'wpforo' ), ]; } /** * Get current admin user's timezone * * Priority: * 1. wpforo_profile table (user's forum timezone) * 2. WordPress site timezone * 3. Default to UTC * * @return DateTimeZone User's timezone object */ public static function get_user_timezone() { static $timezone = null; if ( $timezone !== null ) { return $timezone; } $timezone_string = ''; // 1. Try to get user's timezone from wpforo profile $user_id = get_current_user_id(); if ( $user_id ) { $member = WPF()->member->get_member( $user_id ); if ( ! empty( $member['timezone'] ) ) { $timezone_string = str_replace( '_', ' ', $member['timezone'] ); } } // 2. Fall back to WordPress site timezone if ( empty( $timezone_string ) ) { $timezone_string = wp_timezone_string(); } // 3. Fall back to UTC if ( empty( $timezone_string ) ) { $timezone_string = 'UTC'; } try { // Handle UTC offset format (e.g., "UTC+3", "UTC-5") if ( strpos( $timezone_string, 'UTC/' ) === 0 ) { $timezone_string = str_replace( 'UTC/', '', $timezone_string ); } $timezone = new \DateTimeZone( $timezone_string ); } catch ( \Exception $e ) { $timezone = new \DateTimeZone( 'UTC' ); } return $timezone; } /** * Format UTC datetime in user's timezone * * @param string|int $utc_datetime UTC datetime string or timestamp * @param string $format PHP date format * * @return string Formatted datetime in user's timezone */ public static function format_datetime( $utc_datetime, $format = '' ) { if ( empty( $utc_datetime ) ) { return ''; } // Default format: WordPress date + time format if ( empty( $format ) ) { $format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' ); } try { // Parse the UTC datetime $utc_tz = new \DateTimeZone( 'UTC' ); if ( is_numeric( $utc_datetime ) ) { $datetime = new \DateTime( '@' . $utc_datetime ); } else { $datetime = new \DateTime( $utc_datetime, $utc_tz ); } // Convert to user's timezone $user_tz = self::get_user_timezone(); $datetime->setTimezone( $user_tz ); // Format using date_i18n for translation support return date_i18n( $format, $datetime->getTimestamp() + $datetime->getOffset() ); } catch ( \Exception $e ) { // Fallback to original value if parsing fails return is_numeric( $utc_datetime ) ? date( $format, $utc_datetime ) : $utc_datetime; } } /** * Get status badge class * * @param string $status Status constant * * @return string CSS class */ public static function get_status_class( $status ) { $classes = [ self::STATUS_SUCCESS => 'wpforo-ai-log-status-success', self::STATUS_ERROR => 'wpforo-ai-log-status-error', self::STATUS_CACHED => 'wpforo-ai-log-status-cached', ]; return $classes[ $status ] ?? ''; } /** * Get status display label * * @param string $status Status constant * * @return string Display label */ public static function get_status_label( $status ) { $labels = [ self::STATUS_SUCCESS => __( 'Success', 'wpforo' ), self::STATUS_ERROR => __( 'Error', 'wpforo' ), self::STATUS_CACHED => __( 'Cached', 'wpforo' ), ]; return $labels[ $status ] ?? ucfirst( $status ); } }