$this->render(['botId' => (int) (shortcode_atts(['bot' => 0], (array) $atts)['bot'])])); } public function prepareFloating(): void { $this->floatingBot = null; if (is_admin() || is_feed() || !$this->plugin->modules->isEnabled('chat') || (function_exists('is_checkout') && is_checkout()) || (function_exists('is_cart') && is_cart())) { return; } foreach ($this->plugin->bots->all(get_current_blog_id()) as $bot) { // Only an assistant that can answer gets a launcher (see render()); otherwise the next eligible one is used. if (\Aiwend\Chat\DisplayRules::eligible($bot, is_singular() ? (int) get_queried_object_id() : 0) && $this->plugin->ai()->resolve($bot)['ready'] && $this->plugin->publicChatRefusal($bot) === null) { $this->floatingBot = $bot; wp_enqueue_script(self::HANDLE); wp_enqueue_style(self::HANDLE); break; // One launcher per page; oldest matching assistant wins deterministically. } } } public function renderFloating(): void { if ($this->floatingBot !== null && $this->plugin->modules->isEnabled('chat')) { self::printMarkup($this->markup($this->floatingBot, false, true)); } } /** * Prints the widget container. section() builds it from esc_attr()/esc_html() parts only; the output still passes through wp_kses with the * tags and attributes the container uses (the accent travels in one CSS custom property from a fixed palette, allowed for this call only). */ private static function printMarkup(string $html): void { $allowStyle = static fn(array $props): array => array_merge($props, ['--aiwend-accent']); add_filter('safe_style_css', $allowStyle); echo wp_kses($html, [ 'section' => ['id' => true, 'class' => true, 'style' => true, 'aria-label' => true, 'data-aiwend-theme' => true, 'data-aiwend-config' => true], 'noscript' => [], ]); remove_filter('safe_style_css', $allowStyle); } /** * The block lets an editor choose an assistant by name instead of typing its number. Only assistants that visitors can see (Active and * Public) are offered; the list is handed to the block editor only, never to the front end. */ public function blockEditorData(): void { if (!$this->plugin->modules->isEnabled('chat')) { return; } $choices = []; foreach ($this->plugin->bots->all(get_current_blog_id()) as $bot) { if ($bot['status'] === 'active' && $bot['is_public'] === true) { // The name visitors see (the display name, else the internal name): authors who may edit posts are offered nothing else. $look = \Aiwend\Chat\BotRepository::appearance($bot); $choices[] = ['id' => (int) $bot['id'], 'name' => $look['display_name'] !== '' ? (string) $look['display_name'] : (string) $bot['name']]; } } wp_add_inline_script('aiwend-chat-block-editor', 'window.aiwendChatBlock = ' . wp_json_encode(['assistants' => $choices]) . ';', 'before'); } public function registerAssetsAndBlock(): void { $base = plugins_url('', AIWEND_FILE); wp_register_script(self::HANDLE, $base . '/assets/chat-widget.js', [], AIWEND_VERSION, ['in_footer' => true, 'strategy' => 'defer']); wp_register_style(self::HANDLE, $base . '/assets/chat-widget.css', [], AIWEND_VERSION); wp_register_script('aiwend-chat-block-editor', $base . '/assets/chat-block-editor.js', ['wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components', 'wp-i18n'], AIWEND_VERSION, true); wp_set_script_translations('aiwend-chat-block-editor', 'aiwend'); register_block_type(dirname(AIWEND_FILE) . '/blocks/chat', [ 'render_callback' => fn(array $attrs): string => $this->render($attrs), ]); } /** * Private preview inside the assistant editor: the real widget, talking to the preview * routes (which require the assistant-management capability). Any lifecycle state. */ public function renderPreview(int $botId): string { $bot = $this->plugin->bots->find(get_current_blog_id(), $botId); if ($bot === null || !current_user_can(\Aiwend\Policy\Capabilities::MANAGE_BOTS)) { return ''; } return $this->markup($bot, true); } /** @param array $attrs */ public function render(array $attrs): string { if (is_feed()) { return ''; // a feed reader cannot run the widget: it would receive its markup and configuration as content } $botId = (int) ($attrs['botId'] ?? 0); $bot = $botId > 0 ? $this->plugin->bots->find(get_current_blog_id(), $botId) : null; if ($bot === null || $bot['status'] !== 'active' || $bot['is_public'] !== true) { // Editors see why nothing renders; visitors see nothing. return current_user_can('edit_posts') ? '

' . esc_html__('Aiwend chat: this assistant is shown to visitors only when it is Active and Public. You can test it privately under Aiwend > Assistants.', 'aiwend') . '

' : ''; } $resolved = $this->plugin->ai()->resolve($bot); if (!$resolved['ready']) { // No usable AI (a local configuration lookup; nothing is sent to a provider): a launcher that can only answer "not available" // would mislead visitors. Visitors get nothing and no detail; an editor is told what to fix. return current_user_can('edit_posts') ? '

' . esc_html__('Aiwend chat: this assistant is hidden from visitors because it cannot answer yet.', 'aiwend') . ' ' . esc_html(AiSetup::problemText($resolved['problem'])) . ' ' . esc_html__('Fix it under Aiwend > AI Connections.', 'aiwend') . '

' : ''; } if ($this->plugin->publicChatRefusal($bot) !== null) { // 0.9.0: the server refuses this assistant's visitor messages (xAI without the owner's wp-config.php opt-in). A launcher that can only // fail would mislead visitors, so they see nothing; an editor is told why. This hides the widget only: the refusal itself is enforced // on the chat request path (ChatService), so a hand-made request is refused too. return current_user_can('edit_posts') ? '

' . esc_html__('Aiwend chat: this assistant is hidden from visitors because public chat on xAI is switched off by default. Aiwend cannot check xAI Zero Data Retention. Private preview still works. See the xAI note under Aiwend > AI Connections.', 'aiwend') . '

' : ''; } return $this->markup($bot, false); } /** * The public chat never carries the Aiwend logo: it is the site owner's widget, so the default avatar is the first letter of the display * name (or the speech-bubble launcher) and the logo is shown only in Aiwend's own admin screens (see Brand). */ public static function logo(): array { return ['src' => '', 'srcset' => '']; } /** Image types allowed for a custom avatar (raster only; SVG uploads are not enabled). */ public const AVATAR_MIME = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']; /** * URL of a validated custom avatar, or null (the widget then shows the first letter of the display name). * Only an existing image attachment of an allowed type, at thumbnail size. */ public static function avatarUrl(int $attachmentId): ?string { if ($attachmentId <= 0 || get_post_type($attachmentId) !== 'attachment' || !in_array(get_post_mime_type($attachmentId), self::AVATAR_MIME, true)) { return null; } $src = wp_get_attachment_image_src($attachmentId, 'thumbnail'); $url = is_array($src) ? esc_url_raw((string) $src[0], ['http', 'https']) : ''; return $url !== '' ? $url : null; } /** * Widget configuration for one assistant. Every visible string is translatable and passed as * data; visitor-facing wording never presents simulated answers as real AI output. * * @param array $bot * @return array */ public function config(array $bot, bool $preview, bool $floating = false): array { // Labelled as simulated only when the assistant's effective AI is the demo stand-in. $resolved = $this->plugin->ai()->resolve($bot); $simulated = $resolved['mode'] === 'demo' && $this->plugin->mockProviderEnabled(); // Phase 154: one extra visitor sentence only when the assistant's effective provider is xAI (display-only; the request path is unchanged). Computed per render from the saved assistant, so a changed provider shows the right notice on the next render. $xaiNotice = !$simulated && ($resolved['provider'] ?? null) === 'xai' ? __('Please do not enter personal information.', 'aiwend') . ' ' : ''; $look = \Aiwend\Chat\BotRepository::appearance($bot); $avatar = $look['avatar']; $avatarUrl = $avatar === 'custom' ? self::avatarUrl((int) $look['avatar_id']) : null; if ($avatar === 'custom' && $avatarUrl === null) { $avatar = 'mark'; // missing or deleted image: fall back to the logo } return [ 'restUrl' => esc_url_raw(rest_url($preview ? 'aiwend/v1/preview/' : 'aiwend/v1/')), 'botId' => (int) $bot['id'], 'preview' => $preview, 'floating' => $floating, 'displayRules' => array_intersect_key($look, \Aiwend\Chat\DisplayRules::DEFAULTS), 'theme' => $look['theme'], 'accent' => $look['accent'], 'avatar' => $avatar, 'avatarUrl' => $avatarUrl, 'logo' => self::logo(), 'colors' => $look['colors'], 'themeToggle' => $look['theme_toggle'], 'suggestions' => $look['suggestions'], // Only logged-in users need a REST nonce; guest pages stay cacheable. 'nonce' => is_user_logged_in() ? wp_create_nonce('wp_rest') : null, 'maxLength' => 4000, 'i18n' => [ 'title' => $look['display_name'], 'subtitle' => __('AI assistant', 'aiwend'), 'welcomeHeading' => __('How can we help?', 'aiwend'), 'welcome' => $look['welcome'], 'previewBadge' => $preview ? __('Private preview', 'aiwend') : '', 'demoLabel' => $simulated ? __('Demo · Simulated answers', 'aiwend') : '', 'demoExplain' => __('Responses are simulated. No external AI service is contacted.', 'aiwend'), // Real-provider mode: a short, honest disclosure under the composer. 'disclosure' => trim(($simulated ? '' : __('AI-generated answers can be wrong. Messages and selected source excerpts are sent to the configured AI provider.', 'aiwend') . ' ' . $xaiNotice) . __('Your conversation may be reviewed by authorized site administrators to provide support and improve this assistant\'s information.', 'aiwend')), 'privacyUrl' => get_privacy_policy_url(), 'privacyLabel' => __('Privacy policy', 'aiwend'), 'conversationLabel' => __('Conversation', 'aiwend'), 'close' => __('Close chat', 'aiwend'), 'darkMode' => __('Switch to dark mode', 'aiwend'), 'lightMode' => __('Switch to light mode', 'aiwend'), 'menuLabel' => __('Conversation options', 'aiwend'), 'newConversation' => __('New conversation', 'aiwend'), 'newConversationHint' => __('Start fresh. This conversation is kept.', 'aiwend'), 'clearDraft' => __('Clear message', 'aiwend'), 'draftCleared' => __('Message cleared.', 'aiwend'), 'deleteConversation' => __('Delete conversation', 'aiwend'), 'deleteConversationHint' => __('Permanently remove it from this site.', 'aiwend'), 'newTitle' => __('Start a new conversation?', 'aiwend'), 'newConfirmBusy' => __('The current response will be stopped. This conversation is kept; use Delete conversation to remove it.', 'aiwend'), 'newConfirmDraft' => __('Your unsent message will be discarded.', 'aiwend'), 'alreadyNew' => __('This is already a new conversation.', 'aiwend'), 'newStarted' => __('New conversation started. The previous one is kept under this site’s retention policy.', 'aiwend'), 'deleteTitle' => __('Delete this conversation?', 'aiwend'), 'deleteBody' => __('Your messages, the sources shown and any feedback are permanently removed from this site. This cannot be undone.', 'aiwend'), 'deleteAction' => __('Delete', 'aiwend'), 'cancel' => __('Cancel', 'aiwend'), 'deleted' => __('Conversation deleted.', 'aiwend'), 'suggestions' => __('Suggested questions', 'aiwend'), 'keyboardHelp' => __('Enter to send · Shift+Enter for a new line', 'aiwend'), 'touchHelp' => __('Use the send button to send', 'aiwend'), 'inputLabel' => __('Message to the assistant', 'aiwend'), 'placeholder' => __('Ask a question…', 'aiwend'), 'send' => __('Send message', 'aiwend'), 'stop' => __('Stop response', 'aiwend'), 'stopping' => __('Stopping…', 'aiwend'), 'retry' => __('Try again', 'aiwend'), 'youSaid' => __('You said:', 'aiwend'), /* translators: %d: response number in this conversation */ 'assistantSaid' => __('Assistant response %d:', 'aiwend'), 'responding' => __('The assistant is responding…', 'aiwend'), 'stillWorking' => __('Still working on your answer. It can take a few more seconds.', 'aiwend'), 'replied' => __('The assistant replied:', 'aiwend'), 'stopped' => __('Stopped by you. The response is incomplete.', 'aiwend'), 'stoppedLabel' => __('Stopped by you', 'aiwend'), 'stoppedLocal' => __('Stopped showing the response. It may still have been completed.', 'aiwend'), 'incompleteNote' => __('This response is incomplete.', 'aiwend'), 'interrupted' => __('This response was interrupted and is incomplete.', 'aiwend'), 'noAnswer' => __('No answer was received.', 'aiwend'), 'newMessages' => __('New messages', 'aiwend'), 'reference' => __('Reference:', 'aiwend'), 'networkError' => __('Could not reach the assistant. Check your connection and try again.', 'aiwend'), 'genericError' => __('Something went wrong. Please try again.', 'aiwend'), /* translators: %d: number of characters remaining */ 'remaining' => __('%d characters left', 'aiwend'), 'tooLong' => __('That message is too long. Please shorten it.', 'aiwend'), /* translators: %d: number of sources */ 'oneSource' => __('%d source', 'aiwend'), /* translators: %d: number of sources */ 'manySources' => __('%d sources', 'aiwend'), /* translators: %s: page number in the source document */ 'pageRef' => __('Page %s', 'aiwend'), /* translators: %d: response number */ 'responseActions' => __('Actions for response %d', 'aiwend'), /* translators: %d: response number */ 'copyResponse' => __('Copy response %d', 'aiwend'), 'copyAnswer' => __('Copy', 'aiwend'), 'copied' => __('Copied', 'aiwend'), 'copyFailed' => __('Copy unavailable. Select the text to copy it.', 'aiwend'), /* translators: %d: response number */ 'markUseful' => __('Mark response %d as useful', 'aiwend'), /* translators: %d: response number */ 'markNotUseful' => __('Mark response %d as not useful', 'aiwend'), 'useful' => __('Useful', 'aiwend'), 'notUseful' => __('Not useful', 'aiwend'), 'feedbackSaved' => __('Feedback saved.', 'aiwend'), 'feedbackRemoved' => __('Feedback removed.', 'aiwend'), 'feedbackFailed' => __('Feedback could not be saved. Please try again.', 'aiwend'), 'previewVisualOnly' => __('Visual preview only. Switch to “Test conversation” to send messages.', 'aiwend'), ], ]; } /** @param array $bot */ private function markup(array $bot, bool $preview, bool $floating = false): string { $config = $this->config($bot, $preview, $floating); wp_enqueue_script(self::HANDLE); wp_enqueue_style(self::HANDLE); return self::section($config, 'aiwend-chat-' . (++self::$instances)); } /** @param array $config */ private static function section(array $config, string $id): string { return sprintf( // Accent from the fixed palette (BotRepository::ACCENTS); theme from BotRepository::THEMES. '
', esc_attr($id), esc_attr(sprintf(/* translators: %s: assistant name */ __('Chat with %s', 'aiwend'), (string) $config['i18n']['title'])), esc_attr((string) wp_json_encode($config)), esc_html__('The assistant needs JavaScript to work.', 'aiwend'), esc_attr((string) $config['theme']), esc_attr((string) $config['accent']) ); } /** * Editor preview frame (admin only): a bare page with a neutral sample layout and the real * widget, so floating placement and mobile breakpoints behave as on the site. The editor sends * draft appearance values with postMessage; a visual preview never calls the chat API. Sending * messages requires the explicit "Test conversation" mode (preview routes, counts toward usage). */ public function previewFrame(): void { if (!current_user_can(\Aiwend\Policy\Capabilities::MANAGE_BOTS)) { wp_die(esc_html__('Not authorized.', 'aiwend'), '', ['response' => 403]); } // The frame shows draft and private assistants, so besides the capability it needs a nonce bound to the assistant (the editor // builds the frame address with previewFrameUrl()). Anonymous visitors never reach this point: admin-post.php has no nopriv hook for it. $assistant = isset($_GET['assistant']) ? absint($_GET['assistant']) : 0; $nonce = isset($_GET['_wpnonce']) ? sanitize_text_field(wp_unslash($_GET['_wpnonce'])) : ''; if ($assistant < 1 || wp_verify_nonce($nonce, self::previewNonceAction($assistant)) === false) { wp_die(esc_html__('This preview link has expired. Reopen the assistant editor and try again.', 'aiwend'), '', ['response' => 403]); } $bot = $this->plugin->bots->find(get_current_blog_id(), $assistant); if ($bot === null) { wp_die(esc_html__('Assistant not found.', 'aiwend'), '', ['response' => 404]); } $config = $this->config($bot, true, false); $config['previewVisualOnly'] = true; $config['startOpen'] = true; nocache_headers(); header('Content-Type: text/html; charset=utf-8'); header('X-Frame-Options: SAMEORIGIN'); header('X-Content-Type-Options: nosniff'); $base = plugins_url('', AIWEND_FILE); // A standalone document (no wp_head): register the assets and print them with the WordPress printing functions. wp_register_style('aiwend-preview-widget', $base . '/assets/chat-widget.css', [], AIWEND_VERSION); wp_register_style('aiwend-preview-frame-layout', $base . '/assets/preview-frame.css', [], AIWEND_VERSION); wp_register_script('aiwend-preview-widget', $base . '/assets/chat-widget.js', [], AIWEND_VERSION, ['in_footer' => true]); wp_register_script('aiwend-preview-frame', $base . '/assets/preview-frame.js', ['aiwend-preview-widget'], AIWEND_VERSION, ['in_footer' => true]); echo ''; echo '' . esc_html__('Aiwend preview', 'aiwend') . ''; wp_print_styles('aiwend-preview-widget'); wp_print_styles(['aiwend-preview-frame-layout']); echo ''; echo '

' . esc_html__('Sample page layout (preview only)', 'aiwend') . '

'; for ($i = 0; $i < 6; $i++) { echo '
'; } echo '
'; for ($i = 0; $i < 10; $i++) { echo '
'; } echo '
'; wp_add_inline_script('aiwend-preview-frame', 'window.aiwendPreviewBase = ' . wp_json_encode($config, JSON_HEX_TAG | JSON_HEX_AMP) . ';', 'before'); wp_print_scripts(['aiwend-preview-widget', 'aiwend-preview-frame']); echo ''; exit; } /** Admin URL of the preview frame for one assistant. */ public static function previewFrameUrl(int $botId): string { return add_query_arg(['action' => 'aiwend_preview_frame', 'assistant' => $botId, '_wpnonce' => wp_create_nonce(self::previewNonceAction($botId))], admin_url('admin-post.php')); } /** Nonce action of the preview frame of one assistant. */ public static function previewNonceAction(int $botId): string { return 'aiwend_preview_frame_' . $botId; } }