WP_REST_Server::CREATABLE, 'callback' => 'avcap_rest_ai', 'permission_callback' => '__return_true', 'args' => array( 'message' => array( 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_textarea_field', 'validate_callback' => 'avca_rest_validate_text', ), 'history' => array( 'type' => 'array', 'default' => array(), ), 'lang' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); } add_action( 'rest_api_init', 'avcap_register_ai_route' ); /** * Whether AI answers can run right now. * * @return bool */ function avcap_ai_ready() { $s = avcap_get_settings(); return avcap_is_licensed() && ! empty( $s['ai_enabled'] ) && '' !== $s[ 'key_' . $s['ai_provider'] ]; } /** * Current month's AI usage counter. * * @return int */ function avcap_ai_usage() { $usage = get_option( AVCAP_USAGE_OPTION, array() ); $month = gmdate( 'Y-m' ); return isset( $usage[ $month ] ) ? (int) $usage[ $month ] : 0; } /** * Add one AI reply to this month's counter (keeps the last 12 months). */ function avcap_ai_count_usage() { $usage = get_option( AVCAP_USAGE_OPTION, array() ); $usage = is_array( $usage ) ? $usage : array(); $month = gmdate( 'Y-m' ); $usage[ $month ] = ( isset( $usage[ $month ] ) ? (int) $usage[ $month ] : 0 ) + 1; ksort( $usage ); $usage = array_slice( $usage, -12, null, true ); update_option( AVCAP_USAGE_OPTION, $usage, false ); } /** * Clean the conversation history sent by the browser. * * Returns alternating user/assistant turns that start with a user turn (required by * Claude and Gemini), with at most 8 turns of up to 500 characters each. * * @param mixed $history Raw history. * @return array[] Each item: role (user|assistant), text. */ function avcap_clean_history( $history ) { $turns = array(); foreach ( array_slice( is_array( $history ) ? $history : array(), -8 ) as $item ) { if ( ! is_array( $item ) || empty( $item['text'] ) || empty( $item['role'] ) ) { continue; } $role = 'user' === $item['role'] ? 'user' : 'assistant'; $text = mb_substr( sanitize_textarea_field( (string) $item['text'] ), 0, 500 ); if ( '' === $text ) { continue; } $last = count( $turns ) - 1; if ( $last >= 0 && $turns[ $last ]['role'] === $role ) { $turns[ $last ]['text'] .= "\n" . $text; } else { $turns[] = array( 'role' => $role, 'text' => $text, ); } } while ( $turns && 'user' !== $turns[0]['role'] ) { array_shift( $turns ); } // The new question is added as the final user turn, so history must end with the assistant. if ( $turns && 'user' === $turns[ count( $turns ) - 1 ]['role'] ) { array_pop( $turns ); } return $turns; } /** * Site content that matches the question, for context. * * @param string $question Question. * @return string */ function avcap_site_context( $question ) { $types = (array) avca_get_setting( 'search_types' ); $types = $types ? $types : array( 'post', 'page' ); $query = new WP_Query( array( 's' => $question, 'post_type' => $types, 'post_status' => 'publish', 'posts_per_page' => 3, 'no_found_rows' => true, 'ignore_sticky_posts' => true, 'has_password' => false, ) ); $parts = array(); foreach ( $query->posts as $post ) { $text = wp_strip_all_tags( strip_shortcodes( $post->post_content ) ); $text = trim( preg_replace( '/\s+/', ' ', $text ) ); $parts[] = '### ' . get_the_title( $post ) . ' (' . get_permalink( $post ) . ")\n" . mb_substr( $text, 0, 1500 ); } return implode( "\n\n", $parts ); } /** * Build the system prompt. * * @param string $question Visitor question. * @param string $lang Visitor browser language (e.g. "ta-IN"). * @return string */ function avcap_build_system_prompt( $question, $lang ) { $s = avcap_get_settings(); $free = avca_get_settings(); $lines = array(); $lines[] = '' !== $s['ai_instructions'] ? $s['ai_instructions'] : avcap_get_defaults()['ai_instructions']; $lines[] = sprintf( 'Your name is "%1$s". You answer visitors of the website "%2$s" (%3$s).', $free['bot_name'], wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ), home_url( '/' ) ); $lines[] = 'Reply in plain text without Markdown, in at most 120 words. Never invent prices, policies, phone numbers or links that are not in the information below. If the information is missing, say you are not sure and suggest the visitor leaves their details.'; if ( ! empty( $s['ai_match_lang'] ) ) { $lines[] = 'Always reply in the same language the visitor writes in.' . ( '' !== $lang ? ' Their browser language is ' . $lang . '.' : '' ); } if ( ! empty( $s['ai_use_faqs'] ) ) { $faqs = array(); foreach ( (array) $free['faqs'] as $faq ) { $faqs[] = 'Q: ' . $faq['question'] . "\nA: " . $faq['answer']; } if ( $faqs ) { $lines[] = "Business FAQs:\n" . mb_substr( implode( "\n\n", $faqs ), 0, 8000 ); } } if ( ! empty( $s['ai_use_content'] ) ) { $context = avcap_site_context( $question ); if ( '' !== $context ) { $lines[] = "Relevant pages from the website:\n" . $context; } } return implode( "\n\n", $lines ); } /** * Error helper. * * @param string $code Code. * @param string $message Message. * @param int $status Status. * @return WP_Error */ function avcap_error( $code, $message, $status ) { return new WP_Error( $code, $message, array( 'status' => $status ) ); } /** * Call the configured AI provider. * * @param string $system System prompt. * @param array $turns Previous turns (role user|assistant, text). * @param string $question Current question. * @param array $override Optional settings override (used by "Test connection"). * @return string|WP_Error Answer text or error. */ function avcap_ai_complete( $system, $turns, $question, $override = array() ) { $s = array_merge( avcap_get_settings(), $override ); $provider = $s['ai_provider']; $key = $s[ 'key_' . $provider ]; $model = $s[ 'model_' . $provider ]; $max = (int) $s['ai_max_tokens']; $temp = (float) $s['ai_temperature']; if ( '' === $key ) { return avcap_error( 'avcap_no_key', __( 'No API key is saved for this provider.', 'avi-support-assistant-pro' ), 400 ); } $turns[] = array( 'role' => 'user', 'text' => $question, ); $headers = array( 'Content-Type' => 'application/json' ); switch ( $provider ) { case 'gemini': $url = 'https://generativelanguage.googleapis.com/v1beta/models/' . rawurlencode( $model ) . ':generateContent'; $headers['x-goog-api-key'] = $key; $contents = array(); foreach ( $turns as $turn ) { $contents[] = array( 'role' => 'user' === $turn['role'] ? 'user' : 'model', 'parts' => array( array( 'text' => $turn['text'] ) ), ); } $body = array( 'systemInstruction' => array( 'parts' => array( array( 'text' => $system ) ) ), 'contents' => $contents, 'generationConfig' => array( 'temperature' => $temp, 'maxOutputTokens' => $max, ), ); break; case 'anthropic': $url = 'https://api.anthropic.com/v1/messages'; $headers['x-api-key'] = $key; $headers['anthropic-version'] = '2023-06-01'; $messages = array(); foreach ( $turns as $turn ) { $messages[] = array( 'role' => $turn['role'], 'content' => $turn['text'], ); } $body = array( 'model' => $model, 'max_tokens' => $max, 'temperature' => $temp, 'system' => $system, 'messages' => $messages, ); break; default: // OpenAI-compatible chat completions: OpenAI, OpenRouter, DeepSeek. $endpoints = array( 'openai' => 'https://api.openai.com/v1/chat/completions', 'openrouter' => 'https://openrouter.ai/api/v1/chat/completions', 'deepseek' => 'https://api.deepseek.com/chat/completions', ); $url = isset( $endpoints[ $provider ] ) ? $endpoints[ $provider ] : $endpoints['openai']; $headers['Authorization'] = 'Bearer ' . $key; if ( 'openrouter' === $provider ) { $headers['HTTP-Referer'] = home_url( '/' ); $headers['X-Title'] = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); } $messages = array( array( 'role' => 'system', 'content' => $system, ), ); foreach ( $turns as $turn ) { $messages[] = array( 'role' => $turn['role'], 'content' => $turn['text'], ); } $body = array( 'model' => $model, 'messages' => $messages, ); // Newer OpenAI models use max_completion_tokens and only the default temperature. if ( 'openai' === $provider ) { $body['max_completion_tokens'] = $max; } else { $body['max_tokens'] = $max; $body['temperature'] = $temp; } break; } /** * Filter the AI request before it is sent. * * @param array $request Array with url, headers and body. * @param string $provider Provider. */ $request = apply_filters( 'avcap_ai_request', array( 'url' => $url, 'headers' => $headers, 'body' => $body, ), $provider ); $response = wp_remote_post( $request['url'], array( 'timeout' => 30, 'headers' => $request['headers'], 'body' => wp_json_encode( $request['body'] ), ) ); if ( is_wp_error( $response ) ) { return avcap_error( 'avcap_ai_http', $response->get_error_message(), 502 ); } $code = (int) wp_remote_retrieve_response_code( $response ); $data = json_decode( wp_remote_retrieve_body( $response ), true ); if ( $code < 200 || $code >= 300 ) { $message = ''; if ( is_array( $data ) && isset( $data['error'] ) ) { $message = is_array( $data['error'] ) && isset( $data['error']['message'] ) ? $data['error']['message'] : ( is_string( $data['error'] ) ? $data['error'] : '' ); } /* translators: 1: HTTP status code, 2: Provider error message. */ return avcap_error( 'avcap_ai_provider', sprintf( __( 'The AI provider returned an error (%1$d): %2$s', 'avi-support-assistant-pro' ), $code, sanitize_text_field( $message ) ), 502 ); } $text = ''; if ( 'gemini' === $provider ) { foreach ( (array) ( $data['candidates'][0]['content']['parts'] ?? array() ) as $part ) { $text .= isset( $part['text'] ) ? $part['text'] : ''; } } elseif ( 'anthropic' === $provider ) { foreach ( (array) ( $data['content'] ?? array() ) as $block ) { if ( isset( $block['type'], $block['text'] ) && 'text' === $block['type'] ) { $text .= $block['text']; } } } else { $content = $data['choices'][0]['message']['content'] ?? ''; $text = is_string( $content ) ? $content : ''; } $text = trim( wp_strip_all_tags( $text ) ); // Remove simple Markdown the model may still add. $text = preg_replace( array( '/\*\*(.+?)\*\*/s', '/^#+\s*/m', '/^\s*[-*]\s+/m' ), array( '$1', '', '• ' ), $text ); if ( '' === $text ) { return avcap_error( 'avcap_ai_empty', __( 'The AI provider returned an empty answer.', 'avi-support-assistant-pro' ), 502 ); } return mb_substr( $text, 0, 3000 ); } /** * REST: answer a visitor question with AI. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ function avcap_rest_ai( $request ) { if ( ! avcap_ai_ready() ) { return avcap_error( 'avcap_ai_off', __( 'AI answers are not available.', 'avi-support-assistant-pro' ), 403 ); } $s = avcap_get_settings(); if ( $s['ai_monthly_cap'] > 0 && avcap_ai_usage() >= $s['ai_monthly_cap'] ) { return avcap_error( 'avcap_ai_cap', __( 'AI answers are paused for this month.', 'avi-support-assistant-pro' ), 429 ); } if ( avcap_rate_limited( 'ai', (int) $s['ai_visitor_cap'], HOUR_IN_SECONDS ) ) { return avcap_error( 'avcap_rate_limited', __( 'Too many questions. Please try again later.', 'avi-support-assistant-pro' ), 429 ); } $question = mb_substr( trim( (string) $request['message'] ), 0, 300 ); $lang = substr( preg_replace( '/[^A-Za-z\-]/', '', (string) $request['lang'] ), 0, 12 ); $system = avcap_build_system_prompt( $question, $lang ); $answer = avcap_ai_complete( $system, avcap_clean_history( $request['history'] ), $question ); if ( is_wp_error( $answer ) ) { update_option( 'avcap_last_ai_error', array( 'time' => time(), 'message' => $answer->get_error_message(), ), false ); return avcap_error( 'avcap_ai_failed', __( 'Sorry, I could not get an answer right now.', 'avi-support-assistant-pro' ), 502 ); } avcap_ai_count_usage(); return rest_ensure_response( array( 'answer' => $answer ) ); } /** * Admin AJAX: test the AI connection with the values currently in the form. */ function avcap_ajax_test_ai() { check_ajax_referer( 'avcap_test_ai' ); if ( ! current_user_can( 'manage_options' ) ) { wp_send_json_error( array( 'message' => __( 'Not allowed.', 'avi-support-assistant-pro' ) ), 403 ); } $provider = isset( $_POST['provider'] ) ? sanitize_key( wp_unslash( $_POST['provider'] ) ) : ''; if ( ! array_key_exists( $provider, avcap_ai_providers() ) ) { wp_send_json_error( array( 'message' => __( 'Unknown provider.', 'avi-support-assistant-pro' ) ) ); } $override = array( 'ai_provider' => $provider ); $key = isset( $_POST['key'] ) ? trim( sanitize_text_field( wp_unslash( $_POST['key'] ) ) ) : ''; if ( '' !== $key && '-' !== $key ) { $override[ 'key_' . $provider ] = $key; } $model = isset( $_POST['model'] ) ? preg_replace( '/[^A-Za-z0-9._:\/\-]/', '', sanitize_text_field( wp_unslash( $_POST['model'] ) ) ) : ''; if ( '' !== $model ) { $override[ 'model_' . $provider ] = $model; } $override['ai_max_tokens'] = 50; $answer = avcap_ai_complete( 'You are testing a connection. Reply with exactly: OK', array(), 'Say OK', $override ); if ( is_wp_error( $answer ) ) { wp_send_json_error( array( 'message' => $answer->get_error_message() ) ); } /* translators: %s: Reply from the AI model. */ wp_send_json_success( array( 'message' => sprintf( __( 'Connected. The model replied: %s', 'avi-support-assistant-pro' ), mb_substr( $answer, 0, 80 ) ) ) ); } add_action( 'wp_ajax_avcap_test_ai', 'avcap_ajax_test_ai' );