Site assistant: its settings with a live preview, today's use, the * questions it could not answer, and the chats members agreed to save. */ final class AssistantPage { public const SLUG = 'aipcf-assistant'; private const PER_PAGE = 20; /** Checkboxes on the form: absent means off. */ private const FLAGS = ['chat_enabled', 'chat_floating', 'chat_admin', 'chat_ask_name', 'chat_store', 'chat_turnstile', 'chat_safety', 'chat_safety_answers', 'chat_rerank']; private const FIELDS = [ 'chat_enabled', 'chat_capability', 'chat_floating', 'chat_admin', 'chat_name', 'chat_subtitle', 'chat_avatar', 'chat_accent', 'chat_position', 'chat_welcome', 'chat_placeholder', 'chat_chips', 'chat_no_match', 'chat_offline', 'chat_ask_name', 'chat_store', 'chat_consent_text', 'chat_retention_days', 'chat_daily_neurons', 'chat_min_score', 'chat_rerank', 'chat_min_match', 'chat_public_share', 'chat_visitor_daily', 'chat_turnstile', 'chat_rules', 'chat_blocked', 'chat_blocked_reply', 'chat_safety', 'chat_safety_answers', 'chat_length', 'chat_disclaimer', ]; public static function boot(): void { // Before the Link assistant (20): Settings, Site assistant, Link assistant, Search insights, Activity. add_action('admin_menu', [self::class, 'registerMenu'], 19); add_action('admin_post_aipcf_chat_save', [self::class, 'handleSave']); add_action('admin_post_aipcf_chat_export', [self::class, 'handleExport']); add_action('admin_post_aipcf_chat_clear', [self::class, 'handleClear']); add_action('admin_post_aipcf_chat_toggle', [self::class, 'handleToggle']); add_action('admin_enqueue_scripts', [self::class, 'enqueue']); } public static function registerMenu(): void { // 2.0: opened from its card on Features, not from the menu (which then highlights Features). add_submenu_page('', __('Site assistant', 'aipcf-ai-provider-for-cloudflare'), __('Site assistant', 'aipcf-ai-provider-for-cloudflare'), 'manage_options', self::SLUG, [self::class, 'render']); } public static function pageUrl(array $args = []): string { return add_query_arg($args, admin_url('admin.php?page=' . self::SLUG)); } /** * The widget's own styles, for the live preview. */ public static function enqueue(string $hook): void { if (strpos($hook, self::SLUG) === false) { return; } wp_enqueue_style('aipcf-chat', plugin_dir_url(AIPCF_FILE) . 'assets/chat/chat.css', [], AIPCF_VERSION); } public static function render(): void { if (!current_user_can('manage_options')) { wp_die(esc_html__('You do not have permission to do this.', 'aipcf-ai-provider-for-cloudflare'), 403); } $on = !empty(Settings::get('chat_enabled')); $online = Chat::online(); echo '
'; SettingsPage::header( 'dashicons-format-chat', __('Site assistant', 'aipcf-ai-provider-for-cloudflare'), Chat::isPublic() ? __('A chat for everyone, including visitors, that answers only from your posts and pages.', 'aipcf-ai-provider-for-cloudflare') : __('A chat for logged-in members that answers only from your posts and pages.', 'aipcf-ai-provider-for-cloudflare'), !$on ? ['is-off', __('Off', 'aipcf-ai-provider-for-cloudflare')] : ($online ? ['is-ok', __('Online', 'aipcf-ai-provider-for-cloudflare')] : ['is-warn', __('Offline for today', 'aipcf-ai-provider-for-cloudflare')]) ); echo '
'; self::status($on, $online); self::settingsForm(); self::unanswered(); self::chats(); echo '
'; } /** * Whether everything the assistant needs is in place, step by step, with the fix for each. */ private static function status(bool $on, bool $online): void { $connected = \WordPress\CloudflareWorkersAiProvider\get_account_id() !== '' && \WordPress\CloudflareWorkersAiProvider\get_api_key() !== ''; $embedOn = EmbeddingSettings::enabled(); $counts = $embedOn && \WordPress\CloudflareWorkersAiProvider\Embeddings\IndexTable::exists() ? \WordPress\CloudflareWorkersAiProvider\Embeddings\IndexTable::counts(EmbeddingSettings::signature()) : ['indexed' => 0, 'pending' => 0, 'stale' => 0]; $waiting = (int) $counts['pending'] + (int) $counts['stale']; $used = UsageTracker::todayTaskNeurons(Chat::TASK); $limit = (int) Settings::get('chat_daily_neurons'); $steps = [ [ $connected, __('Connected to Cloudflare', 'aipcf-ai-provider-for-cloudflare'), $connected ? '' : '' . esc_html__('Connect', 'aipcf-ai-provider-for-cloudflare') . '', ], [ $embedOn && (int) $counts['indexed'] > 0, $embedOn /* translators: 1: posts embedded, 2: posts waiting */ ? sprintf(__('Embeddings: %1$s posts ready to answer from%2$s', 'aipcf-ai-provider-for-cloudflare'), number_format_i18n((int) $counts['indexed']), $waiting > 0 ? sprintf(/* translators: %s: number of posts */ __(', %s still being embedded', 'aipcf-ai-provider-for-cloudflare'), number_format_i18n($waiting)) : '') : __('Embeddings are off: the assistant has nothing to answer from', 'aipcf-ai-provider-for-cloudflare'), '' . esc_html($embedOn ? __('Embeddings', 'aipcf-ai-provider-for-cloudflare') : __('Set up Embeddings', 'aipcf-ai-provider-for-cloudflare')) . '', ], [ $on, $on ? __('The Site assistant is on', 'aipcf-ai-provider-for-cloudflare') : __('The Site assistant is off', 'aipcf-ai-provider-for-cloudflare'), self::toggleButton($on), ], [ $online, $online /* translators: 1: neurons used today, 2: daily limit */ ? sprintf(__('Online: %1$s of %2$s neurons used today', 'aipcf-ai-provider-for-cloudflare'), number_format_i18n($used, $used < 10 ? 2 : 0), number_format_i18n($limit)) : __('Offline until midnight UTC: the daily limit or the daily budget is used up', 'aipcf-ai-provider-for-cloudflare'), '' . esc_html__('Budget', 'aipcf-ai-provider-for-cloudflare') . '', ], ]; echo '

' . esc_html__('Ready to answer?', 'aipcf-ai-provider-for-cloudflare') . '

'; $pct = $limit > 0 ? min(100, (int) round($used / $limit * 100)) : 0; printf( '', esc_attr(sprintf(/* translators: %d: percentage */ __('%d%% of the daily limit used', 'aipcf-ai-provider-for-cloudflare'), $pct)), esc_attr($pct >= 100 ? 'is-bad' : ($pct >= 80 ? 'is-warn' : 'is-ok')), (int) $pct ); echo '
'; } /** * Switch on or off in one click, without the whole form. */ private static function toggleButton(bool $on): string { return sprintf( '%s', $on ? '' : ' button-primary', esc_url(wp_nonce_url(admin_url('admin-post.php?action=aipcf_chat_toggle&on=' . ($on ? '0' : '1')), 'aipcf_chat_toggle')), esc_attr__('Saving...', 'aipcf-ai-provider-for-cloudflare'), esc_html($on ? __('Switch off', 'aipcf-ai-provider-for-cloudflare') : __('Switch on', 'aipcf-ai-provider-for-cloudflare')) ); } public static function handleToggle(): void { if (!current_user_can('manage_options')) { wp_die(esc_html__('You do not have permission to do this.', 'aipcf-ai-provider-for-cloudflare'), 403); } check_admin_referer('aipcf_chat_toggle'); $on = !empty($_GET['on']); $settings = get_option(AIPCF_SETTINGS_OPTION, []); $settings = is_array($settings) ? $settings : []; $settings['chat_enabled'] = $on ? 1 : 0; update_option(AIPCF_SETTINGS_OPTION, $settings); set_transient('aipcf_notice_' . get_current_user_id(), [ 'type' => 'success', 'message' => $on ? (EmbeddingSettings::enabled() ? __('The Site assistant is on. Members see it on the site and on their Dashboard.', 'aipcf-ai-provider-for-cloudflare') : __('The Site assistant is on, but Embeddings are off, so it stays hidden until they are on.', 'aipcf-ai-provider-for-cloudflare')) : __('The Site assistant is off.', 'aipcf-ai-provider-for-cloudflare'), ], 60); wp_safe_redirect(self::pageUrl()); exit; } // ================================================================ settings /** The settings tabs, in order. */ private static function tabs(): array { return [ 'where' => __('Where', 'aipcf-ai-provider-for-cloudflare'), 'look' => __('Look', 'aipcf-ai-provider-for-cloudflare'), 'messages' => __('Messages', 'aipcf-ai-provider-for-cloudflare'), 'guard' => __('Guardrails', 'aipcf-ai-provider-for-cloudflare'), 'saving' => __('Saving chats', 'aipcf-ai-provider-for-cloudflare'), 'limits' => __('Limits', 'aipcf-ai-provider-for-cloudflare'), ]; } private static function settingsForm(): void { $g = static fn(string $k) => Settings::get($k); echo '

' . esc_html__('Settings', 'aipcf-ai-provider-for-cloudflare') . '

'; echo '
'; echo ''; wp_nonce_field('aipcf_chat_save'); // Tabs (settings.js); without JavaScript every group shows, one after another. echo ''; self::pane('where'); self::check('chat_enabled', __('Switch the Site assistant on', 'aipcf-ai-provider-for-cloudflare')); $labels = [ 'read' => __('Every logged-in member', 'aipcf-ai-provider-for-cloudflare'), 'edit_posts' => __('Contributors and up', 'aipcf-ai-provider-for-cloudflare'), 'publish_posts' => __('Authors and up', 'aipcf-ai-provider-for-cloudflare'), 'edit_others_posts' => __('Editors and up', 'aipcf-ai-provider-for-cloudflare'), 'manage_options' => __('Administrators only', 'aipcf-ai-provider-for-cloudflare'), ]; $labels = ['public' => __('Everyone, including visitors who are not logged in', 'aipcf-ai-provider-for-cloudflare')] + $labels; echo '


'; self::publicBox(); self::check('chat_floating', __('A chat button in the corner of every page of the site', 'aipcf-ai-provider-for-cloudflare')); self::check('chat_admin', __('A chat panel on the wp-admin Dashboard', 'aipcf-ai-provider-for-cloudflare')); echo '
'; self::pane('look'); self::text('chat_name', __('Name', 'aipcf-ai-provider-for-cloudflare'), __('Assistant', 'aipcf-ai-provider-for-cloudflare'), 40); self::text('chat_subtitle', __('Subtitle', 'aipcf-ai-provider-for-cloudflare'), Chat::subtitle(), 80); self::text('chat_avatar', __('Avatar (an emoji)', 'aipcf-ai-provider-for-cloudflare'), '', 4, __('Leave empty for a chat icon.', 'aipcf-ai-provider-for-cloudflare')); echo '
'; foreach (['๐Ÿค–', '๐Ÿ’ฌ', '๐Ÿ™‹', '๐Ÿณ', '๐Ÿฅ—', '๐Ÿ“š', '๐Ÿ“–', '๐Ÿ“ฑ', '๐Ÿ’ป', '๐Ÿ›๏ธ', 'โœˆ๏ธ', '๐Ÿ ', '๐ŸŽ“', '๐Ÿฉบ', '๐ŸŒฟ', '๐Ÿ’ก'] as $emoji) { printf('', esc_attr($emoji), esc_attr(sprintf(/* translators: %s: an emoji */ __('Use %s', 'aipcf-ai-provider-for-cloudflare'), $emoji))); } printf('', esc_html__('Icon', 'aipcf-ai-provider-for-cloudflare')); echo '
'; printf( '


', esc_html__('Colour', 'aipcf-ai-provider-for-cloudflare'), esc_attr((string) $g('chat_accent')) ); echo '
'; foreach (['#1d4ed8' => __('Blue', 'aipcf-ai-provider-for-cloudflare'), '#0a7c55' => __('Green', 'aipcf-ai-provider-for-cloudflare'), '#e23744' => __('Red', 'aipcf-ai-provider-for-cloudflare'), '#ea580c' => __('Orange', 'aipcf-ai-provider-for-cloudflare'), '#7c3aed' => __('Purple', 'aipcf-ai-provider-for-cloudflare'), '#db2777' => __('Pink', 'aipcf-ai-provider-for-cloudflare'), '#0f766e' => __('Teal', 'aipcf-ai-provider-for-cloudflare'), '#1f2937' => __('Charcoal', 'aipcf-ai-provider-for-cloudflare')] as $hex => $name) { printf('', esc_attr($hex), esc_attr($name)); } echo '

' . esc_html__('For the button, the member\'s messages and the links. The text on it turns black or white to stay readable.', 'aipcf-ai-provider-for-cloudflare') . '

'; echo '
' . esc_html__('Position on the site', 'aipcf-ai-provider-for-cloudflare') . ''; foreach (['right' => __('Bottom right', 'aipcf-ai-provider-for-cloudflare'), 'left' => __('Bottom left', 'aipcf-ai-provider-for-cloudflare')] as $v => $label) { printf(' ', esc_attr($v), checked((string) $g('chat_position'), $v, false), esc_html($label)); } echo '
'; self::pane('messages'); self::text('chat_welcome', __('Welcome message', 'aipcf-ai-provider-for-cloudflare'), Chat::welcome('{name}'), 200, __('{name} becomes the member\'s first name. Leave empty for the text shown.', 'aipcf-ai-provider-for-cloudflare')); self::text('chat_placeholder', __('Text in the empty question box', 'aipcf-ai-provider-for-cloudflare'), __('Ask a question', 'aipcf-ai-provider-for-cloudflare'), 80); $chips = array_pad(array_values((array) $g('chat_chips')), 3, ''); $examples = self::chipExamples(); echo '
' . esc_html__('Starter questions', 'aipcf-ai-provider-for-cloudflare') . '

' . esc_html__('Up to three, shown as buttons before the first question. Good ones match what your posts answer.', 'aipcf-ai-provider-for-cloudflare') . '

'; foreach ($chips as $i => $chip) { printf('

', esc_attr((string) $chip), esc_attr($examples[$i] ?? ''), esc_attr(sprintf(/* translators: %d: 1, 2 or 3 */ __('Starter question %d', 'aipcf-ai-provider-for-cloudflare'), $i + 1))); } if ($examples !== []) { printf('

', esc_html__('Use the examples', 'aipcf-ai-provider-for-cloudflare')); } echo '
'; self::area('chat_no_match', __('Reply when the site has no answer', 'aipcf-ai-provider-for-cloudflare'), Chat::noMatch(), __('Sent without asking the AI, so it costs nothing. Leave empty for the text shown.', 'aipcf-ai-provider-for-cloudflare')); self::area('chat_offline', __('Reply when the daily limit is used up', 'aipcf-ai-provider-for-cloudflare'), Chat::offlineText(), __('Shown instead of the question box until midnight UTC. Say when to come back, or where else to get help (a contact page, an email address).', 'aipcf-ai-provider-for-cloudflare')); echo ''; self::guardPane(); self::pane('saving'); self::check('chat_ask_name', __('Ask for a name (no email or phone) before the first question', 'aipcf-ai-provider-for-cloudflare')); self::check('chat_store', __('Offer to save the chat, so you can read and analyse it here', 'aipcf-ai-provider-for-cloudflare'), __('Only chats the member agrees to save are kept: the name they gave, their questions and the answers. Nothing is kept for members who leave the box unticked.', 'aipcf-ai-provider-for-cloudflare')); self::area('chat_consent_text', __('Consent text', 'aipcf-ai-provider-for-cloudflare'), Chat::consentText(), __('Next to the box members tick. Leave empty for the text shown.', 'aipcf-ai-provider-for-cloudflare')); printf( '


%s

', esc_html__('Keep saved chats for', 'aipcf-ai-provider-for-cloudflare'), (int) $g('chat_retention_days'), esc_html__('days', 'aipcf-ai-provider-for-cloudflare') ); echo ''; self::pane('limits'); printf( '


%s

%s

', esc_html__('Daily limit', 'aipcf-ai-provider-for-cloudflare'), (int) $g('chat_daily_neurons'), esc_html__('neurons a day', 'aipcf-ai-provider-for-cloudflare'), esc_html__('Each answer is one short embedding and one call to your default text model: usually 1 to 5 neurons, so 1,000 is a few hundred answers. A question with no match costs only the embedding. When the limit is used up, the chat goes offline until midnight UTC. It also counts against the daily budget.', 'aipcf-ai-provider-for-cloudflare') ); self::check('chat_rerank', __('Check the best parts of posts again with a second model', 'aipcf-ai-provider-for-cloudflare'), __('About 2 neurons more an answer. Better answers, and an off-topic question gets the "no answer" reply without asking the AI, which saves 10 to 30 neurons.', 'aipcf-ai-provider-for-cloudflare')); printf( '


%s

', esc_html__('Minimum match, when checked again', 'aipcf-ai-provider-for-cloudflare'), esc_attr((string) $g('chat_min_match')), esc_html__('0 to 1. 0.08 suits most sites: right answers usually score far higher, off-topic questions near 0.', 'aipcf-ai-provider-for-cloudflare') ); printf( '


%s

', esc_html__('Minimum match, without the check', 'aipcf-ai-provider-for-cloudflare'), esc_attr((string) $g('chat_min_score')), esc_html__('0 to 1, by meaning only. Parts of posts that match less are left out, unless they contain the question\'s own words. Around 0.35.', 'aipcf-ai-provider-for-cloudflare') ); echo '

' . esc_html__('It reads the text Embeddings keep for each post, including text inside shortcodes. If parts of your posts are meant only for some members, leave those posts out of Embeddings or keep the assistant off.', 'aipcf-ai-provider-for-cloudflare') . '

'; echo ''; echo '
'; submit_button(__('Save', 'aipcf-ai-provider-for-cloudflare'), 'primary', 'submit', false); echo '' . esc_html__('Saves every tab.', 'aipcf-ai-provider-for-cloudflare') . '
'; echo ''; // The live preview: the widget's own markup and styles, updated as the fields change. $u = wp_get_current_user(); $first = trim((string) $u->first_name) !== '' ? (string) $u->first_name : (string) strtok((string) $u->display_name, ' '); $samples = array_map(static fn(\WP_Post $p): string => html_entity_decode(get_the_title($p), ENT_QUOTES, 'UTF-8'), get_posts(['numberposts' => 2, 'post_type' => 'post', 'post_status' => 'publish'])); echo '

' . esc_html__('Preview', 'aipcf-ai-provider-for-cloudflare') . '

'; echo '
'; printf( '
%s
', esc_attr($first), esc_attr((string) wp_json_encode($samples)), esc_attr((string) wp_json_encode([ 'question' => __('What do you have about this?', 'aipcf-ai-provider-for-cloudflare'), /* translators: 1: a post title, 2: another post title */ 'answer' => __('Here is what I found: %1$s [1] covers it, and %2$s [2] adds more detail.', 'aipcf-ai-provider-for-cloudflare'), 'post' => __('A post on your site', 'aipcf-ai-provider-for-cloudflare'), 'yourName' => __('Your name', 'aipcf-ai-provider-for-cloudflare'), 'nameHint' => __('No email or phone number, just what to call you.', 'aipcf-ai-provider-for-cloudflare'), 'start' => __('Start chatting', 'aipcf-ai-provider-for-cloudflare'), 'consent' => Chat::consentText(), 'offline' => Chat::offlineText(), ])), esc_attr(Chat::style()), Chat::panel('aipcf-chat-preview', false) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped parts in panel() ); echo '
'; } /** * Public mode: what changes, and the visitor limits. Shown when "Everyone" is chosen. */ private static function publicBox(): void { $g = static fn(string $k) => Settings::get($k); $plugin = (string) get_option('cfturnstile_key', '') !== '' && (string) get_option('cfturnstile_secret', '') !== ''; echo '
'; echo '

' . esc_html__('Visitors can use it too.', 'aipcf-ai-provider-for-cloudflare') . ' ' . esc_html__('Anyone, including bots, can then ask questions, and each answer costs neurons. These protect the daily limit and your members:', 'aipcf-ai-provider-for-cloudflare') . '

'; printf( '


%s

', esc_html__('Visitors\' share of the daily limit', 'aipcf-ai-provider-for-cloudflare'), (int) $g('chat_public_share'), esc_html__('%', 'aipcf-ai-provider-for-cloudflare') ); printf( '


%s

', esc_html__('Questions per visitor', 'aipcf-ai-provider-for-cloudflare'), (int) $g('chat_visitor_daily'), esc_html__('a day', 'aipcf-ai-provider-for-cloudflare') ); self::check( 'chat_turnstile', __('Ask visitors to confirm they are human with Cloudflare Turnstile (once a day)', 'aipcf-ai-provider-for-cloudflare'), $plugin ? __('Uses the keys of the Simple Cloudflare Turnstile plugin. Usually invisible; a checkbox only when Cloudflare is unsure.', 'aipcf-ai-provider-for-cloudflare') : __('Needs the Simple Cloudflare Turnstile plugin with its site key and secret key set (or the aipcf_chat_turnstile_keys filter). Not set up on this site, so this is skipped.', 'aipcf-ai-provider-for-cloudflare') ); echo '
'; } /** * The Guardrails tab: the built-in rules (fixed), and the ones the owner adds. */ private static function guardPane(): void { $g = static fn(string $k) => Settings::get($k); self::pane('guard'); echo '

' . esc_html__('Always on', 'aipcf-ai-provider-for-cloudflare') . '

'; printf( '


%s

', esc_html__('Your own rules', 'aipcf-ai-provider-for-cloudflare'), esc_attr__("For example:\nUse a warm, simple tone.\nFor health questions, add that readers should ask a doctor.\nDo not compare prices with other shops.", 'aipcf-ai-provider-for-cloudflare'), esc_textarea((string) $g('chat_rules')), esc_html__('Added to the assistant\'s instructions, one rule per line. They cannot switch off the rules above: where they conflict, the rules above win.', 'aipcf-ai-provider-for-cloudflare') ); printf( '


%s

', esc_html__('Blocked topics and words', 'aipcf-ai-provider-for-cloudflare'), esc_attr__("One per line, for example:\ncompetitor name\nrefund", 'aipcf-ai-provider-for-cloudflare'), esc_textarea(implode("\n", (array) $g('chat_blocked'))), esc_html__('A question that names one gets the reply below, without asking the AI (no neurons). An answer that names one is replaced by it too. Whole words and phrases, any case; up to 100.', 'aipcf-ai-provider-for-cloudflare') ); self::area('chat_blocked_reply', __('Reply to a blocked question', 'aipcf-ai-provider-for-cloudflare'), \WordPress\CloudflareWorkersAiProvider\Features\ChatGuard::blockedReply(), __('Also sent when the safety check stops a question or an answer.', 'aipcf-ai-provider-for-cloudflare')); echo '
' . esc_html__('Safety check (Llama Guard on Workers AI)', 'aipcf-ai-provider-for-cloudflare') . ''; self::check('chat_safety', __('Check each question for harmful requests (violence, hate, self-harm, crime, sexual content and similar)', 'aipcf-ai-provider-for-cloudflare')); self::check('chat_safety_answers', __('Check each answer too', 'aipcf-ai-provider-for-cloudflare')); echo '

' . esc_html__('About 20 neurons per check, counted in the assistant\'s daily limit. Recommended when visitors can use it. If the check cannot run, the answer is still limited to your posts.', 'aipcf-ai-provider-for-cloudflare') . '

'; echo '
' . esc_html__('Answer length', 'aipcf-ai-provider-for-cloudflare') . ''; foreach (['short' => __('Short', 'aipcf-ai-provider-for-cloudflare'), 'medium' => __('Medium', 'aipcf-ai-provider-for-cloudflare'), 'long' => __('Long', 'aipcf-ai-provider-for-cloudflare')] as $v => $label) { printf(' ', esc_attr($v), checked((string) $g('chat_length'), $v, false), esc_html($label)); } echo '
' . esc_html__('Longer answers cost more neurons.', 'aipcf-ai-provider-for-cloudflare') . '
'; self::text('chat_disclaimer', __('Line under the chat', 'aipcf-ai-provider-for-cloudflare'), Chat::disclaimer(), 200); echo ''; } /** * Example starter questions from the site's biggest categories. * * @return list */ private static function chipExamples(): array { $terms = get_terms(['taxonomy' => 'category', 'orderby' => 'count', 'order' => 'DESC', 'number' => 4, 'hide_empty' => true]); if (!is_array($terms)) { return []; } // Not "Uncategorized" (filtered here: an exclude list makes a slower query). $default = (int) get_option('default_category'); $terms = array_slice(array_values(array_filter($terms, static fn($t): bool => $t instanceof \WP_Term && (int) $t->term_id !== $default)), 0, 3); return array_values(array_map(static fn(\WP_Term $t): string => sprintf( /* translators: %s: a category name */ __('What do you have about %s?', 'aipcf-ai-provider-for-cloudflare'), html_entity_decode($t->name, ENT_QUOTES, 'UTF-8') ), array_filter($terms, static fn($t): bool => $t instanceof \WP_Term))); } private static function pane(string $key): void { printf('

%2$s

', esc_attr($key), esc_html(self::tabs()[$key])); } private static function check(string $key, string $label, string $help = ''): void { $id = 'aipcf-' . str_replace('_', '-', $key); printf('

%s

', esc_attr($id), esc_attr($id), esc_attr($key), checked(!empty(Settings::get($key)), true, false), esc_html($label), $help !== '' ? '
' . esc_html($help) . '' : ''); } private static function text(string $key, string $label, string $placeholder, int $max, string $help = ''): void { $id = 'aipcf-' . str_replace('_', '-', $key); printf( '


%s

', esc_attr($id), esc_html($label), esc_attr($id), esc_attr($key), esc_attr((string) Settings::get($key)), esc_attr($placeholder), (int) $max, $max > 80 ? 'large-text' : 'regular-text', esc_attr($key), $help !== '' ? '
' . esc_html($help) . '' : '' ); } private static function area(string $key, string $label, string $placeholder, string $help): void { $id = 'aipcf-' . str_replace('_', '-', $key); printf( '


%s

', esc_attr($id), esc_html($label), esc_attr($id), esc_attr($key), esc_attr($placeholder), esc_textarea((string) Settings::get($key)), $help !== '' ? '' . esc_html($help) . '' : '' ); } public static function handleSave(): void { if (!current_user_can('manage_options')) { wp_die(esc_html__('You do not have permission to do this.', 'aipcf-ai-provider-for-cloudflare'), 403); } check_admin_referer('aipcf_chat_save'); $settings = get_option(AIPCF_SETTINGS_OPTION, []); $settings = is_array($settings) ? $settings : []; foreach (self::FIELDS as $key) { if (in_array($key, self::FLAGS, true)) { $settings[$key] = empty($_POST[$key]) ? 0 : 1; } elseif (isset($_POST[$key])) { // Sanitised by the settings filter when the option is saved (Features\Settings::sanitize). $settings[$key] = is_array($_POST[$key]) ? array_map('sanitize_text_field', wp_unslash($_POST[$key])) : sanitize_textarea_field(wp_unslash((string) $_POST[$key])); } } update_option(AIPCF_SETTINGS_OPTION, $settings); set_transient('aipcf_notice_' . get_current_user_id(), ['type' => 'success', 'message' => __('Settings saved.', 'aipcf-ai-provider-for-cloudflare')], 60); wp_safe_redirect(self::pageUrl() . '#aipcf-assistant-settings'); exit; } // ================================================================ chats private static function unanswered(): void { $rows = ChatLog::unanswered(15); echo '

' . esc_html__('Questions it could not answer', 'aipcf-ai-provider-for-cloudflare') . '

'; echo '

' . esc_html__('From saved chats: what members asked that your posts do not cover yet. Ideas for what to write next.', 'aipcf-ai-provider-for-cloudflare') . '

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

' . esc_html__('None yet.', 'aipcf-ai-provider-for-cloudflare') . '

'; return; } echo '
'; foreach ($rows as $r) { printf('', esc_html($r['question']), esc_html(number_format_i18n($r['times'])), esc_html((string) wp_date((string) get_option('date_format'), (int) strtotime($r['last'] . ' UTC')))); } echo '
' . esc_html__('Question', 'aipcf-ai-provider-for-cloudflare') . '' . esc_html__('Times', 'aipcf-ai-provider-for-cloudflare') . '' . esc_html__('Last asked', 'aipcf-ai-provider-for-cloudflare') . '
%s%s%s
'; } private static function chats(): void { $paged = isset($_GET['paged']) && is_string($_GET['paged']) ? max(1, absint($_GET['paged'])) : 1; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- a page number $total = ChatLog::conversationCount(); $convs = ChatLog::conversations(self::PER_PAGE, ($paged - 1) * self::PER_PAGE); $msgs = ChatLog::messages(array_column($convs, 'conv')); echo '

' . esc_html(sprintf( /* translators: %s: number of chats */ _n('%s saved chat', '%s saved chats', $total, 'aipcf-ai-provider-for-cloudflare'), number_format_i18n($total) )) . '

'; if ($total > 0) { printf('%s', esc_url(wp_nonce_url(admin_url('admin-post.php?action=aipcf_chat_export'), 'aipcf_chat_export')), esc_html__('Download CSV', 'aipcf-ai-provider-for-cloudflare')); } echo '
'; echo '

' . esc_html(sprintf( /* translators: %d: number of days */ _n('Chats members agreed to save, kept for %d day.', 'Chats members agreed to save, kept for %d days.', (int) Settings::get('chat_retention_days'), 'aipcf-ai-provider-for-cloudflare'), (int) Settings::get('chat_retention_days') )) . '

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

' . esc_html__('None yet.', 'aipcf-ai-provider-for-cloudflare') . '

'; return; } $format = get_option('date_format') . ' ' . get_option('time_format'); echo '
'; foreach ($convs as $c) { $user = $c['user_id'] > 0 ? get_userdata($c['user_id']) : false; $who = $c['name'] !== '' ? $c['name'] : ($user instanceof \WP_User ? $user->display_name : __('Member', 'aipcf-ai-provider-for-cloudflare')); printf( '
%s %s ยท %s%s
%s
', esc_html($who), esc_html((string) wp_date($format, (int) strtotime($c['started'] . ' UTC'))), /* translators: %d: number of questions */ esc_html(sprintf(_n('%d question', '%d questions', $c['messages'], 'aipcf-ai-provider-for-cloudflare'), $c['messages'])), $c['unanswered'] > 0 ? ' ยท ' . esc_html(sprintf(/* translators: %d: count */ _n('%d without an answer', '%d without an answer', $c['unanswered'], 'aipcf-ai-provider-for-cloudflare'), $c['unanswered'])) . '' : '', esc_html(wp_trim_words($c['first'], 20)) ); foreach ($msgs[$c['conv']] ?? [] as $m) { printf('
%s
%s
', esc_html((string) $m['question']), nl2br(esc_html((string) preg_replace('/\s*\[#\d+\]/', '', (string) $m['answer'])))); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- esc_html inside nl2br } echo '
'; } echo '
'; $pages = (int) ceil($total / self::PER_PAGE); if ($pages > 1) { $links = paginate_links(['base' => add_query_arg('paged', '%#%', self::pageUrl()) . '#aipcf-assistant-chats', 'format' => '', 'current' => $paged, 'total' => $pages, 'prev_text' => __('Newer', 'aipcf-ai-provider-for-cloudflare'), 'next_text' => __('Older', 'aipcf-ai-provider-for-cloudflare')]); if (is_string($links)) { echo ''; } } printf( '

%s

', esc_url(wp_nonce_url(admin_url('admin-post.php?action=aipcf_chat_clear'), 'aipcf_chat_clear')), esc_attr__('Delete every saved chat? This cannot be undone. Download the CSV first to keep a copy.', 'aipcf-ai-provider-for-cloudflare'), esc_attr__('Deleting...', 'aipcf-ai-provider-for-cloudflare'), esc_html__('Delete all saved chats', 'aipcf-ai-provider-for-cloudflare') ); echo ''; } public static function handleClear(): void { if (!current_user_can('manage_options')) { wp_die(esc_html__('You do not have permission to do this.', 'aipcf-ai-provider-for-cloudflare'), 403); } check_admin_referer('aipcf_chat_clear'); $n = ChatLog::clear(); /* translators: %s: number of questions and answers */ set_transient('aipcf_notice_' . get_current_user_id(), ['type' => 'success', 'message' => sprintf(_n('%s saved question deleted.', '%s saved questions deleted.', $n, 'aipcf-ai-provider-for-cloudflare'), number_format_i18n($n))], 60); wp_safe_redirect(self::pageUrl() . '#aipcf-assistant-chats'); exit; } /** * Every saved question and answer as CSV, oldest first, with the same safeguards as the * Activity export (byte-order mark, formula cells defused). */ public static function handleExport(): void { if (!current_user_can('manage_options')) { wp_die(esc_html__('You do not have permission to do this.', 'aipcf-ai-provider-for-cloudflare'), 403); } check_admin_referer('aipcf_chat_export'); wp_raise_memory_limit('admin'); nocache_headers(); header('Content-Type: text/csv; charset=utf-8'); header('Content-Disposition: attachment; filename="aipcf-assistant-chats-' . gmdate('Y-m-d-His') . '.csv"'); header('X-Content-Type-Options: nosniff'); $out = fopen('php://output', 'w'); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- streaming the download, not a file on disk if ($out === false) { exit; } fwrite($out, "\xEF\xBB\xBF"); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite -- streaming the download $put = static function (array $row) use ($out): void { fputcsv($out, array_map(static fn($v): string => Export::cell((string) $v), $row), ',', '"', ''); }; $put([__('Time (UTC)', 'aipcf-ai-provider-for-cloudflare'), __('Chat', 'aipcf-ai-provider-for-cloudflare'), __('Member', 'aipcf-ai-provider-for-cloudflare'), __('Name given', 'aipcf-ai-provider-for-cloudflare'), __('Question', 'aipcf-ai-provider-for-cloudflare'), __('Answer', 'aipcf-ai-provider-for-cloudflare'), __('Answered', 'aipcf-ai-provider-for-cloudflare'), __('Best match', 'aipcf-ai-provider-for-cloudflare'), __('Posts cited', 'aipcf-ai-provider-for-cloudflare')]); $after = 0; while (($rows = ChatLog::page($after, 500)) !== []) { foreach ($rows as $r) { $after = (int) $r['id']; $user = (int) $r['user_id'] > 0 ? get_userdata((int) $r['user_id']) : false; $titles = array_map(static fn($id): string => html_entity_decode(get_the_title((int) $id), ENT_QUOTES, 'UTF-8'), array_filter(explode(',', (string) $r['sources']))); $put([ gmdate('Y-m-d\TH:i:s\Z', (int) strtotime($r['ts'] . ' UTC')), substr((string) $r['conv'], 0, 8), $user instanceof \WP_User ? $user->user_login : '', (string) $r['name'], (string) $r['question'], (string) preg_replace('/\s*\[#\d+\]/', '', (string) $r['answer']), $r['found'] ? __('Yes', 'aipcf-ai-provider-for-cloudflare') : __('No', 'aipcf-ai-provider-for-cloudflare'), $r['score'] === null ? '' : (string) round((float) $r['score'], 2), implode('; ', $titles), ]); } } fclose($out); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose exit; } }