= $limit) { return new \WP_Error('aipcf_budget', __('The assistant\'s daily limit is used up.', 'aipcf-ai-provider-for-cloudflare')); } if (self::isVisitor() && $public >= $limit * max(1, min(100, (int) Settings::get('chat_public_share'))) / 100) { return new \WP_Error('aipcf_budget', __('The visitors\' share of the daily limit is used up.', 'aipcf-ai-provider-for-cloudflare')); } return true; } /** * A one-way key for a visitor, from the IP address and the day (never stored as the IP). */ private static function visitorKey(): string { /** * The visitor's IP address, for the per-visitor limits. Behind a proxy that does not * set REMOTE_ADDR to the real address, return it from the proxy's header here. * * @param string $ip */ $ip = (string) apply_filters('aipcf_visitor_ip', isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash((string) $_SERVER['REMOTE_ADDR'])) : ''); // An IPv6 visitor by their /64 network: devices change the rest of the address often. $packed = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? @inet_pton($ip) : false; if (is_string($packed) && strlen($packed) === 16) { $ip = bin2hex(substr($packed, 0, 8)) . '::/64'; } return substr(hash_hmac('sha256', $ip . '|' . gmdate('Y-m-d'), wp_salt('auth')), 0, 24); } /** * Visitor checks before any neuron is spent: no bots, the hidden field empty, the * Turnstile check passed (once a day), and the questions per visitor per day. * * @param array{honeypot?: string, turnstile?: string} $chat * @return true|\WP_Error */ private static function visitorChecks(array $chat) { $ua = isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash((string) $_SERVER['HTTP_USER_AGENT'])) : ''; if ($ua === '' || preg_match(self::BOTS, $ua) || trim((string) ($chat['honeypot'] ?? '')) !== '') { return new \WP_Error('aipcf_chat_refused', __('The assistant could not answer just now. Try again later.', 'aipcf-ai-provider-for-cloudflare')); } $key = self::visitorKey(); if (self::turnstileKeys() !== null && get_transient('aipcf_chat_ok_' . $key) === false) { $ok = self::verifyTurnstile((string) ($chat['turnstile'] ?? '')); if (is_wp_error($ok)) { return $ok; } set_transient('aipcf_chat_ok_' . $key, 1, DAY_IN_SECONDS); } $day = 'aipcf_chat_day_' . $key; $count = (int) get_transient($day); if ($count >= max(1, (int) Settings::get('chat_visitor_daily'))) { return new \WP_Error('aipcf_chat_rate', __('You have asked as many questions as a visitor can today. Please come back tomorrow, or log in.', 'aipcf-ai-provider-for-cloudflare')); } set_transient($day, $count + 1, DAY_IN_SECONDS); return true; } /** * The Cloudflare Turnstile keys when the owner wants the check and the Simple Cloudflare * Turnstile plugin (or the aipcf_chat_turnstile_keys filter) provides them. * * @return array{site: string, secret: string}|null */ public static function turnstileKeys(): ?array { if (empty(Settings::get('chat_turnstile'))) { return null; } /** * Turnstile keys for the visitor check: ['site' => ..., 'secret' => ...]. * * @param array{site: string, secret: string}|null $keys */ $keys = apply_filters('aipcf_chat_turnstile_keys', ['site' => (string) get_option('cfturnstile_key', ''), 'secret' => (string) get_option('cfturnstile_secret', '')]); return is_array($keys) && ($keys['site'] ?? '') !== '' && ($keys['secret'] ?? '') !== '' ? ['site' => (string) $keys['site'], 'secret' => (string) $keys['secret']] : null; } /** * @return true|\WP_Error Code 'aipcf_chat_verify' when the visitor must pass the check (again). */ private static function verifyTurnstile(string $token) { $keys = self::turnstileKeys(); if ($keys === null) { return true; } $again = new \WP_Error('aipcf_chat_verify', __('Please confirm you are human, then ask again.', 'aipcf-ai-provider-for-cloudflare')); if ($token === '') { return $again; } $r = wp_remote_post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [ 'timeout' => 8, 'body' => ['secret' => $keys['secret'], 'response' => $token], ]); $body = is_wp_error($r) ? null : json_decode((string) wp_remote_retrieve_body($r), true); return is_array($body) && !empty($body['success']) ? true : $again; } /** * A fresh nonce (and whether the chat is online), so a page served from a cache still works. */ public static function ajaxToken(): void { if (!self::allowed()) { wp_send_json_error(['message' => __('You do not have permission to do this.', 'aipcf-ai-provider-for-cloudflare')], 403); } nocache_headers(); wp_send_json_success(['nonce' => wp_create_nonce(self::NONCE), 'online' => self::online()]); } public static function name(): string { $name = trim((string) Settings::get('chat_name')); return $name !== '' ? $name : __('Assistant', 'aipcf-ai-provider-for-cloudflare'); } /** * The first message. "{name}" in the owner's text becomes the member's name (or is left out). */ public static function welcome(string $name = ''): string { $text = trim((string) Settings::get('chat_welcome')); if ($text === '') { $text = $name !== '' /* translators: 1: member's first name, 2: site name */ ? sprintf(__('Hi %1$s! Ask me anything about %2$s. I answer from its posts and pages.', 'aipcf-ai-provider-for-cloudflare'), $name, self::siteName()) /* translators: %s: site name */ : sprintf(__('Ask me anything about %s. I answer from its posts and pages.', 'aipcf-ai-provider-for-cloudflare'), self::siteName()); return $text; } return trim((string) preg_replace('/\s+([,.!?])/', '$1', str_replace('{name}', $name, $text))); } public static function subtitle(): string { $text = trim((string) Settings::get('chat_subtitle')); /* translators: %s: site name */ return $text !== '' ? $text : sprintf(__('Answers from %s', 'aipcf-ai-provider-for-cloudflare'), self::siteName()); } /** * The line under the chat. */ public static function disclaimer(): string { $text = trim((string) Settings::get('chat_disclaimer')); return $text !== '' ? $text : __('Answers come from this site\'s posts and can be wrong. Check the linked posts.', 'aipcf-ai-provider-for-cloudflare'); } public static function noMatch(): string { $text = trim((string) Settings::get('chat_no_match')); return $text !== '' ? $text : __('I could not find that on this site. Try asking in other words, or about something the site covers.', 'aipcf-ai-provider-for-cloudflare'); } public static function offlineText(): string { $text = trim((string) Settings::get('chat_offline')); return $text !== '' ? $text : __('The assistant is offline for today. Please come back tomorrow.', 'aipcf-ai-provider-for-cloudflare'); } public static function consentText(): string { $text = trim((string) Settings::get('chat_consent_text')); if ($text !== '') { return $text; } return sprintf( /* translators: %d: number of days */ _n('Save this chat so the site can improve its answers. Only the name you give and your messages are kept, for %d day.', 'Save this chat so the site can improve its answers. Only the name you give and your messages are kept, for %d days.', (int) Settings::get('chat_retention_days'), 'aipcf-ai-provider-for-cloudflare'), (int) Settings::get('chat_retention_days') ); } /** * Whether the assistant can answer now: off when its daily limit or the daily budget is used up. */ public static function online(): bool { return !is_wp_error(self::gate()); } private static function siteName(): string { return html_entity_decode(get_bloginfo('name'), ENT_QUOTES, 'UTF-8'); } /** * The member's first name, or their display name, for the greeting and the name field. */ private static function firstName(): string { $u = wp_get_current_user(); if (!$u instanceof \WP_User || $u->ID === 0) { return ''; } $first = trim((string) $u->first_name); return $first !== '' ? $first : (string) strtok(trim((string) $u->display_name), ' '); } // ================================================================ answering public static function ajaxAsk(): void { if (!self::allowed() || !check_ajax_referer(self::NONCE, 'nonce', false)) { wp_send_json_error(['message' => __('You do not have permission to do this.', 'aipcf-ai-provider-for-cloudflare')], 403); } $raw = isset($_POST['messages']) && is_string($_POST['messages']) ? json_decode(wp_unslash($_POST['messages']), true) : []; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- decoded, then every field is sanitised in messages() $messages = self::messages(is_array($raw) ? $raw : []); if ($messages === [] || end($messages)['role'] !== 'user') { wp_send_json_error(['message' => __('Type a question first.', 'aipcf-ai-provider-for-cloudflare')]); } $chat = [ 'conv' => isset($_POST['conv']) && is_string($_POST['conv']) ? sanitize_key(wp_unslash($_POST['conv'])) : '', 'name' => isset($_POST['name']) && is_string($_POST['name']) ? sanitize_text_field(wp_unslash($_POST['name'])) : '', 'consent' => !empty($_POST['consent']), 'honeypot' => isset($_POST['website']) && is_string($_POST['website']) ? sanitize_text_field(wp_unslash($_POST['website'])) : '', 'turnstile' => isset($_POST['turnstile']) && is_string($_POST['turnstile']) ? sanitize_text_field(wp_unslash($_POST['turnstile'])) : '', // The posts the previous answer cited; checked again in passages(). 'keep' => isset($_POST['keep']) && is_string($_POST['keep']) ? array_map('absint', explode(',', sanitize_text_field(wp_unslash($_POST['keep'])))) : [], ]; $r = self::answer($messages, $chat); if (is_wp_error($r)) { if ($r->get_error_code() === 'aipcf_budget') { // Out of neurons for today: the chat goes offline with the owner's message. wp_send_json_error(['message' => self::offlineText(), 'offline' => true]); } if ($r->get_error_code() === 'aipcf_chat_verify') { wp_send_json_error(['message' => $r->get_error_message(), 'verify' => true]); } // Provider errors can name models, gateways or accounts: only administrators see them. $known = in_array($r->get_error_code(), ['aipcf_chat_rate', 'aipcf_chat_refused'], true); wp_send_json_error(['message' => $known || current_user_can('manage_options') ? $r->get_error_message() : __('The assistant could not answer just now. Try again later.', 'aipcf-ai-provider-for-cloudflare')]); } wp_send_json_success($r); } /** * One answer to the last question of a conversation. * * @param list $messages * @param array{conv?: string, name?: string, consent?: bool, honeypot?: string, turnstile?: string} $chat Who is asking, and whether the chat may be saved. * @return array{reply: string, sources: list, found: bool}|\WP_Error */ public static function answer(array $messages, array $chat = []) { // The daily limit first: a used-up assistant is offline, whatever the rate. $allowed = self::gate(); if (is_wp_error($allowed)) { return $allowed; } if (self::isVisitor()) { $checked = self::visitorChecks($chat); if (is_wp_error($checked)) { return $checked; } } $limited = self::rateLimited(); if (is_wp_error($limited)) { return $limited; } $question = (string) end($messages)['content']; $replied = self::reply($messages, (array) ($chat['keep'] ?? [])); if (is_wp_error($replied)) { return $replied; } [$out, $passages] = $replied; // Saved only when the member agreed in the chat and the owner keeps chats. if (!empty($chat['consent']) && !empty(Settings::get('chat_store'))) { ChatLog::add( (string) ($chat['conv'] ?? ''), (string) ($chat['name'] ?? ''), $question, $out['reply'], $out['found'], $passages === [] ? null : max(array_column($passages, 'score')), array_column($out['sources'], 'id') ); } unset($out['blocked']); return $out; } /** * The reply to a conversation, with the assistant's guardrails, and the passages it used. * * @param list $messages * @param array $keep * @return array{0: array{reply: string, sources: list, found: bool, blocked?: bool}, 1: list}|\WP_Error */ private static function reply(array $messages, array $keep) { $question = (string) end($messages)['content']; // Guardrails on the question: a blocked topic, or the safety check, gets the owner's reply. if (ChatGuard::blocked($question) !== '' || (!empty(Settings::get('chat_safety')) && !ChatGuard::safe($question, '', self::task()))) { return [['reply' => ChatGuard::blockedReply(), 'sources' => [], 'found' => false, 'blocked' => true], []]; } $passages = self::passages($messages, $keep); if (is_wp_error($passages)) { return $passages; } if ($passages === []) { return [['reply' => self::noMatch(), 'sources' => [], 'found' => false], []]; } $text = self::ask($passages, $messages); if (is_wp_error($text)) { return $text; } $out = self::clean($text, $passages); // And on the answer. if (ChatGuard::blocked($out['reply']) !== '' || (!empty(Settings::get('chat_safety_answers')) && !ChatGuard::safe($question, $out['reply'], self::task()))) { $out = ['reply' => ChatGuard::blockedReply(), 'sources' => [], 'found' => true, 'blocked' => true]; } return [$out, $passages]; } /** * One question answered with the assistant's rules for another feature (AI agents): its own * usage task, no chat window, no saved chat. The caller checks limits and permissions. * * @return array{reply: string, sources: list, found: bool, blocked: bool}|\WP_Error */ public static function answerAs(string $question, string $task) { self::$as = $task; try { $replied = self::reply([['role' => 'user', 'content' => $question]], []); } finally { self::$as = null; } if (is_wp_error($replied)) { return $replied; } $out = $replied[0]; $out['blocked'] = !empty($out['blocked']); return $out; } /** * The passages to answer from: matching well enough, and from posts this member may read, * at most PER_POST from one post; in a follow-up, also the posts the previous answer cited. * * @param list $messages * @param array $keep Post ids the browser sends back. * @return list|\WP_Error */ public static function passages(array $messages, array $keep = []) { $questions = array_values(array_filter($messages, static fn(array $m): bool => $m['role'] === 'user')); $last = (string) end($questions)['content']; // A follow-up ("and the second one?") means little alone: search with the question before it. $query = count($questions) > 1 && mb_strlen($last) < 80 ? $questions[count($questions) - 2]['content'] . "\n" . $last : $last; // By meaning and by the question's own words, then (by default) checked again by a // reranking model: see Retriever. More than needed, since only PER_POST of a post count. $rerank = !empty(Settings::get('chat_rerank')); $found = Retriever::find(mb_substr($query, 0, 500), self::PASSAGES * 2, self::task(), $rerank ? Retriever::MODE_RERANK : Retriever::MODE_HYBRID); if (is_wp_error($found)) { return $found; } $minVector = (float) Settings::get('chat_min_score'); $minMatch = (float) Settings::get('chat_min_match'); $out = []; $perPost = []; foreach ($found as $m) { $id = (int) $m['id']; // Checked by the reranker: its score decides (off-topic questions score near 0). // Not checked: a close meaning, or the question's own words in the post. $match = $m['rerank'] !== null ? $m['rerank'] >= $minMatch : ($m['vector'] >= $minVector || $m['words'] > 0); if (count($out) >= self::PASSAGES || !$match || ($perPost[$id] ?? 0) >= self::PER_POST || !self::readable($id)) { continue; } $text = trim((string) $m['text']); if ($text === '') { $text = self::rebuild($id, (int) $m['chunk']); } $passage = self::passage($id, $text, (float) ($m['rerank'] ?? $m['vector'])); if ($passage !== null) { $out[] = $passage; $perPost[$id] = ($perPost[$id] ?? 0) + 1; } } // A follow-up is often about the posts just cited ("without coconut oil?"): keep them in // view, with their passage closest to the question, after the search's own. if (count($questions) > 1) { foreach (array_slice(array_values(array_unique(array_filter(array_map('intval', $keep)))), 0, self::KEEP) as $id) { if (isset($perPost[$id]) || !self::kept($id)) { continue; } $passage = self::passage($id, Retriever::closestPart($id, $last)[1], 0.0); if ($passage !== null) { $out[] = $passage; $perPost[$id] = 1; } } } return $out; } /** * One passage as the model gets it, or null when the site leaves it out. * * @return array{id: int, title: string, url: string, text: string, score: float}|null */ private static function passage(int $id, string $text, float $score): ?array { /** * A passage the assistant may answer from; return '' to leave it out (for example * text a membership plugin shows only to some members). * * @param string $text The passage text. * @param int $postId The post it comes from. * @param int $userId The member asking. */ $text = trim((string) apply_filters('aipcf_chat_passage', trim($text), $id, get_current_user_id())); if ($text === '') { return null; } return [ 'id' => $id, 'title' => html_entity_decode(get_the_title($id), ENT_QUOTES, 'UTF-8'), 'url' => (string) get_permalink($id), 'text' => mb_substr($text, 0, self::MAX_PASSAGE), 'score' => $score, ]; } /** * Whether a post the browser asks to keep may be: embedded (so not left out by the owner), * not an image, and readable by this member or visitor. */ private static function kept(int $id): bool { $post = $id > 0 ? get_post($id) : null; if (!$post instanceof \WP_Post || $post->post_type === 'attachment' || !self::readable($id)) { return false; } $row = IndexTable::get('post', $id); return $row !== null && !in_array((string) $row['status'], ['deleting', 'skipped', 'failed'], true); } private static function rebuild(int $postId, int $chunk): string { $post = get_post($postId); if (!$post instanceof \WP_Post) { return ''; } $doc = Extractor::extract($post); $chunks = Chunker::chunk($doc['title'], $doc['body'], (int) (Models::get(Models::current())['max_tokens'] ?? 512)); return (string) ($chunks[$chunk]['text'] ?? ''); } private static function readable(int $id): bool { $p = get_post($id); return $p instanceof \WP_Post && $p->post_password === '' && ($p->post_status === 'publish' || current_user_can('read_post', $id)); } /** * @param list $passages * @param list $messages * @return string|\WP_Error */ private static function ask(array $passages, array $messages) { if (!class_exists('\\WordPress\\AiClient\\AiClient')) { return new \WP_Error('aipcf_env', __('The WordPress AI Client is not available.', 'aipcf-ai-provider-for-cloudflare')); } $sources = ''; foreach ($passages as $p) { $sources .= '[#' . $p['id'] . '] ' . $p['title'] . "\n" . $p['text'] . "\n\n"; } $system = ChatGuard::system(self::name(), html_entity_decode(get_bloginfo('name'), ENT_QUOTES, 'UTF-8')); $transcript = ''; foreach ($messages as $m) { $transcript .= ($m['role'] === 'user' ? 'Member: ' : 'Assistant: ') . $m['content'] . "\n"; } $prompt = "SOURCES:\n" . $sources . "CONVERSATION:\n" . $transcript . 'Assistant:'; try { TaskContext::pin(self::task()); $model = \WordPress\AiClient\AiClient::defaultRegistry()->getProviderModel('cloudflare-workers-ai', get_default_model_id()); $result = \WordPress\AiClient\AiClient::prompt($prompt) ->usingModel($model) ->usingSystemInstruction($system) ->usingMaxTokens(ChatGuard::maxTokens()) ->generateTextResult(); return trim($result->toText()); } catch (\Throwable $e) { return new \WP_Error('aipcf_chat', $e->getMessage()); } finally { TaskContext::clear(); } } /** * The reply as shown: plain text, no URLs, only references to the given sources, and the * cited ones as links. A reply that cites none did not answer from the site ("I could not * find…"): it lists no sources and counts as unanswered. * * @param list $passages * @return array{reply: string, sources: list, found: bool} */ public static function clean(string $text, array $passages): array { $byId = []; foreach ($passages as $p) { $byId[$p['id']] ??= ['id' => $p['id'], 'title' => $p['title'], 'url' => $p['url']]; } $text = (string) preg_replace('~\b(?:https?://|www\.)\S+~i', '', $text); $text = trim(wp_strip_all_tags($text)); $cited = []; $text = (string) preg_replace_callback('/\[#(\d+)\]/', static function (array $m) use ($byId, &$cited): string { $id = (int) $m[1]; if (!isset($byId[$id])) { return ''; } $cited[$id] = true; return '[#' . $id . ']'; }, $text); $text = trim((string) preg_replace('/[ \t]{2,}/', ' ', $text)); $sources = array_values(array_intersect_key($byId, $cited)); return ['reply' => $text, 'sources' => array_slice($sources, 0, 5), 'found' => $cited !== []]; } /** * The conversation as the browser sent it, cut to what is safe to pass on: user and * assistant turns only, plain text, the last ten, within a total size. * * @param array $raw * @return list */ public static function messages(array $raw): array { $out = []; foreach ($raw as $m) { if (!is_array($m) || !in_array($m['role'] ?? '', ['user', 'assistant'], true)) { continue; } $text = trim(sanitize_textarea_field((string) ($m['content'] ?? ''))); if ($text !== '') { $out[] = ['role' => (string) $m['role'], 'content' => mb_substr($text, 0, self::MAX_MESSAGE)]; } } $out = array_slice($out, -self::MAX_TURNS); while (count($out) > 1 && array_sum(array_map(static fn(array $m): int => mb_strlen($m['content']), $out)) > self::MAX_TRANSCRIPT) { array_shift($out); } return $out; } /** * @return true|\WP_Error */ private static function rateLimited() { $key = 'aipcf_chat_rate_' . (self::isVisitor() ? 'v' . self::visitorKey() : get_current_user_id()); $count = (int) get_transient($key); if ($count >= self::PER_MINUTE) { return new \WP_Error('aipcf_chat_rate', __('Too many questions in a minute. Wait a moment and try again.', 'aipcf-ai-provider-for-cloudflare')); } set_transient($key, $count + 1, MINUTE_IN_SECONDS); return true; } // ================================================================ the widget /** * What the chat script needs; also printed as data attributes for the markup. * * @return array */ public static function config(): array { $first = self::firstName(); return [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce(self::NONCE), 'name' => self::name(), 'welcome' => self::welcome($first), 'firstName' => $first, 'chips' => array_values((array) Settings::get('chat_chips')), 'askName' => !empty(Settings::get('chat_ask_name')), 'store' => !empty(Settings::get('chat_store')), 'consentText' => self::consentText(), 'online' => self::online(), 'offline' => self::offlineText(), 'user' => get_current_user_id(), 'visitor' => self::isVisitor(), 'turnstile' => self::isVisitor() && self::turnstileKeys() !== null ? self::turnstileKeys()['site'] : '', 'i18n' => [ 'open' => __('Open the site assistant', 'aipcf-ai-provider-for-cloudflare'), 'close' => __('Close', 'aipcf-ai-provider-for-cloudflare'), 'placeholder' => trim((string) Settings::get('chat_placeholder')) !== '' ? (string) Settings::get('chat_placeholder') : __('Ask a question', 'aipcf-ai-provider-for-cloudflare'), 'yourName' => __('Your name', 'aipcf-ai-provider-for-cloudflare'), 'start' => __('Start chatting', 'aipcf-ai-provider-for-cloudflare'), 'nameHint' => __('No email or phone number, just what to call you.', 'aipcf-ai-provider-for-cloudflare'), 'send' => __('Send', 'aipcf-ai-provider-for-cloudflare'), 'newChat' => __('New chat', 'aipcf-ai-provider-for-cloudflare'), 'thinking' => __('Looking through the site...', 'aipcf-ai-provider-for-cloudflare'), 'sources' => __('From:', 'aipcf-ai-provider-for-cloudflare'), 'error' => __('Something went wrong. Try again.', 'aipcf-ai-provider-for-cloudflare'), 'you' => __('You', 'aipcf-ai-provider-for-cloudflare'), 'note' => self::disclaimer(), ], ]; } private static function enqueue(): void { $base = plugin_dir_url(AIPCF_FILE) . 'assets/chat/'; wp_enqueue_style('aipcf-chat', $base . 'chat.css', [], AIPCF_VERSION); wp_enqueue_script('aipcf-chat', $base . 'chat.js', [], AIPCF_VERSION, true); wp_localize_script('aipcf-chat', 'aipcfChat', self::config()); } public static function enqueueFront(): void { if (!empty(Settings::get('chat_floating')) && self::allowed()) { self::enqueue(); } } public static function enqueueAdmin(string $hook): void { if ($hook === 'index.php' && !empty(Settings::get('chat_admin')) && self::allowed()) { self::enqueue(); } } /** * The floating button and its panel, at the end of every front-end page for members who may use it. */ public static function renderFloating(): void { if (empty(Settings::get('chat_floating')) || !self::allowed()) { return; } $avatar = trim((string) Settings::get('chat_avatar')); printf( '
%5$s
', Settings::get('chat_position') === 'left' ? 'left' : 'right', esc_attr(self::style()), esc_html__('Open the site assistant', 'aipcf-ai-provider-for-cloudflare'), $avatar !== '' ? '' : '', self::panel('aipcf-chat-panel', true) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped parts in panel() ); } /** * The owner's colour as the widget's accent variable. */ public static function style(): string { $accent = sanitize_hex_color((string) Settings::get('chat_accent')); return is_string($accent) && $accent !== '' ? '--aipcf-chat-accent:' . $accent . ';--aipcf-chat-accent-ink:' . self::inkFor($accent) . ';' : ''; } /** * Black or white text, whichever reads better on the accent colour. */ private static function inkFor(string $hex): string { $hex = ltrim($hex, '#'); if (strlen($hex) === 3) { $hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2]; } $lin = static function (int $c): float { $c /= 255; return $c <= 0.03928 ? $c / 12.92 : (($c + 0.055) / 1.055) ** 2.4; }; $l = 0.2126 * $lin((int) hexdec(substr($hex, 0, 2))) + 0.7152 * $lin((int) hexdec(substr($hex, 2, 2))) + 0.0722 * $lin((int) hexdec(substr($hex, 4, 2))); // Contrast with white is 1.05 / (L + 0.05); with black (L + 0.05) / 0.05. return (1.05 / ($l + 0.05)) >= (($l + 0.05) / 0.05) ? '#ffffff' : '#111111'; } public static function registerDashboard(): void { if (empty(Settings::get('chat_admin')) || !self::allowed()) { return; } wp_add_dashboard_widget('aipcf_chat', self::name(), static function (): void { echo '
' . self::panel('aipcf-chat-dashboard', false) . '
'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built from escaped parts in panel() }); } /** * The chat panel: header, message log, the form. The name and consent step, starter * questions and the offline state are added by chat.js. */ public static function panel(string $id, bool $floating): string { $title = $id . '-title'; $avatar = trim((string) Settings::get('chat_avatar')); return sprintf( '
' . '
%4$s

%5$s

%6$s

' . '%8$s
' . '

%9$s

' . '
' . '' . '
' . '

%12$s

' . '
', esc_attr($id), $floating ? ' role="dialog" hidden' : '', esc_attr($title), $avatar !== '' ? '' : '', esc_html(self::name()), esc_html(self::subtitle()), esc_html__('New chat', 'aipcf-ai-provider-for-cloudflare'), $floating ? '' : '', esc_html(self::welcome(self::firstName())), esc_attr(trim((string) Settings::get('chat_placeholder')) !== '' ? (string) Settings::get('chat_placeholder') : __('Ask a question', 'aipcf-ai-provider-for-cloudflare')), esc_html__('Send', 'aipcf-ai-provider-for-cloudflare'), esc_html(self::disclaimer()), esc_html__('The assistant needs JavaScript.', 'aipcf-ai-provider-for-cloudflare') ); } }