| 1 |
<?php |
| 2 |
|
| 3 |
namespace wpforo\classes; |
| 4 |
|
| 5 |
/** |
| 6 |
* wpForo AI Logs Manager |
| 7 |
* |
| 8 |
* Handles logging of all AI actions and provides admin interface |
| 9 |
* for viewing, filtering, and managing logs. |
| 10 |
* |
| 11 |
* @since 3.0.0 |
| 12 |
*/ |
| 13 |
class AILogs { |
| 14 |
use AIAjaxTrait; |
| 15 |
use AIUserTrait; |
| 16 |
|
| 17 |
/** |
| 18 |
* Action type constants |
| 19 |
*/ |
| 20 |
const ACTION_SEMANTIC_SEARCH = 'semantic_search'; |
| 21 |
const ACTION_PUBLIC_SEARCH = 'public_search'; |
| 22 |
const ACTION_TRANSLATION = 'translation'; |
| 23 |
const ACTION_TOPIC_SUMMARY = 'topic_summary'; |
| 24 |
const ACTION_TOPIC_SUGGESTIONS = 'topic_suggestions'; |
| 25 |
const ACTION_BOT_REPLY = 'bot_reply'; |
| 26 |
const ACTION_SUGGEST_REPLY = 'suggest_reply'; |
| 27 |
const ACTION_ANALYTICS_INSIGHTS = 'analytics_insights'; |
| 28 |
const ACTION_CONTENT_INDEXING = 'content_indexing'; |
| 29 |
const ACTION_KNOWLEDGE_INDEXING = 'knowledge_indexing'; |
| 30 |
const ACTION_BATCH_EMBEDDING = 'batch_embedding'; |
| 31 |
const ACTION_QUEUE_PROCESSING = 'queue_processing'; |
| 32 |
const ACTION_SPAM_DETECTION = 'spam_detection'; |
| 33 |
const ACTION_MODERATION = 'moderation'; |
| 34 |
const ACTION_TASK_EXECUTION = 'task_execution'; |
| 35 |
const ACTION_CHATBOT = 'chatbot'; |
| 36 |
|
| 37 |
/** |
| 38 |
* User type constants |
| 39 |
*/ |
| 40 |
const USER_TYPE_USER = 'user'; |
| 41 |
const USER_TYPE_GUEST = 'guest'; |
| 42 |
const USER_TYPE_CRON = 'cron'; |
| 43 |
const USER_TYPE_SYSTEM = 'system'; |
| 44 |
|
| 45 |
/** |
| 46 |
* Status constants |
| 47 |
*/ |
| 48 |
const STATUS_SUCCESS = 'success'; |
| 49 |
const STATUS_ERROR = 'error'; |
| 50 |
const STATUS_CACHED = 'cached'; |
| 51 |
|
| 52 |
/** |
| 53 |
* Constructor - Register AJAX handlers |
| 54 |
*/ |
| 55 |
public function __construct() { |
| 56 |
if ( is_admin() ) { |
| 57 |
add_action( 'wp_ajax_wpforo_ai_get_logs', [ $this, 'ajax_get_logs' ] ); |
| 58 |
add_action( 'wp_ajax_wpforo_ai_delete_logs', [ $this, 'ajax_delete_logs' ] ); |
| 59 |
add_action( 'wp_ajax_wpforo_ai_empty_all_logs', [ $this, 'ajax_empty_all_logs' ] ); |
| 60 |
add_action( 'wp_ajax_wpforo_ai_get_log_detail', [ $this, 'ajax_get_log_detail' ] ); |
| 61 |
add_action( 'wp_ajax_wpforo_ai_save_cleanup_days', [ $this, 'ajax_save_cleanup_days' ] ); |
| 62 |
add_action( 'wp_ajax_wpforo_ai_save_per_page', [ $this, 'ajax_save_per_page' ] ); |
| 63 |
add_action( 'wp_ajax_wpforo_ai_get_chat_messages', [ $this, 'ajax_get_chat_messages' ] ); |
| 64 |
add_action( 'wp_ajax_wpforo_ai_get_chat_message_detail', [ $this, 'ajax_get_chat_message_detail' ] ); |
| 65 |
} |
| 66 |
|
| 67 |
// Schedule daily cleanup cron |
| 68 |
add_action( 'wpforo_ai_logs_cleanup', [ $this, 'cron_cleanup_old_logs' ] ); |
| 69 |
} |
| 70 |
|
| 71 |
/** |
| 72 |
* Log an AI action |
| 73 |
* |
| 74 |
* @param array $data Log data |
| 75 |
* |
| 76 |
* @return int|false Insert ID or false on failure |
| 77 |
*/ |
| 78 |
public function log( $data ) { |
| 79 |
global $wpdb; |
| 80 |
|
| 81 |
// Check if table exists |
| 82 |
if ( ! isset( WPF()->tables->ai_logs ) ) { |
| 83 |
return false; |
| 84 |
} |
| 85 |
|
| 86 |
$defaults = [ |
| 87 |
'action_type' => '', |
| 88 |
'userid' => get_current_user_id(), |
| 89 |
'user_type' => self::USER_TYPE_USER, |
| 90 |
'credits_used' => 0, |
| 91 |
'status' => self::STATUS_SUCCESS, |
| 92 |
'content_type' => null, |
| 93 |
'content_id' => null, |
| 94 |
'forumid' => null, |
| 95 |
'topicid' => null, |
| 96 |
'request_summary' => null, |
| 97 |
'response_summary' => null, |
| 98 |
'error_message' => null, |
| 99 |
'duration_ms' => 0, |
| 100 |
'ip_address' => $this->get_client_ip(), |
| 101 |
'extra_data' => null, |
| 102 |
'created' => current_time( 'mysql', true ), // UTC for timezone conversion |
| 103 |
]; |
| 104 |
|
| 105 |
$data = wp_parse_args( $data, $defaults ); |
| 106 |
|
| 107 |
// Validate required field |
| 108 |
if ( empty( $data['action_type'] ) ) { |
| 109 |
return false; |
| 110 |
} |
| 111 |
|
| 112 |
// Determine user type if not explicitly set |
| 113 |
if ( $data['user_type'] === self::USER_TYPE_USER ) { |
| 114 |
if ( $data['userid'] == 0 ) { |
| 115 |
$data['user_type'] = self::USER_TYPE_GUEST; |
| 116 |
} |
| 117 |
if ( defined( 'DOING_CRON' ) && DOING_CRON ) { |
| 118 |
$data['user_type'] = self::USER_TYPE_CRON; |
| 119 |
} |
| 120 |
} |
| 121 |
|
| 122 |
// Encode extra_data if array |
| 123 |
if ( is_array( $data['extra_data'] ) ) { |
| 124 |
$data['extra_data'] = wp_json_encode( $data['extra_data'] ); |
| 125 |
} |
| 126 |
|
| 127 |
// Truncate long strings |
| 128 |
if ( $data['request_summary'] && strlen( $data['request_summary'] ) > 500 ) { |
| 129 |
$data['request_summary'] = substr( $data['request_summary'], 0, 497 ) . '...'; |
| 130 |
} |
| 131 |
if ( $data['response_summary'] && strlen( $data['response_summary'] ) > 500 ) { |
| 132 |
$data['response_summary'] = substr( $data['response_summary'], 0, 497 ) . '...'; |
| 133 |
} |
| 134 |
if ( $data['error_message'] && strlen( $data['error_message'] ) > 1000 ) { |
| 135 |
$data['error_message'] = substr( $data['error_message'], 0, 997 ) . '...'; |
| 136 |
} |
| 137 |
|
| 138 |
$result = $wpdb->insert( |
| 139 |
WPF()->tables->ai_logs, |
| 140 |
[ |
| 141 |
'action_type' => $data['action_type'], |
| 142 |
'userid' => $data['userid'], |
| 143 |
'user_type' => $data['user_type'], |
| 144 |
'credits_used' => $data['credits_used'], |
| 145 |
'status' => $data['status'], |
| 146 |
'content_type' => $data['content_type'], |
| 147 |
'content_id' => $data['content_id'], |
| 148 |
'forumid' => $data['forumid'], |
| 149 |
'topicid' => $data['topicid'], |
| 150 |
'request_summary' => $data['request_summary'], |
| 151 |
'response_summary' => $data['response_summary'], |
| 152 |
'error_message' => $data['error_message'], |
| 153 |
'duration_ms' => $data['duration_ms'], |
| 154 |
'ip_address' => $data['ip_address'], |
| 155 |
'extra_data' => $data['extra_data'], |
| 156 |
'created' => $data['created'], |
| 157 |
], |
| 158 |
[ '%s', '%d', '%s', '%d', '%s', '%s', '%d', '%d', '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s' ] |
| 159 |
); |
| 160 |
|
| 161 |
if ( $result === false ) { |
| 162 |
return false; |
| 163 |
} |
| 164 |
|
| 165 |
$this->schedule_cleanup(); |
| 166 |
|
| 167 |
return $wpdb->insert_id; |
| 168 |
} |
| 169 |
|
| 170 |
/** |
| 171 |
* Get logs with filtering |
| 172 |
* |
| 173 |
* @param array $args Query arguments |
| 174 |
* |
| 175 |
* @return array |
| 176 |
*/ |
| 177 |
public function get_logs( $args = [] ) { |
| 178 |
global $wpdb; |
| 179 |
|
| 180 |
$defaults = [ |
| 181 |
'action_type' => '', |
| 182 |
'date_filter' => 'all', |
| 183 |
'status' => '', |
| 184 |
'user_type' => '', |
| 185 |
'search' => '', |
| 186 |
'limit' => 50, |
| 187 |
'offset' => 0, |
| 188 |
'orderby' => 'created', |
| 189 |
'order' => 'DESC', |
| 190 |
]; |
| 191 |
|
| 192 |
$args = wp_parse_args( $args, $defaults ); |
| 193 |
$table = WPF()->tables->ai_logs; |
| 194 |
|
| 195 |
$where = [ '1=1' ]; |
| 196 |
$prepare_values = []; |
| 197 |
|
| 198 |
// Action type filter |
| 199 |
if ( ! empty( $args['action_type'] ) ) { |
| 200 |
$where[] = 'action_type = %s'; |
| 201 |
$prepare_values[] = $args['action_type']; |
| 202 |
} |
| 203 |
|
| 204 |
// Status filter |
| 205 |
if ( ! empty( $args['status'] ) ) { |
| 206 |
$where[] = 'status = %s'; |
| 207 |
$prepare_values[] = $args['status']; |
| 208 |
} |
| 209 |
|
| 210 |
// User type filter |
| 211 |
if ( ! empty( $args['user_type'] ) ) { |
| 212 |
$where[] = 'user_type = %s'; |
| 213 |
$prepare_values[] = $args['user_type']; |
| 214 |
} |
| 215 |
|
| 216 |
// Date filter |
| 217 |
if ( ! empty( $args['date_filter'] ) && $args['date_filter'] !== 'all' ) { |
| 218 |
$date_sql = $this->get_date_filter_sql( $args['date_filter'] ); |
| 219 |
if ( $date_sql ) { |
| 220 |
$where[] = $date_sql; |
| 221 |
} |
| 222 |
} |
| 223 |
|
| 224 |
// Search |
| 225 |
if ( ! empty( $args['search'] ) ) { |
| 226 |
$where[] = '(request_summary LIKE %s OR response_summary LIKE %s OR error_message LIKE %s)'; |
| 227 |
$search_term = '%' . $wpdb->esc_like( $args['search'] ) . '%'; |
| 228 |
$prepare_values[] = $search_term; |
| 229 |
$prepare_values[] = $search_term; |
| 230 |
$prepare_values[] = $search_term; |
| 231 |
} |
| 232 |
|
| 233 |
$where_clause = implode( ' AND ', $where ); |
| 234 |
|
| 235 |
// Sanitize order |
| 236 |
$allowed_orderby = [ 'id', 'created', 'action_type', 'status', 'credits_used', 'duration_ms' ]; |
| 237 |
$orderby = in_array( $args['orderby'], $allowed_orderby, true ) ? $args['orderby'] : 'created'; |
| 238 |
$order = strtoupper( $args['order'] ) === 'ASC' ? 'ASC' : 'DESC'; |
| 239 |
|
| 240 |
$sql = "SELECT * FROM `{$table}` WHERE {$where_clause} ORDER BY {$orderby} {$order} LIMIT %d OFFSET %d"; |
| 241 |
$prepare_values[] = intval( $args['limit'] ); |
| 242 |
$prepare_values[] = intval( $args['offset'] ); |
| 243 |
|
| 244 |
if ( ! empty( $prepare_values ) ) { |
| 245 |
$sql = $wpdb->prepare( $sql, $prepare_values ); |
| 246 |
} |
| 247 |
|
| 248 |
$logs = $wpdb->get_results( $sql, ARRAY_A ); |
| 249 |
|
| 250 |
// Enrich logs with user display names |
| 251 |
if ( $logs ) { |
| 252 |
$logs = $this->enrich_logs_with_user_data( $logs ); |
| 253 |
} |
| 254 |
|
| 255 |
return $logs ?: []; |
| 256 |
} |
| 257 |
|
| 258 |
/** |
| 259 |
* Get total count for pagination |
| 260 |
* |
| 261 |
* @param array $args Query arguments |
| 262 |
* |
| 263 |
* @return int |
| 264 |
*/ |
| 265 |
public function get_logs_count( $args = [] ) { |
| 266 |
global $wpdb; |
| 267 |
|
| 268 |
$table = WPF()->tables->ai_logs; |
| 269 |
$where = [ '1=1' ]; |
| 270 |
$prepare_values = []; |
| 271 |
|
| 272 |
if ( ! empty( $args['action_type'] ) ) { |
| 273 |
$where[] = 'action_type = %s'; |
| 274 |
$prepare_values[] = $args['action_type']; |
| 275 |
} |
| 276 |
|
| 277 |
if ( ! empty( $args['status'] ) ) { |
| 278 |
$where[] = 'status = %s'; |
| 279 |
$prepare_values[] = $args['status']; |
| 280 |
} |
| 281 |
|
| 282 |
if ( ! empty( $args['user_type'] ) ) { |
| 283 |
$where[] = 'user_type = %s'; |
| 284 |
$prepare_values[] = $args['user_type']; |
| 285 |
} |
| 286 |
|
| 287 |
if ( ! empty( $args['date_filter'] ) && $args['date_filter'] !== 'all' ) { |
| 288 |
$date_sql = $this->get_date_filter_sql( $args['date_filter'] ); |
| 289 |
if ( $date_sql ) { |
| 290 |
$where[] = $date_sql; |
| 291 |
} |
| 292 |
} |
| 293 |
|
| 294 |
if ( ! empty( $args['search'] ) ) { |
| 295 |
$where[] = '(request_summary LIKE %s OR response_summary LIKE %s OR error_message LIKE %s)'; |
| 296 |
$search_term = '%' . $wpdb->esc_like( $args['search'] ) . '%'; |
| 297 |
$prepare_values[] = $search_term; |
| 298 |
$prepare_values[] = $search_term; |
| 299 |
$prepare_values[] = $search_term; |
| 300 |
} |
| 301 |
|
| 302 |
$where_clause = implode( ' AND ', $where ); |
| 303 |
$sql = "SELECT COUNT(*) FROM `{$table}` WHERE {$where_clause}"; |
| 304 |
|
| 305 |
if ( ! empty( $prepare_values ) ) { |
| 306 |
$sql = $wpdb->prepare( $sql, $prepare_values ); |
| 307 |
} |
| 308 |
|
| 309 |
return (int) $wpdb->get_var( $sql ); |
| 310 |
} |
| 311 |
|
| 312 |
/** |
| 313 |
* Get a single log by ID |
| 314 |
* |
| 315 |
* @param int $id Log ID |
| 316 |
* |
| 317 |
* @return array|null |
| 318 |
*/ |
| 319 |
public function get_log( $id ) { |
| 320 |
global $wpdb; |
| 321 |
|
| 322 |
$sql = $wpdb->prepare( |
| 323 |
"SELECT * FROM `" . WPF()->tables->ai_logs . "` WHERE id = %d", |
| 324 |
$id |
| 325 |
); |
| 326 |
|
| 327 |
$log = $wpdb->get_row( $sql, ARRAY_A ); |
| 328 |
|
| 329 |
if ( $log ) { |
| 330 |
$logs = $this->enrich_logs_with_user_data( [ $log ] ); |
| 331 |
$log = $logs[0]; |
| 332 |
} |
| 333 |
|
| 334 |
return $log; |
| 335 |
} |
| 336 |
|
| 337 |
/** |
| 338 |
* Delete specific logs |
| 339 |
* |
| 340 |
* @param array $ids Log IDs |
| 341 |
* |
| 342 |
* @return int|false Number of deleted rows or false on error |
| 343 |
*/ |
| 344 |
public function delete_logs( $ids ) { |
| 345 |
global $wpdb; |
| 346 |
|
| 347 |
if ( empty( $ids ) || ! is_array( $ids ) ) { |
| 348 |
return false; |
| 349 |
} |
| 350 |
|
| 351 |
$ids = array_map( 'intval', $ids ); |
| 352 |
$ids = array_filter( $ids ); |
| 353 |
$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); |
| 354 |
|
| 355 |
return $wpdb->query( $wpdb->prepare( |
| 356 |
"DELETE FROM `" . WPF()->tables->ai_logs . "` WHERE id IN ({$placeholders})", |
| 357 |
$ids |
| 358 |
) ); |
| 359 |
} |
| 360 |
|
| 361 |
/** |
| 362 |
* Empty all logs |
| 363 |
* |
| 364 |
* @return int|false Number of deleted rows or false on error |
| 365 |
*/ |
| 366 |
public function empty_all_logs() { |
| 367 |
global $wpdb; |
| 368 |
|
| 369 |
return $wpdb->query( "TRUNCATE TABLE `" . WPF()->tables->ai_logs . "`" ); |
| 370 |
} |
| 371 |
|
| 372 |
/** |
| 373 |
* Cleanup old logs (called by cron) |
| 374 |
* |
| 375 |
* @param int $days Delete logs older than this many days (default 90) |
| 376 |
* |
| 377 |
* @return int Number of deleted rows |
| 378 |
*/ |
| 379 |
public function cleanup_old_logs( $days = 90 ) { |
| 380 |
global $wpdb; |
| 381 |
|
| 382 |
$days = apply_filters( 'wpforo_ai_logs_retention_days', $days ); |
| 383 |
|
| 384 |
$result = $wpdb->query( |
| 385 |
$wpdb->prepare( |
| 386 |
"DELETE FROM `" . WPF()->tables->ai_logs . "` WHERE created < DATE_SUB(NOW(), INTERVAL %d DAY)", |
| 387 |
$days |
| 388 |
) |
| 389 |
); |
| 390 |
|
| 391 |
return $result !== false ? $result : 0; |
| 392 |
} |
| 393 |
|
| 394 |
/** |
| 395 |
* Cron callback for log cleanup |
| 396 |
*/ |
| 397 |
public function cron_cleanup_old_logs() { |
| 398 |
$days = $this->get_cleanup_days(); |
| 399 |
|
| 400 |
// If 0, auto-cleanup is disabled |
| 401 |
if ( $days <= 0 ) { |
| 402 |
return; |
| 403 |
} |
| 404 |
|
| 405 |
$deleted = $this->cleanup_old_logs( $days ); |
| 406 |
|
| 407 |
if ( $deleted > 0 ) { |
| 408 |
\wpforo_ai_log( 'info', "Cron cleanup: deleted {$deleted} logs older than {$days} days", 'Logs' ); |
| 409 |
} |
| 410 |
} |
| 411 |
|
| 412 |
/** |
| 413 |
* Get cleanup days setting (board-specific) |
| 414 |
* |
| 415 |
* @return int Number of days (0 = keep forever) |
| 416 |
*/ |
| 417 |
public function get_cleanup_days() { |
| 418 |
return (int) wpforo_get_option( 'ai_logs_cleanup_days', 90 ); |
| 419 |
} |
| 420 |
|
| 421 |
/** |
| 422 |
* Save cleanup days setting (board-specific) |
| 423 |
* |
| 424 |
* @param int $days Number of days (0 = keep forever) |
| 425 |
* |
| 426 |
* @return bool Success |
| 427 |
*/ |
| 428 |
public function save_cleanup_days( $days ) { |
| 429 |
$days = max( 0, min( 365, intval( $days ) ) ); |
| 430 |
return wpforo_update_option( 'ai_logs_cleanup_days', $days ); |
| 431 |
} |
| 432 |
|
| 433 |
/** |
| 434 |
* Get per page setting (board-specific) |
| 435 |
* |
| 436 |
* @return int Number of logs per page |
| 437 |
*/ |
| 438 |
public function get_per_page() { |
| 439 |
return (int) wpforo_get_option( 'ai_logs_per_page', 50 ); |
| 440 |
} |
| 441 |
|
| 442 |
/** |
| 443 |
* Save per page setting (board-specific) |
| 444 |
* |
| 445 |
* @param int $per_page Number of logs per page |
| 446 |
* |
| 447 |
* @return bool Success |
| 448 |
*/ |
| 449 |
public function save_per_page( $per_page ) { |
| 450 |
$allowed = [ 25, 50, 100, 200 ]; |
| 451 |
$per_page = in_array( $per_page, $allowed, true ) ? $per_page : 50; |
| 452 |
return wpforo_update_option( 'ai_logs_per_page', $per_page ); |
| 453 |
} |
| 454 |
|
| 455 |
/** |
| 456 |
* Schedule cleanup cron if not already scheduled |
| 457 |
*/ |
| 458 |
public function schedule_cleanup() { |
| 459 |
if ( ! wp_next_scheduled( 'wpforo_ai_logs_cleanup' ) ) { |
| 460 |
wp_schedule_event( strtotime( 'tomorrow 5:00am' ), 'daily', 'wpforo_ai_logs_cleanup' ); |
| 461 |
} |
| 462 |
} |
| 463 |
|
| 464 |
/** |
| 465 |
* Unschedule cleanup cron |
| 466 |
*/ |
| 467 |
public function unschedule_cleanup() { |
| 468 |
$timestamp = wp_next_scheduled( 'wpforo_ai_logs_cleanup' ); |
| 469 |
if ( $timestamp ) { |
| 470 |
wp_unschedule_event( $timestamp, 'wpforo_ai_logs_cleanup' ); |
| 471 |
} |
| 472 |
} |
| 473 |
|
| 474 |
// ========================================================================= |
| 475 |
// AJAX HANDLERS |
| 476 |
// ========================================================================= |
| 477 |
|
| 478 |
/** |
| 479 |
* AJAX: Get logs with filtering |
| 480 |
*/ |
| 481 |
public function ajax_get_logs() { |
| 482 |
$this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); |
| 483 |
$this->switch_board_context(); |
| 484 |
|
| 485 |
// Accept both 'date_filter' and 'date_range' parameter names (JS sends date_range) |
| 486 |
$date_filter = $this->get_post_param( 'date_filter', '' ) ?: $this->get_post_param( 'date_range', 'all' ); |
| 487 |
$page = $this->get_post_param( 'page', 1, 'int' ); |
| 488 |
$per_page = $this->get_post_param( 'per_page', 50, 'int' ); |
| 489 |
|
| 490 |
$args = [ |
| 491 |
'action_type' => $this->get_post_param( 'action_type', '' ), |
| 492 |
'date_filter' => $date_filter, |
| 493 |
'status' => $this->get_post_param( 'status', '' ), |
| 494 |
'user_type' => $this->get_post_param( 'user_type', '' ), |
| 495 |
'search' => $this->get_post_param( 'search', '' ), |
| 496 |
'limit' => $per_page, |
| 497 |
'offset' => ( $page - 1 ) * $per_page, |
| 498 |
'orderby' => $this->get_post_param( 'orderby', 'created' ), |
| 499 |
'order' => $this->get_post_param( 'order', 'DESC' ), |
| 500 |
]; |
| 501 |
|
| 502 |
$logs = $this->get_logs( $args ); |
| 503 |
$total = $this->get_logs_count( $args ); |
| 504 |
|
| 505 |
// Enrich logs with user data |
| 506 |
$logs = $this->enrich_logs_with_user_data( $logs ); |
| 507 |
|
| 508 |
// Render HTML for table rows |
| 509 |
$html = $this->render_logs_table_html( $logs ); |
| 510 |
|
| 511 |
$this->send_success( [ |
| 512 |
'html' => $html, |
| 513 |
'logs' => $logs, |
| 514 |
'total' => $total, |
| 515 |
'page' => $page, |
| 516 |
'per_page' => $per_page, |
| 517 |
'total_pages' => ceil( $total / $per_page ), |
| 518 |
] ); |
| 519 |
} |
| 520 |
|
| 521 |
/** |
| 522 |
* AJAX: Delete selected logs |
| 523 |
*/ |
| 524 |
public function ajax_delete_logs() { |
| 525 |
$this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); |
| 526 |
$this->switch_board_context(); |
| 527 |
|
| 528 |
// Accept both 'log_ids' and 'ids' parameter names |
| 529 |
$ids = $this->get_post_param( 'log_ids', [], 'array_int' ); |
| 530 |
if ( empty( $ids ) ) { |
| 531 |
$ids = $this->get_post_param( 'ids', [], 'array_int' ); |
| 532 |
} |
| 533 |
|
| 534 |
if ( empty( $ids ) ) { |
| 535 |
$this->send_error( __( 'No logs selected', 'wpforo' ), 400 ); |
| 536 |
} |
| 537 |
|
| 538 |
$deleted = $this->delete_logs( $ids ); |
| 539 |
|
| 540 |
if ( $deleted === false ) { |
| 541 |
$this->send_error( __( 'Failed to delete logs', 'wpforo' ), 500 ); |
| 542 |
} |
| 543 |
|
| 544 |
$this->send_success( [ |
| 545 |
'message' => sprintf( __( '%d log(s) deleted', 'wpforo' ), $deleted ), |
| 546 |
'deleted' => $deleted, |
| 547 |
] ); |
| 548 |
} |
| 549 |
|
| 550 |
/** |
| 551 |
* AJAX: Empty all logs |
| 552 |
*/ |
| 553 |
public function ajax_empty_all_logs() { |
| 554 |
$this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); |
| 555 |
$this->switch_board_context(); |
| 556 |
|
| 557 |
$result = $this->empty_all_logs(); |
| 558 |
|
| 559 |
if ( $result === false ) { |
| 560 |
$this->send_error( __( 'Failed to empty logs', 'wpforo' ), 500 ); |
| 561 |
} |
| 562 |
|
| 563 |
$this->send_success( [ |
| 564 |
'message' => __( 'All logs have been deleted', 'wpforo' ), |
| 565 |
] ); |
| 566 |
} |
| 567 |
|
| 568 |
/** |
| 569 |
* AJAX: Get single log detail |
| 570 |
*/ |
| 571 |
public function ajax_get_log_detail() { |
| 572 |
$this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); |
| 573 |
$this->switch_board_context(); |
| 574 |
|
| 575 |
// Accept both 'id' and 'log_id' parameter names |
| 576 |
$id = $this->get_post_param( 'log_id', 0, 'int' ); |
| 577 |
if ( ! $id ) { |
| 578 |
$id = $this->get_post_param( 'id', 0, 'int' ); |
| 579 |
} |
| 580 |
|
| 581 |
if ( ! $id ) { |
| 582 |
$this->send_error( __( 'Invalid log ID', 'wpforo' ), 400 ); |
| 583 |
} |
| 584 |
|
| 585 |
$log = $this->get_log( $id ); |
| 586 |
|
| 587 |
if ( ! $log ) { |
| 588 |
$this->send_error( __( 'Log not found', 'wpforo' ), 404 ); |
| 589 |
} |
| 590 |
|
| 591 |
// Enrich with user data |
| 592 |
$logs = $this->enrich_logs_with_user_data( [ $log ] ); |
| 593 |
$log = $logs[0]; |
| 594 |
|
| 595 |
// Decode extra_data for display |
| 596 |
$extra_data_decoded = null; |
| 597 |
if ( ! empty( $log['extra_data'] ) ) { |
| 598 |
$extra_data_decoded = json_decode( $log['extra_data'], true ); |
| 599 |
} |
| 600 |
|
| 601 |
// Build HTML for modal |
| 602 |
$html = $this->render_log_detail_html( $log, $extra_data_decoded ); |
| 603 |
|
| 604 |
$this->send_success( [ 'html' => $html, 'log' => $log ] ); |
| 605 |
} |
| 606 |
|
| 607 |
/** |
| 608 |
* AJAX: Save cleanup days setting |
| 609 |
*/ |
| 610 |
public function ajax_save_cleanup_days() { |
| 611 |
$this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); |
| 612 |
$this->switch_board_context(); |
| 613 |
|
| 614 |
$days = $this->get_post_param( 'days', 90, 'int' ); |
| 615 |
|
| 616 |
if ( $days < 0 || $days > 365 ) { |
| 617 |
$this->send_error( __( 'Days must be between 0 and 365', 'wpforo' ), 400 ); |
| 618 |
} |
| 619 |
|
| 620 |
$result = $this->save_cleanup_days( $days ); |
| 621 |
|
| 622 |
if ( ! $result ) { |
| 623 |
$this->send_error( __( 'Failed to save setting', 'wpforo' ), 500 ); |
| 624 |
} |
| 625 |
|
| 626 |
if ( $days > 0 ) { |
| 627 |
$message = sprintf( __( 'Logs older than %d days will be automatically deleted', 'wpforo' ), $days ); |
| 628 |
} else { |
| 629 |
$message = __( 'Auto-cleanup disabled. Logs will be kept forever.', 'wpforo' ); |
| 630 |
} |
| 631 |
|
| 632 |
$this->send_success( [ 'message' => $message, 'days' => $days ] ); |
| 633 |
} |
| 634 |
|
| 635 |
/** |
| 636 |
* AJAX: Save per page setting |
| 637 |
*/ |
| 638 |
public function ajax_save_per_page() { |
| 639 |
$this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); |
| 640 |
$this->switch_board_context(); |
| 641 |
|
| 642 |
$per_page = $this->get_post_param( 'per_page', 50, 'int' ); |
| 643 |
$allowed = [ 25, 50, 100, 200 ]; |
| 644 |
|
| 645 |
if ( ! in_array( $per_page, $allowed, true ) ) { |
| 646 |
$this->send_error( __( 'Invalid value', 'wpforo' ), 400 ); |
| 647 |
} |
| 648 |
|
| 649 |
$result = $this->save_per_page( $per_page ); |
| 650 |
|
| 651 |
if ( ! $result ) { |
| 652 |
$this->send_error( __( 'Failed to save setting', 'wpforo' ), 500 ); |
| 653 |
} |
| 654 |
|
| 655 |
$this->send_success( [ |
| 656 |
'message' => __( 'Setting saved', 'wpforo' ), |
| 657 |
'per_page' => $per_page, |
| 658 |
] ); |
| 659 |
} |
| 660 |
|
| 661 |
/** |
| 662 |
* AJAX: Get AI Chatbot messages (displayed as logs) |
| 663 |
*/ |
| 664 |
public function ajax_get_chat_messages() { |
| 665 |
$this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); |
| 666 |
$this->switch_board_context(); |
| 667 |
|
| 668 |
// Accept both 'date_filter' and 'date_range' parameter names |
| 669 |
$date_filter = $this->get_post_param( 'date_filter', '' ) ?: $this->get_post_param( 'date_range', 'all' ); |
| 670 |
$page = $this->get_post_param( 'page', 1, 'int' ); |
| 671 |
$per_page = $this->get_post_param( 'per_page', 50, 'int' ); |
| 672 |
|
| 673 |
$args = [ |
| 674 |
'date_filter' => $date_filter, |
| 675 |
'status' => $this->get_post_param( 'status', '' ), |
| 676 |
'user_type' => $this->get_post_param( 'user_type', '' ), |
| 677 |
'search' => $this->get_post_param( 'search', '' ), |
| 678 |
'limit' => $per_page, |
| 679 |
'offset' => ( $page - 1 ) * $per_page, |
| 680 |
]; |
| 681 |
|
| 682 |
$messages = $this->get_chat_messages( $args ); |
| 683 |
$total = $this->get_chat_messages_count( $args ); |
| 684 |
|
| 685 |
// Render HTML for table rows |
| 686 |
$html = $this->render_chat_messages_table_html( $messages ); |
| 687 |
|
| 688 |
$this->send_success( [ |
| 689 |
'html' => $html, |
| 690 |
'messages' => $messages, |
| 691 |
'total' => $total, |
| 692 |
'page' => $page, |
| 693 |
'per_page' => $per_page, |
| 694 |
'total_pages' => ceil( $total / $per_page ), |
| 695 |
] ); |
| 696 |
} |
| 697 |
|
| 698 |
/** |
| 699 |
* AJAX: Get single chat message detail |
| 700 |
*/ |
| 701 |
public function ajax_get_chat_message_detail() { |
| 702 |
$this->verify_ajax_admin_request( 'wpforo_ai_logs_nonce', 'nonce' ); |
| 703 |
$this->switch_board_context(); |
| 704 |
|
| 705 |
// Accept both 'message_id' and 'id' parameter names |
| 706 |
$message_id = $this->get_post_param( 'message_id', 0, 'int' ); |
| 707 |
if ( ! $message_id ) { |
| 708 |
$message_id = $this->get_post_param( 'id', 0, 'int' ); |
| 709 |
} |
| 710 |
|
| 711 |
if ( ! $message_id ) { |
| 712 |
$this->send_error( __( 'Invalid message ID', 'wpforo' ), 400 ); |
| 713 |
} |
| 714 |
|
| 715 |
$message = $this->get_chat_message( $message_id ); |
| 716 |
|
| 717 |
if ( ! $message ) { |
| 718 |
$this->send_error( __( 'Message not found', 'wpforo' ), 404 ); |
| 719 |
} |
| 720 |
|
| 721 |
// Build HTML for modal |
| 722 |
$html = $this->render_chat_message_detail_html( $message ); |
| 723 |
|
| 724 |
$this->send_success( [ 'html' => $html, 'message' => $message ] ); |
| 725 |
} |
| 726 |
|
| 727 |
/** |
| 728 |
* Get chat messages with filtering |
| 729 |
* |
| 730 |
* @param array $args Query arguments |
| 731 |
* |
| 732 |
* @return array |
| 733 |
*/ |
| 734 |
public function get_chat_messages( $args = [] ) { |
| 735 |
global $wpdb; |
| 736 |
|
| 737 |
$defaults = [ |
| 738 |
'date_filter' => 'all', |
| 739 |
'status' => '', |
| 740 |
'user_type' => '', |
| 741 |
'search' => '', |
| 742 |
'limit' => 50, |
| 743 |
'offset' => 0, |
| 744 |
]; |
| 745 |
|
| 746 |
$args = wp_parse_args( $args, $defaults ); |
| 747 |
$messages_table = WPF()->tables->ai_chat_messages; |
| 748 |
$conversations_table = WPF()->tables->ai_chat_conversations; |
| 749 |
|
| 750 |
$where = [ '1=1' ]; |
| 751 |
$prepare_values = []; |
| 752 |
|
| 753 |
// Date filter |
| 754 |
if ( ! empty( $args['date_filter'] ) && $args['date_filter'] !== 'all' ) { |
| 755 |
$date_sql = $this->get_chat_date_filter_sql( $args['date_filter'] ); |
| 756 |
if ( $date_sql ) { |
| 757 |
$where[] = $date_sql; |
| 758 |
} |
| 759 |
} |
| 760 |
|
| 761 |
// Status filter - for chat messages, we interpret this as role |
| 762 |
// 'success' = assistant messages (responses), 'error' would be messages with no credits, etc. |
| 763 |
// Since chat messages don't have status, we skip this filter for now |
| 764 |
|
| 765 |
// User type filter - filter by conversation owner type |
| 766 |
if ( ! empty( $args['user_type'] ) ) { |
| 767 |
if ( $args['user_type'] === 'guest' ) { |
| 768 |
$where[] = 'c.userid = 0'; |
| 769 |
} elseif ( $args['user_type'] === 'user' ) { |
| 770 |
$where[] = 'c.userid > 0'; |
| 771 |
} |
| 772 |
// 'cron' and 'system' don't apply to chat messages |
| 773 |
} |
| 774 |
|
| 775 |
// Search in message content or conversation title |
| 776 |
if ( ! empty( $args['search'] ) ) { |
| 777 |
$where[] = '(m.content LIKE %s OR c.title LIKE %s)'; |
| 778 |
$search_term = '%' . $wpdb->esc_like( $args['search'] ) . '%'; |
| 779 |
$prepare_values[] = $search_term; |
| 780 |
$prepare_values[] = $search_term; |
| 781 |
} |
| 782 |
|
| 783 |
$where_clause = implode( ' AND ', $where ); |
| 784 |
|
| 785 |
$sql = "SELECT m.*, c.title as conversation_title, c.userid |
| 786 |
FROM `{$messages_table}` m |
| 787 |
LEFT JOIN `{$conversations_table}` c ON m.conversation_id = c.conversation_id |
| 788 |
WHERE {$where_clause} |
| 789 |
ORDER BY m.created_at DESC |
| 790 |
LIMIT %d OFFSET %d"; |
| 791 |
|
| 792 |
$prepare_values[] = intval( $args['limit'] ); |
| 793 |
$prepare_values[] = intval( $args['offset'] ); |
| 794 |
|
| 795 |
if ( ! empty( $prepare_values ) ) { |
| 796 |
$sql = $wpdb->prepare( $sql, $prepare_values ); |
| 797 |
} |
| 798 |
|
| 799 |
$messages = $wpdb->get_results( $sql, ARRAY_A ); |
| 800 |
|
| 801 |
// Enrich with user data |
| 802 |
if ( $messages ) { |
| 803 |
$messages = $this->enrich_chat_messages_with_user_data( $messages ); |
| 804 |
} |
| 805 |
|
| 806 |
return $messages ?: []; |
| 807 |
} |
| 808 |
|
| 809 |
/** |
| 810 |
* Get total count of chat messages for pagination |
| 811 |
* |
| 812 |
* @param array $args Query arguments |
| 813 |
* |
| 814 |
* @return int |
| 815 |
*/ |
| 816 |
public function get_chat_messages_count( $args = [] ) { |
| 817 |
global $wpdb; |
| 818 |
|
| 819 |
$messages_table = WPF()->tables->ai_chat_messages; |
| 820 |
$conversations_table = WPF()->tables->ai_chat_conversations; |
| 821 |
|
| 822 |
$where = [ '1=1' ]; |
| 823 |
$prepare_values = []; |
| 824 |
|
| 825 |
if ( ! empty( $args['date_filter'] ) && $args['date_filter'] !== 'all' ) { |
| 826 |
$date_sql = $this->get_chat_date_filter_sql( $args['date_filter'] ); |
| 827 |
if ( $date_sql ) { |
| 828 |
$where[] = $date_sql; |
| 829 |
} |
| 830 |
} |
| 831 |
|
| 832 |
if ( ! empty( $args['user_type'] ) ) { |
| 833 |
if ( $args['user_type'] === 'guest' ) { |
| 834 |
$where[] = 'c.userid = 0'; |
| 835 |
} elseif ( $args['user_type'] === 'user' ) { |
| 836 |
$where[] = 'c.userid > 0'; |
| 837 |
} |
| 838 |
} |
| 839 |
|
| 840 |
if ( ! empty( $args['search'] ) ) { |
| 841 |
$where[] = '(m.content LIKE %s OR c.title LIKE %s)'; |
| 842 |
$search_term = '%' . $wpdb->esc_like( $args['search'] ) . '%'; |
| 843 |
$prepare_values[] = $search_term; |
| 844 |
$prepare_values[] = $search_term; |
| 845 |
} |
| 846 |
|
| 847 |
$where_clause = implode( ' AND ', $where ); |
| 848 |
|
| 849 |
$sql = "SELECT COUNT(*) |
| 850 |
FROM `{$messages_table}` m |
| 851 |
LEFT JOIN `{$conversations_table}` c ON m.conversation_id = c.conversation_id |
| 852 |
WHERE {$where_clause}"; |
| 853 |
|
| 854 |
if ( ! empty( $prepare_values ) ) { |
| 855 |
$sql = $wpdb->prepare( $sql, $prepare_values ); |
| 856 |
} |
| 857 |
|
| 858 |
return (int) $wpdb->get_var( $sql ); |
| 859 |
} |
| 860 |
|
| 861 |
/** |
| 862 |
* Get a single chat message by ID |
| 863 |
* |
| 864 |
* @param int $message_id Message ID |
| 865 |
* |
| 866 |
* @return array|null |
| 867 |
*/ |
| 868 |
public function get_chat_message( $message_id ) { |
| 869 |
global $wpdb; |
| 870 |
|
| 871 |
$messages_table = WPF()->tables->ai_chat_messages; |
| 872 |
$conversations_table = WPF()->tables->ai_chat_conversations; |
| 873 |
|
| 874 |
$sql = $wpdb->prepare( |
| 875 |
"SELECT m.*, c.title as conversation_title, c.userid, c.running_summary, c.message_count, c.total_credits |
| 876 |
FROM `{$messages_table}` m |
| 877 |
LEFT JOIN `{$conversations_table}` c ON m.conversation_id = c.conversation_id |
| 878 |
WHERE m.message_id = %d", |
| 879 |
$message_id |
| 880 |
); |
| 881 |
|
| 882 |
$message = $wpdb->get_row( $sql, ARRAY_A ); |
| 883 |
|
| 884 |
if ( $message ) { |
| 885 |
$messages = $this->enrich_chat_messages_with_user_data( [ $message ] ); |
| 886 |
$message = $messages[0]; |
| 887 |
} |
| 888 |
|
| 889 |
return $message; |
| 890 |
} |
| 891 |
|
| 892 |
/** |
| 893 |
* Get date filter SQL for chat messages |
| 894 |
* |
| 895 |
* @param string $filter Date filter key |
| 896 |
* |
| 897 |
* @return string SQL condition |
| 898 |
*/ |
| 899 |
private function get_chat_date_filter_sql( $filter ) { |
| 900 |
switch ( $filter ) { |
| 901 |
case 'last_hour': |
| 902 |
return 'm.created_at >= DATE_SUB(NOW(), INTERVAL 1 HOUR)'; |
| 903 |
case 'last_day': |
| 904 |
return 'm.created_at >= DATE_SUB(NOW(), INTERVAL 1 DAY)'; |
| 905 |
case 'last_week': |
| 906 |
return 'm.created_at >= DATE_SUB(NOW(), INTERVAL 1 WEEK)'; |
| 907 |
case 'last_month': |
| 908 |
return 'm.created_at >= DATE_SUB(NOW(), INTERVAL 1 MONTH)'; |
| 909 |
default: |
| 910 |
return ''; |
| 911 |
} |
| 912 |
} |
| 913 |
|
| 914 |
/** |
| 915 |
* Enrich chat messages with user display names |
| 916 |
* |
| 917 |
* @param array $messages Array of chat messages |
| 918 |
* |
| 919 |
* @return array Enriched messages |
| 920 |
*/ |
| 921 |
private function enrich_chat_messages_with_user_data( $messages ) { |
| 922 |
return $this->enrich_items_with_user_data( $messages, 'userid', [ |
| 923 |
'user_type_field' => 'user_type', |
| 924 |
'output_field' => 'user_display', |
| 925 |
'use_type_labels' => false, // Chat messages only have user or guest |
| 926 |
] ); |
| 927 |
} |
| 928 |
|
| 929 |
/** |
| 930 |
* Render chat messages as log table rows HTML |
| 931 |
* |
| 932 |
* @param array $messages Array of chat messages |
| 933 |
* |
| 934 |
* @return string HTML content for table tbody |
| 935 |
*/ |
| 936 |
private function render_chat_messages_table_html( $messages ) { |
| 937 |
if ( empty( $messages ) ) { |
| 938 |
ob_start(); |
| 939 |
?> |
| 940 |
<tr class="wpforo-ai-logs-empty-row"> |
| 941 |
<td colspan="8"> |
| 942 |
<div class="wpforo-ai-logs-empty"> |
| 943 |
<span class="dashicons dashicons-info-outline"></span> |
| 944 |
<?php esc_html_e( 'No chat messages found matching your filters.', 'wpforo' ); ?> |
| 945 |
</div> |
| 946 |
</td> |
| 947 |
</tr> |
| 948 |
<?php |
| 949 |
return ob_get_clean(); |
| 950 |
} |
| 951 |
|
| 952 |
ob_start(); |
| 953 |
foreach ( $messages as $message ) { |
| 954 |
$this->render_chat_message_row( $message ); |
| 955 |
} |
| 956 |
return ob_get_clean(); |
| 957 |
} |
| 958 |
|
| 959 |
/** |
| 960 |
* Render a single chat message as a log table row |
| 961 |
* |
| 962 |
* @param array $message Chat message data |
| 963 |
*/ |
| 964 |
private function render_chat_message_row( $message ) { |
| 965 |
$message_id = intval( $message['message_id'] ); |
| 966 |
$role = sanitize_text_field( $message['role'] ); |
| 967 |
$user_type = sanitize_text_field( $message['user_type'] ?? 'user' ); |
| 968 |
$credits = intval( $message['credits_spent'] ?? 0 ); |
| 969 |
$created = $message['created_at']; |
| 970 |
|
| 971 |
// Format date in user's timezone |
| 972 |
$date_display = self::format_datetime( $created, 'M j, Y' ); |
| 973 |
$time_display = self::format_datetime( $created, 'g:i a' ); |
| 974 |
|
| 975 |
// Get display values |
| 976 |
$user_display = isset( $message['user_display'] ) ? $message['user_display'] : __( 'Unknown', 'wpforo' ); |
| 977 |
|
| 978 |
// Build summary: conversation title + truncated message |
| 979 |
$conv_title = ! empty( $message['conversation_title'] ) ? $message['conversation_title'] : __( 'Conversation', 'wpforo' ); |
| 980 |
$msg_preview = wp_trim_words( wp_strip_all_tags( $message['content'] ), 12 ); |
| 981 |
$summary = '<strong>' . esc_html( $conv_title ) . ':</strong> ' . esc_html( $msg_preview ); |
| 982 |
|
| 983 |
// Role badge - user or assistant |
| 984 |
$role_label = $role === 'assistant' ? __( 'AI Response', 'wpforo' ) : __( 'User Message', 'wpforo' ); |
| 985 |
$role_class = $role === 'assistant' ? 'wpforo-ai-chat-role-assistant' : 'wpforo-ai-chat-role-user'; |
| 986 |
|
| 987 |
// Status based on role and credits |
| 988 |
$status_label = $role === 'assistant' ? __( 'Response', 'wpforo' ) : __( 'Query', 'wpforo' ); |
| 989 |
$status_class = $role === 'assistant' ? 'wpforo-ai-log-status-success' : 'wpforo-ai-log-status-cached'; |
| 990 |
?> |
| 991 |
<tr data-message-id="<?php echo esc_attr( $message_id ); ?>" data-role="<?php echo esc_attr( $role ); ?>" class="wpforo-ai-chat-message-row"> |
| 992 |
<td class="check-column"> |
| 993 |
<input type="checkbox" name="message_ids[]" value="<?php echo esc_attr( $message_id ); ?>" class="wpforo-ai-log-checkbox" disabled> |
| 994 |
</td> |
| 995 |
<td class="column-datetime"> |
| 996 |
<span class="wpforo-ai-log-date"><?php echo esc_html( $date_display ); ?></span> |
| 997 |
<span class="wpforo-ai-log-time"><?php echo esc_html( $time_display ); ?></span> |
| 998 |
</td> |
| 999 |
<td class="column-action-type"> |
| 1000 |
<span class="wpforo-ai-log-action-badge <?php echo esc_attr( $role_class ); ?>"> |
| 1001 |
<?php echo esc_html( $role_label ); ?> |
| 1002 |
</span> |
| 1003 |
</td> |
| 1004 |
<td class="column-user"> |
| 1005 |
<span class="wpforo-ai-log-user wpforo-ai-user-type-<?php echo esc_attr( $user_type ); ?>"> |
| 1006 |
<?php echo esc_html( $user_display ); ?> |
| 1007 |
</span> |
| 1008 |
</td> |
| 1009 |
<td class="column-credits"> |
| 1010 |
<?php if ( $credits > 0 ) : ?> |
| 1011 |
<span class="wpforo-ai-log-credits"><?php echo number_format( $credits ); ?></span> |
| 1012 |
<?php else : ?> |
| 1013 |
<span class="wpforo-ai-log-credits-zero">-</span> |
| 1014 |
<?php endif; ?> |
| 1015 |
</td> |
| 1016 |
<td class="column-status"> |
| 1017 |
<span class="wpforo-ai-log-status <?php echo esc_attr( $status_class ); ?>"> |
| 1018 |
<?php echo esc_html( $status_label ); ?> |
| 1019 |
</span> |
| 1020 |
</td> |
| 1021 |
<td class="column-summary"> |
| 1022 |
<span class="wpforo-ai-log-summary"><?php echo $summary; ?></span> |
| 1023 |
</td> |
| 1024 |
<td class="column-actions"> |
| 1025 |
<button type="button" class="button button-small wpforo-ai-chat-message-view" data-message-id="<?php echo esc_attr( $message_id ); ?>" title="<?php esc_attr_e( 'View Details', 'wpforo' ); ?>"> |
| 1026 |
<span class="dashicons dashicons-visibility"></span> |
| 1027 |
</button> |
| 1028 |
</td> |
| 1029 |
</tr> |
| 1030 |
<?php |
| 1031 |
} |
| 1032 |
|
| 1033 |
/** |
| 1034 |
* Render chat message detail HTML for modal |
| 1035 |
* |
| 1036 |
* @param array $message Chat message data |
| 1037 |
* |
| 1038 |
* @return string HTML content |
| 1039 |
*/ |
| 1040 |
private function render_chat_message_detail_html( $message ) { |
| 1041 |
$role_label = $message['role'] === 'assistant' ? __( 'AI Response', 'wpforo' ) : __( 'User Message', 'wpforo' ); |
| 1042 |
$role_class = $message['role'] === 'assistant' ? 'wpforo-ai-chat-role-assistant' : 'wpforo-ai-chat-role-user'; |
| 1043 |
$user_display = $message['user_display'] ?? __( 'Unknown', 'wpforo' ); |
| 1044 |
|
| 1045 |
// Decode sources if present |
| 1046 |
$sources = null; |
| 1047 |
if ( ! empty( $message['sources_json'] ) ) { |
| 1048 |
$sources = json_decode( $message['sources_json'], true ); |
| 1049 |
} |
| 1050 |
|
| 1051 |
// Process message content: convert markdown and post references |
| 1052 |
$content = $this->format_chat_message_content( $message['content'] ); |
| 1053 |
|
| 1054 |
ob_start(); |
| 1055 |
?> |
| 1056 |
<div class="wpforo-ai-log-detail-grid"> |
| 1057 |
<div class="wpforo-ai-log-detail-item"> |
| 1058 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'Message Type', 'wpforo' ); ?></div> |
| 1059 |
<div class="wpforo-ai-log-detail-value"> |
| 1060 |
<span class="wpforo-ai-log-action-badge <?php echo esc_attr( $role_class ); ?>"> |
| 1061 |
<?php echo esc_html( $role_label ); ?> |
| 1062 |
</span> |
| 1063 |
</div> |
| 1064 |
</div> |
| 1065 |
<div class="wpforo-ai-log-detail-item"> |
| 1066 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'Conversation', 'wpforo' ); ?></div> |
| 1067 |
<div class="wpforo-ai-log-detail-value"> |
| 1068 |
<?php echo esc_html( $message['conversation_title'] ?? __( 'Untitled', 'wpforo' ) ); ?> |
| 1069 |
</div> |
| 1070 |
</div> |
| 1071 |
<div class="wpforo-ai-log-detail-item"> |
| 1072 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'Date/Time', 'wpforo' ); ?></div> |
| 1073 |
<div class="wpforo-ai-log-detail-value"> |
| 1074 |
<?php echo esc_html( self::format_datetime( $message['created_at'], 'F j, Y \a\t g:i:s a' ) ); ?> |
| 1075 |
</div> |
| 1076 |
</div> |
| 1077 |
<div class="wpforo-ai-log-detail-item"> |
| 1078 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'User', 'wpforo' ); ?></div> |
| 1079 |
<div class="wpforo-ai-log-detail-value"> |
| 1080 |
<?php echo esc_html( $user_display ); ?> |
| 1081 |
</div> |
| 1082 |
</div> |
| 1083 |
<?php if ( intval( $message['credits_spent'] ?? 0 ) > 0 ) : ?> |
| 1084 |
<div class="wpforo-ai-log-detail-item"> |
| 1085 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'Credits Used', 'wpforo' ); ?></div> |
| 1086 |
<div class="wpforo-ai-log-detail-value"> |
| 1087 |
<?php echo number_format( $message['credits_spent'] ); ?> |
| 1088 |
</div> |
| 1089 |
</div> |
| 1090 |
<?php endif; ?> |
| 1091 |
<?php if ( intval( $message['tokens_used'] ?? 0 ) > 0 ) : ?> |
| 1092 |
<div class="wpforo-ai-log-detail-item"> |
| 1093 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'Tokens Used', 'wpforo' ); ?></div> |
| 1094 |
<div class="wpforo-ai-log-detail-value"> |
| 1095 |
<?php echo number_format( $message['tokens_used'] ); ?> |
| 1096 |
</div> |
| 1097 |
</div> |
| 1098 |
<?php endif; ?> |
| 1099 |
<?php if ( ! empty( $message['quality_tier'] ) ) : ?> |
| 1100 |
<div class="wpforo-ai-log-detail-item"> |
| 1101 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'Quality Tier', 'wpforo' ); ?></div> |
| 1102 |
<div class="wpforo-ai-log-detail-value"> |
| 1103 |
<?php echo esc_html( ucfirst( $message['quality_tier'] ) ); ?> |
| 1104 |
</div> |
| 1105 |
</div> |
| 1106 |
<?php endif; ?> |
| 1107 |
<?php if ( intval( $message['sources_count'] ?? 0 ) > 0 ) : ?> |
| 1108 |
<div class="wpforo-ai-log-detail-item"> |
| 1109 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'Sources Used', 'wpforo' ); ?></div> |
| 1110 |
<div class="wpforo-ai-log-detail-value"> |
| 1111 |
<?php echo intval( $message['sources_count'] ); ?> |
| 1112 |
</div> |
| 1113 |
</div> |
| 1114 |
<?php endif; ?> |
| 1115 |
<div class="wpforo-ai-log-detail-item full-width"> |
| 1116 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'Message Content', 'wpforo' ); ?></div> |
| 1117 |
<div class="wpforo-ai-log-detail-value wpforo-ai-chat-message-content"> |
| 1118 |
<?php echo $content; ?> |
| 1119 |
</div> |
| 1120 |
</div> |
| 1121 |
<?php if ( ! empty( $sources ) && is_array( $sources ) ) : ?> |
| 1122 |
<div class="wpforo-ai-log-detail-item full-width"> |
| 1123 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'Sources', 'wpforo' ); ?></div> |
| 1124 |
<div class="wpforo-ai-log-detail-value wpforo-ai-sources-list"> |
| 1125 |
<ul> |
| 1126 |
<?php foreach ( $sources as $source ) : ?> |
| 1127 |
<?php |
| 1128 |
// Handle different source formats |
| 1129 |
$url = ''; |
| 1130 |
$title = ''; |
| 1131 |
$postid = 0; |
| 1132 |
|
| 1133 |
if ( is_array( $source ) ) { |
| 1134 |
// Source is an array with metadata |
| 1135 |
$postid = isset( $source['postid'] ) ? intval( $source['postid'] ) : ( isset( $source['post_id'] ) ? intval( $source['post_id'] ) : 0 ); |
| 1136 |
$title = isset( $source['title'] ) ? $source['title'] : ''; |
| 1137 |
$url = isset( $source['url'] ) ? $source['url'] : ''; |
| 1138 |
} elseif ( is_numeric( $source ) ) { |
| 1139 |
// Source is just a post ID |
| 1140 |
$postid = intval( $source ); |
| 1141 |
} |
| 1142 |
|
| 1143 |
// Get URL from post ID if not provided |
| 1144 |
if ( empty( $url ) && $postid > 0 ) { |
| 1145 |
$url = WPF()->post->get_url( $postid ); |
| 1146 |
} |
| 1147 |
|
| 1148 |
// Get title from post if not provided |
| 1149 |
if ( empty( $title ) && $postid > 0 ) { |
| 1150 |
$post = WPF()->post->get_post( $postid ); |
| 1151 |
if ( $post ) { |
| 1152 |
$title = ! empty( $post['title'] ) ? $post['title'] : sprintf( __( 'Post #%d', 'wpforo' ), $postid ); |
| 1153 |
} |
| 1154 |
} |
| 1155 |
|
| 1156 |
// Fallback title |
| 1157 |
if ( empty( $title ) ) { |
| 1158 |
$title = $postid > 0 ? sprintf( __( 'Post #%d', 'wpforo' ), $postid ) : __( 'Source', 'wpforo' ); |
| 1159 |
} |
| 1160 |
?> |
| 1161 |
<li> |
| 1162 |
<?php if ( ! empty( $url ) ) : ?> |
| 1163 |
<a href="<?php echo esc_url( $url ); ?>" target="_blank" class="wpforo-ai-source-link"> |
| 1164 |
<?php echo esc_html( $title ); ?> |
| 1165 |
</a> |
| 1166 |
<?php else : ?> |
| 1167 |
<?php echo esc_html( $title ); ?> |
| 1168 |
<?php endif; ?> |
| 1169 |
</li> |
| 1170 |
<?php endforeach; ?> |
| 1171 |
</ul> |
| 1172 |
</div> |
| 1173 |
</div> |
| 1174 |
<?php endif; ?> |
| 1175 |
<?php if ( ! empty( $message['running_summary'] ) ) : ?> |
| 1176 |
<div class="wpforo-ai-log-detail-item full-width"> |
| 1177 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'Conversation Summary', 'wpforo' ); ?></div> |
| 1178 |
<div class="wpforo-ai-log-detail-value"> |
| 1179 |
<?php echo esc_html( $message['running_summary'] ); ?> |
| 1180 |
</div> |
| 1181 |
</div> |
| 1182 |
<?php endif; ?> |
| 1183 |
</div> |
| 1184 |
<?php |
| 1185 |
return ob_get_clean(); |
| 1186 |
} |
| 1187 |
|
| 1188 |
/** |
| 1189 |
* Format chat message content: convert markdown to HTML and post references to links |
| 1190 |
* |
| 1191 |
* Delegates to AIMarkdown for consistent markdown conversion. |
| 1192 |
* |
| 1193 |
* @param string $content Raw message content |
| 1194 |
* |
| 1195 |
* @return string Formatted HTML content |
| 1196 |
*/ |
| 1197 |
private function format_chat_message_content( $content ) { |
| 1198 |
if ( empty( $content ) ) { |
| 1199 |
return ''; |
| 1200 |
} |
| 1201 |
|
| 1202 |
// Convert citations to links (using inline format for admin view) |
| 1203 |
$content = AIMarkdown::convert_citations( $content, [ |
| 1204 |
'format' => 'inline', |
| 1205 |
'class' => 'wpforo-ai-post-link', |
| 1206 |
] ); |
| 1207 |
|
| 1208 |
// Convert markdown to HTML (admin mode with strikethrough support) |
| 1209 |
return AIMarkdown::to_html( $content, AIMarkdown::MODE_ADMIN ); |
| 1210 |
} |
| 1211 |
|
| 1212 |
/** |
| 1213 |
* Convert basic markdown to HTML |
| 1214 |
* |
| 1215 |
* Delegates to AIMarkdown for consistent markdown conversion. |
| 1216 |
* |
| 1217 |
* @param string $text Markdown text |
| 1218 |
* |
| 1219 |
* @return string HTML |
| 1220 |
*/ |
| 1221 |
private function markdown_to_html( $text ) { |
| 1222 |
return AIMarkdown::to_html( $text, AIMarkdown::MODE_ADMIN ); |
| 1223 |
} |
| 1224 |
|
| 1225 |
/** |
| 1226 |
* Render log detail HTML for modal |
| 1227 |
* |
| 1228 |
* @param array $log Log data |
| 1229 |
* @param array|null $extra_data_decoded Decoded extra data |
| 1230 |
* |
| 1231 |
* @return string HTML content |
| 1232 |
*/ |
| 1233 |
private function render_log_detail_html( $log, $extra_data_decoded = null ) { |
| 1234 |
$action_label = self::get_action_label( $log['action_type'] ); |
| 1235 |
$status_label = self::get_status_label( $log['status'] ); |
| 1236 |
$status_class = self::get_status_class( $log['status'] ); |
| 1237 |
$user_display = $log['user_display'] ?? $this->get_user_type_display_label( $log['user_type'] ); |
| 1238 |
|
| 1239 |
ob_start(); |
| 1240 |
?> |
| 1241 |
<div class="wpforo-ai-log-detail-grid"> |
| 1242 |
<div class="wpforo-ai-log-detail-item"> |
| 1243 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'Action Type', 'wpforo' ); ?></div> |
| 1244 |
<div class="wpforo-ai-log-detail-value"> |
| 1245 |
<span class="wpforo-ai-log-action-badge wpforo-ai-action-<?php echo esc_attr( $log['action_type'] ); ?>"> |
| 1246 |
<?php echo esc_html( $action_label ); ?> |
| 1247 |
</span> |
| 1248 |
</div> |
| 1249 |
</div> |
| 1250 |
<div class="wpforo-ai-log-detail-item"> |
| 1251 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'Status', 'wpforo' ); ?></div> |
| 1252 |
<div class="wpforo-ai-log-detail-value"> |
| 1253 |
<span class="wpforo-ai-log-status <?php echo esc_attr( $status_class ); ?>"> |
| 1254 |
<?php echo esc_html( $status_label ); ?> |
| 1255 |
</span> |
| 1256 |
</div> |
| 1257 |
</div> |
| 1258 |
<div class="wpforo-ai-log-detail-item"> |
| 1259 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'Date/Time', 'wpforo' ); ?></div> |
| 1260 |
<div class="wpforo-ai-log-detail-value"> |
| 1261 |
<?php echo esc_html( self::format_datetime( $log['created'], 'F j, Y \a\t g:i:s a' ) ); ?> |
| 1262 |
</div> |
| 1263 |
</div> |
| 1264 |
<div class="wpforo-ai-log-detail-item"> |
| 1265 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'User', 'wpforo' ); ?></div> |
| 1266 |
<div class="wpforo-ai-log-detail-value"> |
| 1267 |
<?php echo esc_html( $user_display ); ?> |
| 1268 |
<?php if ( $log['user_type'] !== 'user' ) : ?> |
| 1269 |
<small>(<?php echo esc_html( $log['user_type'] ); ?>)</small> |
| 1270 |
<?php endif; ?> |
| 1271 |
</div> |
| 1272 |
</div> |
| 1273 |
<div class="wpforo-ai-log-detail-item"> |
| 1274 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'Credits Used', 'wpforo' ); ?></div> |
| 1275 |
<div class="wpforo-ai-log-detail-value"> |
| 1276 |
<?php echo intval( $log['credits_used'] ) > 0 ? number_format( $log['credits_used'] ) : '-'; ?> |
| 1277 |
</div> |
| 1278 |
</div> |
| 1279 |
<div class="wpforo-ai-log-detail-item"> |
| 1280 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'Duration', 'wpforo' ); ?></div> |
| 1281 |
<div class="wpforo-ai-log-detail-value"> |
| 1282 |
<?php echo intval( $log['duration_ms'] ) > 0 ? number_format( $log['duration_ms'] ) . 'ms' : '-'; ?> |
| 1283 |
</div> |
| 1284 |
</div> |
| 1285 |
<?php if ( ! empty( $log['content_type'] ) || ! empty( $log['content_id'] ) ) : ?> |
| 1286 |
<div class="wpforo-ai-log-detail-item"> |
| 1287 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'Content', 'wpforo' ); ?></div> |
| 1288 |
<div class="wpforo-ai-log-detail-value"> |
| 1289 |
<?php |
| 1290 |
$content_info = []; |
| 1291 |
if ( ! empty( $log['content_type'] ) ) { |
| 1292 |
$content_info[] = ucfirst( $log['content_type'] ); |
| 1293 |
} |
| 1294 |
if ( ! empty( $log['content_id'] ) ) { |
| 1295 |
$content_info[] = '#' . $log['content_id']; |
| 1296 |
} |
| 1297 |
echo esc_html( implode( ' ', $content_info ) ); |
| 1298 |
?> |
| 1299 |
</div> |
| 1300 |
</div> |
| 1301 |
<?php endif; ?> |
| 1302 |
<?php if ( ! empty( $log['ip_address'] ) ) : ?> |
| 1303 |
<div class="wpforo-ai-log-detail-item"> |
| 1304 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'IP Address', 'wpforo' ); ?></div> |
| 1305 |
<div class="wpforo-ai-log-detail-value"><?php echo esc_html( $log['ip_address'] ); ?></div> |
| 1306 |
</div> |
| 1307 |
<?php endif; ?> |
| 1308 |
<?php if ( ! empty( $log['request_summary'] ) ) : ?> |
| 1309 |
<div class="wpforo-ai-log-detail-item full-width"> |
| 1310 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'Request Summary', 'wpforo' ); ?></div> |
| 1311 |
<div class="wpforo-ai-log-detail-value"><?php echo esc_html( $log['request_summary'] ); ?></div> |
| 1312 |
</div> |
| 1313 |
<?php endif; ?> |
| 1314 |
<?php if ( ! empty( $log['response_summary'] ) ) : ?> |
| 1315 |
<div class="wpforo-ai-log-detail-item full-width"> |
| 1316 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'Response Summary', 'wpforo' ); ?></div> |
| 1317 |
<div class="wpforo-ai-log-detail-value"><?php echo esc_html( $log['response_summary'] ); ?></div> |
| 1318 |
</div> |
| 1319 |
<?php endif; ?> |
| 1320 |
<?php if ( ! empty( $log['error_message'] ) ) : ?> |
| 1321 |
<div class="wpforo-ai-log-detail-item full-width wpforo-ai-log-detail-error"> |
| 1322 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'Error Message', 'wpforo' ); ?></div> |
| 1323 |
<div class="wpforo-ai-log-detail-value"><?php echo esc_html( $log['error_message'] ); ?></div> |
| 1324 |
</div> |
| 1325 |
<?php endif; ?> |
| 1326 |
<?php if ( ! empty( $extra_data_decoded ) && wpforo_setting( 'general', 'debug_mode' ) ) : ?> |
| 1327 |
<div class="wpforo-ai-log-detail-item full-width"> |
| 1328 |
<div class="wpforo-ai-log-detail-label"><?php esc_html_e( 'Extra Data', 'wpforo' ); ?></div> |
| 1329 |
<div class="wpforo-ai-log-detail-value"> |
| 1330 |
<pre><?php echo esc_html( json_encode( $extra_data_decoded, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE ) ); ?></pre> |
| 1331 |
</div> |
| 1332 |
</div> |
| 1333 |
<?php endif; ?> |
| 1334 |
</div> |
| 1335 |
<?php |
| 1336 |
return ob_get_clean(); |
| 1337 |
} |
| 1338 |
|
| 1339 |
/** |
| 1340 |
* Render logs table rows HTML for AJAX response |
| 1341 |
* |
| 1342 |
* @param array $logs Array of log entries (already enriched with user data) |
| 1343 |
* |
| 1344 |
* @return string HTML content for table tbody |
| 1345 |
*/ |
| 1346 |
private function render_logs_table_html( $logs ) { |
| 1347 |
if ( empty( $logs ) ) { |
| 1348 |
ob_start(); |
| 1349 |
?> |
| 1350 |
<tr class="wpforo-ai-logs-empty-row"> |
| 1351 |
<td colspan="8"> |
| 1352 |
<div class="wpforo-ai-logs-empty"> |
| 1353 |
<span class="dashicons dashicons-info-outline"></span> |
| 1354 |
<?php esc_html_e( 'No logs found matching your filters.', 'wpforo' ); ?> |
| 1355 |
</div> |
| 1356 |
</td> |
| 1357 |
</tr> |
| 1358 |
<?php |
| 1359 |
return ob_get_clean(); |
| 1360 |
} |
| 1361 |
|
| 1362 |
ob_start(); |
| 1363 |
foreach ( $logs as $log ) { |
| 1364 |
$this->render_log_row( $log ); |
| 1365 |
} |
| 1366 |
return ob_get_clean(); |
| 1367 |
} |
| 1368 |
|
| 1369 |
/** |
| 1370 |
* Render a single log table row |
| 1371 |
* |
| 1372 |
* @param array $log Log data (enriched with user_display) |
| 1373 |
*/ |
| 1374 |
private function render_log_row( $log ) { |
| 1375 |
$log_id = intval( $log['id'] ); |
| 1376 |
$action_type = sanitize_text_field( $log['action_type'] ); |
| 1377 |
$status = sanitize_text_field( $log['status'] ); |
| 1378 |
$user_type = sanitize_text_field( $log['user_type'] ); |
| 1379 |
$credits = intval( $log['credits_used'] ); |
| 1380 |
$duration = intval( $log['duration_ms'] ); |
| 1381 |
$created = $log['created']; |
| 1382 |
|
| 1383 |
// Format date in user's timezone (database stores UTC) |
| 1384 |
$date_display = self::format_datetime( $created, 'M j, Y' ); |
| 1385 |
$time_display = self::format_datetime( $created, 'g:i a' ); |
| 1386 |
|
| 1387 |
// Get display values |
| 1388 |
$action_label = self::get_action_label( $action_type ); |
| 1389 |
$status_label = self::get_status_label( $status ); |
| 1390 |
$status_class = self::get_status_class( $status ); |
| 1391 |
$user_display = isset( $log['user_display'] ) ? $log['user_display'] : __( 'Unknown', 'wpforo' ); |
| 1392 |
|
| 1393 |
// Build summary |
| 1394 |
$summary = ''; |
| 1395 |
if ( ! empty( $log['request_summary'] ) ) { |
| 1396 |
$summary = esc_html( wp_trim_words( $log['request_summary'], 15 ) ); |
| 1397 |
} elseif ( ! empty( $log['response_summary'] ) ) { |
| 1398 |
$summary = esc_html( wp_trim_words( $log['response_summary'], 15 ) ); |
| 1399 |
} elseif ( ! empty( $log['error_message'] ) ) { |
| 1400 |
$summary = '<span class="wpforo-ai-log-error-summary">' . esc_html( wp_trim_words( $log['error_message'], 15 ) ) . '</span>'; |
| 1401 |
} |
| 1402 |
?> |
| 1403 |
<tr data-log-id="<?php echo esc_attr( $log_id ); ?>" data-action-type="<?php echo esc_attr( $action_type ); ?>" data-status="<?php echo esc_attr( $status ); ?>"> |
| 1404 |
<td class="check-column"> |
| 1405 |
<input type="checkbox" name="log_ids[]" value="<?php echo esc_attr( $log_id ); ?>" class="wpforo-ai-log-checkbox"> |
| 1406 |
</td> |
| 1407 |
<td class="column-datetime"> |
| 1408 |
<span class="wpforo-ai-log-date"><?php echo esc_html( $date_display ); ?></span> |
| 1409 |
<span class="wpforo-ai-log-time"><?php echo esc_html( $time_display ); ?></span> |
| 1410 |
</td> |
| 1411 |
<td class="column-action-type"> |
| 1412 |
<span class="wpforo-ai-log-action-badge wpforo-ai-action-<?php echo esc_attr( $action_type ); ?>"> |
| 1413 |
<?php echo esc_html( $action_label ); ?> |
| 1414 |
</span> |
| 1415 |
</td> |
| 1416 |
<td class="column-user"> |
| 1417 |
<span class="wpforo-ai-log-user wpforo-ai-user-type-<?php echo esc_attr( $user_type ); ?>"> |
| 1418 |
<?php echo esc_html( $user_display ); ?> |
| 1419 |
</span> |
| 1420 |
</td> |
| 1421 |
<td class="column-credits"> |
| 1422 |
<?php if ( $credits > 0 ) : ?> |
| 1423 |
<span class="wpforo-ai-log-credits"><?php echo number_format( $credits ); ?></span> |
| 1424 |
<?php else : ?> |
| 1425 |
<span class="wpforo-ai-log-credits-zero">-</span> |
| 1426 |
<?php endif; ?> |
| 1427 |
</td> |
| 1428 |
<td class="column-status"> |
| 1429 |
<span class="wpforo-ai-log-status <?php echo esc_attr( $status_class ); ?>"> |
| 1430 |
<?php echo esc_html( $status_label ); ?> |
| 1431 |
</span> |
| 1432 |
</td> |
| 1433 |
<td class="column-summary"> |
| 1434 |
<span class="wpforo-ai-log-summary"><?php echo $summary; ?></span> |
| 1435 |
<?php if ( $duration > 0 ) : ?> |
| 1436 |
<span class="wpforo-ai-log-duration" title="<?php esc_attr_e( 'Duration', 'wpforo' ); ?>"> |
| 1437 |
(<?php echo number_format( $duration ); ?>ms) |
| 1438 |
</span> |
| 1439 |
<?php endif; ?> |
| 1440 |
</td> |
| 1441 |
<td class="column-actions"> |
| 1442 |
<button type="button" class="button button-small wpforo-ai-log-view" data-log-id="<?php echo esc_attr( $log_id ); ?>" title="<?php esc_attr_e( 'View Details', 'wpforo' ); ?>"> |
| 1443 |
<span class="dashicons dashicons-visibility"></span> |
| 1444 |
</button> |
| 1445 |
<button type="button" class="button button-small wpforo-ai-log-delete" data-log-id="<?php echo esc_attr( $log_id ); ?>" title="<?php esc_attr_e( 'Delete', 'wpforo' ); ?>"> |
| 1446 |
<span class="dashicons dashicons-trash"></span> |
| 1447 |
</button> |
| 1448 |
</td> |
| 1449 |
</tr> |
| 1450 |
<?php |
| 1451 |
} |
| 1452 |
|
| 1453 |
// ========================================================================= |
| 1454 |
// HELPER METHODS |
| 1455 |
// ========================================================================= |
| 1456 |
|
| 1457 |
/** |
| 1458 |
* Get date filter SQL condition |
| 1459 |
* |
| 1460 |
* @param string $filter Date filter key |
| 1461 |
* |
| 1462 |
* @return string SQL condition |
| 1463 |
*/ |
| 1464 |
private function get_date_filter_sql( $filter ) { |
| 1465 |
switch ( $filter ) { |
| 1466 |
case 'last_hour': |
| 1467 |
return 'created >= DATE_SUB(NOW(), INTERVAL 1 HOUR)'; |
| 1468 |
case 'last_day': |
| 1469 |
return 'created >= DATE_SUB(NOW(), INTERVAL 1 DAY)'; |
| 1470 |
case 'last_week': |
| 1471 |
return 'created >= DATE_SUB(NOW(), INTERVAL 1 WEEK)'; |
| 1472 |
case 'last_month': |
| 1473 |
return 'created >= DATE_SUB(NOW(), INTERVAL 1 MONTH)'; |
| 1474 |
default: |
| 1475 |
return ''; |
| 1476 |
} |
| 1477 |
} |
| 1478 |
|
| 1479 |
/** |
| 1480 |
* Enrich logs with user display names |
| 1481 |
* |
| 1482 |
* @param array $logs Array of log entries |
| 1483 |
* |
| 1484 |
* @return array Enriched logs |
| 1485 |
*/ |
| 1486 |
private function enrich_logs_with_user_data( $logs ) { |
| 1487 |
return $this->enrich_items_with_user_data( $logs, 'userid', [ |
| 1488 |
'user_type_field' => 'user_type', |
| 1489 |
'output_field' => 'user_display', |
| 1490 |
'use_type_labels' => true, // Logs can have cron, system types |
| 1491 |
] ); |
| 1492 |
} |
| 1493 |
|
| 1494 |
/** |
| 1495 |
* Get client IP address |
| 1496 |
* |
| 1497 |
* @return string|null |
| 1498 |
*/ |
| 1499 |
private function get_client_ip() { |
| 1500 |
$ip_keys = [ |
| 1501 |
'HTTP_CF_CONNECTING_IP', |
| 1502 |
'HTTP_CLIENT_IP', |
| 1503 |
'HTTP_X_FORWARDED_FOR', |
| 1504 |
'HTTP_X_FORWARDED', |
| 1505 |
'HTTP_X_CLUSTER_CLIENT_IP', |
| 1506 |
'HTTP_FORWARDED_FOR', |
| 1507 |
'HTTP_FORWARDED', |
| 1508 |
'REMOTE_ADDR', |
| 1509 |
]; |
| 1510 |
|
| 1511 |
foreach ( $ip_keys as $key ) { |
| 1512 |
if ( ! empty( $_SERVER[ $key ] ) ) { |
| 1513 |
$ip = sanitize_text_field( $_SERVER[ $key ] ); |
| 1514 |
// Handle comma-separated list (X-Forwarded-For) |
| 1515 |
if ( strpos( $ip, ',' ) !== false ) { |
| 1516 |
$ip = trim( explode( ',', $ip )[0] ); |
| 1517 |
} |
| 1518 |
if ( filter_var( $ip, FILTER_VALIDATE_IP ) ) { |
| 1519 |
return $ip; |
| 1520 |
} |
| 1521 |
} |
| 1522 |
} |
| 1523 |
|
| 1524 |
return null; |
| 1525 |
} |
| 1526 |
|
| 1527 |
/** |
| 1528 |
* Get action type display label |
| 1529 |
* |
| 1530 |
* @param string $action_type Action type constant |
| 1531 |
* |
| 1532 |
* @return string Display label |
| 1533 |
*/ |
| 1534 |
public static function get_action_label( $action_type ) { |
| 1535 |
$labels = [ |
| 1536 |
self::ACTION_SEMANTIC_SEARCH => __( 'Semantic Search', 'wpforo' ), |
| 1537 |
self::ACTION_PUBLIC_SEARCH => __( 'Public Search', 'wpforo' ), |
| 1538 |
self::ACTION_TRANSLATION => __( 'Translation', 'wpforo' ), |
| 1539 |
self::ACTION_TOPIC_SUMMARY => __( 'Topic Summary', 'wpforo' ), |
| 1540 |
self::ACTION_TOPIC_SUGGESTIONS => __( 'Topic Suggestions', 'wpforo' ), |
| 1541 |
self::ACTION_BOT_REPLY => __( 'Bot Reply', 'wpforo' ), |
| 1542 |
self::ACTION_SUGGEST_REPLY => __( 'Suggest Reply', 'wpforo' ), |
| 1543 |
self::ACTION_ANALYTICS_INSIGHTS => __( 'Analytics Insights', 'wpforo' ), |
| 1544 |
self::ACTION_CONTENT_INDEXING => __( 'Content Indexing', 'wpforo' ), |
| 1545 |
self::ACTION_KNOWLEDGE_INDEXING => __( 'Knowledge Indexing', 'wpforo' ), |
| 1546 |
self::ACTION_BATCH_EMBEDDING => __( 'Batch Embedding', 'wpforo' ), |
| 1547 |
self::ACTION_QUEUE_PROCESSING => __( 'Queue Processing', 'wpforo' ), |
| 1548 |
self::ACTION_SPAM_DETECTION => __( 'Spam Detection', 'wpforo' ), |
| 1549 |
self::ACTION_MODERATION => __( 'Moderation', 'wpforo' ), |
| 1550 |
self::ACTION_TASK_EXECUTION => __( 'Task Execution', 'wpforo' ), |
| 1551 |
self::ACTION_CHATBOT => __( 'AI Chatbot', 'wpforo' ), |
| 1552 |
]; |
| 1553 |
|
| 1554 |
return $labels[ $action_type ] ?? ucwords( str_replace( '_', ' ', $action_type ) ); |
| 1555 |
} |
| 1556 |
|
| 1557 |
/** |
| 1558 |
* Get all action types for filter dropdown |
| 1559 |
* |
| 1560 |
* Note: AI Chatbot is excluded - chat messages are viewed separately via the "AI ChatBot Messages" button |
| 1561 |
* |
| 1562 |
* @return array |
| 1563 |
*/ |
| 1564 |
public static function get_action_types() { |
| 1565 |
return [ |
| 1566 |
self::ACTION_SEMANTIC_SEARCH => __( 'Semantic Search', 'wpforo' ), |
| 1567 |
self::ACTION_PUBLIC_SEARCH => __( 'Public Search', 'wpforo' ), |
| 1568 |
self::ACTION_TRANSLATION => __( 'Translation', 'wpforo' ), |
| 1569 |
self::ACTION_TOPIC_SUMMARY => __( 'Topic Summary', 'wpforo' ), |
| 1570 |
self::ACTION_TOPIC_SUGGESTIONS => __( 'Topic Suggestions', 'wpforo' ), |
| 1571 |
self::ACTION_BOT_REPLY => __( 'Bot Reply', 'wpforo' ), |
| 1572 |
self::ACTION_SUGGEST_REPLY => __( 'Suggest Reply', 'wpforo' ), |
| 1573 |
self::ACTION_ANALYTICS_INSIGHTS => __( 'Analytics Insights', 'wpforo' ), |
| 1574 |
self::ACTION_CONTENT_INDEXING => __( 'Content Indexing', 'wpforo' ), |
| 1575 |
self::ACTION_KNOWLEDGE_INDEXING => __( 'Knowledge Indexing', 'wpforo' ), |
| 1576 |
self::ACTION_SPAM_DETECTION => __( 'Spam Detection', 'wpforo' ), |
| 1577 |
self::ACTION_MODERATION => __( 'Moderation', 'wpforo' ), |
| 1578 |
self::ACTION_TASK_EXECUTION => __( 'Task Execution', 'wpforo' ), |
| 1579 |
]; |
| 1580 |
} |
| 1581 |
|
| 1582 |
/** |
| 1583 |
* Get current admin user's timezone |
| 1584 |
* |
| 1585 |
* Priority: |
| 1586 |
* 1. wpforo_profile table (user's forum timezone) |
| 1587 |
* 2. WordPress site timezone |
| 1588 |
* 3. Default to UTC |
| 1589 |
* |
| 1590 |
* @return DateTimeZone User's timezone object |
| 1591 |
*/ |
| 1592 |
public static function get_user_timezone() { |
| 1593 |
static $timezone = null; |
| 1594 |
|
| 1595 |
if ( $timezone !== null ) { |
| 1596 |
return $timezone; |
| 1597 |
} |
| 1598 |
|
| 1599 |
$timezone_string = ''; |
| 1600 |
|
| 1601 |
// 1. Try to get user's timezone from wpforo profile |
| 1602 |
$user_id = get_current_user_id(); |
| 1603 |
if ( $user_id ) { |
| 1604 |
$member = WPF()->member->get_member( $user_id ); |
| 1605 |
if ( ! empty( $member['timezone'] ) ) { |
| 1606 |
$timezone_string = str_replace( '_', ' ', $member['timezone'] ); |
| 1607 |
} |
| 1608 |
} |
| 1609 |
|
| 1610 |
// 2. Fall back to WordPress site timezone |
| 1611 |
if ( empty( $timezone_string ) ) { |
| 1612 |
$timezone_string = wp_timezone_string(); |
| 1613 |
} |
| 1614 |
|
| 1615 |
// 3. Fall back to UTC |
| 1616 |
if ( empty( $timezone_string ) ) { |
| 1617 |
$timezone_string = 'UTC'; |
| 1618 |
} |
| 1619 |
|
| 1620 |
try { |
| 1621 |
// Handle UTC offset format (e.g., "UTC+3", "UTC-5") |
| 1622 |
if ( strpos( $timezone_string, 'UTC/' ) === 0 ) { |
| 1623 |
$timezone_string = str_replace( 'UTC/', '', $timezone_string ); |
| 1624 |
} |
| 1625 |
$timezone = new \DateTimeZone( $timezone_string ); |
| 1626 |
} catch ( \Exception $e ) { |
| 1627 |
$timezone = new \DateTimeZone( 'UTC' ); |
| 1628 |
} |
| 1629 |
|
| 1630 |
return $timezone; |
| 1631 |
} |
| 1632 |
|
| 1633 |
/** |
| 1634 |
* Format UTC datetime in user's timezone |
| 1635 |
* |
| 1636 |
* @param string|int $utc_datetime UTC datetime string or timestamp |
| 1637 |
* @param string $format PHP date format |
| 1638 |
* |
| 1639 |
* @return string Formatted datetime in user's timezone |
| 1640 |
*/ |
| 1641 |
public static function format_datetime( $utc_datetime, $format = '' ) { |
| 1642 |
if ( empty( $utc_datetime ) ) { |
| 1643 |
return ''; |
| 1644 |
} |
| 1645 |
|
| 1646 |
// Default format: WordPress date + time format |
| 1647 |
if ( empty( $format ) ) { |
| 1648 |
$format = get_option( 'date_format' ) . ' ' . get_option( 'time_format' ); |
| 1649 |
} |
| 1650 |
|
| 1651 |
try { |
| 1652 |
// Parse the UTC datetime |
| 1653 |
$utc_tz = new \DateTimeZone( 'UTC' ); |
| 1654 |
|
| 1655 |
if ( is_numeric( $utc_datetime ) ) { |
| 1656 |
$datetime = new \DateTime( '@' . $utc_datetime ); |
| 1657 |
} else { |
| 1658 |
$datetime = new \DateTime( $utc_datetime, $utc_tz ); |
| 1659 |
} |
| 1660 |
|
| 1661 |
// Convert to user's timezone |
| 1662 |
$user_tz = self::get_user_timezone(); |
| 1663 |
$datetime->setTimezone( $user_tz ); |
| 1664 |
|
| 1665 |
// Format using date_i18n for translation support |
| 1666 |
return date_i18n( $format, $datetime->getTimestamp() + $datetime->getOffset() ); |
| 1667 |
} catch ( \Exception $e ) { |
| 1668 |
// Fallback to original value if parsing fails |
| 1669 |
return is_numeric( $utc_datetime ) ? date( $format, $utc_datetime ) : $utc_datetime; |
| 1670 |
} |
| 1671 |
} |
| 1672 |
|
| 1673 |
/** |
| 1674 |
* Get status badge class |
| 1675 |
* |
| 1676 |
* @param string $status Status constant |
| 1677 |
* |
| 1678 |
* @return string CSS class |
| 1679 |
*/ |
| 1680 |
public static function get_status_class( $status ) { |
| 1681 |
$classes = [ |
| 1682 |
self::STATUS_SUCCESS => 'wpforo-ai-log-status-success', |
| 1683 |
self::STATUS_ERROR => 'wpforo-ai-log-status-error', |
| 1684 |
self::STATUS_CACHED => 'wpforo-ai-log-status-cached', |
| 1685 |
]; |
| 1686 |
|
| 1687 |
return $classes[ $status ] ?? ''; |
| 1688 |
} |
| 1689 |
|
| 1690 |
/** |
| 1691 |
* Get status display label |
| 1692 |
* |
| 1693 |
* @param string $status Status constant |
| 1694 |
* |
| 1695 |
* @return string Display label |
| 1696 |
*/ |
| 1697 |
public static function get_status_label( $status ) { |
| 1698 |
$labels = [ |
| 1699 |
self::STATUS_SUCCESS => __( 'Success', 'wpforo' ), |
| 1700 |
self::STATUS_ERROR => __( 'Error', 'wpforo' ), |
| 1701 |
self::STATUS_CACHED => __( 'Cached', 'wpforo' ), |
| 1702 |
]; |
| 1703 |
|
| 1704 |
return $labels[ $status ] ?? ucfirst( $status ); |
| 1705 |
} |
| 1706 |
} |
| 1707 |
|