plugin->bots->all($site); if ($bots === []) { if (current_user_can(Capabilities::MANAGE_BOTS)) { echo '

' . esc_html__('Create an assistant', 'aiwend') . '

'; } return; } if (!current_user_can(Capabilities::MANAGE_BOTS)) { return; } $bot = $bots[0]; $real = $this->plugin->ai()->resolve($bot)['mode'] !== 'demo'; echo '

' . esc_html(sprintf(/* translators: %s: assistant name */ __('Test “%s” privately', 'aiwend'), (string) $bot['name'])) . '

' . '

' . esc_html($real ? __('This assistant uses a real AI connection. Opening the test sends nothing; the first message you send there goes to your AI provider, may cost money on your provider account, and counts against your usage limit.', 'aiwend') : __('This assistant answers with simulated demo replies: nothing is sent to an AI provider and nothing is charged. Opening the test sends nothing.', 'aiwend')) . '

'; } public function register(): void { add_action('admin_menu', function (): void { add_submenu_page('aiwend', __('Conversations', 'aiwend'), __('Conversations', 'aiwend'), Capabilities::EXPORT_CONVERSATIONS, 'aiwend-conversations', [$this, 'render']); }); add_action('admin_post_aiwend_conversation', [$this, 'handle']); } public function handle(): void { if (!current_user_can(Capabilities::EXPORT_CONVERSATIONS)) { wp_die('Not authorized.', '', ['response' => 403]); } check_admin_referer('aiwend_conversation'); $session = absint($_POST['session'] ?? 0); $action = sanitize_key(Input::post('operation')); try { $rows = (new Conversations($this->plugin->db))->transcript($this->plugin->currentPrincipal(), $session, 1, 1000); if ($action === 'delete' && current_user_can(Capabilities::MANAGE) && !empty($_POST['confirm_delete'])) { $this->plugin->sessions->delete(get_current_blog_id(), $session); $this->plugin->audit->record(get_current_blog_id(), $this->plugin->currentPrincipal()->actorRef(), 'conversation.delete', 'session:' . $session, 'ok'); wp_safe_redirect(AdminView::url('aiwend-conversations', ['aiwend_notice' => 'conversation_deleted'])); exit; } if ($action === 'export') { if (count($rows) > 1000) { wp_die(esc_html__('This conversation exceeds the 1,000-message export limit. Use paginated inspection.', 'aiwend')); } $this->plugin->audit->record(get_current_blog_id(), $this->plugin->currentPrincipal()->actorRef(), 'conversation.export', 'session:' . $session, 'ok'); nocache_headers(); header('Content-Type: application/json; charset=utf-8'); header('Content-Disposition: attachment; filename="aiwend-conversation-' . $session . '.json"'); header('X-Content-Type-Options: nosniff'); echo wp_json_encode(['messages' => $rows], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); exit; } } catch (ChatException $error) { wp_die(esc_html__('Conversation unavailable.', 'aiwend'), '', ['response' => (int) $error->status]); } wp_die(esc_html__('Action not permitted.', 'aiwend'), '', ['response' => 403]); } public function render(): void { if (!current_user_can(Capabilities::EXPORT_CONVERSATIONS)) { wp_die('Not authorized.'); } nocache_headers(); $service = new Conversations($this->plugin->db); $principal = $this->plugin->currentPrincipal(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation or filter parameter; no state is changed $session = absint($_GET['session'] ?? 0); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation or filter parameter; no state is changed $page = max(1, min(10000, absint($_GET['paged'] ?? 1))); /* translators: %d: number of days conversations are kept */ AdminView::open($this->plugin, 'aiwend-conversations', __('Conversations', 'aiwend'), sprintf(__('Visitor conversations on this site. They are kept for %d days from creation, then removed automatically. Deletion removes local messages, sources shown and feedback; it does not erase external provider records or backups.', 'aiwend'), $this->plugin->retentionDays('aiwend_chat_retention_days'))); AdminView::notice(); try { if ($session > 0) { $rows = $service->transcript($principal, $session, $page); echo '

← ' . esc_html__('Back to conversations', 'aiwend') . '

'; /* translators: %d: conversation number */ echo '

' . esc_html(sprintf(__('Conversation %d', 'aiwend'), $session)) . '

'; if ($rows === []) { echo '

' . esc_html__('This conversation has no messages.', 'aiwend') . '

'; } foreach (array_slice($rows, 0, 100) as $row) { $assistant = $row['role'] === 'assistant'; echo '

' . esc_html($assistant ? __('Assistant', 'aiwend') : __('Visitor', 'aiwend')) . ' ' . esc_html(wp_date('Y-m-d H:i', (int) $row['created_at'])) . ''; if ($row['role'] === 'assistant' && $row['outcome'] === 'stopped') { echo wp_kses( ' ' . AdminView::badge(__('Stopped (incomplete)', 'aiwend'), 'warn'), AdminView::allowedHtml()); } if ($row['rating'] !== null) { echo wp_kses( ' ' . AdminView::badge((int) $row['rating'] === 1 ? __('Useful', 'aiwend') : __('Not useful', 'aiwend'), (int) $row['rating'] === 1 ? 'success' : 'warn'), AdminView::allowedHtml()); } echo '

' . esc_html($row['content']) . '

'; if ($row['citations'] !== []) { echo '

' . esc_html__('Sources shown', 'aiwend') . '

    '; foreach ($row['citations'] as $c) { echo '
  1. ' . esc_html($c['title'] . ((string) $c['anchor'] !== '' ? ' (' . $c['anchor'] . ')' : '')) . '
  2. '; } echo '
'; } echo '
'; } $this->pager($page, count($rows) > 100); echo '

' . esc_html__('Export or delete', 'aiwend') . '

' . esc_html__('Export downloads this conversation as JSON (up to 1,000 messages). It never includes session credentials or visitor identifiers.', 'aiwend') . '

'; $this->actionForm($session, 'export', __('Export JSON', 'aiwend')); if (current_user_can(Capabilities::MANAGE)) { echo '

' . esc_html__('Delete', 'aiwend') . '

'; $this->actionForm($session, 'delete', __('Delete conversation', 'aiwend')); echo '
'; } echo '
'; } else { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation or filter parameter; no state is changed $search = sanitize_text_field(Input::get('search')); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation or filter parameter; no state is changed $bot = absint($_GET['bot'] ?? 0); $rating = (int) sanitize_text_field(Input::get('rating')); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation or filter parameter; no state is changed $period = absint($_GET['period'] ?? 0); $period = in_array($period, [1, 7, 30, 90], true) ? $period : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation or filter parameter; no state is changed $attention = sanitize_key(Input::get('show')); $attention = in_array($attention, ['attention', 'no_source'], true) ? $attention : ''; echo '
'; echo '

'; echo '

'; if ($search !== '' || $bot > 0 || $rating !== 0 || $period > 0 || $attention !== '') { echo ' ' . esc_html__('Clear filters', 'aiwend') . ''; } echo '

'; if ($page === 1) { $this->insightsPanel($principal, $period, $bot); } $rows = $service->listing($principal, $search, $bot, $page, $rating, $period > 0 ? time() - $period * 86400 : 0, $attention); if ($rows === []) { $filtered = $search !== '' || $bot > 0 || $rating !== 0 || $period > 0 || $attention !== ''; echo '

' . esc_html($filtered ? __('No matching conversations', 'aiwend') : __('No conversations yet', 'aiwend')) . '

' . esc_html($filtered ? __('Try a different search or clear the filters.', 'aiwend') : __('Conversations appear here after visitors chat with a public assistant. Private previews in the assistant editor are included too.', 'aiwend')) . '

'; if (!$filtered) { $this->emptyStateTest(); } echo '
'; } else { echo '
'; foreach (array_slice($rows, 0, 50) as $row) { $open = !(int) $row['revoked'] && (int) $row['expires_at'] > time(); /* translators: %d: conversation number */ $label = sprintf(__('Conversation %d', 'aiwend'), (int) $row['id']); echo wp_kses('', AdminView::allowedHtml()); } echo '
' . esc_html__('Assistant', 'aiwend') . '' . esc_html__('Started', 'aiwend') . '' . esc_html__('Messages', 'aiwend') . '' . esc_html__('Useful / Not useful', 'aiwend') . '' . esc_html__('Needs attention', 'aiwend') . '' . esc_html__('State', 'aiwend') . '
' . esc_html($row['name'] ?? __('Deleted assistant', 'aiwend')) . ' — ' . esc_html($label) . '
' . esc_html($label) . '
' . esc_html(wp_date('Y-m-d H:i', (int) $row['created_at'])) . '' . (int) $row['message_count'] . '' . (int) $row['useful'] . ' / ' . (int) $row['not_useful'] . '' . self::reasonBadges($row['reasons'] ?? []) . '' . AdminView::badge($open ? __('Open', 'aiwend') : __('Closed', 'aiwend'), $open ? 'info' : 'neutral') . '
'; } $this->pager($page, count($rows) > 50); } } catch (ChatException) { echo '

' . esc_html__('Conversation unavailable. It may have been deleted or removed by the retention policy.', 'aiwend') . '

'; } AdminView::close(); } /** @param list $reasons */ private static function reasonBadges(array $reasons): string { $labels = [ 'no_match' => [__('No source found', 'aiwend'), 'warn'], 'withdrawn' => [__('Source withdrawn', 'aiwend'), 'warn'], 'no_match_inferred' => [__('No source found (estimated)', 'aiwend'), 'neutral'], 'not_useful' => [__('Rated not useful', 'aiwend'), 'warn'], ]; $out = []; foreach ($reasons as $reason) { if (isset($labels[$reason])) { $out[] = AdminView::badge($labels[$reason][0], $labels[$reason][1]); } } return $out === [] ? '—' : implode(' ', $out); } /** Compact Stage D report: counts, their exact meaning and limits, and bounded recurring unanswered questions. Read-only. */ private function insightsPanel(\Aiwend\Policy\Principal $principal, int $period, int $bot): void { $days = in_array($period, [1, 7, 30], true) ? $period : 30; $series = UsageSeries::days(time(), wp_timezone(), $days); $since = $series[count($series) - 1]['start']; $keep = $this->plugin->retentionDays('aiwend_chat_retention_days'); $insights = new SupportInsights($this->plugin->db, $this->plugin->clock); $sum = $insights->summary($principal, $since, $bot, $this->plugin->retentionDays('aiwend_audit_retention_days')); $rec = $insights->recurring($principal, $since, $bot); $n = static fn(int $v): string => number_format_i18n($v); echo '

' . esc_html(sprintf( /* translators: %d: number of days */ _n('Support insights: last %d day', 'Support insights: last %d days', $days, 'aiwend'), $days )) . '

' . esc_html__('Counted from the conversations stored on this site, by the site time zone. Deleted conversations are not counted, and nothing here says that a customer’s problem was solved.', 'aiwend') . '

'; if ($keep < $days) { echo '

' . esc_html(sprintf( /* translators: %d: retention days */ __('Conversations are kept for %d days, so these counts cover at most that long.', 'aiwend'), $keep )) . '

'; } $rated = $sum['rated']; $rate = $sum['rateable'] > 0 ? sprintf('%s of %s (%s%%)', $n($rated), $n($sum['rateable']), $n((int) round(100 * $rated / $sum['rateable']))) : '—'; $inferred = $sum['no_match_inferred'] > 0 ? ' ' . sprintf( /* translators: %s: number of older answers */ _n('plus %s older answer estimated from stored data', 'plus %s older answers estimated from stored data', $sum['no_match_inferred'], 'aiwend'), $n($sum['no_match_inferred']) ) : ''; $rows = [ [__('Conversations started', 'aiwend'), $n($sum['conversations']), __('Chats started in this period in which a visitor sent at least one message. Not the same as people.', 'aiwend')], [__('Answers given', 'aiwend'), $n($sum['answers']), __('Assistant messages, including ones the visitor stopped.', 'aiwend')], [__('Answers with no source found', 'aiwend'), $n($sum['no_match_exact']) . $inferred, __('Questions for which no matching Knowledge was found, so the assistant said it could not find the information. The question may still be answerable; this measures the search, not the customer.', 'aiwend')], [__('Rated not useful', 'aiwend'), $n($sum['negative']), __('Answers a visitor rated not useful. Visitors rate only if they choose to.', 'aiwend')], [__('Answers rated', 'aiwend'), $rate, __('Rated answers out of answers that were not stopped.', 'aiwend')], [__('Recurring unanswered questions', 'aiwend'), $n(count($rec['groups'])), sprintf( /* translators: 1: minimum times asked, 2: number of answers analysed */ __('Questions with no source found that were asked at least %1$d times in the same words (see below). Based on the newest %2$d such answers.', 'aiwend'), $rec['minimum'], $rec['analysed'] )], [__('Failed provider requests', 'aiwend'), $n($sum['provider_failures']) . ($sum['limit_refusals'] > 0 ? ' (+' . $n($sum['limit_refusals']) . ' ' . __('refused at a usage limit', 'aiwend') . ')' : ''), sprintf( /* translators: %s: date */ $sum['failure_partial'] ? __('Requests that failed or whose outcome is uncertain, all assistants, from the activity log, which only reaches back to %s. This is not a complete count for the period.', 'aiwend') : __('Requests that failed or whose outcome is uncertain, all assistants, from the activity log (since %s).', 'aiwend'), wp_date(get_option('date_format'), (int) $sum['failure_window_start']) )], ]; echo '
'; foreach ($rows as $r) { echo ''; } echo '
' . esc_html__('Measure', 'aiwend') . '' . esc_html__('Value', 'aiwend') . '' . esc_html__('What it counts', 'aiwend') . '
' . esc_html($r[0]) . '' . esc_html(html_entity_decode((string) $r[1])) . '' . esc_html($r[2]) . '

'; if (current_user_can(Capabilities::VIEW_USAGE)) { echo '' . esc_html__('Usage and cost', 'aiwend') . ' ' . esc_html__('Diagnostics', 'aiwend') . ' '; } if (current_user_can(Capabilities::MANAGE_KNOWLEDGE)) { echo '' . esc_html__('Knowledge', 'aiwend') . ''; } echo '

'; echo '

' . esc_html__('Recurring unanswered questions', 'aiwend') . '

'; if ($rec['groups'] === []) { echo '

' . esc_html(sprintf( /* translators: %d: minimum times asked */ __('No question with no source found was asked %d or more times in the same words in this period.', 'aiwend'), $rec['minimum'] )) . '

'; } else { echo '

' . esc_html__('Questions are grouped only when they match after lower-casing and removing punctuation; there is no similarity matching, so related questions in other words are listed separately. Repeats may come from one visitor. Email addresses and long numbers are hidden. Consider adding or improving Knowledge about these topics.', 'aiwend') . '

'; echo '
'; foreach ($rec['groups'] as $g) { echo ''; } echo '
' . esc_html__('Question (normalized)', 'aiwend') . '' . esc_html__('Times asked', 'aiwend') . '' . esc_html__('Conversations', 'aiwend') . '' . esc_html__('Examples', 'aiwend') . '
' . esc_html($g['label']) . '' . (int) $g['occurrences'] . '' . (int) $g['conversations'] . ''; foreach ($g['sessions'] as $sid) { /* translators: %d: conversation number */ echo '' . esc_html(sprintf(__('Conversation %d', 'aiwend'), (int) $sid)) . ' '; } echo '
'; } if ($rec['capped']) { echo '

' . esc_html(sprintf( /* translators: %d: number of answers analysed */ __('Only the newest %d such answers were analysed.', 'aiwend'), $rec['analysed'] )) . '

'; } echo '
'; } private function actionForm(int $session, string $action, string $label): void { // Manual nonce field without an id: two forms on one screen must not share element IDs. echo wp_kses('
', AdminView::allowedHtml()); echo ''; wp_referer_field(); if ($action === 'delete') { echo '

'; } echo '

'; } private function pager(int $page, bool $more): void { if ($page <= 1 && !$more) { return; } echo ''; } }