PluginProbe
wpForo Forum / 3.0.2
wpForo Forum v3.0.2
3.1.5 3.1.4 3.1.2 3.1.1 3.1.0 3.0.9 3.0.8 3.0.7 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.1.1 1.1.2 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.10 1.4.11 1.4.12 1.4.13 1.4.2 All 137 releases
wpforo / classes / AILogs.php

AILogs.php in wpForo Forum 3.0.2, at classes/AILogs.php

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