PluginProbe
wpForo Forum / 3.1.1
wpForo Forum v3.1.1
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.1.1, at classes/AILogs.php

1,707 lines 53.3 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_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