settings ) ) ->widget( 'maxToolCallsPerTurn', 3 ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- vendor (NeuronAI) property name. $this->toolMaxRuns = max( 1, $max_runs ); } /** * Get AI provider * * $model = selected model for public chat from settings * * $maxTokens = selected max tokens for public chat from settings. AI generated response will not exceed this limit. * * $params = parameters for AI provider * * @return AIProviderInterface * @throws \Exception When required provider credentials are not configured. */ protected function provider(): AIProviderInterface { $provider = (string) $this->settings->get( 'publicChat.provider', '' ); $model = (string) $this->settings->get( 'publicChat.model', '' ); /* * Public chat has no connection of its own in the settings UI — the * provider modal only writes adminAssistant.provider/model. When the * widget has neither, follow the admin connection instead of dropping * to the Gemini default, which most sites hold no key for. */ if ( '' === $provider && '' === $model ) { $provider = (string) $this->settings->get( 'adminAssistant.provider', '' ); $model = (string) $this->settings->get( 'adminAssistant.model', '' ); } // Aisentic AI Provider (highest priority) if ( 'aisentic' === $provider ) { $endpoint = (string) $this->settings->get( 'llmProviders.aisentic.endpoint', AISENTIC_AI_PROVIDER ); $api_key = (string) $this->settings->get( 'llmProviders.aisentic.apiKey', '' ); if ( empty( $endpoint ) || empty( $api_key ) ) { throw new \Exception( 'Aisentic AI Provider endpoint and API key must be configured.' ); } return new Aisentic_Provider( $endpoint, $api_key ); } // Fall back to the historical default when no model is resolved. if ( '' === $model ) { $model = 'gemini-2.5-flash'; } // Get max tokens from the settings, default to 1024 $max_tokens = (int) $this->settings->get( 'publicChat.maxTokens', 1024 ); // If model starts with 'gemini', use Google provider, and check if google key is configured if ( stripos( $model, 'gemini' ) === 0 ) { $key = (string) $this->settings->get( 'llmProviders.google.apiKey' ); if ( empty( $key ) ) { throw new \Exception( 'Google API key is not configured.' ); } return new \NeuronAI\Providers\Gemini\Gemini( $key, $model ); } else { // Default to OpenAI $key = (string) $this->settings->get( 'llmProviders.openai.apiKey' ); if ( empty( $key ) ) { throw new \Exception( 'OpenAI API key is not configured.' ); } // Get parameters for OpenAI provider $params = []; if ( $max_tokens > 0 ) { // GPT-5, GPT-4o, and o1/o3 series use max_completion_tokens instead of max_tokens $use_max_completion_tokens = ( stripos( $model, 'gpt-5' ) !== false || stripos( $model, 'gpt-4o' ) !== false || stripos( $model, 'o1-' ) !== false || stripos( $model, 'o3-' ) !== false ); if ( $use_max_completion_tokens ) { $params['max_completion_tokens'] = $max_tokens; } else { $params['max_tokens'] = $max_tokens; } } return new \NeuronAI\Providers\OpenAI\OpenAI( $key, $model, $params ); } } /** * Get system instructions * * @return string */ public function instructions(): string { // Get prompting rules from settings for public chat $whitelist = $this->settings->get( 'publicChat.whitelist', [] ); $company_details = $this->settings->get( 'publicChat.companyDetails', [] ); $company_faq = $this->settings->get( 'publicChat.companyFaq', [] ); $refusal_message = $this->settings->get( 'publicChat.refusalMessage', 'I can only assist with questions related to our website.' ); // Prepare default prompt $prompt = "You are a helpful AI assistant for website visitors. Be concise, friendly, and professional.\n\n"; $prompt .= Common_Prompt_Bank::getSystemPrompts( 'publicChat' ); // Core rules $prompt .= Common_Prompt_Bank::getPublicChatCorePrompts( $refusal_message ); // Scope of assistance if ( ! empty( $whitelist ) && is_array( $whitelist ) ) { $prompt .= "## What You Can Help With\n"; foreach ( $whitelist as $item ) { $prompt .= '- ' . $item . "\n"; } $prompt .= "\n"; } // Company info if ( ! empty( $company_details['name'] ) || ! empty( $company_details['description'] ) ) { $prompt .= "## Company Information\n"; if ( ! empty( $company_details['name'] ) ) { $prompt .= 'Company Name: ' . $company_details['name'] . "\n"; } if ( ! empty( $company_details['description'] ) ) { $prompt .= 'Description: ' . $company_details['description'] . "\n"; } $prompt .= "\n"; } // FAQ if ( ! empty( $company_faq ) && is_array( $company_faq ) ) { $prompt .= "## Frequently Asked Questions\n"; foreach ( $company_faq as $faq ) { if ( ! empty( $faq['question'] ) && ! empty( $faq['answer'] ) ) { $prompt .= 'Q: ' . $faq['question'] . "\n"; $prompt .= 'A: ' . $faq['answer'] . "\n\n"; } } } // A merchant-supplied systemPrompt AUGMENTS the assembled prompt — it must // never replace it, or a merchant adding a preamble would silently disable // the safety rails (core rules, whitelist, company info, the never-call- // unregistered-tools rule). Runtime context is appended AFTER the static // base so the static prefix stays eligible for provider-side prompt caching. $custom = (string) $this->settings->get( 'publicChat.systemPrompt', '' ); $base = $prompt . ( '' !== $custom ? "\n\n" . $custom : '' ); // Allow integrations to inject prompt fragments (e.g., WooCommerce sales mode) $base = apply_filters( 'aisentic_public_chat_instructions', $base, $this->settings ); // Quick reply chips only exist when another public tool is on. if ( $this->integration_tools() ) { $base .= "\n\n" . 'When you ask the visitor a question that has 2 to 4 short likely answers, call `suggest-replies` with them after your question. Do not also list those answers in your text: the chips show them. Never for open questions.'; } // Last, so the long integration rules above do not outweigh it. $base .= "\n\n" . Common_Prompt_Bank::get_public_voice_prompt(); return $base . "\n\n" . $this->runtime_context(); } /** * The live context plus the page the visitor is on, so "book this" needs no * question. The widget sends "post_type:id"; a visitor can send anything, * so only a published, public page without a password is named. * * @return string */ protected function runtime_context(): string { $context = parent::runtime_context(); if ( ! preg_match( '/^([a-z0-9_-]+):(\d+)$/', $this->current_page, $match ) ) { return $context; } $post = get_post( (int) $match[2] ); if ( ! $post instanceof \WP_Post || $match[1] !== $post->post_type || 'publish' !== get_post_status( (int) $post->ID ) || post_password_required( (int) $post->ID ) || ! is_post_type_viewable( $post->post_type ) ) { return $context; } $title = Common_Prompt_Bank::merchant_text( wp_strip_all_tags( (string) $post->post_title ) ); return $context . 'The visitor is on the page of "' . $title . '" (id ' . (int) $post->ID . '). When they say "this", "here" or "it" without naming anything, they mean it.' . "\n\n"; } /** * Get available tools: the integrations' public tools plus quick replies. * * @return array */ protected function tools(): array { $tools = $this->integration_tools(); if ( $tools ) { $tools[] = new Suggest_Replies_Tool(); } return $tools; } /** * Same workflow as Agent::compose(), with a tool step that ends the turn * after quick replies instead of asking the model again. * * The approval and argument screens follow it: Abstract_Agent::middleware() * registers them for Quick_Reply_Tool_Node too. * * @param array|Node $nodes Mode nodes (chat, stream). * @return void */ protected function compose( array|Node $nodes ): void { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Vendor (NeuronAI) property name. if ( $this->parallelToolCalls || [] !== $this->eventNodeMap ) { parent::compose( $nodes ); return; } $this->addNodes( [ ...( is_array( $nodes ) ? $nodes : [ $nodes ] ), // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- Vendor (NeuronAI) property name. new Quick_Reply_Tool_Node( $this->toolMaxRuns, $this->resolveToolErrorHandler() ), ] ); } /** * Public tools the integrations register, through the allowlist. * * @return array */ private function integration_tools(): array { $tools = apply_filters( 'aisentic_public_chat_tools', [], $this->settings ); // Public-channel tools MUST be read-only or validation-only. The approve // endpoint treats public actions as user_id=0, so an Approvable write tool // registered here would let any anonymous visitor approve any other // visitor's pending write. Filter the third-party-populated list through an // allowlist so a rogue plugin cannot slip a destructive tool into the public // agent. Extend the allowlist only with read-only/validation-only tools. $allowed = apply_filters( 'aisentic_public_chat_allowed_tools', [ Wc_Product_Search_Tool::class, Wc_Cart_Action_Tool::class, Eventin_Event_Search_Tool::class, Eventin_Ticket_Action_Tool::class, Booktics_Service_Search_Tool::class, Booktics_Available_Slots_Tool::class, Booktics_Booking_Link_Tool::class, Booktics_Manage_Links_Tool::class, Timetics_Service_Search_Tool::class, Timetics_Available_Slots_Tool::class, Timetics_Booking_Link_Tool::class, Timetics_Manage_Links_Tool::class, ] ); return array_values( array_filter( is_array( $tools ) ? $tools : [], static function ( $tool ) use ( $allowed ) { return is_object( $tool ) && in_array( get_class( $tool ), $allowed, true ); } ) ); } }