diff --git a/Gruntfile.js b/Gruntfile.js index 0d92b313..3ff55989 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -1,4 +1,3 @@ -/* eslint-disable camelcase */ /* jshint node:true */ /* global require */ diff --git a/inc/API.php b/inc/API.php index f2e0717f..0078fe8c 100644 --- a/inc/API.php +++ b/inc/API.php @@ -97,7 +97,7 @@ public function register_routes() { } $routes = [ - 'settings' => [ + 'settings' => [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_settings' ], @@ -116,7 +116,7 @@ public function register_routes() { 'callback' => [ $this, 'update_settings' ], ], ], - 'data' => [ + 'data' => [ [ 'methods' => \WP_REST_Server::READABLE, 'args' => [ @@ -166,19 +166,19 @@ public function register_routes() { 'callback' => [ $this, 'delete_data' ], ], ], - 'data/counts' => [ + 'data/counts' => [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_data_counts' ], ], ], - 'stats' => [ + 'stats' => [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'get_stats' ], ], ], - 'threads' => [ + 'threads' => [ [ 'methods' => \WP_REST_Server::READABLE, 'args' => [ @@ -207,7 +207,7 @@ public function register_routes() { }, ], ], - 'qdrant' => [ + 'qdrant' => [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'qdrant_status' ], @@ -217,7 +217,25 @@ public function register_routes() { 'callback' => [ $this, 'qdrant_deactivate' ], ], ], - 'chat' => [ + 'connect' => [ + [ + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'connect_disconnect' ], + 'args' => [ + 'mode' => [ + 'type' => 'string', + 'enum' => [ 'import', 'clear' ], + ], + ], + ], + ], + 'connect/reconcile' => [ + [ + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'connect_reconcile' ], + ], + ], + 'chat' => [ [ 'methods' => \WP_REST_Server::READABLE, 'args' => [ @@ -339,6 +357,12 @@ public function update_settings( $request ) { $validation = apply_filters( 'hyve_settings_validation', [ + 'ai_mode' => [ + 'validate' => function ( $value ) { + return in_array( $value, [ Hyve_Connect::MODE_CONNECT, Hyve_Connect::MODE_SELF ], true ); + }, + 'sanitize' => 'sanitize_text_field', + ], 'api_key' => [ 'validate' => function ( $value ) { return is_string( $value ); @@ -481,6 +505,30 @@ public function update_settings( $request ) { $updated[ $key ] = $validation[ $key ]['sanitize']( $value ); } + // The mode this save resolves to: any incoming ai_mode wins, else the + // stored one (get_settings always provides a default). Capture the prior + // mode too so we can detect a switch into Connect after the save lands. + $prev_mode = isset( $settings['ai_mode'] ) ? $settings['ai_mode'] : Hyve_Connect::MODE_SELF; + $mode = isset( $updated['ai_mode'] ) ? $updated['ai_mode'] : $settings['ai_mode']; + + // Qdrant and Hyve Connect are mutually exclusive; refuse the switch + // while Qdrant is still active. Disconnect Qdrant first. + if ( Hyve_Connect::MODE_CONNECT === $mode && Qdrant_API::is_active() ) { + return $this->settings_response( + [ + 'error' => __( 'Disconnect Qdrant before enabling Hyve Connect.', 'hyve-lite' ), + ] + ); + } + + if ( Hyve_Connect::MODE_SELF === $mode && Hyve_Connect::MODE_CONNECT === $prev_mode ) { + return $this->settings_response( + [ + 'error' => __( 'Use the Disconnect option to leave Hyve Connect.', 'hyve-lite' ), + ] + ); + } + $api_warning = ''; $api_key_error = null; $key_validated = false; @@ -488,7 +536,8 @@ public function update_settings( $request ) { foreach ( $updated as $key => $value ) { $settings[ $key ] = $value; - if ( 'api_key' === $key && ! empty( $value ) ) { + // Connect mode runs on hosted AI, so there is no OpenAI key to validate. + if ( 'api_key' === $key && ! empty( $value ) && Hyve_Connect::MODE_SELF === $mode ) { $openai = new OpenAI( $value ); // Validate against the embeddings endpoint. Suppress automatic @@ -517,7 +566,7 @@ public function update_settings( $request ) { } } - if ( ( isset( $updated['qdrant_api_key'] ) && ! empty( $updated['qdrant_api_key'] ) ) || ( isset( $updated['qdrant_endpoint'] ) && ! empty( $updated['qdrant_endpoint'] ) ) ) { + if ( Hyve_Connect::MODE_SELF === $mode && ( ( isset( $updated['qdrant_api_key'] ) && ! empty( $updated['qdrant_api_key'] ) ) || ( isset( $updated['qdrant_endpoint'] ) && ! empty( $updated['qdrant_endpoint'] ) ) ) ) { $qdrant = new Qdrant_API( $data['qdrant_api_key'], $data['qdrant_endpoint'] ); $init = $qdrant->init(); @@ -528,6 +577,14 @@ public function update_settings( $request ) { update_option( 'hyve_settings', $settings ); + // Switching into Connect: push any existing self-hosted content up to the + // platform so the site does not start with an empty hosted KB. Runs on a + // cron batch; fresh/empty sites are a no-op. + if ( Hyve_Connect::MODE_CONNECT === $mode && Hyve_Connect::MODE_CONNECT !== $prev_mode ) { + Hyve_Connect::flush_stats(); + $this->table->connect_start_sync(); + } + // Reconcile the dashboard service-error notice with the key that was just // saved — only now that the save has actually landed (no earlier exit can // leave a notice for an unsaved key). Clear any stale notice first, then @@ -678,6 +735,13 @@ public function get_data( $request ) { ]; } + // The unified "Indexed content" listing includes the plugin-owned sources, + // so a site without Pro can still see and remove them. hyve_docs must + // be named explicitly: it is exclude_from_search, which `any` skips. + if ( 'included' === $status && 'any' === $request->get_param( 'type' ) ) { + $args['post_type'] = $this->table->connect_indexed_post_types(); + } + /** * Filters the WP_Query arguments of the dashboard data listings. * @@ -712,6 +776,33 @@ public function get_data( $request ) { 'chunks' => $chunk_counts[ $post_id ] ?? 0, ]; + // In Connect mode a source can be indexed locally but not (yet) + // on the platform (mid-sync, or skipped over the plan limit); + // report it so the listing does not call it indexed. + if ( Hyve_Connect::is_active() ) { + $post_data['synced'] = '' !== (string) get_post_meta( $post_id, '_hyve_connect_synced_hash', true ); + } + + // Plugin-owned sources: label them by origin and flag that + // removing one is permanent (no life outside the Knowledge Base). + if ( 'hyve_docs' === $post_type ) { + $labels = [ + '' => __( 'Custom Data', 'hyve-lite' ), + 'link' => __( 'URL', 'hyve-lite' ), + 'sitemap' => __( 'Sitemap', 'hyve-lite' ), + 'document' => __( 'Document', 'hyve-lite' ), + ]; + + $docs_type = (string) get_post_meta( $post_id, '_hyve_type', true ); + + $post_data['type'] = $labels[ $docs_type ] ?? $labels['']; + $post_data['permanent'] = true; + + if ( empty( $post_data['title'] ) ) { + $post_data['title'] = (string) get_post_meta( $post_id, '_hyve_source', true ); + } + } + if ( 'moderation' === $status ) { $review = get_post_meta( $post_id, '_hyve_moderation_review', true ); @@ -740,12 +831,20 @@ public function get_data( $request ) { $posts_data[] = apply_filters( 'hyve_data_post', $post_data, $post_id ); } + // Connect mode keeps no local rows; the KB footprint is the hosted total. + if ( Hyve_Connect::is_active() ) { + $connect = Hyve_Connect::instance()->stats(); + $total_chunks = isset( $connect['kb']['chunks'] ) ? (int) $connect['kb']['chunks'] : 0; + } else { + $total_chunks = $this->table->get_count(); + } + $posts = [ 'posts' => $posts_data, 'more' => $page['more'], 'total' => $page['total'], 'per_page' => 20, - 'totalChunks' => $this->table->get_count(), + 'totalChunks' => $total_chunks, ]; return rest_ensure_response( $posts ); @@ -818,12 +917,21 @@ public function get_data_counts() { * @return \WP_REST_Response */ public function get_stats() { - return rest_ensure_response( - [ - 'stats' => apply_filters( 'hyve_stats', [] ), - 'chart' => apply_filters( 'hyve_chart_data', [] ), - ] - ); + $data = [ + 'stats' => apply_filters( 'hyve_stats', [] ), + 'chart' => apply_filters( 'hyve_chart_data', [] ), + ]; + + // An explicit stats fetch force-refreshes the hosted aggregate (e.g. right + // after connecting or activating a license), bypassing the page-load cache. + if ( Hyve_Connect::is_active() ) { + $this->table->connect_sync_watchdog(); + + $data['connect'] = Hyve_Connect::instance()->stats( true ); + $data['connectSync'] = $this->table->connect_sync_status(); + } + + return rest_ensure_response( $data ); } /** @@ -885,7 +993,13 @@ public function add_data( $request ) { public function delete_data( $request ) { $id = $request->get_param( 'id' ); - if ( Qdrant_API::is_active() ) { + if ( Hyve_Connect::is_active() ) { + $deleted = Hyve_Connect::instance()->kb_delete( [ (int) $id ] ); + + if ( is_wp_error( $deleted ) ) { + return rest_ensure_response( [ 'error' => Hyve_Connect::user_message( $deleted ) ] ); + } + } elseif ( Qdrant_API::is_active() ) { try { $delete_result = Qdrant_API::instance()->delete_point( $id ); @@ -915,6 +1029,12 @@ public function delete_data( $request ) { */ do_action( 'hyve_data_deleted', (int) $id ); + // Without Pro listening on the action above, plugin-owned sources are + // removed here; they exist only as Knowledge Base entries. + if ( 'hyve_docs' === get_post_type( $id ) ) { + wp_delete_post( (int) $id, true ); + } + return rest_ensure_response( true ); } @@ -1046,6 +1166,286 @@ public function qdrant_deactivate() { return rest_ensure_response( __( 'Qdrant deactivated.', 'hyve-lite' ) ); } + /** + * Disconnect Hyve Connect, on one of two user-chosen paths. + * + * `import`: pull the hosted content back into local rows (no re-embed, the + * model matches), then remove the hosted copy. `clear`: delete everything + * locally and on the platform. Both flip the mode back to self-hosted. + * + * @param \WP_REST_Request> $request Request object. + * + * @return \WP_REST_Response + */ + public function connect_disconnect( $request ) { + $mode = 'clear' === $request->get_param( 'mode' ) ? 'clear' : 'import'; + $settings = Main::get_settings(); + + $blocked = apply_filters( 'hyve_connect_disconnect_blocked_reason', '', $mode ); + + if ( is_string( $blocked ) && '' !== $blocked ) { + return rest_ensure_response( [ 'error' => $blocked ] ); + } + + wp_clear_scheduled_hook( DB_Table::CONNECT_SYNC_HOOK ); + + if ( 'import' === $mode ) { + $imported = $this->connect_import(); + + if ( is_wp_error( $imported ) ) { + return rest_ensure_response( [ 'error' => Hyve_Connect::user_message( $imported ) ] ); + } + } + + // Both paths remove the hosted copy for this identity. + $deleted = Hyve_Connect::instance()->kb_delete_all(); + + if ( is_wp_error( $deleted ) ) { + return rest_ensure_response( [ 'error' => Hyve_Connect::user_message( $deleted ) ] ); + } + + // The hosted copy is gone: leave Connect mode BEFORE the local cleanup, + // or `before_delete_post` fires a platform delete per removed source + // (thousands of sequential requests; times out on large KBs). + $settings['ai_mode'] = Hyve_Connect::MODE_SELF; + update_option( 'hyve_settings', $settings ); + + if ( 'clear' === $mode ) { + // Nothing is indexed anywhere now: drop the local bookkeeping too. + $this->connect_clear_local(); + } else { + // Sources that never reached the platform (over the plan limit or + // rejected) had nothing to export: unmark them, or they would list + // as indexed with no local chunks behind them. + $this->connect_drop_unsynced(); + + // Import keeps the sources but the hosted copy is gone, so forget the + // synced markers; a future re-enable then re-pushes cleanly. + $this->table->connect_reset_sync_markers(); + + // Back on the local engine the local chunk limit applies again: + // prune the oldest content over it, same as Qdrant deactivation. + $over_limit = $this->table->get_posts_over_limit(); + + if ( ! empty( $over_limit ) ) { + wp_schedule_single_event( time(), 'hyve_delete_posts', [ $over_limit ] ); + } + } + + delete_option( DB_Table::CONNECT_SYNC_OPTION ); + Hyve_Connect::flush_stats(); + + return rest_ensure_response( true ); + } + + /** + * Reconcile the hosted knowledge base against local content (the source of + * truth): the cloud drops orphaned sources and the plugin re-pushes any that + * are stale or missing. Triggered by the "Sync" control on the Connect screen. + * + * @param \WP_REST_Request> $request Request object. + * + * @return \WP_REST_Response + */ + public function connect_reconcile( $request ) { + $result = $this->table->connect_reconcile(); + + if ( is_wp_error( $result ) ) { + $message = 0 === strpos( (string) $result->get_error_code(), 'connect_' ) + ? $result->get_error_message() + : Hyve_Connect::user_message( $result ); + + return rest_ensure_response( [ 'error' => $message ] ); + } + + Hyve_Connect::flush_stats(); + + return rest_ensure_response( true ); + } + + /** + * Pull the hosted knowledge base back into local rows for self-hosted use. + * + * Paginates `hyve-kb export` and rebuilds native per-chunk rows + * (`storage = WordPress`) directly from the returned vectors, so no + * re-embedding is needed (the export model matches the local model). + * + * @return true|\WP_Error + */ + private function connect_import() { + $client = Hyve_Connect::instance(); + $cursor = null; + $seen = []; + + // Compare by model base name: the platform reports a provider-prefixed + // name (e.g. "openai/text-embedding-3-small") for the same model the local + // engine calls "text-embedding-3-small". + $model_base = static function ( $model ) { + $model = strtolower( trim( (string) $model ) ); + $slash = strrpos( $model, '/' ); + + return false === $slash ? $model : substr( $model, $slash + 1 ); + }; + + do { + $batch = $client->kb_export( $cursor, 50 ); + + if ( is_wp_error( $batch ) ) { + return $batch; + } + + // The export vectors are only reusable if they share the local + // embedding space; importing a different model's vectors would make + // local search return garbage, so reject it (the user can "clear"). + $model = isset( $batch['model'] ) ? (string) $batch['model'] : ''; + + if ( '' !== $model && $model_base( $model ) !== $model_base( OpenAI::EMBEDDING_MODEL ) ) { + return new \WP_Error( + 'connect_import_model_mismatch', + __( 'The hosted content was indexed with a different embedding model and cannot be imported.', 'hyve-lite' ) + ); + } + + $items = isset( $batch['items'] ) && is_array( $batch['items'] ) ? $batch['items'] : []; + + foreach ( $items as $item ) { + $post_id = (int) ( $item['id'] ?? 0 ); + + // A source whose local post is gone would import as an unreachable + // ghost chunk (nothing to manage or display it): skip it. + if ( 0 === $post_id || false === get_post_status( $post_id ) ) { + continue; + } + + // Idempotent per post: clear any rows left by an earlier failed + // import once, before appending this run's chunks, so a retried + // disconnect never duplicates the knowledge base. + if ( ! isset( $seen[ $post_id ] ) ) { + $this->table->delete_by_post_id( $post_id ); + $seen[ $post_id ] = true; + } + + $this->table->insert( + [ + 'post_id' => $post_id, + 'post_title' => get_the_title( $post_id ), + 'post_content' => (string) ( $item['content'] ?? '' ), + 'embedding_model' => OpenAI::EMBEDDING_MODEL, + 'token_count' => (int) ( $item['token_count'] ?? 0 ), + 'embeddings' => wp_json_encode( $item['embedding'] ?? [] ), + 'post_status' => 'processed', + 'storage' => 'WordPress', + ] + ); + } + + $cursor = $batch['next_cursor'] ?? null; + } while ( ! empty( $cursor ) ); + + return true; + } + + /** + * Unmark sources that never reached the platform after an import. + * + * @return void + */ + private function connect_drop_unsynced() { + $candidates = $this->table->connect_source_ids( + [ + [ + 'key' => '_hyve_added', + 'compare' => 'EXISTS', + ], + [ + 'key' => '_hyve_connect_synced_hash', + 'compare' => 'NOT EXISTS', + ], + ] + ); + + if ( empty( $candidates ) ) { + return; + } + + // Keep unsynced sources that still have local chunks (a cancelled sync never reached them); only forget the ones with nothing behind them. + $counts = $this->table->get_counts_by_post_ids( $candidates ); + + foreach ( $candidates as $post_id ) { + if ( empty( $counts[ (int) $post_id ] ) ) { + $this->connect_forget_source( (int) $post_id ); + } + } + } + + /** + * Clear local Knowledge Base bookkeeping when disconnecting with "clear". + * + * @return void + */ + private function connect_clear_local() { + $this->connect_forget_sources( + [ + [ + 'key' => '_hyve_added', + 'compare' => 'EXISTS', + ], + ], + true + ); + } + + /** + * Forget every indexed source matching a meta query (disconnect cleanup). + * + * @param array> $meta_query A meta_query clause list. + * @param bool $delete_chunks Also delete each source's local chunk rows. + * + * @return void + */ + private function connect_forget_sources( array $meta_query, $delete_chunks = false ) { + foreach ( $this->table->connect_source_ids( $meta_query ) as $post_id ) { + $this->connect_forget_source( (int) $post_id, $delete_chunks ); + } + } + + /** + * Remove a source from the local Knowledge Base bookkeeping. + * + * Plugin-owned sources (custom data, URLs, imported pages) exist only as + * KB entries, so forgetting one deletes the post itself. + * + * @param int $post_id Post id. + * @param bool $delete_chunks Also delete the post's local chunk rows (clear, not import). + * + * @return void + */ + private function connect_forget_source( $post_id, $delete_chunks = false ) { + if ( 'hyve_docs' === get_post_type( $post_id ) ) { + wp_delete_post( $post_id, true ); + do_action( 'hyve_data_deleted', $post_id ); + + return; + } + + delete_post_meta( $post_id, '_hyve_added' ); + $this->table->connect_forget_sync( $post_id ); + delete_post_meta( $post_id, '_hyve_needs_update' ); + delete_post_meta( $post_id, '_hyve_moderation_failed' ); + delete_post_meta( $post_id, '_hyve_moderation_review' ); + delete_post_meta( $post_id, '_hyve_processing_error' ); + + // A "clear" disconnect wipes the local index too. In Connect mode the sync + // deletes each post's local chunk rows as it uploads, but a disconnect that + // interrupts an in-flight sync leaves the not-yet-synced posts' rows behind, + // so drop them here. Import keeps them, hence the flag. + if ( $delete_chunks ) { + $this->table->delete_by_post_id( $post_id ); + } + + do_action( 'hyve_data_deleted', $post_id ); + } + /** * Get chat. * @@ -1054,6 +1454,10 @@ public function qdrant_deactivate() { * @return \WP_REST_Response */ public function get_chat( $request ) { + if ( Hyve_Connect::is_active() ) { + return $this->get_chat_connect( $request ); + } + $run_id = $request->get_param( 'run_id' ); $thread_id = $request->get_param( 'thread_id' ); $query = $request->get_param( 'message' ); @@ -1515,6 +1919,42 @@ private function append_source_link( $response, $run_id ) { return $this->maybe_append_source_link( $response, $post_ids ); } + /** + * Resolve a Connect chat result to its final reply text, shared by the poll + * and stream response paths: the model reply plus an optional source link, + * or the unanswered fallback. + * + * @param array $result The platform job result. + * @param array $settings Hyve settings (reads show_source_link). + * @param string $unanswered Message shown when the KB could not answer. + * + * @return array{answered: bool, reply: string, final: string} + */ + public function connect_reply_final( $result, $settings, $unanswered ) { + $answered = ! empty( $result['answered'] ); + $reply = isset( $result['reply'] ) ? (string) $result['reply'] : ''; + + if ( ! $answered ) { + return [ + 'answered' => false, + 'reply' => $reply, + 'final' => $unanswered, + ]; + } + + $final = $reply; + + if ( ! empty( $settings['show_source_link'] ) && ! empty( $result['sources'] ) ) { + $final = $this->maybe_append_source_link( $final, array_column( $result['sources'], 'id' ) ); + } + + return [ + 'answered' => true, + 'reply' => $reply, + 'final' => $final, + ]; + } + /** * Append source links to a chat response for the publicly accessible sources. * @@ -1610,6 +2050,47 @@ private function build_source_link( $post_id, $number ) { ); } + /** + * Whether the requesting IP has exceeded the chat rate limit. Fixed-window + * per-IP counters (filterable window => max), so a steady visitor is fine + * but a flood is capped before it reaches the metered backend. + * + * @return bool + */ + private function chat_rate_limited() { + // REMOTE_ADDR is the transport peer (not a spoofable forwarded header); + // validate it and key the coarse throttle on it. + $raw = isset( $_SERVER['REMOTE_ADDR'] ) ? wp_unslash( $_SERVER['REMOTE_ADDR'] ) : ''; // phpcs:ignore WordPressVIPMinimum.Variables.ServerVariables.UserControlledHeaders, WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___SERVER__REMOTE_ADDR__, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Validated as an IP on the next line. + $ip = filter_var( $raw, FILTER_VALIDATE_IP ); + + if ( ! $ip ) { + return false; + } + + $limits = apply_filters( + 'hyve_chat_rate_limits', + [ + MINUTE_IN_SECONDS => 15, + HOUR_IN_SECONDS => 100, + ] + ); + + $base = 'hyve_chat_rl_' . md5( $ip ); + + foreach ( $limits as $window => $max ) { + $key = $base . '_' . $window . '_' . (int) floor( time() / $window ); + $count = (int) get_transient( $key ); + + if ( $count >= $max ) { + return true; + } + + set_transient( $key, $count + 1, $window ); + } + + return false; + } + /** * Send chat. * @@ -1618,6 +2099,18 @@ private function build_source_link( $post_id, $number ) { * @return \WP_REST_Response */ public function send_chat( $request ) { + // Public chat is metered (platform allowance in Connect mode, the site's + // OpenAI key otherwise). Throttle logged-out visitors per IP so a loop + // cannot drain the allowance; admins (the preview) are exempt. + if ( ! current_user_can( 'manage_options' ) && $this->chat_rate_limited() ) { + return rest_ensure_response( + [ + 'error' => __( 'You are sending messages too quickly. Please wait a moment and try again.', 'hyve-lite' ), + 'code' => 'rate_limited', + ] + ); + } + $prepared = $this->prepare_chat( $request ); if ( is_wp_error( $prepared ) ) { @@ -1662,6 +2155,12 @@ public function send_chat( $request ) { ); } + // Connect mode has no OpenAI background run; the platform answers in one + // synchronous call. Buffer it and hand back a token the poll flow reads. + if ( Hyve_Connect::is_active() ) { + return $this->send_chat_connect( $prepared, $is_test, $request_record ); + } + // Default path: background run + client polling (unchanged behavior). $thread_id = $prepared['thread_id']; $query_run = $this->create_background_run( $prepared['context'], $prepared['message'], $thread_id ); @@ -1684,6 +2183,117 @@ public function send_chat( $request ) { ); } + /** + * Answer a chat turn through Hyve Connect (buffered) and stash it for the poll. + * + * The platform run is synchronous, so the whole answer is fetched here and + * stored under a run token that `get_chat` reads back, keeping the widget's + * send-then-poll contract intact without an OpenAI background run. + * + * @param array{thread_id:string,message:string,context:string} $prepared Prepared turn. + * @param bool $is_test Admin live-preview chat. + * @param int|null $record_id Existing conversation record. + * + * @return \WP_REST_Response + */ + private function send_chat_connect( $prepared, $is_test, $record_id ) { + $result = Hyve_Connect::instance()->chat( + [ + 'message' => $prepared['message'], + 'thread_id' => '' !== $prepared['thread_id'] ? $prepared['thread_id'] : null, + 'settings' => Hyve_Connect::chat_settings(), + 'stream' => false, + ] + ); + + if ( is_wp_error( $result ) ) { + return rest_ensure_response( + [ + 'error' => Hyve_Connect::visitor_message(), + 'code' => $result->get_error_code(), + ] + ); + } + + $thread_id = isset( $result['thread_id'] ) ? $result['thread_id'] : $prepared['thread_id']; + + if ( ! $is_test ) { + $record_id = apply_filters( 'hyve_chat_request', $thread_id, $record_id, $prepared['message'] ); + } + + $token = wp_generate_password( 24, false ); + + set_transient( + 'hyve_connect_run_' . $token, + [ + 'result' => $result, + 'message' => $prepared['message'], + 'thread_id' => $thread_id, + 'record_id' => $record_id, + 'is_test' => $is_test, + ], + 5 * MINUTE_IN_SECONDS + ); + + return rest_ensure_response( + [ + 'thread_id' => $thread_id, + 'query_run' => $token, + 'record_id' => $record_id ? $record_id : null, + 'content' => '', + ] + ); + } + + /** + * Serve a buffered Hyve Connect reply for the poll flow. + * + * The plugin owns presentation exactly as in self-hosted mode: the + * default_message fallback on `answered:false` and source-link rendering. + * + * @param \WP_REST_Request> $request Request object. + * + * @return \WP_REST_Response + */ + private function get_chat_connect( $request ) { + $run_id = $request->get_param( 'run_id' ); + $job = $run_id ? get_transient( 'hyve_connect_run_' . $run_id ) : false; + + if ( ! is_array( $job ) ) { + return rest_ensure_response( [ 'error' => __( 'No messages found.', 'hyve-lite' ) ] ); + } + + delete_transient( 'hyve_connect_run_' . $run_id ); + + Main::add_labels_to_default_settings(); + $settings = Main::get_settings(); + + $result = is_array( $job['result'] ) ? $job['result'] : []; + $resolved = $this->connect_reply_final( $result, $settings, $settings['default_message'] ); + $answered = $resolved['answered']; + $reply = $resolved['reply']; + $final = $resolved['final']; + + $payload = [ + 'success' => $answered, + 'response' => $answered ? $reply : '', + ]; + + if ( empty( $job['is_test'] ) ) { + do_action( 'hyve_chat_response', (string) $run_id, $job['thread_id'], $job['message'], $job['record_id'], $payload, $final ); + } + + $data = [ + 'status' => 'completed', + 'success' => $answered, + 'message' => $final, + ]; + + $reply = apply_filters( 'hyve_chat_reply_data', $data, $payload, $answered ); + + return rest_ensure_response( is_array( $reply ) ? $reply : $data ); + } + /** * Prepare a chat turn before a model run is created. * @@ -1704,6 +2314,20 @@ private function prepare_chat( $request ) { return new \WP_Error( 'missing_message', __( 'Message was flagged.', 'hyve-lite' ) ); } + // Connect mode: moderation, embedding, retrieval, and thread minting all + // happen server-side inside hyve-chat, so there is no local prep. The + // platform mints the thread id on turn 1 and echoes it back. + if ( Hyve_Connect::is_active() ) { + $this->source_post_ids = []; + $thread_id = $request->get_param( 'thread_id' ); + + return [ + 'thread_id' => $thread_id ? $thread_id : '', + 'message' => $message, + 'context' => '', + ]; + } + $moderation = OpenAI::instance()->moderate_chunks( $message ); if ( true !== $moderation ) { diff --git a/inc/DB_Table.php b/inc/DB_Table.php index ad87523f..c1506813 100644 --- a/inc/DB_Table.php +++ b/inc/DB_Table.php @@ -30,7 +30,7 @@ class DB_Table { * @since 1.2.0 * @var string */ - public $version = '1.1.0'; + public $version = '1.2.0'; /** * Cache prefix. @@ -48,6 +48,44 @@ class DB_Table { */ const MAX_PROCESS_ATTEMPTS = 5; + /** + * Source documents pushed to Hyve Connect per sync batch. + * + * @var int + */ + const CONNECT_SYNC_BATCH = 10; + + /** + * The option key tracking the to-Connect sync job. + * + * @var string + */ + const CONNECT_SYNC_OPTION = 'hyve_connect_sync'; + + /** + * Fingerprint of the platform account (license) the KB was last synced to, so + * an identity change (e.g. free -> paid activation) can trigger a re-push. + * + * @var string + */ + const CONNECT_IDENTITY_OPTION = 'hyve_connect_identity'; + + /** + * The cron hook that drives the to-Connect sync job. + * + * @var string + */ + const CONNECT_SYNC_HOOK = 'hyve_lite_connect_sync'; + + /** + * How long an `in_progress` sync may go without advancing before the + * watchdog treats its run as lost and reschedules it. Comfortably beyond a + * single batch (a ~60s HTTP call plus the 10s inter-pass gap). + * + * @var int + */ + const CONNECT_SYNC_STALL = 300; + /** * The single instance of the class. * @@ -80,6 +118,10 @@ public function __construct() { add_action( 'hyve_process_post', function ( $id ) { + if ( Hyve_Connect::is_active() ) { + return; + } + // Discard the return value: a cron/action callback must not return anything. $this->process_post( $id ); }, @@ -118,6 +160,7 @@ public function create_table() { post_title mediumtext NOT NULL, post_content longtext NOT NULL, embeddings longtext NOT NULL, + embedding_model VARCHAR(255) NOT NULL DEFAULT "", token_count int(11) NOT NULL DEFAULT 0, post_status VARCHAR(255) NOT NULL DEFAULT "scheduled", storage VARCHAR(255) NOT NULL DEFAULT "WordPress", @@ -150,15 +193,16 @@ public function table_exists() { */ public function get_columns() { return [ - 'date' => '%s', - 'modified' => '%s', - 'post_id' => '%s', - 'post_title' => '%s', - 'post_content' => '%s', - 'embeddings' => '%s', - 'token_count' => '%d', - 'post_status' => '%s', - 'storage' => '%s', + 'date' => '%s', + 'modified' => '%s', + 'post_id' => '%s', + 'post_title' => '%s', + 'post_content' => '%s', + 'embeddings' => '%s', + 'embedding_model' => '%s', + 'token_count' => '%d', + 'post_status' => '%s', + 'storage' => '%s', ]; } @@ -171,15 +215,16 @@ public function get_columns() { */ public function get_column_defaults() { return [ - 'date' => gmdate( 'Y-m-d H:i:s' ), - 'modified' => gmdate( 'Y-m-d H:i:s' ), - 'post_id' => '', - 'post_title' => '', - 'post_content' => '', - 'embeddings' => '', - 'token_count' => 0, - 'post_status' => 'scheduled', - 'storage' => 'WordPress', + 'date' => gmdate( 'Y-m-d H:i:s' ), + 'modified' => gmdate( 'Y-m-d H:i:s' ), + 'post_id' => '', + 'post_title' => '', + 'post_content' => '', + 'embeddings' => '', + 'embedding_model' => '', + 'token_count' => 0, + 'post_status' => 'scheduled', + 'storage' => 'WordPress', ]; } @@ -363,6 +408,19 @@ public function get_by_storage( string $storage, int $limit = 100 ): array { return $results; } + /** + * Count rows held in a given storage backend. + * + * @param string $storage Storage backend (e.g. WordPress|Qdrant). + * + * @return int + */ + public function get_count_by_storage( string $storage ): int { + global $wpdb; + + return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE storage = %s', $this->table_name, $storage ) ); + } + /** * Get embeddings with pagination. * @@ -500,10 +558,14 @@ public function get_posts_over_limit() { public function add_post( $post_id, $action = 'add' ) { update_post_meta( $post_id, '_hyve_post_processing', 1 ); + $content = Hyve_Connect::is_active() + ? get_post_field( 'post_content', $post_id ) + : apply_filters( 'the_content', get_post_field( 'post_content', $post_id ) ); + $result = $this->ingest_document( [ 'title' => get_the_title( $post_id ), - 'content' => apply_filters( 'the_content', get_post_field( 'post_content', $post_id ) ), + 'content' => $content, ], [ 'action' => 'update' === $action ? 'update' : 'add', @@ -558,6 +620,12 @@ public function add_post( $post_id, $action = 'add' ) { * @throws \Exception If Qdrant API fails. */ public function ingest_document( $doc, $args = [] ) { + // Connect mode ships the whole document to the platform, which chunks, + // moderates, embeds, and stores it. No local chunking/embedding/rows. + if ( Hyve_Connect::is_active() ) { + return $this->ingest_document_connect( $doc, $args ); + } + $action = $args['action'] ?? 'add'; $override = ! empty( $args['override'] ); $create = ! empty( $args['create'] ); @@ -678,193 +746,1200 @@ public function ingest_document( $doc, $args = [] ) { } /** - * Process posts. - * - * @since 1.2.0 - * - * @param int $id The post ID. - * @param bool $allow_retry Whether a transient failure may be retried - * asynchronously via the hyve_process_post cron. - * When false, a failure is terminal and returned to - * the caller for immediate, synchronous handling. - * Default true. + * Ingest a document via Hyve Connect. * - * @return true|\WP_Error True on success, or the error encountered. + * The hosted counterpart to the local pipeline: the whole document goes to + * `hyve-kb upsert` (the platform chunks/moderates/embeds/stores), and the + * post keeps the same `_hyve_*` bookkeeping the KB listing already reads, so + * both modes look identical in the UI. No local chunk rows or vectors. + * + * @param array $doc The document, with `title` and `content`. + * @param array $args Ingestion options (see ingest_document()). + * + * @return true|\WP_Error */ - public function process_post( $id, $allow_retry = true ) { - $post = $this->get( $id ); - $content = $post->post_content; - $openai = OpenAI::instance(); - $stripped = wp_strip_all_tags( $content ); + private function ingest_document_connect( $doc, $args ) { + $action = $args['action'] ?? 'add'; + $create = ! empty( $args['create'] ); + $post_id = $args['post_id'] ?? null; + $post_type = $args['post_type'] ?? 'hyve_docs'; + $extra_meta = $args['meta'] ?? []; + $persist_moderation = ! empty( $args['persist_moderation'] ); - // Prepend the title to the text sent for embedding so it contributes to the vector. - if ( ! empty( $post->post_title ) ) { - $stripped = $post->post_title . ' ' . $stripped; + // Resolve the owning post. Managed sources (links, custom text, sitemap) + // create/update a hyve_docs post; the posts source already has one. A + // source created here that the platform then rejects is cleaned up so no + // orphan is left behind, mirroring the local path's moderate-then-create. + $created_here = false; + + if ( 'update' === $action && $create && $post_id ) { + $updated = wp_update_post( + [ + 'ID' => $post_id, + 'post_title' => $doc['title'], + 'post_content' => $doc['content'], + ] + ); + + if ( ! $updated ) { + return new \WP_Error( 'failed_update_post', __( 'Failed to update post.', 'hyve-lite' ) ); + } + } elseif ( $create ) { + $post_id = wp_insert_post( + [ + 'post_title' => $doc['title'], + 'post_content' => $doc['content'], + 'post_status' => 'publish', + 'post_type' => $post_type, + ] + ); + + if ( ! $post_id ) { + return new \WP_Error( 'failed_insert_post', __( 'Failed to insert post.', 'hyve-lite' ) ); + } + + $created_here = true; } - $embeddings = $openai->create_embeddings( $stripped ); - if ( is_wp_error( $embeddings ) || ! $embeddings ) { - $error = is_wp_error( $embeddings ) - ? $embeddings - : new \WP_Error( 'unknown_error', __( 'An unexpected error occurred while indexing this content.', 'hyve-lite' ) ); + if ( ! $post_id ) { + return new \WP_Error( 'missing_post', __( 'Missing post reference.', 'hyve-lite' ) ); + } - $this->handle_processing_failure( $id, (int) $post->post_id, $error, $allow_retry ); - return $error; + $document = $this->connect_document( (int) $post_id, $doc ); + $result = Hyve_Connect::instance()->kb_upsert( [ $document ] ); + + if ( is_wp_error( $result ) ) { + if ( $created_here ) { + wp_delete_post( $post_id, true ); + } + + return $result; } - $embeddings = reset( $embeddings ); - $embeddings = $embeddings->embedding; - $storage = 'WordPress'; + $status = isset( $result['results'][0] ) && is_array( $result['results'][0] ) ? $result['results'][0] : []; + $state = $status['status'] ?? 'failed'; - if ( Qdrant_API::is_active() ) { - try { - $success = Qdrant_API::instance()->add_point( - $embeddings, - [ - 'post_id' => $post->post_id, - 'post_title' => $post->post_title, - 'post_content' => $post->post_content, - 'token_count' => $post->token_count, - 'website_url' => get_site_url(), - ] - ); + // Did not fit the plan's remaining budget (the platform skips instead + // of refusing); surface it as the usual limit message. + if ( 'skipped' === $state ) { + if ( $created_here ) { + wp_delete_post( $post_id, true ); + } else { + delete_post_meta( $post_id, '_hyve_needs_update' ); + $this->connect_forget_sync( (int) $post_id ); - $storage = 'Qdrant'; - } catch ( \Exception $e ) { - $success = new \WP_Error( 'qdrant_error', $e->getMessage() ); + $status = $this->connect_sync_status(); + + if ( empty( $status['in_progress'] ) && empty( $status['blocked'] ) ) { + $this->connect_start_sync(); + } } - if ( is_wp_error( $success ) ) { - $this->handle_processing_failure( $id, (int) $post->post_id, $success, $allow_retry ); - return $success; + return new \WP_Error( + 'hyve_connect_quota_exceeded', + __( 'You have reached your Hyve Connect limit for now. Upgrade your plan for more.', 'hyve-lite' ) + ); + } + + if ( 'stored' !== $state ) { + $review = $this->connect_moderation_review( $status ); + + // A real post keeps its moderation meta for the listing; an orphan + // created here for a rejected managed source is removed instead. + // On a retained rejection the platform kept the previously-synced + // copy, and the synced hash from that sync already describes it. + if ( 'rejected' === $state && $persist_moderation && ! $created_here ) { + update_post_meta( $post_id, '_hyve_moderation_failed', 1 ); + update_post_meta( $post_id, '_hyve_moderation_review', $review ); + } + + if ( $created_here ) { + wp_delete_post( $post_id, true ); + } + + if ( 'rejected' === $state ) { + return new \WP_Error( + 'content_failed_moderation', + __( 'The content failed moderation policies.', 'hyve-lite' ), + [ 'review' => $review ] + ); } + + return new \WP_Error( 'connect_index_failed', __( 'Hyve Connect could not index this content.', 'hyve-lite' ) ); } - $embeddings = wp_json_encode( $embeddings ); + update_post_meta( $post_id, '_hyve_added', 1 ); + // The synced hash marks the source as on the platform (so the sync job + // skips it) and feeds cheap reconcile fingerprints. + $this->connect_store_synced_hash( (int) $post_id, hash( 'sha256', (string) $document['content'] ) ); - $this->update( - $id, - [ - 'embeddings' => $embeddings, - 'post_status' => 'processed', - 'storage' => $storage, - ] - ); + foreach ( $extra_meta as $meta_key => $meta_value ) { + update_post_meta( $post_id, $meta_key, $meta_value ); + } - // Processing succeeded; clear any error and retry counter from a previous attempt. - delete_post_meta( (int) $post->post_id, '_hyve_processing_error' ); - delete_transient( self::CACHE_PREFIX . 'process_attempts_' . $id ); + if ( $persist_moderation ) { + delete_post_meta( $post_id, '_hyve_moderation_failed' ); + delete_post_meta( $post_id, '_hyve_moderation_review' ); + delete_post_meta( $post_id, '_hyve_needs_update' ); + } + + Hyve_Connect::flush_stats(); return true; } /** - * Handle a failed processing attempt. + * Build the contract-shaped document for a Hyve Connect upsert. * - * Fatal errors (bad key, no credits, billing) will not resolve by retrying, - * so they stop immediately and the entry is marked failed. Transient errors - * (rate limits, network blips) are retried with a linear backoff up to - * MAX_PROCESS_ATTEMPTS, then also marked failed. Either way the reason is - * recorded for the Knowledge Base UI. See Codeinwp/hyve#199. + * @param int $post_id The owning post id (site-scoped source id). + * @param array $doc The document, with `title` and `content`. * - * @since 1.4.0 + * @return array + */ + private function connect_document( $post_id, $doc ) { + $url = get_permalink( $post_id ); + + return [ + 'id' => $post_id, + 'type' => $this->connect_source_type( $post_id ), + 'title' => (string) $doc['title'], + 'url' => $url ? $url : null, + // The plugin extracts text; the platform chunks it. + 'content' => wp_strip_all_tags( (string) $doc['content'] ), + ]; + } + + /** + * Map a WordPress post type to a Hyve Connect source type. * - * @param int $id The chunk row ID. - * @param int $post_id The source post ID. - * @param \WP_Error $error The error encountered while processing. - * @param bool $allow_retry Whether an async retry may be scheduled. When - * false the failure is terminal. Default true. + * @param int $post_id The post id. * - * @return void + * @return string One of post|page|product|doc. */ - private function handle_processing_failure( $id, $post_id, $error, $allow_retry = true ) { - $transient = self::CACHE_PREFIX . 'process_attempts_' . $id; - $attempts = (int) get_transient( $transient ) + 1; - $is_fatal = OpenAI::is_fatal_error_code( $error->get_error_code() ); - $will_retry = $allow_retry && ! $is_fatal && $attempts < self::MAX_PROCESS_ATTEMPTS; - - $this->record_processing_error( $post_id, $error, $will_retry ); + private function connect_source_type( $post_id ) { + $map = [ + 'page' => 'page', + 'product' => 'product', + 'hyve_docs' => 'doc', + ]; - if ( $will_retry ) { - set_transient( $transient, $attempts, DAY_IN_SECONDS ); - wp_schedule_single_event( time() + ( MINUTE_IN_SECONDS * $attempts ), 'hyve_process_post', [ $id ] ); - return; - } + $type = (string) get_post_type( $post_id ); - // Terminal: stop retrying and mark the chunk failed. - delete_transient( $transient ); - $this->update( $id, [ 'post_status' => 'failed' ] ); + return $map[ $type ] ?? 'post'; } /** - * Record a processing error against the source post so it can be surfaced - * in the Knowledge Base UI instead of failing silently. The stored message - * includes whether the entry will be retried or needs the admin to act. - * See Codeinwp/hyve#199. - * - * @since 1.4.0 + * Translate a platform `rejected` result's categories into the local + * `_hyve_moderation_review` shape (category => score). * - * @param int $post_id The source post ID. - * @param \WP_Error $error The error encountered while processing. - * @param bool $will_retry Whether another attempt is scheduled. + * @param array $status A single upsert result. * - * @return void + * @return array */ - private function record_processing_error( $post_id, $error, $will_retry ) { - if ( empty( $post_id ) ) { - return; - } + private function connect_moderation_review( $status ) { + $categories = isset( $status['moderation']['categories'] ) && is_array( $status['moderation']['categories'] ) + ? $status['moderation']['categories'] + : []; - $message = OpenAI::get_error_message_for_code( $error->get_error_code() ); + $review = []; - if ( null === $message ) { - $message = $error->get_error_message(); + foreach ( $categories as $category ) { + $review[ (string) $category ] = 1.0; } - $suffix = $will_retry - ? __( 'It will be retried automatically.', 'hyve-lite' ) - : __( 'Please fix the problem and re-add this content.', 'hyve-lite' ); - - update_post_meta( $post_id, '_hyve_processing_error', $message . ' ' . $suffix ); + return $review; } /** - * Update posts. - * - * @since 1.3.1 - * - * @return void + * Posts indexed locally but not yet pushed to Hyve Connect. + * + * These carry the KB bookkeeping meta (`_hyve_added`) but the platform does + * not yet hold them (no synced hash): the existing self-hosted content to + * sync on enable, or everything after an inactivity purge. + * + * @param int $limit Max posts to return (-1 for all). + * + * @return array Post ids. */ - public function update_posts() { - $args = [ - 'post_type' => 'any', - 'post_status' => [ 'publish', 'private' ], - 'posts_per_page' => 5, - 'fields' => 'ids', - 'meta_query' => [ - 'relation' => 'AND', + public function connect_pending_posts( $limit = self::CONNECT_SYNC_BATCH ) { + return $this->connect_source_ids( + [ [ - 'key' => '_hyve_needs_update', - 'value' => '1', - 'compare' => '=', + 'key' => '_hyve_added', + 'compare' => 'EXISTS', + ], + [ + 'key' => '_hyve_connect_synced_hash', + 'compare' => 'NOT EXISTS', ], + // A source the platform rejected on moderation, or one that + // failed to index (no text content), stays out of the pending + // set so the batch never re-picks it forever. [ 'key' => '_hyve_moderation_failed', 'compare' => 'NOT EXISTS', ], + [ + 'key' => '_hyve_processing_error', + 'compare' => 'NOT EXISTS', + ], ], - ]; - - $query = new \WP_Query( $args ); - - if ( ! $query->have_posts() ) { - return; - } + $limit + ); + } - $posts = $query->posts; + /** + * How many sources still need pushing to Hyve Connect. + * + * @return int + */ + public function connect_pending_count() { + return count( $this->connect_pending_posts( -1 ) ); + } - foreach ( $posts as $post_id ) { - /** + /** + * Whether any source is currently believed to live on Hyve Connect. + * + * @return bool + */ + public function connect_has_synced() { + return ! empty( + $this->connect_source_ids( + [ + [ + 'key' => '_hyve_connect_synced_hash', + 'compare' => 'EXISTS', + ], + ], + 1 + ) + ); + } + + /** + * Begin (or resume) pushing existing local content to Hyve Connect. + * + * Called when a site with indexed content switches into Connect mode, and by + * the purge auto-recovery path. Seeds the progress option and schedules the + * first cron run; a no-op when there is nothing pending. + * + * @return void + */ + public function connect_start_sync() { + $this->connect_clear_processing_errors(); + + $pending = $this->connect_pending_count(); + + if ( 0 === $pending ) { + return; + } + + update_option( + self::CONNECT_SYNC_OPTION, + [ + 'total' => $pending, + 'current' => 0, + 'in_progress' => true, + 'blocked' => false, + 'message' => '', + 'heartbeat' => time(), + ] + ); + + Hyve_Connect::flush_stats(); + wp_schedule_single_event( time(), self::CONNECT_SYNC_HOOK ); + } + + /** + * Forget per-source processing errors so a new sync round re-attempts them. + * + * An error recorded before the switch to Connect comes from the local + * OpenAI pipeline (e.g. a bad key) and says nothing about whether the + * platform can index the source; left in place it would exclude the source + * from the pending set forever. An error recorded by a previous Connect + * round gets one fresh attempt per new round (switch, recovery, identity + * change), never a retry loop within the same round. + * + * @return void + */ + private function connect_clear_processing_errors() { + $post_ids = $this->connect_source_ids( + [ + [ + 'key' => '_hyve_added', + 'compare' => 'EXISTS', + ], + [ + 'key' => '_hyve_processing_error', + 'compare' => 'EXISTS', + ], + ] + ); + + foreach ( $post_ids as $post_id ) { + delete_post_meta( (int) $post_id, '_hyve_processing_error' ); + } + } + + /** + * Cron handler: push one batch of pending sources to Hyve Connect. + * + * Builds whole documents from the posts we still hold (title + content) so a + * re-sync never depends on local chunk rows, upserts them, then marks each + * accepted source synced and drops its now-redundant local chunk rows (the + * platform owns the content in Connect mode). Reschedules until drained. + * + * @return void + */ + public function connect_run_sync() { + if ( ! Hyve_Connect::is_active() ) { + $this->connect_finish_sync(); + return; + } + + $post_ids = $this->connect_pending_posts(); + + if ( empty( $post_ids ) ) { + $this->connect_finish_sync(); + return; + } + + $documents = []; + $empty = 0; + + foreach ( $post_ids as $post_id ) { + // Keyed by post id so the success loop can recover each sent hash. + $document = $this->connect_document( + (int) $post_id, + [ + 'title' => get_the_title( $post_id ), + 'content' => get_post_field( 'post_content', $post_id ), + ] + ); + + // No text to index (media-only content, empty page): terminal, the + // platform has nothing to store and moderation rejects empty input. + if ( '' === trim( (string) $document['content'] ) ) { + $this->record_processing_error( + (int) $post_id, + new \WP_Error( 'connect_empty_content', __( 'There is no text content to index.', 'hyve-lite' ) ), + false + ); + ++$empty; + continue; + } + + $documents[ (int) $post_id ] = $document; + } + + if ( empty( $documents ) ) { + $this->connect_advance_sync( $empty ); + return; + } + + $result = Hyve_Connect::instance()->kb_upsert( $documents ); + + if ( is_wp_error( $result ) ) { + $error_code = $result->get_error_code(); + + // Over the plan's storage/churn cap: retrying will not help until the + // user upgrades or trims content, so stop and surface the block. + if ( is_string( $error_code ) && false !== strpos( $error_code, 'quota_exceeded' ) ) { + $data = $result->get_error_data(); + + $this->connect_block_sync( + $result->get_error_message(), + is_array( $data ) && isset( $data['quota'] ) && is_array( $data['quota'] ) ? $data['quota'] : [] + ); + return; + } + + // Transient failure (unreachable/provider): back off and retry. + wp_schedule_single_event( time() + 30, self::CONNECT_SYNC_HOOK ); + return; + } + + $live_settings = get_option( 'hyve_settings', [] ); + $live_mode = is_array( $live_settings ) ? ( $live_settings['ai_mode'] ?? '' ) : ''; + + if ( Hyve_Connect::MODE_CONNECT !== $live_mode ) { + $this->connect_finish_sync(); + return; + } + + $status_by_id = []; + + foreach ( ( isset( $result['results'] ) && is_array( $result['results'] ) ? $result['results'] : [] ) as $row ) { + if ( is_array( $row ) && isset( $row['id'] ) ) { + $status_by_id[ (int) $row['id'] ] = $row; + } + } + + $handled = 0; + $storage_skip = false; + + foreach ( array_keys( $documents ) as $post_id ) { + $row = $status_by_id[ (int) $post_id ] ?? []; + // A document absent from the response is treated as failed, never + // stored: assuming success would write a synced hash for content that + // may not be on the platform, so it would never be re-pushed. + $state = isset( $row['status'] ) ? $row['status'] : 'failed'; + + // Did not fit the plan's remaining budget: stays pending for a + // later batch, or the block below when nothing fits anymore. + if ( 'skipped' === $state ) { + $storage_skip = $storage_skip || 'storage' === ( $row['reason'] ?? '' ); + continue; + } + + ++$handled; + + // Either way the local chunk rows are redundant in Connect mode. + $this->delete_by_post_id( $post_id ); + + // Flag the rejection for the KB listing; a retained prior sync keeps + // its existing hash, a brand-new rejected source stays unsynced. + if ( 'rejected' === $state ) { + update_post_meta( $post_id, '_hyve_moderation_failed', 1 ); + update_post_meta( $post_id, '_hyve_moderation_review', $this->connect_moderation_review( $row ) ); + + continue; + } + + // The platform could not index it (nothing extractable, provider + // failure): surface the error and stop re-picking the source. + if ( 'failed' === $state ) { + $this->record_processing_error( + $post_id, + new \WP_Error( 'connect_index_failed', __( 'Hyve Connect could not index this content.', 'hyve-lite' ) ), + false + ); + + continue; + } + + // The synced hash marks the source as on the platform. + $doc_content = isset( $documents[ (int) $post_id ]['content'] ) ? (string) $documents[ (int) $post_id ]['content'] : ''; + $this->connect_store_synced_hash( (int) $post_id, hash( 'sha256', $doc_content ) ); + } + + // The platform fills a batch greedily, so a batch where nothing fit + // means nothing more will: same terminal state as a refused batch. + // Empty-content sources handled locally still count as progress. + if ( 0 === $handled && 0 === $empty ) { + $storage = isset( $result['kb']['storage'] ) && is_array( $result['kb']['storage'] ) ? $result['kb']['storage'] : []; + + $this->connect_block_sync( + $storage_skip + ? __( 'Knowledge base storage quota exceeded.', 'hyve-lite' ) + : __( 'Knowledge base indexing limit reached for this period.', 'hyve-lite' ), + $storage_skip + ? [ + 'kind' => 'storage', + 'limit' => isset( $storage['limit'] ) ? (int) $storage['limit'] : 0, + 'used' => isset( $storage['used'] ) ? (int) $storage['used'] : 0, + ] + : [ 'kind' => 'indexing' ] + ); + return; + } + + $this->connect_advance_sync( $handled + $empty ); + } + + /** + * Record progress after a batch and reschedule if any sources remain. + * + * @param int $done Sources handled in the batch just finished. + * + * @return void + */ + private function connect_advance_sync( $done ) { + $status = get_option( self::CONNECT_SYNC_OPTION, [] ); + + if ( empty( $status ) ) { + return; + } + + $status['current'] = (int) ( $status['current'] ?? 0 ) + (int) $done; + $has_more = $this->connect_pending_count() > 0; + $status['in_progress'] = $has_more; + $status['heartbeat'] = time(); + + update_option( self::CONNECT_SYNC_OPTION, $status ); + Hyve_Connect::flush_stats(); + + if ( $has_more ) { + wp_schedule_single_event( time() + 10, self::CONNECT_SYNC_HOOK ); + } + } + + /** + * Rescue a sync whose cron run was lost (event unscheduled, then the process + * killed mid-batch, e.g. the 60s HTTP call outlasting max_execution_time). + * When a sync claims to be in progress but nothing is queued to drive it and + * it has not advanced within CONNECT_SYNC_STALL, reschedule it. Cheap enough + * to call on every admin load; a healthy run (recent heartbeat or a queued + * pass) is left untouched. + * + * @return void + */ + public function connect_sync_watchdog() { + $status = $this->connect_sync_status(); + + if ( empty( $status['in_progress'] ) ) { + return; + } + + // A pass is already queued to carry the job forward. + if ( wp_next_scheduled( self::CONNECT_SYNC_HOOK ) ) { + return; + } + + // Advanced recently: a batch is likely still executing, not stranded. + $heartbeat = isset( $status['heartbeat'] ) ? (int) $status['heartbeat'] : 0; + + if ( ( time() - $heartbeat ) < self::CONNECT_SYNC_STALL ) { + return; + } + + wp_schedule_single_event( time(), self::CONNECT_SYNC_HOOK ); + } + + /** + * Mark the sync job complete (nothing left to push). + * + * @return void + */ + private function connect_finish_sync() { + $status = get_option( self::CONNECT_SYNC_OPTION, [] ); + + if ( empty( $status ) ) { + return; + } + + $status['in_progress'] = false; + update_option( self::CONNECT_SYNC_OPTION, $status ); + Hyve_Connect::flush_stats(); + } + + /** + * Stop the sync job because the plan's limit was hit, recording the reason + * and the quota numbers at block time (so auto-resume can tell "something + * changed" from "still does not fit"). + * + * @param string $message The platform's limit message. + * @param array $quota The platform's quota snapshot ({kind, limit, used}). + * + * @return void + */ + private function connect_block_sync( $message, $quota = [] ) { + $status = get_option( self::CONNECT_SYNC_OPTION, [] ); + + $status['in_progress'] = false; + $status['blocked'] = true; + $status['message'] = (string) $message; + $status['quota'] = $quota; + + update_option( self::CONNECT_SYNC_OPTION, $status ); + Hyve_Connect::flush_stats(); + } + + /** + * Current to-Connect sync job status (for the UI progress state). + * + * @return array + */ + public function connect_sync_status() { + $status = get_option( self::CONNECT_SYNC_OPTION, [] ); + + return is_array( $status ) ? $status : []; + } + + /** + * Forget which sources live on Hyve Connect (disconnect, or before re-sync). + * + * @return void + */ + public function connect_reset_sync_markers() { + $synced = $this->connect_source_ids( + [ + [ + 'key' => '_hyve_connect_synced_hash', + 'compare' => 'EXISTS', + ], + ] + ); + + foreach ( $synced as $post_id ) { + $this->connect_forget_sync( $post_id ); + } + } + + /** + * Resume a plan-blocked sync once the quota numbers have actually changed + * (upgrade, freed space on another site, a new usage window). + * + * Cheap on every admin load: reads the cached stats, so the platform is + * consulted at most once per cache window. Both storage and the indexing + * windows must have headroom, or resuming would immediately re-block. For + * a storage block the numbers must also have MOVED since the block (cap + * raised or space freed): leftover room alone means the knowledge base + * simply does not fit, and resuming would retry-loop against the cap. + * + * @return void + */ + public function connect_maybe_resume_blocked() { + $status = $this->connect_sync_status(); + + if ( empty( $status['blocked'] ) ) { + return; + } + + $stats = Hyve_Connect::instance()->stats(); + $storage = isset( $stats['kb']['storage'] ) && is_array( $stats['kb']['storage'] ) ? $stats['kb']['storage'] : []; + $limit = isset( $storage['limit'] ) ? (int) $storage['limit'] : 0; + $used = isset( $storage['used'] ) ? (int) $storage['used'] : 0; + + if ( $limit <= 0 || $used >= $limit ) { + return; + } + + $windows = isset( $stats['indexing']['windows'] ) && is_array( $stats['indexing']['windows'] ) ? $stats['indexing']['windows'] : []; + + foreach ( $windows as $window ) { + if ( isset( $window['remaining'] ) && (int) $window['remaining'] <= 0 ) { + return; + } + } + + $snapshot = isset( $status['quota'] ) && is_array( $status['quota'] ) ? $status['quota'] : []; + + if ( isset( $snapshot['kind'] ) && 'storage' === $snapshot['kind'] ) { + $snap_limit = isset( $snapshot['limit'] ) ? (int) $snapshot['limit'] : 0; + $snap_used = isset( $snapshot['used'] ) ? (int) $snapshot['used'] : 0; + + if ( $limit <= $snap_limit && $used >= $snap_used ) { + return; + } + } + + delete_option( self::CONNECT_SYNC_OPTION ); + $this->connect_start_sync(); + } + + /** + * Re-push local content when Hyve Connect has lost it (inactivity purge). + * + * Compares what we believe is synced against the platform's reported KB + * state; if the platform is empty/purged while we still hold synced sources, + * clears the stale markers and restarts the sync from the posts we keep. + * + * @return void + */ + public function connect_check_recovery() { + if ( ! Hyve_Connect::is_active() ) { + return; + } + + $status = $this->connect_sync_status(); + + // Do not stack recovery on an in-flight sync. A plan-blocked one + // does not stop it: an empty account (e.g. right after an upgrade) + // still needs the full re-sync, which re-blocks if the cap holds. + if ( ! empty( $status['in_progress'] ) ) { + return; + } + + if ( ! $this->connect_has_synced() ) { + return; + } + + $stats = Hyve_Connect::instance()->stats( true ); + $state = isset( $stats['kb']['state'] ) ? $stats['kb']['state'] : ''; + + if ( in_array( $state, [ 'empty', 'purged' ], true ) ) { + $this->connect_reset_sync_markers(); + $this->connect_start_sync(); + } + } + + /** + * Re-push the whole KB when the platform account changes underneath it. + * + * The account is keyed by license (free -> paid activation, or a key swap), + * so a change points the site at a different, mostly-empty account. Sources + * synced to the old one are absent from the new one, and the recovery check + * above never fires because the new account is not fully empty (e.g. content + * synced after the switch). Forget the stale markers and restart the sync so + * everything lands on the current account. First run just records the + * identity, leaving any in-progress sync untouched. + * + * @return void + */ + public function connect_check_identity() { + if ( ! Hyve_Connect::is_active() ) { + return; + } + + $license = (string) apply_filters( 'product_hyve_license_key', '' ); + $fingerprint = hash( 'sha256', '' !== $license ? $license : 'free' ); + $stored = (string) get_option( self::CONNECT_IDENTITY_OPTION, '' ); + + if ( $fingerprint === $stored ) { + return; + } + + update_option( self::CONNECT_IDENTITY_OPTION, $fingerprint ); + + // No prior identity: record only, so shipping this never disturbs a + // healthy, already-synced site. + if ( '' === $stored ) { + return; + } + + $this->connect_reset_sync_markers(); + $this->connect_start_sync(); + Hyve_Connect::flush_stats(); + } + + /** + * Content hash of one source, computed exactly as the upsert path sends it + * (via connect_document), so it matches the platform's stored content_hash. + * + * @param int $post_id The source post id. + * + * @return string sha256 of the sent content. + */ + public function connect_source_hash( $post_id ) { + $doc = $this->connect_document( + (int) $post_id, + [ + 'title' => get_the_title( $post_id ), + 'content' => get_post_field( 'post_content', $post_id ), + ] + ); + + return hash( 'sha256', (string) $doc['content'] ); + } + + /** + * Record the hash a source last synced to the cloud, plus the post's modified + * time then, so the manifest can reuse it while the post is unchanged. + * + * @param int $post_id The source post id. + * @param string $hash Hash of the content that was synced. + * + * @return void + */ + private function connect_store_synced_hash( $post_id, $hash ) { + update_post_meta( (int) $post_id, '_hyve_connect_synced_hash', $hash ); + update_post_meta( (int) $post_id, '_hyve_connect_synced_modified', (int) get_post_modified_time( 'U', true, $post_id ) ); + } + + /** + * Post types a KB source can live under. Listed explicitly because hyve_docs + * is registered exclude_from_search, which the "any" pseudo-type skips. + * + * @return array + */ + public function connect_indexed_post_types() { + $types = get_post_types( [ 'exclude_from_search' => false ] ); + $types['hyve_docs'] = 'hyve_docs'; + + return array_values( $types ); + } + + /** + * Post ids across the indexed post types matching a meta query. The single + * shared shape behind every Connect source lookup (pending, synced, manifest). + * + * @param array> $meta_query A meta_query clause list. + * @param int $limit Max ids (-1 for all). + * + * @return array + */ + public function connect_source_ids( array $meta_query, $limit = -1 ) { + return get_posts( + [ + 'post_type' => $this->connect_indexed_post_types(), + 'post_status' => 'any', + 'fields' => 'ids', + 'posts_per_page' => $limit, + 'meta_query' => $meta_query, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- one-off Connect read, not a hot path. + ] + ); + } + + /** + * Forget a source's synced markers (its hash and cache-invalidation time). + * + * @param int $post_id The source post id. + * + * @return void + */ + public function connect_forget_sync( $post_id ) { + delete_post_meta( (int) $post_id, '_hyve_connect_synced_hash' ); + delete_post_meta( (int) $post_id, '_hyve_connect_synced_modified' ); + } + + /** + * Every local source that belongs on Hyve Connect, as a {id, hash} manifest + * for reconcile. The hash is the cached last-synced hash while the post is + * unchanged (no re-hash), or the current content hash once it changes. A + * moderation-failed source is listed at its last-synced hash when it was ever + * synced (its kept cloud copy), and skipped otherwise. + * + * @return array + */ + public function connect_local_manifest() { + $post_ids = $this->connect_source_ids( + [ + [ + 'key' => '_hyve_added', + 'compare' => 'EXISTS', + ], + ] + ); + + $manifest = []; + + foreach ( $post_ids as $post_id ) { + $post_id = (int) $post_id; + $synced_hash = (string) get_post_meta( $post_id, '_hyve_connect_synced_hash', true ); + $flagged = '' !== (string) get_post_meta( $post_id, '_hyve_moderation_failed', true ); + + if ( $flagged ) { + // Keep the last-good copy that is still on the cloud; a never-synced + // rejected source is not on the cloud, so leave it out. + if ( '' === $synced_hash ) { + continue; + } + + $manifest[] = [ + 'id' => $post_id, + 'hash' => $synced_hash, + ]; + continue; + } + + $synced_modified = (int) get_post_meta( $post_id, '_hyve_connect_synced_modified', true ); + $current_modified = (int) get_post_modified_time( 'U', true, $post_id ); + + $hash = ( '' !== $synced_hash && $synced_modified === $current_modified ) + ? $synced_hash + : $this->connect_source_hash( $post_id ); + + $manifest[] = [ + 'id' => $post_id, + 'hash' => $hash, + ]; + } + + return $manifest; + } + + /** + * Reconcile the hosted KB against local (the source of truth). + * + * Reconcile the cloud KB with local content via a drill-down: root aggregate + * -> differing buckets -> scoped manifest. The cloud deletes orphans; the + * plugin re-pushes stale/missing sources through the sync job. + * + * @return true|\WP_Error True when reconciled (or already in sync). + */ + public function connect_reconcile() { + if ( ! Hyve_Connect::is_active() ) { + return new \WP_Error( 'connect_inactive', __( 'Hyve Connect is not active.', 'hyve-lite' ) ); + } + + $status = $this->connect_sync_status(); + + if ( ! empty( $status['in_progress'] ) ) { + return new \WP_Error( 'connect_busy', __( 'A sync is already in progress.', 'hyve-lite' ) ); + } + + // A manual Sync retries a plan-blocked job. Since the platform admits + // documents greedily (skips cost nothing), the retry is one cheap round: + // it stores whatever fits and re-blocks with fresh numbers if nothing + // does. Clear the block only once the network round below resolves; a + // failed reconcile must leave the blocked state (and its banner) intact. + $was_blocked = ! empty( $status['blocked'] ); + + $manifest = $this->connect_local_manifest(); + $connect = Hyve_Connect::instance(); + + // 1. Root check: one aggregate. Match -> nothing to do. + $root = $connect->kb_reconcile( [], Hyve_Connect::kb_aggregate( $manifest ) ); + + if ( is_wp_error( $root ) ) { + return $root; + } + + if ( ! empty( $root['in_sync'] ) ) { + $this->connect_clear_blocked_state( $was_blocked ); + return true; + } + + // 2. Bucket compare: find which slices differ. + $compare = $connect->kb_reconcile( [], null, Hyve_Connect::kb_bucket_hashes( $manifest ) ); + + if ( is_wp_error( $compare ) ) { + return $compare; + } + + $differing = ( isset( $compare['differing_buckets'] ) && is_array( $compare['differing_buckets'] ) ) + ? array_map( 'intval', $compare['differing_buckets'] ) + : []; + + if ( empty( $differing ) ) { + $this->connect_clear_blocked_state( $was_blocked ); + return true; + } + + // 3. Scoped detail: send only the differing buckets' sources. The cloud + // deletes orphans within those buckets and returns what to re-push. + $scope = array_flip( $differing ); + $scoped = array_values( + array_filter( + $manifest, + function ( $entry ) use ( $scope ) { + return isset( $scope[ Hyve_Connect::kb_bucket_of( $entry['id'] ) ] ); + } + ) + ); + + $diff = $connect->kb_reconcile( $scoped, null, [], $differing ); + + if ( is_wp_error( $diff ) ) { + return $diff; + } + + // Stale (changed) + missing (never landed) -> re-push. Unmark them so the + // sync job picks exactly these up, then kick it. + $to_push = array_merge( + isset( $diff['stale'] ) ? $diff['stale'] : [], + isset( $diff['missing'] ) ? $diff['missing'] : [] + ); + + foreach ( $to_push as $id ) { + $this->connect_forget_sync( $id ); + } + + if ( ! empty( $to_push ) ) { + // Kicks a fresh sync, overwriting the blocked state with in_progress. + $this->connect_start_sync(); + } else { + $this->connect_clear_blocked_state( $was_blocked ); + } + + return true; + } + + /** + * Clear a resolved plan-block once a reconcile round has actually confirmed + * there is nothing left to push. No-op unless the job was blocked, so a + * healthy (unblocked) sync state is never disturbed. + * + * @param bool $was_blocked Whether the sync was plan-blocked on entry. + * + * @return void + */ + private function connect_clear_blocked_state( $was_blocked ) { + if ( $was_blocked ) { + delete_option( self::CONNECT_SYNC_OPTION ); + } + } + + /** + * Process posts. + * + * @since 1.2.0 + * + * @param int $id The post ID. + * @param bool $allow_retry Whether a transient failure may be retried + * asynchronously via the hyve_process_post cron. + * When false, a failure is terminal and returned to + * the caller for immediate, synchronous handling. + * Default true. + * + * @return true|\WP_Error True on success, or the error encountered. + */ + public function process_post( $id, $allow_retry = true ) { + $post = $this->get( $id ); + $content = $post->post_content; + $openai = OpenAI::instance(); + $stripped = wp_strip_all_tags( $content ); + + // Prepend the title to the text sent for embedding so it contributes to the vector. + if ( ! empty( $post->post_title ) ) { + $stripped = $post->post_title . ' ' . $stripped; + } + $embeddings = $openai->create_embeddings( $stripped ); + + if ( is_wp_error( $embeddings ) || ! $embeddings ) { + $error = is_wp_error( $embeddings ) + ? $embeddings + : new \WP_Error( 'unknown_error', __( 'An unexpected error occurred while indexing this content.', 'hyve-lite' ) ); + + $this->handle_processing_failure( $id, (int) $post->post_id, $error, $allow_retry ); + return $error; + } + + $embeddings = reset( $embeddings ); + $embeddings = $embeddings->embedding; + $storage = 'WordPress'; + + if ( Qdrant_API::is_active() ) { + try { + $success = Qdrant_API::instance()->add_point( + $embeddings, + [ + 'post_id' => $post->post_id, + 'post_title' => $post->post_title, + 'post_content' => $post->post_content, + 'token_count' => $post->token_count, + 'website_url' => get_site_url(), + ] + ); + + $storage = 'Qdrant'; + } catch ( \Exception $e ) { + $success = new \WP_Error( 'qdrant_error', $e->getMessage() ); + } + + if ( is_wp_error( $success ) ) { + $this->handle_processing_failure( $id, (int) $post->post_id, $success, $allow_retry ); + return $success; + } + } + + $embeddings = wp_json_encode( $embeddings ); + + $this->update( + $id, + [ + 'embeddings' => $embeddings, + 'embedding_model' => OpenAI::EMBEDDING_MODEL, + 'post_status' => 'processed', + 'storage' => $storage, + ] + ); + + // Processing succeeded; clear any error and retry counter from a previous attempt. + delete_post_meta( (int) $post->post_id, '_hyve_processing_error' ); + delete_transient( self::CACHE_PREFIX . 'process_attempts_' . $id ); + + return true; + } + + /** + * Handle a failed processing attempt. + * + * Fatal errors (bad key, no credits, billing) will not resolve by retrying, + * so they stop immediately and the entry is marked failed. Transient errors + * (rate limits, network blips) are retried with a linear backoff up to + * MAX_PROCESS_ATTEMPTS, then also marked failed. Either way the reason is + * recorded for the Knowledge Base UI. See Codeinwp/hyve#199. + * + * @since 1.4.0 + * + * @param int $id The chunk row ID. + * @param int $post_id The source post ID. + * @param \WP_Error $error The error encountered while processing. + * @param bool $allow_retry Whether an async retry may be scheduled. When + * false the failure is terminal. Default true. + * + * @return void + */ + private function handle_processing_failure( $id, $post_id, $error, $allow_retry = true ) { + $transient = self::CACHE_PREFIX . 'process_attempts_' . $id; + $attempts = (int) get_transient( $transient ) + 1; + $is_fatal = OpenAI::is_fatal_error_code( $error->get_error_code() ); + $will_retry = $allow_retry && ! $is_fatal && $attempts < self::MAX_PROCESS_ATTEMPTS; + + $this->record_processing_error( $post_id, $error, $will_retry ); + + if ( $will_retry ) { + set_transient( $transient, $attempts, DAY_IN_SECONDS ); + wp_schedule_single_event( time() + ( MINUTE_IN_SECONDS * $attempts ), 'hyve_process_post', [ $id ] ); + return; + } + + // Terminal: stop retrying and mark the chunk failed. + delete_transient( $transient ); + $this->update( $id, [ 'post_status' => 'failed' ] ); + } + + /** + * Record a processing error against the source post so it can be surfaced + * in the Knowledge Base UI instead of failing silently. The stored message + * includes whether the entry will be retried or needs the admin to act. + * See Codeinwp/hyve#199. + * + * @since 1.4.0 + * + * @param int $post_id The source post ID. + * @param \WP_Error $error The error encountered while processing. + * @param bool $will_retry Whether another attempt is scheduled. + * + * @return void + */ + private function record_processing_error( $post_id, $error, $will_retry ) { + if ( empty( $post_id ) ) { + return; + } + + $message = OpenAI::get_error_message_for_code( $error->get_error_code() ); + + if ( null === $message ) { + $message = $error->get_error_message(); + } + + $suffix = $will_retry + ? __( 'It will be retried automatically.', 'hyve-lite' ) + : __( 'Please fix the problem and re-add this content.', 'hyve-lite' ); + + update_post_meta( $post_id, '_hyve_processing_error', $message . ' ' . $suffix ); + } + + /** + * Update posts. + * + * @since 1.3.1 + * + * @return void + */ + public function update_posts() { + $args = [ + 'post_type' => 'any', + 'post_status' => [ 'publish', 'private' ], + 'posts_per_page' => 5, + 'fields' => 'ids', + 'meta_query' => [ + 'relation' => 'AND', + [ + 'key' => '_hyve_needs_update', + 'value' => '1', + 'compare' => '=', + ], + [ + 'key' => '_hyve_moderation_failed', + 'compare' => 'NOT EXISTS', + ], + ], + ]; + + $query = new \WP_Query( $args ); + + if ( ! $query->have_posts() ) { + return; + } + + $posts = $query->posts; + + foreach ( $posts as $post_id ) { + /** * The post id. * * @var int $post_id @@ -887,6 +1962,10 @@ public function update_posts() { public function delete_posts( array $posts ): void { $twenty = array_slice( $posts, 0, 20 ); + if ( Hyve_Connect::is_active() && ! empty( $twenty ) ) { + Hyve_Connect::instance()->kb_delete( $twenty ); + } + foreach ( $twenty as $id ) { $this->delete_by_post_id( $id ); diff --git a/inc/Hyve_Connect.php b/inc/Hyve_Connect.php new file mode 100644 index 00000000..05754d4a --- /dev/null +++ b/inc/Hyve_Connect.php @@ -0,0 +1,792 @@ +> $documents Normalized documents ({id,type,title,url,content,meta}). + * + * @return array|\WP_Error The job_complete payload, or an error. + */ + public function kb_upsert( $documents ) { + return $this->workflow( + self::SLUG_KB, + [ + 'action' => 'upsert', + 'documents' => array_values( $documents ), + ] + ); + } + + /** + * Delete documents from the hosted knowledge base by source id. + * + * @param array $ids Site-scoped source ids. + * + * @return array|\WP_Error + */ + public function kb_delete( $ids ) { + return $this->workflow( + self::SLUG_KB, + [ + 'action' => 'delete', + 'ids' => array_values( array_map( 'intval', $ids ) ), + ] + ); + } + + /** + * Reconcile bucket count. Power of two so the index is a cheap crc32 bitmask. + * MUST match the platform's KnowledgeBase::BUCKETS. + */ + const KB_BUCKETS = 256; + + /** + * Deterministic fingerprint of a whole KB manifest, for the reconcile + * fast-path. MUST stay byte-identical with the platform's aggregate + * (App\Neuron\Workflows\Hyve\KnowledgeBase::aggregate): sha256 over "id:hash" + * lines sorted by id as strings. Equal aggregates mean both sides are in sync. + * + * @param array $manifest Local sources. + * + * @return string + */ + public static function kb_aggregate( $manifest ) { + $map = []; + + foreach ( $manifest as $entry ) { + $map[ (string) $entry['id'] ] = (string) $entry['hash']; + } + + ksort( $map, SORT_STRING ); + + $lines = []; + foreach ( $map as $id => $hash ) { + $lines[] = $id . ':' . $hash; + } + + return hash( 'sha256', implode( "\n", $lines ) ); + } + + /** + * The bucket a source id falls in. MUST match KnowledgeBase::bucketOf. + * + * @param int|string $id Source id. + * + * @return int + */ + public static function kb_bucket_of( $id ) { + return crc32( (string) $id ) & ( self::KB_BUCKETS - 1 ); + } + + /** + * Per-bucket aggregate of a manifest: group sources by bucket, aggregate + * each. Only non-empty buckets appear. MUST match KnowledgeBase::bucketHashes. + * + * @param array $manifest Local sources. + * + * @return array bucket index => aggregate + */ + public static function kb_bucket_hashes( $manifest ) { + $grouped = []; + + foreach ( $manifest as $entry ) { + $grouped[ self::kb_bucket_of( $entry['id'] ) ][] = $entry; + } + + $out = []; + foreach ( $grouped as $idx => $entries ) { + $out[ $idx ] = self::kb_aggregate( $entries ); + } + + return $out; + } + + /** + * Reconcile the hosted knowledge base against a local manifest. + * + * Pure read on the platform: returns the diff (orphaned/stale/missing) so the + * caller can converge the two sides. Pass only an aggregate for the fast + * in-sync check, or the full manifest for the detailed diff. + * + * @param array $manifest Local sources, or [] for the fast/bucket paths. + * @param string|null $aggregate Local aggregate checksum, or null. + * @param array $bucket_hashes idx => bucket aggregate (bucket-compare mode), or []. + * @param array|null $buckets Bucket indices this manifest is scoped to, or null. + * + * @return array|\WP_Error + */ + public function kb_reconcile( $manifest = [], $aggregate = null, $bucket_hashes = [], $buckets = null ) { + $payload = [ 'action' => 'reconcile' ]; + + if ( ! empty( $manifest ) ) { + $payload['manifest'] = array_values( $manifest ); + } + + if ( null !== $aggregate ) { + $payload['aggregate'] = (string) $aggregate; + } + + if ( ! empty( $bucket_hashes ) ) { + $payload['bucket_hashes'] = $bucket_hashes; + } + + if ( null !== $buckets ) { + $payload['buckets'] = array_values( array_map( 'intval', $buckets ) ); + } + + return $this->workflow( self::SLUG_KB, $payload ); + } + + /** + * Delete the whole hosted knowledge base for this identity (disconnect/clear). + * + * @return array|\WP_Error + */ + public function kb_delete_all() { + return $this->workflow( + self::SLUG_KB, + [ + 'action' => 'delete', + 'all' => true, + ] + ); + } + + /** + * Export a batch of stored chunks + vectors for local re-import on disconnect. + * + * @param string|null $cursor Pagination cursor; null starts. + * @param int $batch_size Items per batch. + * + * @return array|\WP_Error + */ + public function kb_export( $cursor = null, $batch_size = 50 ) { + return $this->workflow( + self::SLUG_KB, + [ + 'action' => 'export', + 'cursor' => $cursor, + 'batch_size' => (int) $batch_size, + ] + ); + } + + /** + * Fetch the aggregated dashboard quota (plan, service, kb, chat, indexing). + * + * This route returns plain JSON, not SSE. + * + * @return array|\WP_Error + */ + public function get_quota() { + $response = wp_safe_remote_get( + $this->base_url() . self::PATH_QUOTA, + [ + 'headers' => $this->get_headers( 'application/json' ), + // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- Hosted service aggregate; a short timeout would flap on cold starts. + 'timeout' => 30, + ] + ); + + if ( is_wp_error( $response ) ) { + return new \WP_Error( 'hyve_connect_unreachable', $response->get_error_message() ); + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + $body = wp_remote_retrieve_body( $response ); + + if ( $code >= 400 ) { + return $this->map_http_error( $code, $body ); + } + + $decoded = json_decode( $body, true ); + + if ( ! is_array( $decoded ) ) { + return new \WP_Error( 'hyve_connect_bad_response', __( 'Unexpected response from the hosted AI.', 'hyve-lite' ) ); + } + + return $decoded; + } + + /** + * Relay a chat turn from the platform to the browser as it streams. + * + * Unlike self-hosted (where the plugin extracts the response field from raw + * OpenAI deltas), the platform already emits clean `delta` events plus + * `kb_state`/`sources`, so those are forwarded straight through `$on_event`. + * The terminal `job_complete` payload (reply, answered, sources, thread_id, + * usage) is returned to the caller. + * + * @param array $payload hyve-chat input ({message,thread_id,settings,stream}). + * @param callable(string, array): void $on_event Receives each intra-stream event (delta, kb_state, sources). + * + * @return array|\WP_Error The job_complete payload, or an error. + */ + public function stream_chat( $payload, $on_event ) { + if ( ! function_exists( 'curl_init' ) ) { + return new \WP_Error( 'no_curl', __( 'cURL is not available.', 'hyve-lite' ) ); + } + + $body = wp_json_encode( $payload ); + + if ( false === $body ) { + return new \WP_Error( 'hyve_connect_invalid_params', __( 'Invalid request.', 'hyve-lite' ) ); + } + + $buffer = ''; + $result = null; + $error = null; + + $write = function ( $ch, $chunk ) use ( &$buffer, &$result, &$error, $on_event ) { + $buffer .= $chunk; + + while ( false !== ( $pos = strpos( $buffer, "\n\n" ) ) ) { + $frame = substr( $buffer, 0, $pos ); + $buffer = substr( $buffer, $pos + 2 ); + $parsed = self::parse_frame( $frame ); + + if ( null === $parsed ) { + continue; + } + + if ( 'job_complete' === $parsed['event'] ) { + $result = $parsed['data']; + } elseif ( 'error' === $parsed['event'] ) { + $error = $parsed['data']; + } elseif ( '' !== $parsed['event'] && ! in_array( $parsed['event'], [ 'stream_start', 'stream_end' ], true ) ) { + call_user_func( $on_event, $parsed['event'], $parsed['data'] ); + } + } + + if ( function_exists( 'connection_aborted' ) && connection_aborted() ) { + return 0; + } + + return strlen( $chunk ); + }; + + // Streaming requires reading the response body incrementally, which + // wp_remote_* cannot do, so cURL is used directly here. + // phpcs:disable WordPress.WP.AlternativeFunctions.curl_curl_init, WordPress.WP.AlternativeFunctions.curl_curl_setopt_array, WordPress.WP.AlternativeFunctions.curl_curl_exec, WordPress.WP.AlternativeFunctions.curl_curl_error, WordPress.WP.AlternativeFunctions.curl_curl_getinfo, WordPress.WP.AlternativeFunctions.curl_curl_close + $handle = curl_init(); + + curl_setopt_array( + $handle, + [ + CURLOPT_URL => $this->base_url() . self::SLUG_CHAT . '/start', + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_HTTPHEADER => $this->curl_headers(), + CURLOPT_RETURNTRANSFER => false, + CURLOPT_CONNECTTIMEOUT => 15, + CURLOPT_TIMEOUT => 120, + CURLOPT_WRITEFUNCTION => $write, + ] + ); + + $ok = curl_exec( $handle ); + $err = curl_error( $handle ); + $code = (int) curl_getinfo( $handle, CURLINFO_HTTP_CODE ); + curl_close( $handle ); + // phpcs:enable WordPress.WP.AlternativeFunctions.curl_curl_init, WordPress.WP.AlternativeFunctions.curl_curl_setopt_array, WordPress.WP.AlternativeFunctions.curl_curl_exec, WordPress.WP.AlternativeFunctions.curl_curl_error, WordPress.WP.AlternativeFunctions.curl_curl_getinfo, WordPress.WP.AlternativeFunctions.curl_curl_close + + if ( null !== $error ) { + return $this->map_sse_error( $error ); + } + + if ( $code >= 400 ) { + return $this->map_http_error( $code, '' ); + } + + if ( null === $result ) { + if ( false === $ok && ! ( function_exists( 'connection_aborted' ) && connection_aborted() ) ) { + return new \WP_Error( 'hyve_connect_stream_failed', $err ? $err : __( 'Streaming request failed.', 'hyve-lite' ) ); + } + + return new \WP_Error( 'hyve_connect_no_result', __( 'The hosted AI did not return a result.', 'hyve-lite' ) ); + } + + return $result; + } + + /** + * Run a chat turn without streaming (buffered), for the poll fallback path. + * + * Same envelope as the stream, minus `delta` events; returns the terminal + * `job_complete` payload. + * + * @param array $payload hyve-chat input ({message,thread_id,settings,stream:false}). + * + * @return array|\WP_Error + */ + public function chat( $payload ) { + return $this->workflow( self::SLUG_CHAT, $payload ); + } + + /** + * The site persona/config passed through to the platform's prompting. + * + * Lite ships the basics; pro enriches them through the filter. + * + * @param array|null $settings Plugin settings (fetched if null). + * + * @return array + */ + public static function chat_settings( $settings = null ) { + if ( null === $settings ) { + $settings = Main::get_settings(); + } + + $instructions = trim( (string) apply_filters( 'hyve_system_prompt', $settings['system_prompt'] ?? '' ) ); + + return apply_filters( + 'hyve_connect_chat_settings', + [ + 'instructions' => mb_substr( $instructions, 0, 4000 ), + ], + $settings + ); + } + + /** + * The dashboard aggregate, cached in a transient so it is fetched at most + * once every few minutes rather than on every admin page load. + * + * @param bool $force Bypass the cache (e.g. right after connecting). + * + * @return array The quota aggregate, or a `{service:error}` marker. + */ + public function stats( $force = false ) { + $key = 'hyve_connect_stats'; + + if ( ! $force ) { + $cached = get_transient( $key ); + + if ( is_array( $cached ) ) { + return $cached; + } + } + + $quota = $this->get_quota(); + + if ( is_wp_error( $quota ) ) { + // Cache a short-lived degraded marker so a down service is not polled + // on every page load; the UI reads `service` to show the offline state. + $degraded = [ 'service' => 'error' ]; + set_transient( $key, $degraded, MINUTE_IN_SECONDS ); + + return $degraded; + } + + set_transient( $key, $quota, 5 * MINUTE_IN_SECONDS ); + + return $quota; + } + + /** + * Drop the cached stats so the next read re-fetches (connect/disconnect, + * license change). + * + * @return void + */ + public static function flush_stats() { + delete_transient( 'hyve_connect_stats' ); + } + + /** + * Turn a Connect WP_Error into a visitor/admin-facing message. + * + * @param \WP_Error $error The error returned by a client method. + * + * @return string + */ + public static function user_message( $error ) { + $data = $error->get_error_data(); + $code = isset( $data['code'] ) ? $data['code'] : $error->get_error_code(); + + $messages = [ + 'quota_exceeded' => __( 'You have reached your Hyve Connect limit for now. Upgrade your plan for more.', 'hyve-lite' ), + 'kb_unavailable' => __( 'The knowledge base is being prepared. Please try again shortly.', 'hyve-lite' ), + 'provider_error' => __( 'The hosted AI is temporarily unavailable. Please try again.', 'hyve-lite' ), + ]; + + foreach ( $messages as $needle => $message ) { + if ( false !== strpos( (string) $code, $needle ) ) { + return $message; + } + } + + return __( 'The hosted AI is temporarily unavailable. Please try again.', 'hyve-lite' ); + } + + /** + * Generic, visitor-safe chat error. The reason (quota, expiry, provider) is + * for the admin, so the widget shows one neutral line for every code. + * + * @return string + */ + public static function visitor_message() { + return __( 'The assistant is temporarily unavailable. Please try again later.', 'hyve-lite' ); + } + + /** + * Run a bounded workflow action over buffered SSE and return its terminal event. + * + * @param string $slug Workflow slug. + * @param array $payload Request body. + * + * @return array|\WP_Error + */ + private function workflow( $slug, $payload ) { + $body = wp_json_encode( $payload ); + + if ( false === $body ) { + return new \WP_Error( 'hyve_connect_invalid_params', __( 'Invalid request.', 'hyve-lite' ) ); + } + + $response = wp_remote_post( + $this->base_url() . $slug . '/start', + [ + 'headers' => $this->get_headers(), + 'body' => $body, + // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- Buffered SSE workflow (moderation + embedding + storage) legitimately runs longer. + 'timeout' => 60, + ] + ); + + if ( is_wp_error( $response ) ) { + return new \WP_Error( 'hyve_connect_unreachable', $response->get_error_message() ); + } + + $code = (int) wp_remote_retrieve_response_code( $response ); + $body = wp_remote_retrieve_body( $response ); + + if ( $code >= 400 ) { + return $this->map_http_error( $code, $body ); + } + + return $this->terminal_result( self::parse_sse( $body ) ); + } + + /** + * Reduce a parsed SSE stream to its terminal event. + * + * An `error` event is terminal with no trailing `stream_end`; success is a + * `job_complete` whose data holds the result fields directly (contract). + * + * @param array}> $events Parsed frames. + * + * @return array|\WP_Error + */ + private function terminal_result( $events ) { + foreach ( $events as $event ) { + if ( 'error' === $event['event'] ) { + return $this->map_sse_error( $event['data'] ); + } + + if ( 'job_complete' === $event['event'] ) { + + return $event['data']; + } + } + + return new \WP_Error( 'hyve_connect_no_result', __( 'The hosted AI did not return a result.', 'hyve-lite' ) ); + } + + /** + * Parse a buffered SSE body into an ordered list of events. + * + * @param string $body Raw SSE body. + * + * @return array}> + */ + public static function parse_sse( $body ) { + $events = []; + + foreach ( explode( "\n\n", (string) $body ) as $frame ) { + $parsed = self::parse_frame( $frame ); + + if ( null !== $parsed ) { + $events[] = $parsed; + } + } + + return $events; + } + + /** + * Parse a single SSE frame into its event name and decoded data. + * + * @param string $frame Raw frame (lines separated by \n). + * + * @return array{event: string, data: array}|null Null for blank/comment-only frames. + */ + private static function parse_frame( $frame ) { + $frame = trim( $frame ); + + if ( '' === $frame ) { + return null; + } + + $name = ''; + $data_lines = []; + + foreach ( explode( "\n", $frame ) as $line ) { + $line = rtrim( $line, "\r" ); + + if ( 0 === strpos( $line, ':' ) ) { + continue; // SSE comment, e.g. ": connected". + } + + if ( 0 === strpos( $line, 'event:' ) ) { + $name = trim( substr( $line, 6 ) ); + } elseif ( 0 === strpos( $line, 'data:' ) ) { + $data_lines[] = ltrim( substr( $line, 5 ), ' ' ); + } + } + + if ( '' === $name && empty( $data_lines ) ) { + return null; + } + + $decoded = empty( $data_lines ) ? null : json_decode( implode( "\n", $data_lines ), true ); + + return [ + 'event' => $name, + 'data' => is_array( $decoded ) ? $decoded : [], + ]; + } + + /** + * Request headers for wp_remote_* calls. + * + * Free installs send only `X-Site-Url` (domain identity); licensed installs + * add the base64'd license key as the bearer to unlock paid quotas. + * + * @param string $accept Accept header value. + * + * @return array + */ + private function get_headers( $accept = 'text/event-stream' ) { + $headers = [ + 'Content-Type' => 'application/json', + 'Accept' => $accept, + 'X-Site-Url' => get_site_url(), + 'X-Site-Token' => $this->site_token(), + 'X-Hyve-Version' => defined( 'HYVE_LITE_VERSION' ) ? HYVE_LITE_VERSION : '', + ]; + + $license = (string) apply_filters( 'product_hyve_license_key', '' ); + + if ( '' !== $license ) { + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- contract requires base64(license_key) as the bearer. + $headers['Authorization'] = 'Bearer ' . base64_encode( $license ); + } + + return $headers; + } + + /** + * This site's per-site token, generated once and kept in `wp_options`. + * + * Sent on every request so it is in place across free/paid transitions; the + * platform only enforces it for the free tier (paid uses the license key). + * + * @return string + */ + private function site_token() { + $token = get_option( self::SITE_TOKEN_OPTION ); + + if ( ! is_string( $token ) || '' === $token ) { + $token = wp_generate_password( 64, false, false ); + update_option( self::SITE_TOKEN_OPTION, $token, false ); + } + + return $token; + } + + /** + * The same headers flattened to the `Key: Value` list cURL expects. + * + * @return array + */ + private function curl_headers() { + $flat = []; + + foreach ( $this->get_headers() as $key => $value ) { + $flat[] = $key . ': ' . $value; + } + + return $flat; + } + + /** + * Map an SSE `error` event to a WP_Error, carrying the code + context data. + * + * @param array $data The error event data ({message, code, quota?, categories?, kb_state?}). + * + * @return \WP_Error + */ + private function map_sse_error( $data ) { + $code = isset( $data['code'] ) ? (string) $data['code'] : 'provider_error'; + $message = isset( $data['message'] ) ? (string) $data['message'] : __( 'The hosted AI request failed.', 'hyve-lite' ); + $error = new \WP_Error( 'hyve_connect_' . $code, $message, array_merge( $data, [ 'code' => $code ] ) ); + + return $error; + } + + /** + * Map a non-2xx HTTP response to a WP_Error. + * + * @param int $code HTTP status. + * @param string $body Response body (may be JSON). + * + * @return \WP_Error + */ + private function map_http_error( $code, $body ) { + $decoded = json_decode( (string) $body, true ); + $message = is_array( $decoded ) && isset( $decoded['message'] ) ? (string) $decoded['message'] : sprintf( 'HTTP %d', $code ); + + $slugs = [ + 401 => 'auth_failed', + 422 => 'invalid_request', + 429 => 'quota_exceeded', + ]; + $slug = isset( $slugs[ $code ] ) ? $slugs[ $code ] : 'http_error'; + + $error = new \WP_Error( + 'hyve_connect_' . $slug, + $message, + [ + 'code' => $slug, + 'status' => $code, + 'quota' => is_array( $decoded ) && isset( $decoded['quota'] ) ? $decoded['quota'] : null, + ] + ); + + + return $error; + } +} diff --git a/inc/Main.php b/inc/Main.php index 7ac05ecd..1511c36b 100644 --- a/inc/Main.php +++ b/inc/Main.php @@ -58,7 +58,8 @@ public function __construct() { add_action( 'admin_menu', [ $this, 'register_menu_page' ] ); add_filter( 'user_has_cap', [ $this, 'grant_message_capabilities' ] ); add_action( 'save_post', [ $this, 'update_meta' ], 10, 3 ); - add_action( 'delete_post', [ $this, 'delete_post' ] ); + add_action( 'before_delete_post', [ $this, 'delete_post' ] ); + add_action( DB_Table::CONNECT_SYNC_HOOK, [ $this->table, 'connect_run_sync' ] ); add_filter( 'themeisle_sdk_enable_telemetry', '__return_true' ); add_filter( 'hyve_global_chat_enabled', [ $this, 'is_global_chat_enabled' ] ); @@ -80,8 +81,11 @@ public function __construct() { add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_addons_assets' ] ); } + // The chat can run on a local OpenAI key or on Hyve Connect; either one + // makes the frontend assets meaningful. if ( - isset( $settings['api_key'] ) && ! empty( $settings['api_key'] ) + ( isset( $settings['api_key'] ) && ! empty( $settings['api_key'] ) ) + || Hyve_Connect::is_active() ) { add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_assets' ] ); } @@ -108,8 +112,9 @@ function ( $data, $page_slug ) { /** * Register suggested privacy policy content. * - * Surfaces Hyve's third-party data processing (message storage and OpenAI - * processing) in the core Privacy Policy guide at Settings → Privacy. + * Surfaces Hyve's third-party data processing in the core Privacy Policy + * guide at Settings → Privacy. The disclosed data flow depends on the active + * mode: Hyve Connect (hosted) or self-hosted with the site's own OpenAI key. * * @since 1.4.2 * @@ -124,13 +129,23 @@ public function add_privacy_policy_content() { '

' . __( 'This information is provided to help you disclose how the Hyve chat assistant processes visitor data. Review it and adapt it to your site before publishing.', 'hyve-lite' ) . '

' . - '

' . __( 'When visitors use the Hyve chat assistant on this site, the messages they send are stored on this website so the site administrator can review chat history. No account is required to use the chat.', 'hyve-lite' ) . '

' . - '

' . __( 'To generate replies, the messages are also sent to OpenAI, L.L.C. — a third-party service based in the United States. OpenAI processes the messages to moderate their content, to create numerical representations (embeddings) used to find relevant information, and to generate the assistant\'s responses.', 'hyve-lite' ) . '

' . - '

' . __( 'For details on how OpenAI handles data, see OpenAI\'s privacy policy at https://openai.com/policies/privacy-policy/.', 'hyve-lite' ) . '

'; - - // Only disclose Qdrant when it is actually connected, so the suggested text reflects the site's real data flows. - if ( Qdrant_API::is_active() ) { - $content .= '

' . __( 'This site also uses Qdrant, a third-party vector database. A numerical representation (embedding) of your message is sent to Qdrant to look up relevant information. See Qdrant\'s privacy policy at https://qdrant.tech/legal/privacy-policy/.', 'hyve-lite' ) . '

'; + '

' . __( 'When visitors use the Hyve chat assistant on this site, the messages they send are stored on this website so the site administrator can review chat history. No account is required to use the chat.', 'hyve-lite' ) . '

'; + + // The AI provider differs by mode: Hyve Connect is the hosted service, otherwise the site uses its own OpenAI key. Disclose only the flow that is actually in use. + if ( Hyve_Connect::is_active() ) { + $content .= + '

' . __( 'To generate replies, the messages are also sent to Hyve Connect, a hosted service operated by ThemeIsle. Hyve Connect processes the messages on its servers, including through third-party AI providers, to moderate their content, to create numerical representations (embeddings) used to find relevant information, and to generate the assistant\'s responses.', 'hyve-lite' ) . '

' . + '

' . __( 'To answer questions about this site, the content of the pages selected for indexing is also sent to Hyve Connect and stored there in a vector database so it can be searched when visitors chat.', 'hyve-lite' ) . '

' . + '

' . __( 'For details on how ThemeIsle handles data, see ThemeIsle\'s privacy policy at https://themeisle.com/privacy-policy/.', 'hyve-lite' ) . '

'; + } else { + $content .= + '

' . __( 'To generate replies, the messages are also sent to OpenAI, L.L.C. — a third-party service based in the United States. OpenAI processes the messages to moderate their content, to create numerical representations (embeddings) used to find relevant information, and to generate the assistant\'s responses.', 'hyve-lite' ) . '

' . + '

' . __( 'For details on how OpenAI handles data, see OpenAI\'s privacy policy at https://openai.com/policies/privacy-policy/.', 'hyve-lite' ) . '

'; + + // Only disclose Qdrant when it is actually connected, so the suggested text reflects the site's real data flows. + if ( Qdrant_API::is_active() ) { + $content .= '

' . __( 'This site also uses Qdrant, a third-party vector database. A numerical representation (embedding) of your message is sent to Qdrant to look up relevant information. See Qdrant\'s privacy policy at https://qdrant.tech/legal/privacy-policy/.', 'hyve-lite' ) . '

'; + } } wp_add_privacy_policy_content( 'Hyve', wp_kses_post( $content ) ); @@ -291,6 +306,17 @@ public function menu_page() { public function admin_init() { $settings = self::get_settings(); + if ( Hyve_Connect::is_active() ) { + if ( false === get_transient( 'hyve_connect_recovery_check' ) ) { + set_transient( 'hyve_connect_recovery_check', 1, HOUR_IN_SECONDS ); + $this->table->connect_check_recovery(); + } + + $this->table->connect_check_identity(); + $this->table->connect_maybe_resume_blocked(); + $this->table->connect_sync_watchdog(); + } + $post_types = get_post_types( [ 'public' => true ], 'objects' ); $post_types_for_js = []; @@ -347,6 +373,9 @@ function ( $data ) use ( $settings, $post_types_for_js, $current_view ) { 'hasAPIKey' => isset( $settings['api_key'] ) && ! empty( $settings['api_key'] ), 'isApiKeyConnected' => self::is_api_key_connected( $settings ), 'chunksLimit' => apply_filters( 'hyve_chunks_limit', 500 ), + 'aiMode' => Hyve_Connect::get_mode(), + 'connect' => Hyve_Connect::is_active() ? Hyve_Connect::instance()->stats() : null, + 'connectSync' => Hyve_Connect::is_active() ? $this->table->connect_sync_status() : null, 'isQdrantActive' => Qdrant_API::is_active(), 'assets' => [ 'images' => HYVE_LITE_URL . 'assets/images/', @@ -424,6 +453,7 @@ public static function get_default_settings() { return apply_filters( 'hyve_default_settings', [ + 'ai_mode' => 'self_hosted', 'api_key' => '', 'qdrant_api_key' => '', 'qdrant_endpoint' => '', @@ -715,17 +745,12 @@ public function get_frontend_data( $overrides = [] ) { /** * Enqueue the chat widget on the Hyve dashboard as a live test preview. * - * Available in the free version too: as long as an OpenAI API key is set the - * widget appears on every Hyve settings screen, so admins can try the bot - * and — with Pro — watch appearance changes apply live. Test chats are - * flagged (`isPreview`) so they are not recorded in history or analytics. - * * @return void */ public function enqueue_chat_preview() { $settings = self::get_settings(); - if ( empty( $settings['api_key'] ) ) { + if ( empty( $settings['api_key'] ) && ! Hyve_Connect::is_active() ) { return; } @@ -869,10 +894,19 @@ public function add_to_knowledge_base_row_action( $actions, $post ) { * @return array */ public function get_stats() { + // In Connect mode the knowledge base lives on the platform, so the chunk + // count comes from the hosted aggregate, not the (dormant) local table. + if ( Hyve_Connect::is_active() ) { + $connect = Hyve_Connect::instance()->stats(); + $total_chunks = isset( $connect['kb']['chunks'] ) ? (int) $connect['kb']['chunks'] : 0; + } else { + $total_chunks = $this->table->get_count(); + } + return [ 'threads' => Threads::get_thread_count(), 'messages' => Threads::get_messages_count(), - 'totalChunks' => $this->table->get_count(), + 'totalChunks' => $total_chunks, ]; } @@ -992,6 +1026,8 @@ public function update_meta( $post_id, $post, $update ) { update_post_meta( $post_id, '_hyve_needs_update', 1 ); delete_post_meta( $post_id, '_hyve_moderation_failed' ); delete_post_meta( $post_id, '_hyve_moderation_review' ); + // An edit may fix whatever failed indexing (e.g. no text content). + delete_post_meta( $post_id, '_hyve_processing_error' ); wp_schedule_single_event( time(), 'hyve_update_posts' ); } @@ -1010,6 +1046,9 @@ public function delete_post( $post_id ) { if ( Qdrant_API::is_active() ) { $this->qdrant->delete_point( $post_id ); + } elseif ( Hyve_Connect::is_active() && get_post_meta( $post_id, '_hyve_added', true ) ) { + Hyve_Connect::instance()->kb_delete( [ (int) $post_id ] ); + Hyve_Connect::flush_stats(); } } diff --git a/inc/OpenAI.php b/inc/OpenAI.php index 8824c8a0..8a095fbf 100644 --- a/inc/OpenAI.php +++ b/inc/OpenAI.php @@ -58,6 +58,13 @@ class OpenAI { */ public const ERROR_OPTION_KEY = 'hyve_open_ai_api_error'; + /** + * The embedding model used for local (self-hosted) indexing. + * + * @var string + */ + public const EMBEDDING_MODEL = 'text-embedding-3-small'; + /** * Default moderation category thresholds (0-100 scale). * @@ -217,7 +224,7 @@ public function save_service_error( $error ) { * * @return mixed */ - public function create_embeddings( $content, $model = 'text-embedding-3-small' ) { + public function create_embeddings( $content, $model = self::EMBEDDING_MODEL ) { $response = $this->request( 'embeddings', [ diff --git a/inc/Qdrant_API.php b/inc/Qdrant_API.php index 913260c1..3a7a7e99 100644 --- a/inc/Qdrant_API.php +++ b/inc/Qdrant_API.php @@ -120,7 +120,7 @@ public function init() { update_option( 'hyve_qdrant_status', 'active' ); - $existing_chunks = DB_Table::instance()->get_count(); + $existing_chunks = DB_Table::instance()->get_count_by_storage( 'WordPress' ); if ( $existing_chunks > 0 ) { update_option( @@ -361,6 +361,13 @@ public function migrate_data() { $posts = $db_table->get_by_storage( 'WordPress' ); if ( empty( $posts ) ) { + $migration_status = get_option( 'hyve_qdrant_migration', [] ); + + if ( ! empty( $migration_status ) ) { + $migration_status['in_progress'] = false; + update_option( 'hyve_qdrant_migration', $migration_status ); + } + return; } diff --git a/inc/Stream.php b/inc/Stream.php index 75008ee9..589428fa 100644 --- a/inc/Stream.php +++ b/inc/Stream.php @@ -181,6 +181,14 @@ public function stream() { $this->open_stream(); + // Connect mode relays the platform's own stream: it already emits clean + // `delta` events (plus kb_state/sources), so they are forwarded straight + // through and the terminal reply is assembled from job_complete. + if ( Hyve_Connect::is_active() ) { + $this->stream_connect( $message, $thread_id, $record_id, $is_test, $settings, $default_message ); + exit; + } + $items = OpenAI::build_chat_items( $context, $message ); // Stream the `response` field of the structured reply as it grows so the @@ -291,6 +299,102 @@ public function stream() { exit; } + /** + * Relay a Hyve Connect chat turn to the browser. + * + * The platform streams clean `delta` events (and a `sources` event), which + * are forwarded as-is; the terminal `job_complete` carries the raw pieces + * from which the plugin assembles the final reply, owning presentation + * (default_message on unanswered, source links) exactly as self-hosted. + * + * @param string $message Visitor message. + * @param string $thread_id Thread id (empty starts a conversation). + * @param int|null $record_id Existing conversation record. + * @param bool $is_test Admin live-preview chat (not recorded). + * @param array $settings Plugin settings. + * @param string $default_message Fallback shown when the model cannot answer. + * + * @return void + */ + private function stream_connect( $message, $thread_id, $record_id, $is_test, $settings, $default_message ) { + $sources = []; + + $on_event = function ( $event, $data ) use ( &$sources ) { + if ( 'delta' === $event ) { + $this->send_event( 'delta', [ 'text' => isset( $data['text'] ) ? $data['text'] : '' ] ); + } elseif ( 'sources' === $event && isset( $data['items'] ) && is_array( $data['items'] ) ) { + $sources = $data['items']; + } + // kb_state is informational here; the terminal event drives the outcome. + }; + + $result = Hyve_Connect::instance()->stream_chat( + [ + 'message' => $message, + 'thread_id' => '' !== $thread_id ? $thread_id : null, + 'settings' => Hyve_Connect::chat_settings( $settings ), + 'stream' => true, + ], + $on_event + ); + + if ( is_wp_error( $result ) ) { + $error_code = $result->get_error_code(); + + // An empty/purged KB is visitor-facing parity with self-hosted: show + // the site's default_message rather than an error. + if ( is_string( $error_code ) && false !== strpos( $error_code, 'kb_unavailable' ) ) { + $this->send_event( + 'done', + [ + 'success' => false, + 'message' => esc_html( $default_message ), + 'record_id' => $record_id ? $record_id : null, + ] + ); + } else { + $this->send_event( 'error', [ 'message' => Hyve_Connect::visitor_message() ] ); + } + + return; + } + + $resolved = API::instance()->connect_reply_final( $result, $settings, esc_html( $default_message ) ); + $answered = $resolved['answered']; + $reply = $resolved['reply']; + $final = $resolved['final']; + $thread = isset( $result['thread_id'] ) ? $result['thread_id'] : $thread_id; + + $payload = [ + 'success' => $answered, + 'response' => $answered ? $reply : '', + ]; + + if ( $answered && ! empty( $result['follow_ups'] ) && is_array( $result['follow_ups'] ) ) { + $payload['follow_ups'] = $result['follow_ups']; + } + + if ( ! $is_test ) { + $record_id = apply_filters( 'hyve_chat_request', $thread, $record_id, $message ); + do_action( 'hyve_chat_response', $thread, $thread, $message, $record_id, $payload, $final ); + } + + $data = [ + 'success' => $answered, + 'message' => $final, + 'record_id' => $record_id ? $record_id : null, + 'thread_id' => $thread, + ]; + + $reply = apply_filters( 'hyve_chat_reply_data', $data, $payload, $answered ); + + if ( is_array( $reply ) ) { + $data = $reply; + } + + $this->send_event( 'done', $data ); + } + /** * Send the SSE headers, disable buffering and emit an initial comment so the * client can quickly detect whether the pipe is flushable. diff --git a/inc/Threads.php b/inc/Threads.php index 19e04e00..dbb52c55 100644 --- a/inc/Threads.php +++ b/inc/Threads.php @@ -89,7 +89,9 @@ public function record_message( $run_id, $thread_id, $query, $record_id, $messag [ 'thread_id' => $thread_id, 'sender' => 'bot', - 'message' => $response, + // The admin Messages screen renders this as HTML; strip anything + // unsafe (the reply is model/platform output, not trusted). + 'message' => wp_kses_post( $response ), ] ); } diff --git a/src/backend/App.js b/src/backend/App.js index 88bf86f9..b87df725 100644 --- a/src/backend/App.js +++ b/src/backend/App.js @@ -61,6 +61,10 @@ const App = () => { select( 'hyve' ).getAttentionCount() ); + const isConnectActive = useSelect( ( select ) => + select( 'hyve' ).isConnectActive() + ); + const { setSettings, setLoading } = useDispatch( 'hyve' ); useEffect( () => { @@ -118,7 +122,10 @@ const App = () => { const subs = current?.subs ? Object.entries( current.subs ).filter( - ( [ , entry ] ) => ! entry.hidden + ( [ key, entry ] ) => + ! entry.hidden && + // Qdrant and Hyve Connect are mutually exclusive. + ! ( 'qdrant' === key && isConnectActive ) ) : []; diff --git a/src/backend/components/SetupChecklist.js b/src/backend/components/SetupChecklist.js index 5ede5482..93d3f8df 100644 --- a/src/backend/components/SetupChecklist.js +++ b/src/backend/components/SetupChecklist.js @@ -24,21 +24,28 @@ const SetupChecklist = () => { const totalChunks = Number( chunks ?? 0 ); + // AI is "connected" via hosted Hyve Connect or a personal OpenAI key. + const aiConnected = hasAPI; + /** * Setup steps. Pro prepends its license step (and locks the rest while * the license is inactive) through the same filter. */ const steps = applyFilters( 'hyve.setup-steps', [ { - title: __( 'Connect OpenAI', 'hyve-lite' ), + title: __( 'Connect to AI', 'hyve-lite' ), description: __( - 'Hyve uses your OpenAI API key to index content and answer visitors.', + 'Let Hyve Connect handle the AI, no API key or setup needed.', 'hyve-lite' ), - done: hasAPI, + done: aiConnected, locked: false, action: { - label: __( 'Add API key', 'hyve-lite' ), + label: __( 'Connect to AI', 'hyve-lite' ), + onClick: () => navigate( 'settings', 'hyve-connect' ), + }, + secondary: { + label: __( 'Use your own API key instead', 'hyve-lite' ), onClick: () => navigate( 'settings', 'ai-provider' ), }, }, @@ -49,7 +56,7 @@ const SetupChecklist = () => { 'hyve-lite' ), done: 0 < totalChunks, - locked: ! hasAPI, + locked: ! aiConnected, action: { label: __( 'Add content', 'hyve-lite' ), onClick: () => navigate( 'kb' ), @@ -106,28 +113,40 @@ const SetupChecklist = () => {

{ step.description }

{ ! step.done && ( - +
+ + + { step.secondary && ! step.locked && ( + + ) } +
) } ) ) } diff --git a/src/backend/components/StatCard.js b/src/backend/components/StatCard.js index b303c9e4..783bb60b 100644 --- a/src/backend/components/StatCard.js +++ b/src/backend/components/StatCard.js @@ -11,10 +11,10 @@ const StatCard = ( { meter, foot, chip, - planned = false, + compact = false, } ) => { return ( -
+
{ icon && } { label } diff --git a/src/backend/router.js b/src/backend/router.js index 8b44fe67..643e631b 100644 --- a/src/backend/router.js +++ b/src/backend/router.js @@ -155,12 +155,12 @@ const ROUTES = { group: __( 'Chat', 'hyve-lite' ), requiresAPI: true, }, - 'ai-provider': { - label: __( 'Provider & model', 'hyve-lite' ), + 'hyve-connect': { + label: __( 'Hyve Connect', 'hyve-lite' ), group: __( 'AI', 'hyve-lite' ), }, - 'ai-advanced': { - label: __( 'Advanced', 'hyve-lite' ), + 'ai-provider': { + label: __( 'Provider & model', 'hyve-lite' ), group: __( 'AI', 'hyve-lite' ), }, qdrant: { diff --git a/src/backend/screens/AI.js b/src/backend/screens/AI.js index 9e5c3705..3b0af438 100644 --- a/src/backend/screens/AI.js +++ b/src/backend/screens/AI.js @@ -11,7 +11,7 @@ import { TextControl, } from '@wordpress/components'; -import { useDispatch } from '@wordpress/data'; +import { useDispatch, useSelect } from '@wordpress/data'; import { useState } from '@wordpress/element'; @@ -22,6 +22,7 @@ import Card from '../components/Card'; import Chip from '../components/Chip'; import FieldRow from '../components/FieldRow'; import useSaveSettings from '../data/useSaveSettings'; +import { navigate } from '../router'; /** * Selectable chat models. @@ -136,10 +137,6 @@ const MODEL_OPTIONS = [ }, ]; -const ADVANCED_DEFAULTS = { - similarity_score_threshold: 0.4, -}; - const getInitialApiStatus = () => { if ( window.hyve?.isApiKeyConnected ) { return 'connected'; @@ -172,6 +169,14 @@ export const ProviderPanel = () => { const { setSetting, setHasAPI } = useDispatch( 'hyve' ); const { createNotice } = useDispatch( 'core/notices' ); + const isConnectActive = useSelect( ( select ) => + select( 'hyve' ).isConnectActive() + ); + + const hasStoredKey = Boolean( window.hyve?.hasAPIKey ); + const providerLocked = isConnectActive; + const apiKeyLocked = isConnectActive && ! hasStoredKey; + const onSave = async () => { const response = await save(); @@ -245,6 +250,31 @@ export const ProviderPanel = () => { } > + { isConnectActive && ( +
+
+ + { __( + 'Hyve Connect is handling AI.', + 'hyve-lite' + ) } + { ' ' } + { __( + 'These OpenAI settings stay inactive while Connect is on.', + 'hyve-lite' + ) }{ ' ' } + +
+
+ ) } + { label={ __( 'API key', 'hyve-lite' ) } type="password" value={ settings.api_key || '' } - disabled={ isSaving } + disabled={ isSaving || apiKeyLocked } onChange={ ( value ) => { setSetting( 'api_key', value ); setApiStatus( 'editing' ); @@ -268,7 +298,7 @@ export const ProviderPanel = () => { />
- { 'none' === apiStatus && ( + { 'none' === apiStatus && ! apiKeyLocked && (

{ __( 'Get an API key', 'hyve-lite' ) } @@ -294,7 +324,7 @@ export const ProviderPanel = () => { label, value, } ) ) } - disabled={ isSaving } + disabled={ isSaving || providerLocked } onChange={ ( value ) => setSetting( 'chat_model', value ) } /> { selectedModelOption?.description && ( @@ -308,44 +338,7 @@ export const ProviderPanel = () => {

- - ); -}; -export const AdvancedPanel = () => { - const { settings, isSaving, save } = useSaveSettings(); - - const { setSetting } = useDispatch( 'hyve' ); - - const resetDefaults = () => { - Object.entries( ADVANCED_DEFAULTS ).forEach( ( [ key, value ] ) => - setSetting( key, value ) - ); - }; - - return ( - - - - - } - > { min={ -1 } max={ 1 } step={ 0.01 } - disabled={ isSaving } + disabled={ isSaving || providerLocked } onChange={ ( value ) => setSetting( 'similarity_score_threshold', value ) } diff --git a/src/backend/screens/ChatAppearance.js b/src/backend/screens/ChatAppearance.js index 42af0a2f..e5f25332 100644 --- a/src/backend/screens/ChatAppearance.js +++ b/src/backend/screens/ChatAppearance.js @@ -28,7 +28,7 @@ import { applyFilters } from '@wordpress/hooks'; /** * Internal dependencies. */ -import { setUtm } from '../utils'; +import { isLicenseActive, setUtm } from '../utils'; import Card from '../components/Card'; import Chip from '../components/Chip'; import FieldRow from '../components/FieldRow'; @@ -159,7 +159,7 @@ const ColorTile = ( { label, value, disabled, onChange } ) => { }; const ChatAppearance = () => { - const isPro = Boolean( window.hyve?.license ); + const isPro = isLicenseActive(); const { settings, isSaving, save } = useSaveSettings(); diff --git a/src/backend/screens/ChatBehavior.js b/src/backend/screens/ChatBehavior.js index a193e7b8..0d4f6430 100644 --- a/src/backend/screens/ChatBehavior.js +++ b/src/backend/screens/ChatBehavior.js @@ -18,7 +18,7 @@ import { createInterpolateElement } from '@wordpress/element'; /** * Internal dependencies. */ -import { setUtm } from '../utils'; +import { isLicenseActive, setUtm } from '../utils'; import Card from '../components/Card'; import Chip from '../components/Chip'; import FieldRow from '../components/FieldRow'; @@ -207,7 +207,7 @@ const VisibilityCard = () => { }; const ConversationCard = () => { - const isPro = Boolean( window.hyve?.license ); + const isPro = isLicenseActive(); const { settings, isSaving, save } = useSaveSettings(); const { setSetting } = useDispatch( 'hyve' ); @@ -334,7 +334,7 @@ const ConversationCard = () => { }; const SuggestionsCard = () => { - const isPro = Boolean( window.hyve?.license ); + const isPro = isLicenseActive(); const { settings, isSaving, save } = useSaveSettings(); diff --git a/src/backend/screens/Connect.js b/src/backend/screens/Connect.js new file mode 100644 index 00000000..521e3913 --- /dev/null +++ b/src/backend/screens/Connect.js @@ -0,0 +1,670 @@ +/** + * WordPress dependencies. + */ +import { __, sprintf } from '@wordpress/i18n'; + +import apiFetch from '@wordpress/api-fetch'; + +import { Button, Modal } from '@wordpress/components'; + +import { useDispatch, useSelect } from '@wordpress/data'; + +import { useCallback, useEffect, useState } from '@wordpress/element'; + +/** + * Internal dependencies. + */ +import { setUtm, percentOf, quotaOf } from '../utils'; +import Card from '../components/Card'; +import Chip from '../components/Chip'; +import FieldRow from '../components/FieldRow'; + +/** + * A labelled usage meter row (used / limit with a bar). + * + * @param {Object} props Component props. + * @param {string} props.label Row label. + * @param {number} props.used Amount used. + * @param {number} props.limit Allowance. + * @param {string} props.unit Trailing unit/label after the numbers. + * @param {string} [props.sub] Optional sub-line under the bar. + * + * @return {Element} The row. + */ +const QuotaRow = ( { label, used, limit, unit, sub } ) => { + const percent = percentOf( used, limit ); + + return ( +
+
+ { label } + + { Number( used ).toLocaleString() } /{ ' ' } + { Number( limit ).toLocaleString() } { unit } + +
+ + { sub &&
{ sub }
} +
+ ); +}; + +/** + * A selectable disconnect path (radio + label) for the confirm modal. + * + * @param {Object} props Component props. + * @param {string} props.value Option value. + * @param {boolean} props.selected Whether this option is chosen. + * @param {Function} props.onSelect Selection handler. + * @param {string} props.title Option title. + * @param {string} props.text Option description. + * + * @return {Element} The option. + */ +const DisconnectOption = ( { value, selected, onSelect, title, text } ) => { + const id = `hyve-disconnect-${ value }`; + + return ( +
+ + +
+ ); +}; + +export const ConnectPanel = () => { + const isConnectActive = useSelect( ( select ) => + select( 'hyve' ).isConnectActive() + ); + const connect = useSelect( ( select ) => select( 'hyve' ).getConnect() ); + const connectSync = useSelect( ( select ) => + select( 'hyve' ).getConnectSync() + ); + const settings = useSelect( ( select ) => select( 'hyve' ).getSettings() ); + const isQdrantActive = useSelect( ( select ) => + select( 'hyve' ).isQdrantActive() + ); + + const { setSetting, setAiMode, setConnect, setConnectSync } = + useDispatch( 'hyve' ); + const { createNotice } = useDispatch( 'core/notices' ); + + const [ isBusy, setBusy ] = useState( false ); + const [ isConfirmOpen, setConfirmOpen ] = useState( false ); + const [ disconnectMode, setDisconnectMode ] = useState( 'import' ); + + // Pull the latest Connect stats and mirror them into the store. + const refreshConnectStats = useCallback( async () => { + const stats = await apiFetch( { path: `${ window.hyve.api }/stats` } ); + setConnect( stats?.connect ?? null ); + setConnectSync( stats?.connectSync ?? null ); + }, [ setConnect, setConnectSync ] ); + + const isSyncing = Boolean( connectSync?.in_progress ); + + // While the to-Connect sync job runs, poll the stats route so the progress + // bar advances and the panel flips to its normal view when it finishes. + useEffect( () => { + if ( ! isSyncing ) { + return undefined; + } + + const timer = setInterval( async () => { + try { + await refreshConnectStats(); + } catch { + // A transient poll failure just retries on the next tick. + } + }, 4000 ); + + return () => clearInterval( timer ); + }, [ isSyncing, refreshConnectStats ] ); + + const hasKey = Boolean( window.hyve?.hasAPIKey ); + const isPro = Boolean( window.hyve?.license ); + const isOffline = isConnectActive && connect?.service === 'error'; + + const onEnable = async () => { + setBusy( true ); + + try { + const next = { ...settings, ai_mode: 'hyve_connect' }; + const response = await apiFetch( { + path: `${ window.hyve.api }/settings`, + method: 'POST', + data: { data: next }, + } ); + + if ( response.error ) { + throw new Error( response.error ); + } + + setSetting( 'ai_mode', 'hyve_connect' ); + setAiMode( 'hyve_connect' ); + + await refreshConnectStats(); + + createNotice( 'success', __( 'Hyve Connect is on.', 'hyve-lite' ), { + type: 'snackbar', + isDismissible: true, + } ); + } catch ( error ) { + createNotice( 'error', error?.message ?? String( error ), { + type: 'snackbar', + isDismissible: true, + } ); + } + + setBusy( false ); + }; + + const onDisconnect = async () => { + setBusy( true ); + + try { + const response = await apiFetch( { + path: `${ window.hyve.api }/connect`, + method: 'POST', + data: { mode: disconnectMode }, + } ); + + if ( response.error ) { + throw new Error( response.error ); + } + + setSetting( 'ai_mode', 'self_hosted' ); + setAiMode( 'self_hosted' ); + setConnect( null ); + setConnectSync( null ); + setConfirmOpen( false ); + + createNotice( + 'success', + 'import' === disconnectMode + ? __( + 'Hyve Connect disconnected. Your content was imported back.', + 'hyve-lite' + ) + : __( + 'Hyve Connect disconnected and your knowledge base was cleared.', + 'hyve-lite' + ), + { type: 'snackbar', isDismissible: true } + ); + } catch ( error ) { + createNotice( 'error', error?.message ?? String( error ), { + type: 'snackbar', + isDismissible: true, + } ); + } + + setBusy( false ); + }; + + const onSync = async () => { + setBusy( true ); + + try { + const response = await apiFetch( { + path: `${ window.hyve.api }/connect/reconcile`, + method: 'POST', + } ); + + if ( response?.error ) { + throw new Error( response.error ); + } + + // Reconcile may have queued a re-push; refresh so progress shows. + await refreshConnectStats(); + + createNotice( + 'success', + __( 'Knowledge base synced with Hyve Connect.', 'hyve-lite' ), + { type: 'snackbar', isDismissible: true } + ); + } catch ( error ) { + createNotice( 'error', error?.message ?? String( error ), { + type: 'snackbar', + isDismissible: true, + } ); + } + + setBusy( false ); + }; + + // --- Connected --------------------------------------------------------- + if ( isConnectActive ) { + const kbQuota = quotaOf( connect, 'kb' ); + const chatQuota = quotaOf( connect, 'chat' ); + const isExhausted = kbQuota.full || chatQuota.full; + + const isBlocked = Boolean( connectSync?.blocked ); + const isLicenseExpired = 'expired' === connect?.license; + const syncTotal = Number( connectSync?.total ?? 0 ); + const syncCurrent = Math.min( + syncTotal, + Number( connectSync?.current ?? 0 ) + ); + const syncPercent = syncTotal + ? Math.round( ( syncCurrent / syncTotal ) * 100 ) + : 0; + + let blockedReason = __( + 'It goes over the free plan limit. Upgrade to Pro to sync everything.', + 'hyve-lite' + ); + if ( isLicenseExpired ) { + blockedReason = __( + 'It goes over the free plan limit that applies while your license is expired.', + 'hyve-lite' + ); + } else if ( isPro ) { + blockedReason = __( + 'It goes over your current plan limit.', + 'hyve-lite' + ); + } + + let statusText = __( + 'Running on the free plan. Connect a license to raise these limits.', + 'hyve-lite' + ); + if ( isOffline ) { + statusText = __( + "Can't reach Hyve Connect right now. We'll keep retrying.", + 'hyve-lite' + ); + } else if ( isPro ) { + statusText = __( + 'Running on Pro. Limits pool across all your sites.', + 'hyve-lite' + ); + } + + const chip = isOffline ? ( + { __( 'Offline', 'hyve-lite' ) } + ) : ( + { __( 'Connected', 'hyve-lite' ) } + ); + + return ( + <> + + { isSyncing && ( +
+
+ + { __( + 'Syncing your knowledge base…', + 'hyve-lite' + ) } + +

+ { sprintf( + /* translators: %1$d: sources synced. %2$d: total sources. */ + __( + 'Moving your content to Hyve Connect: %1$d of %2$d sources.', + 'hyve-lite' + ), + syncCurrent, + syncTotal + ) } +

+ +
+
+ ) } + + { isLicenseExpired && ( +
+
+ + { __( + 'Your Hyve license has expired.', + 'hyve-lite' + ) } + { ' ' } + { __( + 'You are on free plan limits until you renew it. Your assistant keeps answering in the meantime.', + 'hyve-lite' + ) } +
+
+ ) } + + { isBlocked && ( +
+
+ + { __( + "Some content couldn't be synced.", + 'hyve-lite' + ) } + { ' ' } + { blockedReason } +
+
+ ) } + + +
+ { chip } + { statusText } +
+
+ + { ! isOffline && ( + +
+ + +
+
+ ) } + + { ! isPro && ( +
+ + { isExhausted + ? __( + "You've reached your free limit", + 'hyve-lite' + ) + : __( 'Need more room?', 'hyve-lite' ) } + +

+ { isExhausted + ? __( + "You've used up your free Hyve Connect allowance. Upgrade to Pro to keep indexing content and answering visitors.", + 'hyve-lite' + ) + : __( + 'Hyve Pro raises your knowledge base and monthly messages, pooled across all your sites.', + 'hyve-lite' + ) } +

+ +
+ ) } + + { isPro && isExhausted && ( +
+
+ + { __( + "You've reached your plan limit.", + 'hyve-lite' + ) } + { ' ' } + { __( + 'Usage pools across your licensed sites and resets next cycle.', + 'hyve-lite' + ) } +
+
+ ) } + +
+ + +
+
+ + { isConfirmOpen && ( + setConfirmOpen( false ) } + > +

+ { __( + 'Your site will stop using Hyve Connect. Choose what happens to the content it indexed:', + 'hyve-lite' + ) } +

+
+ setDisconnectMode( 'import' ) } + title={ __( + 'Import my content, then disconnect', + 'hyve-lite' + ) } + text={ __( + 'Bring your knowledge base back to this site. Add your own OpenAI key afterward and keep answering, no re-indexing.', + 'hyve-lite' + ) } + /> + setDisconnectMode( 'clear' ) } + title={ __( + 'Clear my knowledge base', + 'hyve-lite' + ) } + text={ __( + 'Delete everything on this site and on Hyve Connect. This cannot be undone.', + 'hyve-lite' + ) } + /> +
+
+ + +
+
+ ) } + + ); + } + + // --- Not connected ----------------------------------------------------- + return ( + { __( 'Not connected', 'hyve-lite' ) } + } + > + { isQdrantActive ? ( +
+
+ + { __( 'Qdrant is connected.', 'hyve-lite' ) } + { ' ' } + { __( + "Qdrant and Hyve Connect can't run at the same time. Disconnect Qdrant first, then switch this site to Hyve Connect.", + 'hyve-lite' + ) } +
+
+ ) : ( + hasKey && ( +
+
+ + { __( + "You're using your own OpenAI key.", + 'hyve-lite' + ) } + { ' ' } + { __( + "Hyve Connect and a self-hosted key can't run at the same time. Enabling it stops using your key, and your indexed content will be synced to Hyve Connect.", + 'hyve-lite' + ) } +
+
+ ) + ) } + +
+

+ { __( + 'Turn on Hyve Connect to index your content and answer visitors without an OpenAI key. We host the AI; you just switch it on.', + 'hyve-lite' + ) } +

+
    +
  • + { __( 'No API key or account to set up', 'hyve-lite' ) } +
  • +
  • + { __( + 'Indexing, embeddings, and chat all handled for you', + 'hyve-lite' + ) } +
  • +
  • + { __( + 'Your license unlocks higher limits automatically', + 'hyve-lite' + ) } +
  • +
+
+ +
+ + + { isQdrantActive + ? __( + 'Disconnect Qdrant to enable Hyve Connect.', + 'hyve-lite' + ) + : __( 'Free plan, no key required.', 'hyve-lite' ) } + +
+
+ ); +}; + +export default ConnectPanel; diff --git a/src/backend/screens/Dashboard.js b/src/backend/screens/Dashboard.js index 2f65bf0a..9984d92e 100644 --- a/src/backend/screens/Dashboard.js +++ b/src/backend/screens/Dashboard.js @@ -19,7 +19,7 @@ import { archive, brush, cloud, comment, help, people } from '@wordpress/icons'; * Internal dependencies. */ import { navigate } from '../router'; -import { setUtm } from '../utils'; +import { setUtm, percentOf, quotaOf, isLicenseActive } from '../utils'; import Card from '../components/Card'; import Chip from '../components/Chip'; import SetupChecklist from '../components/SetupChecklist'; @@ -145,23 +145,85 @@ const VisibilityNotice = ( { mode } ) => { }; const StatsGrid = () => { - const { stats, totalChunks, isQdrantActive } = useSelect( ( select ) => ( { - stats: select( 'hyve' ).getStats(), - totalChunks: select( 'hyve' ).getTotalChunks(), - isQdrantActive: select( 'hyve' ).isQdrantActive(), - } ) ); + const { stats, totalChunks, isQdrantActive, isConnectActive, connect } = + useSelect( ( select ) => ( { + stats: select( 'hyve' ).getStats(), + totalChunks: select( 'hyve' ).getTotalChunks(), + isQdrantActive: select( 'hyve' ).isQdrantActive(), + isConnectActive: select( 'hyve' ).isConnectActive(), + connect: select( 'hyve' ).getConnect(), + } ) ); const sessions = Number( stats.threads ?? 0 ); const messages = Number( stats.messages ?? 0 ); const chunks = Number( totalChunks ?? 0 ); const chunksLimit = Number( window.hyve?.chunksLimit ?? 500 ); - const usedPercent = Math.min( - 100, - Math.round( ( chunks / chunksLimit ) * 100 ) - ); - - const needsStorage = ! isQdrantActive && 400 < chunks; + const usedPercent = percentOf( chunks, chunksLimit ); + + const needsStorage = ! isQdrantActive && ! isConnectActive && 400 < chunks; + + // The Knowledge Base card reflects where content actually lives: the free + // local limit, an unlimited Qdrant cluster, or the Hyve Connect plan quota. + const kbQuota = quotaOf( connect, 'kb' ); + + let kbCard; + + if ( isConnectActive ) { + kbCard = { + value: kbQuota.used.toLocaleString(), + suffix: sprintf( + /* translators: %s: the chunk limit of the Hyve Connect plan. */ + __( '/ %s chunks', 'hyve-lite' ), + kbQuota.limit.toLocaleString() + ), + meter: kbQuota.percent, + foot: kbQuota.full + ? __( 'Plan limit reached.', 'hyve-lite' ) + : sprintf( + /* translators: %d: percentage of the Hyve Connect plan used. */ + __( + '%d%% of your Hyve Connect plan used.', + 'hyve-lite' + ), + kbQuota.percent + ), + }; + } else if ( isQdrantActive ) { + kbCard = { + value: chunks.toLocaleString(), + suffix: __( 'chunks', 'hyve-lite' ), + meter: undefined, + foot: __( 'Stored in your Qdrant cluster.', 'hyve-lite' ), + }; + } else { + kbCard = { + value: chunks.toLocaleString(), + suffix: sprintf( + /* translators: %s: the chunk limit of the free plan. */ + __( '/ %s chunks', 'hyve-lite' ), + chunksLimit.toLocaleString() + ), + meter: usedPercent, + foot: ( + <> + { sprintf( + /* translators: %d: percentage of the free limit used. */ + __( '%d%% of the free limit used.', 'hyve-lite' ), + usedPercent + ) }{ ' ' } + { needsStorage && ( + + ) } + + ), + }; + } return (
@@ -188,60 +250,111 @@ const StatsGrid = () => { + + { isConnectActive ? ( + ( () => { + const { used, limit, percent, full } = quotaOf( + connect, + 'chat' + ); + const offline = connect?.service === 'error'; + + let connectChip = ( + + { __( 'Connected', 'hyve-lite' ) } + + ); + if ( offline ) { + connectChip = ( + + { __( 'Offline', 'hyve-lite' ) } + + ); + } else if ( full ) { + connectChip = ( + + { __( 'Limit reached', 'hyve-lite' ) } + + ); + } + + return ( + + navigate( + 'settings', + 'hyve-connect' + ) + } + > + { __( 'Manage', 'hyve-lite' ) } + + ) + } + /> + ); + } )() + ) : ( + + { __( 'Not connected', 'hyve-lite' ) } + + } + foot={ <> - { sprintf( - /* translators: %d: percentage of the free limit used. */ - __( - '%d%% of the free limit used.', - 'hyve-lite' - ), - usedPercent + { __( + 'Answer questions and index your content with zero setup.', + 'hyve-lite' ) }{ ' ' } - { needsStorage && ( - - ) } + - ) - } - /> - - - { __( 'Coming soon', 'hyve-lite' ) } - - } - foot={ __( - 'Hosted AI usage will show up here once Hyve Connect launches.', - 'hyve-lite' - ) } - /> + } + /> + ) }
); }; @@ -294,7 +407,7 @@ const UsageCard = () => { }; const RecentConversations = () => { - const isPro = Boolean( window.hyve?.license ); + const isPro = isLicenseActive(); const limit = isPro ? 5 : 3; const [ threads, setThreads ] = useState( null ); diff --git a/src/backend/screens/Integrations.js b/src/backend/screens/Integrations.js index 47b04219..8d8ee115 100644 --- a/src/backend/screens/Integrations.js +++ b/src/backend/screens/Integrations.js @@ -19,7 +19,7 @@ import { useCallback, useEffect, useRef, useState } from '@wordpress/element'; /** * Internal dependencies. */ -import { setUtm } from '../utils'; +import { isLicenseActive, setUtm } from '../utils'; import Card from '../components/Card'; import Chip from '../components/Chip'; import FieldRow from '../components/FieldRow'; @@ -325,7 +325,7 @@ export const QdrantPanel = () => { }; export const ApiAccessPanel = () => { - const isPro = Boolean( window.hyve?.license ); + const isPro = isLicenseActive(); return ( <> diff --git a/src/backend/screens/KnowledgeBase.js b/src/backend/screens/KnowledgeBase.js index 800ecde2..a4e692ff 100644 --- a/src/backend/screens/KnowledgeBase.js +++ b/src/backend/screens/KnowledgeBase.js @@ -23,7 +23,7 @@ import { addQueryArgs } from '@wordpress/url'; * Internal dependencies. */ import { getRoutes, navigate } from '../router'; -import { onProcessData, setUtm } from '../utils'; +import { isLicenseActive, onProcessData, setUtm } from '../utils'; import Card from '../components/Card'; import Chip from '../components/Chip'; import ChunkLimitNotice from '../components/ChunkLimitNotice'; @@ -54,7 +54,7 @@ const getSources = () => ); const SourcesGrid = () => { - const isPro = Boolean( window.hyve?.license ); + const isPro = isLicenseActive(); return ( @@ -137,6 +137,16 @@ const IndexedContent = () => { select( 'hyve' ).getTotalChunks() ); + // In Connect mode the platform owns the chunks, so the per-source Chunks + // column is dropped in favor of the sync-state Status column. + const isConnectActive = useSelect( ( select ) => + select( 'hyve' ).isConnectActive() + ); + const connectSync = useSelect( ( select ) => + select( 'hyve' ).getConnectSync() + ); + const isSyncBlocked = Boolean( connectSync?.blocked ); + const { setTotalChunks, setAttentionCount } = useDispatch( 'hyve' ); const { createNotice } = useDispatch( 'core/notices' ); @@ -332,26 +342,55 @@ const IndexedContent = () => { key: 'type', label: __( 'Source', 'hyve-lite' ), }, - { - key: 'chunks', - label: __( 'Chunks', 'hyve-lite' ), - align: 'num', - render: ( row ) => - Number( row.chunks ?? 0 ).toLocaleString(), - }, + ...( isConnectActive + ? [] + : [ + { + key: 'chunks', + label: __( 'Chunks', 'hyve-lite' ), + align: 'num', + render: ( row ) => + Number( + row.chunks ?? 0 + ).toLocaleString(), + }, + ] ), { key: 'status', label: __( 'Status', 'hyve-lite' ), - render: ( row ) => - row.error ? ( - - { __( 'Indexing failed', 'hyve-lite' ) } - - ) : ( + render: ( row ) => { + if ( row.error ) { + return ( + + { __( 'Indexing failed', 'hyve-lite' ) } + + ); + } + + // Indexed locally but not on the platform: + // mid-sync, or skipped over the plan limit. + if ( isConnectActive && false === row.synced ) { + return ( + + { isSyncBlocked + ? __( + 'Over plan limit', + 'hyve-lite' + ) + : __( + 'Waiting to sync', + 'hyve-lite' + ) } + + ); + } + + return ( { __( 'Indexed', 'hyve-lite' ) } - ), + ); + }, }, { key: 'actions', @@ -1493,7 +1532,7 @@ const LOCKED_PREVIEWS = { }; const LockedSource = ( { subKey } ) => { - const isPro = Boolean( window.hyve?.license ); + const isPro = isLicenseActive(); const source = getRoutes().kb?.subs?.[ subKey ]; const copy = LOCKED_COPY[ subKey ]; @@ -1589,7 +1628,7 @@ const FAQ_PREVIEW = [ // The working FAQ panel is pro-owned (`GET {api}/faq`) and ships with P2. const FaqPanel = () => { - const isPro = Boolean( window.hyve?.license ); + const isPro = isLicenseActive(); return ( { }; const ConversationsPanel = () => { - const isPro = Boolean( window.hyve?.license ); + const isPro = isLicenseActive(); const [ posts, setPosts ] = useState( [] ); const [ hasMore, setHasMore ] = useState( false ); diff --git a/src/backend/screens/Settings.js b/src/backend/screens/Settings.js index 10ae220d..a59c539c 100644 --- a/src/backend/screens/Settings.js +++ b/src/backend/screens/Settings.js @@ -11,15 +11,16 @@ import { useSelect } from '@wordpress/data'; import { getRoutes } from '../router'; import ChatBehavior from './ChatBehavior'; import ChatAppearance from './ChatAppearance'; -import { ProviderPanel, AdvancedPanel } from './AI'; +import { ProviderPanel } from './AI'; import SettingsGeneral from './SettingsGeneral'; import { QdrantPanel, ApiAccessPanel } from './Integrations'; +import ConnectPanel from './Connect'; const PANELS = { 'chat-behavior': ChatBehavior, 'chat-appearance': ChatAppearance, 'ai-provider': ProviderPanel, - 'ai-advanced': AdvancedPanel, + 'hyve-connect': ConnectPanel, qdrant: QdrantPanel, 'api-access': ApiAccessPanel, general: SettingsGeneral, diff --git a/src/backend/store.js b/src/backend/store.js index 015049ef..d0ccc559 100644 --- a/src/backend/store.js +++ b/src/backend/store.js @@ -3,12 +3,17 @@ */ import { createReduxStore, register } from '@wordpress/data'; +const AI_MODE = window.hyve.aiMode || 'self_hosted'; + const DEFAULT_STATE = { route: window.hyve?.view || 'dashboard', hasLoaded: false, settings: {}, processed: [], - hasAPI: Boolean( window.hyve.hasAPIKey ), + aiMode: AI_MODE, + connect: window.hyve.connect || null, + connectSync: window.hyve.connectSync || null, + hasKey: Boolean( window.hyve.hasAPIKey ), isQdrantActive: Boolean( window.hyve.isQdrantActive ), stats: window.hyve.stats || {}, chart: window.hyve.chart || null, @@ -72,6 +77,24 @@ const actions = { isQdrantActive, }; }, + setAiMode( aiMode ) { + return { + type: 'SET_AI_MODE', + aiMode, + }; + }, + setConnect( connect ) { + return { + type: 'SET_CONNECT', + connect, + }; + }, + setConnectSync( connectSync ) { + return { + type: 'SET_CONNECT_SYNC', + connectSync, + }; + }, setAttentionCount( attentionCount ) { return { type: 'SET_ATTENTION_COUNT', @@ -97,7 +120,8 @@ const selectors = { return state.settings; }, hasAPI( state ) { - return state.hasAPI; + // A saved key, or Connect handling the AI, both count as "AI is set up". + return state.hasKey || 'hyve_connect' === state.aiMode; }, getTotalChunks( state ) { return state.totalChunks; @@ -111,12 +135,22 @@ const selectors = { hasReachedLimit( state ) { return ( window.hyve.chunksLimit <= Number( state.totalChunks ) && - ! state.isQdrantActive + ! state.isQdrantActive && + 'hyve_connect' !== state.aiMode ); }, isQdrantActive( state ) { return state.isQdrantActive; }, + isConnectActive( state ) { + return 'hyve_connect' === state.aiMode; + }, + getConnect( state ) { + return state.connect; + }, + getConnectSync( state ) { + return state.connectSync; + }, getAttentionCount( state ) { return state.attentionCount; }, @@ -153,7 +187,7 @@ const reducer = ( state = DEFAULT_STATE, action ) => { case 'SET_HAS_API': return { ...state, - hasAPI: action.hasAPI, + hasKey: action.hasAPI, }; case 'SET_TOTAL_CHUNKS': return { @@ -175,6 +209,21 @@ const reducer = ( state = DEFAULT_STATE, action ) => { ...state, isQdrantActive: action.isQdrantActive, }; + case 'SET_AI_MODE': + return { + ...state, + aiMode: action.aiMode, + }; + case 'SET_CONNECT': + return { + ...state, + connect: action.connect, + }; + case 'SET_CONNECT_SYNC': + return { + ...state, + connectSync: action.connectSync, + }; case 'SET_ATTENTION_COUNT': return { ...state, diff --git a/src/backend/style.scss b/src/backend/style.scss index 4aa65ca2..e92b5bb7 100644 --- a/src/backend/style.scss +++ b/src/backend/style.scss @@ -563,16 +563,6 @@ color: var(--hyve-muted); } - &.is-planned { - background: var(--hyve-surface-2); - border: 1px dashed var(--hyve-border-2); - color: var(--hyve-muted); - font-size: 9.5px; - font-weight: 800; - letter-spacing: 0.04em; - text-transform: uppercase; - } - &.is-pro { background: #f0f6fc; border: 1px solid #c5d9ec; @@ -703,6 +693,20 @@ } } +.hyve-next-checklist__actions { + align-items: flex-end; + display: flex; + flex-direction: column; + gap: 3px; + margin-left: auto; +} + +.hyve-next-checklist__alt.components-button.is-link { + color: var(--hyve-muted); + font-size: 11px; + height: auto; +} + .hyve-next-checklist__optional { background: var(--hyve-surface-2); border: 1px solid var(--hyve-border); @@ -762,6 +766,14 @@ border-left-color: var(--hyve-bad-dot); } + &.is-flush { + margin-bottom: 0; + } + + &.is-info { + border-left-color: var(--hyve-wp-blue); + } + &.is-compact { box-shadow: none; margin: 10px 0 0; @@ -777,6 +789,11 @@ @media (max-width: 640px) { min-width: 220px; } + + /* The sync-progress bar sits under its label inside the notice body. */ + .hyve-next-meter { + margin-top: 8px; + } } .hyve-next-notice__title { @@ -812,12 +829,6 @@ p.hyve-next-notice__text { box-shadow: var(--hyve-shadow); padding: 16px; - &.is-planned { - background: transparent; - border-color: var(--hyve-border-2); - border-style: dashed; - box-shadow: none; - } } .hyve-next-stat__top { @@ -847,9 +858,6 @@ p.hyve-next-notice__text { font-weight: 500; } - .is-planned & { - color: var(--hyve-faint); - } } .hyve-next-stat__meter { @@ -888,6 +896,10 @@ p.hyve-next-notice__text { display: flex; flex-direction: column; margin-bottom: 0; + + /* Let the track shrink: without this, the chart canvas's rendered + * width becomes the column's minimum and ratchets it wider, pushing + * the conversations column out of the grid. */ min-width: 0; } @@ -980,6 +992,12 @@ p.hyve-next-notice__text { font-size: 12.5px; margin: 2px 0 10px; } + + /* Free allowance spent: warm the panel so the limit reads at a glance. */ + &.is-exhausted { + background: #fcf3e6; + border-top-color: var(--hyve-warn-dot); + } } .hyve-next-backrow { @@ -1264,6 +1282,16 @@ p.hyve-next-notice__text { justify-content: flex-end; margin-top: 20px; + /* Pin the actions to the bottom of the modal so, in tall modals (e.g. the + sitemap pages list), the primary actions stay visible instead of hiding + below a scroll. */ + position: sticky; + bottom: 0; + z-index: 1; + background: var(--hyve-surface); + border-top: 1px solid var(--hyve-border); + padding-top: 12px; + @media (max-width: 480px) { flex-wrap: wrap; } @@ -1815,3 +1843,110 @@ div.notice.themeisle-sale { padding: 0 3px; } } + +/* Hyve Connect settings panel. */ +.hyve-next-quota { + display: flex; + flex-direction: column; + gap: 14px; +} + +.hyve-next-quota__lab { + display: flex; + justify-content: space-between; + font-size: 12.5px; + margin-bottom: 5px; +} + +.hyve-next-quota__k { + color: var(--hyve-text); + font-weight: 600; +} + +.hyve-next-quota__v { + color: var(--hyve-muted); + font-variant-numeric: tabular-nums; +} + +.hyve-next-quota__sub { + color: var(--hyve-faint); + font-size: 11.5px; + margin-top: 4px; +} + +.hyve-next-quota__status { + align-items: center; + color: var(--hyve-muted); + display: flex; + font-size: 12.5px; + gap: 10px; +} + +.hyve-next-feats { + display: flex; + flex-direction: column; + gap: 6px; + list-style: none; + margin: 6px 0 0; + padding: 0; + + li { + color: var(--hyve-text-2); + font-size: 12.5px; + padding-left: 20px; + position: relative; + + &::before { + color: var(--hyve-ok); + content: '\2713'; + font-weight: 700; + left: 0; + position: absolute; + } + } +} + +.hyve-next-opts { + display: flex; + flex-direction: column; + gap: 10px; + margin: 12px 0; +} + +.hyve-next-opt { + align-items: flex-start; + border: 1px solid var(--hyve-border); + border-radius: var(--hyve-radius-in); + cursor: pointer; + display: flex; + gap: 10px; + padding: 12px; + + &.is-selected { + background: #f0f6fc; + border-color: var(--hyve-wp-blue); + } + + input { + margin-top: 2px; + } +} + +.hyve-next-opt__txt { + color: var(--hyve-text-2); + display: flex; + flex-direction: column; + font-size: 12.5px; + gap: 3px; + + strong { + color: var(--hyve-text); + font-size: 13px; + } +} + +.hyve-next-stat.is-compact .hyve-next-stat__num { + font-size: 15px; + font-weight: 600; + letter-spacing: 0; +} diff --git a/src/backend/utils.js b/src/backend/utils.js index 7225df7a..954dd258 100644 --- a/src/backend/utils.js +++ b/src/backend/utils.js @@ -210,6 +210,65 @@ export const setUtm = ( urlAdress, linkArea ) => { return urlLink.toString(); }; +/** + * Usage as a whole-number percent of an allowance, clamped to 0-100. + * + * @param {number} used Amount used. + * @param {number} limit Allowance (0 or falsy yields 0). + * @return {number} Percent, 0-100. + */ +export const percentOf = ( used, limit ) => + limit + ? Math.min( + 100, + Math.round( ( Number( used ) / Number( limit ) ) * 100 ) + ) + : 0; + +/** + * Read a Hyve Connect stats block's usage for one dimension. KB usage lives on + * the storage sub-block (falling back to the chunk count); chat is a flat block. + * + * @param {Object} connect The `connect` stats object. + * @param {string} kind 'kb' or 'chat'. + * @return {{used: number, limit: number, percent: number, full: boolean}} Usage. + */ +export const quotaOf = ( connect, kind ) => { + const block = connect?.[ kind ] ?? {}; + const used = + 'kb' === kind + ? Number( block.storage?.used ?? block.chunks ?? 0 ) + : Number( block.used ?? 0 ); + const limit = Number( + ( 'kb' === kind ? block.storage?.limit : block.limit ) ?? 0 + ); + + return { + used, + limit, + percent: percentOf( used, limit ), + full: limit > 0 && used >= limit, + }; +}; + +/** + * Whether the license currently unlocks Pro features. + * + * Mirrors the server-side rule: a valid or expired-but-activated license + * keeps features; a missing, invalid or deactivated one locks them. The Pro + * plugin being installed is not enough on its own. + * + * @return {boolean} Whether Pro features are unlocked. + */ +export const isLicenseActive = () => { + const license = window.hyve?.license || {}; + + return ( + [ 'valid', 'active_expired' ].includes( license.valid ) || + 'valid' === license.license + ); +}; + export const getChatIcons = () => [ { icon: ChatBubbleLeftEllipsisIcon, diff --git a/src/frontend/App.js b/src/frontend/App.js index 50e40309..0d919790 100644 --- a/src/frontend/App.js +++ b/src/frontend/App.js @@ -431,7 +431,13 @@ class App { // A real content/server error (e.g. flagged) — not a transport // problem, so surface it instead of falling back. this.removeMessage( 'hyve-preloader' ); - this.add( strings.tryAgain, 'bot' ); + // The server sends the throttle message already translated. + this.add( + 'rate_limited' === setup.code + ? setup.error + : strings.tryAgain, + 'bot' + ); this.setLoading( false ); return true; } @@ -675,12 +681,16 @@ class App { this.removeMessage( 'hyve-preloader' ); if ( response.error ) { - this.add( - 'content_flagged' === response.code - ? strings.flagged - : strings.tryAgain, - 'bot' - ); + let text = strings.tryAgain; + + if ( 'content_flagged' === response.code ) { + text = strings.flagged; + } else if ( 'rate_limited' === response.code ) { + // The server sends the throttle message already translated. + text = response.error; + } + + this.add( text, 'bot' ); this.setLoading( false ); return; } diff --git a/tests/e2e/specs/connect.spec.js b/tests/e2e/specs/connect.spec.js new file mode 100644 index 00000000..0e5a23f3 --- /dev/null +++ b/tests/e2e/specs/connect.spec.js @@ -0,0 +1,98 @@ +import { test, expect } from '@wordpress/e2e-test-utils-playwright'; + +const HYVE_ADMIN = 'admin.php?page=hyve'; + +test.describe( 'Hyve Connect', () => { + test( 'nav: Hyve Connect sits in the AI group, not Integrations', async ( { + page, + admin, + } ) => { + await admin.visitAdminPage( `${ HYVE_ADMIN }&nav=settings` ); + + const nav = page.getByRole( 'navigation', { + name: 'Settings sections', + } ); + + // Vertical order encodes the grouping: the Hyve Connect entry must fall + // between the "AI" heading and the "Integrations" heading. + const aiY = ( + await nav.getByText( 'AI', { exact: true } ).boundingBox() + ).y; + const integrationsY = ( + await nav.getByText( 'Integrations' ).boundingBox() + ).y; + const connectY = ( + await nav + .getByRole( 'button', { name: 'Hyve Connect' } ) + .boundingBox() + ).y; + + expect( connectY ).toBeGreaterThan( aiY ); + expect( connectY ).toBeLessThan( integrationsY ); + } ); + + test( 'not connected: renders the value prop and the switch CTA', async ( { + page, + admin, + } ) => { + await admin.visitAdminPage( + `${ HYVE_ADMIN }&nav=settings&sub=hyve-connect` + ); + + await expect( + page.getByRole( 'heading', { name: 'Hyve Connect' } ) + ).toBeVisible(); + await expect( + page.getByText( 'Not connected', { exact: true } ) + ).toBeVisible(); + await expect( + page.getByText( 'We host the AI; you just switch it on.' ) + ).toBeVisible(); + + // The test site carries a dummy key, so the CTA offers to switch; a + // key-less site would read "Enable Hyve Connect". + await expect( + page.getByRole( 'button', { + name: /Switch to Hyve Connect|Enable Hyve Connect/, + } ) + ).toBeEnabled(); + } ); + + test( 'provider settings lock while Connect handles AI', async ( { + page, + admin, + } ) => { + await admin.visitAdminPage( + `${ HYVE_ADMIN }&nav=settings&sub=ai-provider` + ); + + // Drive the store into Connect mode the same way enabling Connect does, + // so the panel reacts without needing the hosted platform. + await page.evaluate( () => + window.wp.data.dispatch( 'hyve' ).setAiMode( 'hyve_connect' ) + ); + + await expect( + page.getByText( 'Hyve Connect is handling AI.' ) + ).toBeVisible(); + + // Model and retrieval tuning do nothing under Connect, so they lock. + await expect( page.getByLabel( 'Model' ) ).toBeDisabled(); + await expect( + page.getByRole( 'slider', { name: 'Similarity threshold' } ).first() + ).toBeDisabled(); + + // The API key is editable under Connect only when one is already stored + // (so it can be removed); a key-less site cannot add one here. + const hasStoredKey = await page.evaluate( () => + Boolean( window.hyve?.hasAPIKey ) + ); + const apiKey = page.getByLabel( 'API key' ); + + if ( hasStoredKey ) { + await expect( apiKey ).toBeEnabled(); + } else { + await expect( apiKey ).toBeDisabled(); + } + } ); +} ); diff --git a/tests/e2e/specs/dashboard.spec.js b/tests/e2e/specs/dashboard.spec.js index 47bbe9ca..527c0f06 100644 --- a/tests/e2e/specs/dashboard.spec.js +++ b/tests/e2e/specs/dashboard.spec.js @@ -205,11 +205,11 @@ test.describe( 'Dashboard', () => { admin, } ) => { await admin.visitAdminPage( - `${ HYVE_ADMIN }&nav=settings&sub=ai-advanced` + `${ HYVE_ADMIN }&nav=settings&sub=hyve-connect` ); await expect( - page.getByRole( 'heading', { name: 'Advanced tuning' } ) + page.getByRole( 'heading', { name: 'Hyve Connect' } ) ).toBeVisible(); await page.getByRole( 'button', { name: 'Provider & model' } ).click(); @@ -219,7 +219,7 @@ test.describe( 'Dashboard', () => { await page.goBack(); await expect( - page.getByRole( 'heading', { name: 'Advanced tuning' } ) + page.getByRole( 'heading', { name: 'Hyve Connect' } ) ).toBeVisible(); } ); diff --git a/tests/e2e/specs/settings.spec.js b/tests/e2e/specs/settings.spec.js index 27cbe4a8..ced5eb14 100644 --- a/tests/e2e/specs/settings.spec.js +++ b/tests/e2e/specs/settings.spec.js @@ -195,27 +195,21 @@ test.describe( 'Settings', () => { expect( saves[ 0 ]?.data?.chat_model ).toBe( 'gpt-4.1-nano' ); } ); - test( 'advanced: sliders render and reset restores the defaults', async ( { + test( 'provider: similarity threshold lives with the model settings', async ( { page, admin, } ) => { await admin.visitAdminPage( - `${ HYVE_ADMIN }&nav=settings&sub=ai-advanced` + `${ HYVE_ADMIN }&nav=settings&sub=ai-provider` ); - await expect( - page.getByRole( 'heading', { name: 'Advanced tuning' } ) - ).toBeVisible(); - const similarity = page .getByRole( 'slider', { name: 'Similarity threshold' } ) .first(); await expect( similarity ).toBeVisible(); await similarity.fill( '0.8' ); - await page.getByRole( 'button', { name: 'Reset to defaults' } ).click(); - - await expect( similarity ).toHaveValue( '0.4' ); + await expect( similarity ).toHaveValue( '0.8' ); } ); test( 'general: toggles save automatically', async ( { page, admin } ) => { @@ -253,7 +247,7 @@ test.describe( 'Settings', () => { await expect( page.getByLabel( 'Qdrant API key' ) ).toBeVisible(); await expect( page.getByLabel( 'Qdrant endpoint' ) ).toBeVisible(); await expect( - page.getByRole( 'button', { name: 'Connect' } ) + page.getByRole( 'button', { name: 'Connect', exact: true } ) ).toBeVisible(); } ); diff --git a/tests/php/unit/tests/test-connect-chat.php b/tests/php/unit/tests/test-connect-chat.php new file mode 100644 index 00000000..9cbf94a0 --- /dev/null +++ b/tests/php/unit/tests/test-connect-chat.php @@ -0,0 +1,83 @@ + $data Terminal event payload. + * + * @return void + */ + private function intercept_job_complete( array $data ) { + $body = 'event: job_complete' . "\n" . 'data: ' . wp_json_encode( $data ) . "\n\n"; + + add_filter( + 'pre_http_request', + function () use ( $body ) { + return [ + 'response' => [ 'code' => 200 ], + 'body' => $body, + ]; + }, + 10, + 3 + ); + } + + /** + * The poll flow records both the visitor message and the assistant reply on + * one thread, and hands the record id back so the next turn threads onto it. + */ + public function test_connect_poll_chat_records_the_conversation() { + update_option( 'hyve_settings', [ 'ai_mode' => Hyve_Connect::MODE_CONNECT ] ); + + $this->intercept_job_complete( + [ + 'thread_id' => 'th-1', + 'answered' => true, + 'reply' => 'Hello there.', + ] + ); + + $send = new WP_REST_Request( 'POST', '/hyve/v1/chat' ); + $send->set_param( 'message', 'Hi' ); + + $sent = API::instance()->send_chat( $send )->get_data(); + + // A thread record was created and its id returned (previously null). + $this->assertNotEmpty( $sent['record_id'] ); + $this->assertNotEmpty( $sent['query_run'] ); + $this->assertSame( 1, (int) Threads::get_thread_count() ); + + $poll = new WP_REST_Request( 'GET', '/hyve/v1/chat' ); + $poll->set_param( 'run_id', $sent['query_run'] ); + + API::instance()->get_chat( $poll ); + + // Still one thread, now holding the visitor message and the bot reply. + $this->assertSame( 1, (int) Threads::get_thread_count() ); + $this->assertSame( 2, (int) get_post_meta( (int) $sent['record_id'], '_hyve_thread_count', true ) ); + } +} diff --git a/tests/php/unit/tests/test-connect-import.php b/tests/php/unit/tests/test-connect-import.php new file mode 100644 index 00000000..42dbe5bb --- /dev/null +++ b/tests/php/unit/tests/test-connect-import.php @@ -0,0 +1,232 @@ + $data Terminal payload. + * + * @return void + */ + private function intercept( array $data ) { + $body = 'event: job_complete' . "\n" . 'data: ' . wp_json_encode( $data ) . "\n\n"; + + add_filter( + 'pre_http_request', + function () use ( $body ) { + return [ + 'response' => [ 'code' => 200 ], + 'body' => $body, + ]; + }, + 10, + 3 + ); + } + + /** + * Import rebuilds one row set per surviving post: a stale row from an earlier + * attempt is cleared (no duplication) and a source whose post is gone is + * skipped rather than imported as an unreachable ghost chunk. + */ + public function test_import_skips_ghosts_and_does_not_duplicate() { + update_option( 'hyve_settings', [ 'ai_mode' => Hyve_Connect::MODE_CONNECT ] ); + + $post = self::factory()->post->create(); + update_post_meta( $post, '_hyve_added', 1 ); + + // A leftover row from a previous, failed import attempt. + DB_Table::instance()->insert( + [ + 'post_id' => (string) $post, + 'post_status' => 'processed', + 'storage' => 'WordPress', + ] + ); + + $this->intercept( + [ + 'model' => 'openai/text-embedding-3-small', + 'dims' => 1536, + 'items' => [ + [ + 'id' => $post, + 'chunk_index' => 0, + 'content' => 'First chunk.', + 'embedding' => [ 0.1, 0.2 ], + 'token_count' => 3, + ], + [ + 'id' => $post, + 'chunk_index' => 1, + 'content' => 'Second chunk.', + 'embedding' => [ 0.3, 0.4 ], + 'token_count' => 2, + ], + [ + 'id' => 987654, + 'chunk_index' => 0, + 'content' => 'Ghost.', + 'embedding' => [ 0.9 ], + 'token_count' => 1, + ], + ], + 'next_cursor' => null, + ] + ); + + $request = new WP_REST_Request( 'POST', '/hyve/v1/connect/disconnect' ); + $request->set_param( 'mode', 'import' ); + + API::instance()->connect_disconnect( $request ); + + // Exactly the two real chunks: the stale row was cleared, the ghost skipped. + $this->assertSame( 2, (int) DB_Table::instance()->get_count() ); + } + + /** + * Disconnecting with "clear" while a sync is still in flight must not leave + * not-yet-synced posts' local chunk rows behind: clear wipes the local index. + */ + public function test_clear_disconnect_deletes_leftover_local_chunks() { + update_option( 'hyve_settings', [ 'ai_mode' => Hyve_Connect::MODE_CONNECT ] ); + + // A regular post that was indexed locally but not yet synced up: it still + // carries its local chunk rows (the sync had not reached it). + $post = self::factory()->post->create(); + update_post_meta( $post, '_hyve_added', 1 ); + DB_Table::instance()->insert( + [ + 'post_id' => (string) $post, + 'post_status' => 'processed', + 'storage' => 'WordPress', + ] + ); + $this->assertSame( 1, (int) DB_Table::instance()->get_count() ); + + // kb_delete_all on the platform succeeds. + $this->intercept( + [ + 'deleted' => [], + 'all' => true, + ] + ); + + $request = new WP_REST_Request( 'POST', '/hyve/v1/connect/disconnect' ); + $request->set_param( 'mode', 'clear' ); + + API::instance()->connect_disconnect( $request ); + + // The leftover local chunk row is gone and the site is self-hosted again. + $this->assertSame( 0, (int) DB_Table::instance()->get_count() ); + $this->assertSame( Hyve_Connect::MODE_SELF, \ThemeIsle\HyveLite\Main::get_settings()['ai_mode'] ); + } + + /** + * A mismatched embedding model aborts the import (its vectors are unusable + * locally) and leaves the site in Connect mode rather than half-migrated. + */ + public function test_import_rejects_a_mismatched_model() { + update_option( 'hyve_settings', [ 'ai_mode' => Hyve_Connect::MODE_CONNECT ] ); + + $post = self::factory()->post->create(); + update_post_meta( $post, '_hyve_added', 1 ); + + $this->intercept( + [ + 'model' => 'cohere/embed-english-v3', + 'dims' => 1024, + 'items' => [ + [ + 'id' => $post, + 'chunk_index' => 0, + 'content' => 'Nope.', + 'embedding' => [ 0.1 ], + 'token_count' => 1, + ], + ], + 'next_cursor' => null, + ] + ); + + $request = new WP_REST_Request( 'POST', '/hyve/v1/connect/disconnect' ); + $request->set_param( 'mode', 'import' ); + + $response = API::instance()->connect_disconnect( $request )->get_data(); + + $this->assertArrayHasKey( 'error', (array) $response ); + // Nothing imported and still connected: the user can retry or clear. + $this->assertSame( 0, (int) DB_Table::instance()->get_count() ); + $this->assertSame( Hyve_Connect::MODE_CONNECT, ThemeIsle\HyveLite\Main::get_settings()['ai_mode'] ); + } + + /** + * Cancelling a migration (disconnect-import before the sync finishes) must + * not drop sources it never reached. A not-yet-synced source that still has + * local chunks is kept; a truly empty one (rejected/failed, no chunks) is + * forgotten and, if plugin-owned, deleted. + */ + public function test_import_keeps_unsynced_sources_with_local_chunks() { + update_option( 'hyve_settings', [ 'ai_mode' => Hyve_Connect::MODE_CONNECT ] ); + + // Not-yet-pushed source: marked, local chunks intact, no synced hash. + $pending = self::factory()->post->create(); + update_post_meta( $pending, '_hyve_added', 1 ); + DB_Table::instance()->insert( + [ + 'post_id' => (string) $pending, + 'post_status' => 'processed', + 'storage' => 'WordPress', + ] + ); + + // Plugin-owned source that never had content to export (no chunks). + $empty = self::factory()->post->create( [ 'post_type' => 'hyve_docs' ] ); + update_post_meta( $empty, '_hyve_added', 1 ); + + // Platform is empty (the sync had barely started), delete-all succeeds. + $this->intercept( [ 'items' => [], 'next_cursor' => null ] ); + + $request = new WP_REST_Request( 'POST', '/hyve/v1/connect/disconnect' ); + $request->set_param( 'mode', 'import' ); + + API::instance()->connect_disconnect( $request ); + + // The pending source survives with its marker and its chunk row. + $this->assertSame( '1', get_post_meta( $pending, '_hyve_added', true ) ); + $this->assertSame( 1, (int) DB_Table::instance()->get_count() ); + + // The empty plugin-owned source is forgotten (post deleted). + $this->assertNull( get_post( $empty ) ); + } +} diff --git a/tests/php/unit/tests/test-connect-sync.php b/tests/php/unit/tests/test-connect-sync.php new file mode 100644 index 00000000..6dfe671d --- /dev/null +++ b/tests/php/unit/tests/test-connect-sync.php @@ -0,0 +1,1421 @@ + Hyve_Connect::MODE_CONNECT ] ); + } + + /** + * Intercept every HTTP request and return a canned body. + * + * @param string $body Response body. + * @param int $code HTTP status. + * + * @return void + */ + private function intercept( $body, $code = 200 ) { + add_filter( + 'pre_http_request', + function () use ( $body, $code ) { + return [ + 'response' => [ 'code' => $code ], + 'body' => $body, + ]; + }, + 10, + 3 + ); + } + + /** + * Build an SSE body from [event, data] frames. + * + * @param array}> $frames Frames. + * + * @return string + */ + private function sse( array $frames ) { + $out = ''; + + foreach ( $frames as $frame ) { + $out .= 'event: ' . $frame[0] . "\n" . 'data: ' . wp_json_encode( $frame[1] ) . "\n\n"; + } + + return $out; + } + + /** + * Create a post that looks indexed, optionally already on the platform. + * + * @param bool $synced Whether to mark it synced (`_hyve_connect_synced_hash`). + * + * @return int + */ + private function indexed_post( $synced = false ) { + $post_id = self::factory()->post->create(); + update_post_meta( $post_id, '_hyve_added', 1 ); + + if ( $synced ) { + update_post_meta( $post_id, '_hyve_connect_synced_hash', 'HASH' ); + } + + return $post_id; + } + + /** + * The pending set is indexed sources not yet on the platform. + */ + public function test_pending_posts_excludes_already_synced() { + $this->indexed_post( false ); + $this->indexed_post( false ); + $this->indexed_post( true ); + + $this->assertSame( 2, DB_Table::instance()->connect_pending_count() ); + } + + /** + * A stale processing error (e.g. a bad OpenAI key from before the switch) + * must not keep a source out of the sync forever: starting a round clears + * it so the source re-enters the pending set. + */ + public function test_start_sync_reattempts_sources_with_stale_errors() { + $this->enable_connect(); + $post = $this->indexed_post( false ); + update_post_meta( $post, '_hyve_processing_error', 'Incorrect OpenAI API key.' ); + + $this->assertSame( 0, DB_Table::instance()->connect_pending_count() ); + + DB_Table::instance()->connect_start_sync(); + + $this->assertSame( '', get_post_meta( $post, '_hyve_processing_error', true ) ); + $this->assertSame( 1, DB_Table::instance()->connect_sync_status()['total'] ); + } + + /** + * An embedding retry queued before the switch (hyve_process_post) must not + * run the local OpenAI pipeline in Connect mode, where the missing key + * would record a bogus error against the source. + */ + public function test_process_post_cron_noops_in_connect_mode() { + $this->enable_connect(); + $post = $this->indexed_post( false ); + $row = DB_Table::instance()->insert( + [ + 'post_id' => $post, + 'post_title' => 'Title', + 'post_content' => 'Content', + ] + ); + + $requests = 0; + add_filter( + 'pre_http_request', + function ( $response ) use ( &$requests ) { + ++$requests; + return $response; + } + ); + + do_action( 'hyve_process_post', $row ); + + $this->assertSame( 0, $requests ); + $this->assertSame( '', get_post_meta( $post, '_hyve_processing_error', true ) ); + $this->assertSame( 'scheduled', DB_Table::instance()->get( $row )->post_status ); + } + + /** + * Starting the sync seeds the progress option and schedules the cron. + */ + public function test_start_sync_seeds_progress_and_schedules() { + $this->enable_connect(); + $this->indexed_post( false ); + $this->indexed_post( false ); + + DB_Table::instance()->connect_start_sync(); + + $status = DB_Table::instance()->connect_sync_status(); + $this->assertSame( 2, $status['total'] ); + $this->assertTrue( $status['in_progress'] ); + $this->assertNotFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); + } + + /** + * With nothing indexed, starting the sync is a no-op. + */ + public function test_start_sync_noop_when_nothing_pending() { + $this->enable_connect(); + + DB_Table::instance()->connect_start_sync(); + + $this->assertSame( [], DB_Table::instance()->connect_sync_status() ); + $this->assertFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); + } + + /** + * A run pushes the batch, marks each source synced, and finishes when drained. + */ + public function test_run_sync_syncs_batch_and_finishes() { + $this->enable_connect(); + $a = $this->indexed_post( false ); + $b = $this->indexed_post( false ); + + $this->intercept( + $this->sse( + [ + [ + 'job_complete', + [ + 'results' => [ + [ + 'id' => $a, + 'status' => 'stored', + ], + [ + 'id' => $b, + 'status' => 'stored', + ], + ], + 'kb' => [ 'chunks' => 6 ], + ], + ], + ] + ) + ); + + DB_Table::instance()->connect_start_sync(); + DB_Table::instance()->connect_run_sync(); + + $this->assertNotSame( '', get_post_meta( $a, '_hyve_connect_synced_hash', true ) ); + $this->assertNotSame( '', get_post_meta( $b, '_hyve_connect_synced_hash', true ) ); + $this->assertSame( 0, DB_Table::instance()->connect_pending_count() ); + + $status = DB_Table::instance()->connect_sync_status(); + $this->assertFalse( $status['in_progress'] ); + $this->assertSame( 2, $status['current'] ); + } + + /** + * A disconnect landing while the upsert is in flight must not let the sync + * job delete local rows or write synced markers on the now self-hosted site. + */ + public function test_run_sync_aborts_when_disconnected_mid_upsert() { + $this->enable_connect(); + $post = $this->indexed_post( false ); + + DB_Table::instance()->connect_start_sync(); + + // The store returns "stored", but a disconnect flips the site to + // self-hosted from inside the call, exactly as a racing disconnect would. + $body = $this->sse( + [ + [ + 'job_complete', + [ + 'results' => [ + [ + 'id' => $post, + 'status' => 'stored', + ], + ], + 'kb' => [ 'chunks' => 3 ], + ], + ], + ] + ); + + add_filter( + 'pre_http_request', + function () use ( $body ) { + update_option( 'hyve_settings', [ 'ai_mode' => Hyve_Connect::MODE_SELF ] ); + + return [ + 'response' => [ 'code' => 200 ], + 'body' => $body, + ]; + }, + 10, + 3 + ); + + DB_Table::instance()->connect_run_sync(); + + // The post-upsert mode re-check aborted before the mutation loop: no + // synced hash was written (and no local rows were deleted), so the source + // stays pending for a clean re-push if Connect is re-enabled. + $this->assertSame( '', get_post_meta( $post, '_hyve_connect_synced_hash', true ) ); + } + + /** + * The watchdog reschedules a sync whose run was lost: it still claims to be + * in progress, nothing is queued to drive it, and it has not advanced within + * the stall window. + */ + public function test_watchdog_reschedules_a_stranded_sync() { + $this->enable_connect(); + update_option( + DB_Table::CONNECT_SYNC_OPTION, + [ + 'in_progress' => true, + 'heartbeat' => time() - ( DB_Table::CONNECT_SYNC_STALL + MINUTE_IN_SECONDS ), + ] + ); + wp_clear_scheduled_hook( DB_Table::CONNECT_SYNC_HOOK ); + + DB_Table::instance()->connect_sync_watchdog(); + + $this->assertNotFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); + } + + /** + * The watchdog leaves a sync that advanced recently alone (its batch is + * likely still executing), so it never double-drives a healthy run. + */ + public function test_watchdog_leaves_a_recently_active_sync_alone() { + $this->enable_connect(); + update_option( + DB_Table::CONNECT_SYNC_OPTION, + [ + 'in_progress' => true, + 'heartbeat' => time(), + ] + ); + wp_clear_scheduled_hook( DB_Table::CONNECT_SYNC_HOOK ); + + DB_Table::instance()->connect_sync_watchdog(); + + $this->assertFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); + } + + /** + * A document the platform omits from its results is treated as failed, never + * assumed stored: no synced hash is written (so it is never silently believed + * to be on the platform) and the error is surfaced. + */ + public function test_run_sync_treats_a_missing_result_as_failed() { + $this->enable_connect(); + $post = $this->indexed_post( false ); + + $this->intercept( + $this->sse( + [ + [ + 'job_complete', + [ + 'results' => [], + 'kb' => [ 'chunks' => 0 ], + ], + ], + ] + ) + ); + + DB_Table::instance()->connect_start_sync(); + DB_Table::instance()->connect_run_sync(); + + $this->assertSame( '', get_post_meta( $post, '_hyve_connect_synced_hash', true ) ); + $this->assertNotSame( '', (string) get_post_meta( $post, '_hyve_processing_error', true ) ); + } + + /** + * A changed platform account (e.g. free -> paid activation) forgets the stale + * synced markers and restarts the sync so the whole KB re-lands on the new + * account rather than staying split across two. + */ + public function test_identity_change_resets_markers_and_restarts_sync() { + $this->enable_connect(); + $post = $this->indexed_post( true ); + update_option( DB_Table::CONNECT_IDENTITY_OPTION, hash( 'sha256', 'free' ) ); + + add_filter( + 'product_hyve_license_key', + function () { + return 'PAID-KEY'; + } + ); + + DB_Table::instance()->connect_check_identity(); + + $this->assertSame( '', get_post_meta( $post, '_hyve_connect_synced_hash', true ) ); + $this->assertNotFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); + $this->assertSame( hash( 'sha256', 'PAID-KEY' ), get_option( DB_Table::CONNECT_IDENTITY_OPTION ) ); + } + + /** + * The first identity observation only records it: a healthy, already-synced + * site is never disturbed just because the fingerprint had not been stored. + */ + public function test_identity_first_run_records_without_resync() { + $this->enable_connect(); + $post = $this->indexed_post( true ); + delete_option( DB_Table::CONNECT_IDENTITY_OPTION ); + + DB_Table::instance()->connect_check_identity(); + + $this->assertNotSame( '', (string) get_option( DB_Table::CONNECT_IDENTITY_OPTION ) ); + $this->assertNotSame( '', get_post_meta( $post, '_hyve_connect_synced_hash', true ) ); + $this->assertFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); + } + + /** + * An edit that is over quota clears its per-minute update flag (stopping the + * update_posts retry loop) and becomes pending for the background sync, whose + * block/resume path carries it once the quota frees. + */ + public function test_over_quota_edit_stops_retry_loop_and_defers_to_sync() { + $this->enable_connect(); + $post = $this->indexed_post( true ); + update_post_meta( $post, '_hyve_needs_update', 1 ); + + $this->intercept( + $this->sse( + [ + [ + 'job_complete', + [ + 'results' => [ + [ + 'id' => $post, + 'status' => 'skipped', + 'reason' => 'storage', + ], + ], + 'kb' => [ + 'storage' => [ + 'limit' => 1000, + 'used' => 1000, + ], + ], + ], + ], + ] + ) + ); + + $result = DB_Table::instance()->add_post( $post, 'update' ); + + $this->assertWPError( $result ); + $this->assertStringContainsString( 'quota_exceeded', $result->get_error_code() ); + $this->assertSame( '', get_post_meta( $post, '_hyve_needs_update', true ) ); + $this->assertSame( '', get_post_meta( $post, '_hyve_connect_synced_hash', true ) ); + $this->assertNotFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); + } + + /** + * A rejected source is marked handled (so the batch advances) and flagged. + */ + public function test_run_sync_records_rejection_and_advances() { + $this->enable_connect(); + $post = $this->indexed_post( false ); + + $this->intercept( + $this->sse( + [ + [ + 'job_complete', + [ + 'results' => [ + [ + 'id' => $post, + 'status' => 'rejected', + 'moderation' => [ + 'flagged' => true, + 'categories' => [ 'violence' ], + ], + ], + ], + ], + ], + ] + ) + ); + + DB_Table::instance()->connect_start_sync(); + DB_Table::instance()->connect_run_sync(); + + // Rejected content is flagged and leaves the pending set, but is not + // marked synced (it never reached the platform). + $this->assertSame( 1, (int) get_post_meta( $post, '_hyve_moderation_failed', true ) ); + $this->assertSame( '', get_post_meta( $post, '_hyve_connect_synced_hash', true ) ); + $this->assertSame( 0, DB_Table::instance()->connect_pending_count() ); + } + + /** + * A source with no extractable text is never sent: it gets a processing + * error (terminal, shown in the listing) while the rest of the batch syncs. + */ + public function test_run_sync_marks_empty_content_sources_failed() { + $this->enable_connect(); + + $empty = self::factory()->post->create( [ 'post_content' => '' ] ); + update_post_meta( $empty, '_hyve_added', 1 ); + $post = $this->indexed_post( false ); + + $this->intercept( + $this->sse( + [ + [ + 'job_complete', + [ + 'results' => [ + [ + 'id' => $post, + 'status' => 'stored', + ], + ], + ], + ], + ] + ) + ); + + DB_Table::instance()->connect_start_sync(); + DB_Table::instance()->connect_run_sync(); + + $this->assertNotSame( '', get_post_meta( $empty, '_hyve_processing_error', true ) ); + $this->assertSame( '', get_post_meta( $empty, '_hyve_connect_synced_hash', true ) ); + $this->assertNotSame( '', get_post_meta( $post, '_hyve_connect_synced_hash', true ) ); + $this->assertSame( 0, DB_Table::instance()->connect_pending_count() ); + $this->assertFalse( DB_Table::instance()->connect_sync_status()['in_progress'] ); + } + + /** + * A platform-side per-document failure is terminal: the source records the + * error, is not marked synced, and leaves the pending set. + */ + public function test_run_sync_records_platform_failure_and_advances() { + $this->enable_connect(); + $post = $this->indexed_post( false ); + + $this->intercept( + $this->sse( + [ + [ + 'job_complete', + [ + 'results' => [ + [ + 'id' => $post, + 'status' => 'failed', + 'reason' => 'empty', + ], + ], + ], + ], + ] + ) + ); + + DB_Table::instance()->connect_start_sync(); + DB_Table::instance()->connect_run_sync(); + + $this->assertNotSame( '', get_post_meta( $post, '_hyve_processing_error', true ) ); + $this->assertSame( '', get_post_meta( $post, '_hyve_connect_synced_hash', true ) ); + $this->assertSame( 0, DB_Table::instance()->connect_pending_count() ); + } + + /** + * Hitting the plan cap stops the job, records the block, and leaves the + * sources pending (retrying would not help until the user upgrades). + */ + public function test_run_sync_blocks_on_quota_exceeded() { + $this->enable_connect(); + $this->indexed_post( false ); + + $this->intercept( + $this->sse( + [ + [ + 'error', + [ + 'code' => 'quota_exceeded', + 'message' => 'Over the free limit', + 'quota' => [ + 'kind' => 'storage', + 'limit' => 100, + 'used' => 100, + ], + ], + ], + ] + ) + ); + + DB_Table::instance()->connect_start_sync(); + DB_Table::instance()->connect_run_sync(); + + $status = DB_Table::instance()->connect_sync_status(); + $this->assertTrue( $status['blocked'] ); + $this->assertFalse( $status['in_progress'] ); + // The block-time snapshot lets auto-resume tell "changed" from "still full". + $this->assertSame( 100, $status['quota']['limit'] ); + $this->assertSame( 1, DB_Table::instance()->connect_pending_count() ); + } + + /** + * A source skipped for quota stays pending while the stored ones advance; + * the job keeps going as long as something fits. + */ + public function test_run_sync_keeps_skipped_sources_pending() { + $this->enable_connect(); + $a = $this->indexed_post( false ); + $b = $this->indexed_post( false ); + + $this->intercept( + $this->sse( + [ + [ + 'job_complete', + [ + 'results' => [ + [ + 'id' => $a, + 'status' => 'stored', + ], + [ + 'id' => $b, + 'status' => 'skipped', + 'reason' => 'storage', + ], + ], + 'kb' => [ + 'storage' => [ + 'used' => 999, + 'limit' => 1000, + ], + ], + ], + ], + ] + ) + ); + + DB_Table::instance()->connect_start_sync(); + DB_Table::instance()->connect_run_sync(); + + $this->assertNotSame( '', get_post_meta( $a, '_hyve_connect_synced_hash', true ) ); + $this->assertSame( '', get_post_meta( $b, '_hyve_connect_synced_hash', true ) ); + $this->assertSame( 1, DB_Table::instance()->connect_pending_count() ); + $this->assertEmpty( DB_Table::instance()->connect_sync_status()['blocked'] ); + } + + /** + * A batch where nothing fits is terminal: block with the quota snapshot, + * exactly like a refused batch. + */ + public function test_run_sync_blocks_when_nothing_fits() { + $this->enable_connect(); + $post = $this->indexed_post( false ); + + $this->intercept( + $this->sse( + [ + [ + 'job_complete', + [ + 'results' => [ + [ + 'id' => $post, + 'status' => 'skipped', + 'reason' => 'storage', + ], + ], + 'kb' => [ + 'storage' => [ + 'used' => 1000, + 'limit' => 1000, + ], + ], + ], + ], + ] + ) + ); + + DB_Table::instance()->connect_start_sync(); + DB_Table::instance()->connect_run_sync(); + + $status = DB_Table::instance()->connect_sync_status(); + $this->assertTrue( $status['blocked'] ); + $this->assertSame( 'storage', $status['quota']['kind'] ); + $this->assertSame( 1000, $status['quota']['limit'] ); + $this->assertSame( 1, DB_Table::instance()->connect_pending_count() ); + } + + /** + * When the platform has purged our content, recovery clears the stale synced + * markers and restarts the sync from the posts we still hold. + */ + public function test_recovery_restarts_sync_when_platform_empty() { + $this->enable_connect(); + $post = $this->indexed_post( true ); + + // The aggregate the recovery check reads reports an empty hosted KB. + $this->intercept( + wp_json_encode( + [ + 'plan' => 'free', + 'service' => 'ok', + 'kb' => [ 'state' => 'empty' ], + ] + ) + ); + + DB_Table::instance()->connect_check_recovery(); + + $this->assertSame( '', get_post_meta( $post, '_hyve_connect_synced_hash', true ) ); + + $status = DB_Table::instance()->connect_sync_status(); + $this->assertTrue( $status['in_progress'] ); + $this->assertSame( 1, $status['total'] ); + $this->assertNotFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); + } + + /** + * A healthy hosted KB is left alone: no re-sync, no stale-marker churn. + */ + public function test_recovery_noop_when_platform_has_content() { + $this->enable_connect(); + $post = $this->indexed_post( true ); + + $this->intercept( + wp_json_encode( + [ + 'plan' => 'free', + 'service' => 'ok', + 'kb' => [ 'state' => 'ok' ], + ] + ) + ); + + DB_Table::instance()->connect_check_recovery(); + + $this->assertNotSame( '', get_post_meta( $post, '_hyve_connect_synced_hash', true ) ); + $this->assertSame( [], DB_Table::instance()->connect_sync_status() ); + } + + /** + * Regression: a direct ingest (e.g. a sitemap import) grows the KB without the + * sync bookkeeping, so a stale "empty" aggregate can linger in the cache. + * Recovery must read fresh state, not that cache, or it wrongly resets the + * markers and re-syncs everything already on the platform. + */ + public function test_recovery_ignores_stale_empty_cache() { + $this->enable_connect(); + $post = $this->indexed_post( true ); + + // The cache still holds the pre-ingest snapshot: an empty hosted KB. + set_transient( + 'hyve_connect_stats', + [ + 'plan' => 'free', + 'service' => 'ok', + 'kb' => [ 'state' => 'empty' ], + ], + 5 * MINUTE_IN_SECONDS + ); + + // The platform, freshly queried, reports the content is actually there. + $this->intercept( + wp_json_encode( + [ + 'plan' => 'free', + 'service' => 'ok', + 'kb' => [ 'state' => 'ok' ], + ] + ) + ); + + DB_Table::instance()->connect_check_recovery(); + + $this->assertNotSame( '', get_post_meta( $post, '_hyve_connect_synced_hash', true ) ); + $this->assertSame( [], DB_Table::instance()->connect_sync_status() ); + $this->assertFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); + } + + /** + * Intercept HTTP requests and return each canned body in turn (last repeats). + * + * @param array $bodies Ordered response bodies. + * + * @return void + */ + private function intercept_sequence( $bodies ) { + $i = 0; + + add_filter( + 'pre_http_request', + function () use ( &$i, $bodies ) { + $body = $bodies[ min( $i, count( $bodies ) - 1 ) ]; + $i++; + + return [ + 'response' => [ 'code' => 200 ], + 'body' => $body, + ]; + }, + 10, + 3 + ); + } + + /** + * The root aggregate matching stops reconcile before any manifest is sent. + */ + public function test_reconcile_stops_when_root_in_sync() { + $this->enable_connect(); + $this->indexed_post( true ); + + $this->intercept( + $this->sse( + [ + [ + 'job_complete', + [ + 'in_sync' => true, + 'aggregate' => 'x', + ], + ], + ] + ) + ); + + $this->assertTrue( DB_Table::instance()->connect_reconcile() ); + $this->assertFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); + } + + /** + * The drill-down (root -> buckets -> scoped detail) re-pushes a stale source: + * it is unmarked so the sync job re-sends it. + */ + public function test_reconcile_drilldown_repushes_stale_source() { + $this->enable_connect(); + + $post = self::factory()->post->create( [ 'post_content' => 'New content' ] ); + update_post_meta( $post, '_hyve_added', 1 ); + update_post_meta( $post, '_hyve_connect_synced_hash', 'HASH' ); + + $bucket = Hyve_Connect::kb_bucket_of( $post ); + + $this->intercept_sequence( + [ + $this->sse( + [ + [ + 'job_complete', + [ + 'in_sync' => false, + 'aggregate' => 'x', + ], + ], + ] + ), + $this->sse( + [ + [ + 'job_complete', + [ + 'in_sync' => false, + 'differing_buckets' => [ $bucket ], + ], + ], + ] + ), + $this->sse( + [ + [ + 'job_complete', + [ + 'deleted' => [], + 'stale' => [ (string) $post ], + 'missing' => [], + ], + ], + ] + ), + ] + ); + + $this->assertTrue( DB_Table::instance()->connect_reconcile() ); + + // Unmarked for re-push, and the sync job is scheduled to send it. + $this->assertSame( '', get_post_meta( $post, '_hyve_connect_synced_hash', true ) ); + $this->assertNotFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); + } + + /** + * Build an indexed source post with plain content and optional meta. + * + * @param string $content Post content (tag-free, so its hash is stable). + * @param array $meta Extra post meta to set. + * + * @return int + */ + private function source_post( $content, $meta = [] ) { + // hyve_docs is registered exclude_from_search, so this also guards the + // manifest query against the post_type "any" pitfall. + $post_id = self::factory()->post->create( + [ + 'post_content' => $content, + 'post_type' => 'hyve_docs', + ] + ); + update_post_meta( $post_id, '_hyve_added', 1 ); + + foreach ( $meta as $key => $value ) { + update_post_meta( $post_id, $key, $value ); + } + + return $post_id; + } + + /** + * Reduce the manifest to an id => hash map for assertions. + * + * @return array + */ + private function manifest_map() { + $map = []; + + foreach ( DB_Table::instance()->connect_local_manifest() as $entry ) { + $map[ (int) $entry['id'] ] = $entry['hash']; + } + + return $map; + } + + /** + * A never-synced indexed source is listed at its current content hash. + */ + public function test_manifest_lists_unsynced_source_at_current_hash() { + $post = $this->source_post( 'Alpha content' ); + + $this->assertSame( + hash( 'sha256', 'Alpha content' ), + $this->manifest_map()[ $post ] + ); + } + + /** + * When the post is unchanged since sync, the cached hash is reused verbatim + * (a sentinel that differs from the real content hash proves no recompute). + */ + public function test_manifest_reuses_cached_hash_when_unchanged() { + $post = $this->source_post( + 'Alpha content', + [ + '_hyve_connect_synced_hash' => 'SENTINEL', + ] + ); + update_post_meta( $post, '_hyve_connect_synced_modified', (int) get_post_modified_time( 'U', true, $post ) ); + + $this->assertSame( 'SENTINEL', $this->manifest_map()[ $post ] ); + } + + /** + * A modified-time mismatch forces a recompute of the real content hash. + */ + public function test_manifest_recomputes_when_modified_changed() { + $post = $this->source_post( + 'Alpha content', + [ + '_hyve_connect_synced_hash' => 'SENTINEL', + '_hyve_connect_synced_modified' => 1, // Stale timestamp. + ] + ); + + $this->assertSame( + hash( 'sha256', 'Alpha content' ), + $this->manifest_map()[ $post ] + ); + } + + /** + * A moderation-failed source that was previously synced stays in the manifest + * at its last-synced hash (its kept cloud copy must not be orphaned). + */ + public function test_manifest_keeps_flagged_previously_synced() { + $post = $this->source_post( + 'Rejected new content', + [ + '_hyve_moderation_failed' => 1, + '_hyve_connect_synced_hash' => 'SENTINEL', + ] + ); + + $this->assertSame( 'SENTINEL', $this->manifest_map()[ $post ] ); + } + + /** + * A moderation-failed source that was never synced is left out entirely. + */ + public function test_manifest_excludes_flagged_never_synced() { + $post = $this->source_post( 'Bad content', [ '_hyve_moderation_failed' => 1 ] ); + + $this->assertArrayNotHasKey( $post, $this->manifest_map() ); + } + + /** + * An in-flight sync refuses a concurrent reconcile. + */ + public function test_reconcile_refuses_while_sync_in_flight() { + $this->enable_connect(); + update_option( DB_Table::CONNECT_SYNC_OPTION, [ 'in_progress' => true ] ); + + $result = DB_Table::instance()->connect_reconcile(); + + $this->assertWPError( $result ); + $this->assertSame( 'connect_busy', $result->get_error_code() ); + } + + /** + * A manual Sync retries a plan-blocked job (cheap under greedy admission: + * it stores whatever fits or just re-blocks with fresh numbers). + */ + public function test_reconcile_retries_a_blocked_sync() { + $this->enable_connect(); + update_option( + DB_Table::CONNECT_SYNC_OPTION, + [ + 'in_progress' => false, + 'blocked' => true, + 'message' => 'Over the limit', + ] + ); + + $this->intercept( + $this->sse( + [ + [ + 'job_complete', + [ + 'in_sync' => true, + 'aggregate' => 'x', + ], + ], + ] + ) + ); + + $this->assertTrue( DB_Table::instance()->connect_reconcile() ); + $this->assertSame( [], DB_Table::instance()->connect_sync_status() ); + } + + /** + * A reconcile that fails its network round must leave the blocked state (and + * its banner) intact, so the job is not silently stranded with no way back. + */ + public function test_reconcile_keeps_block_when_network_fails() { + $this->enable_connect(); + update_option( + DB_Table::CONNECT_SYNC_OPTION, + [ + 'in_progress' => false, + 'blocked' => true, + 'message' => 'Over the limit', + ] + ); + + // The store is unreachable, so the reconcile round errors out. + $this->intercept( '', 500 ); + + $result = DB_Table::instance()->connect_reconcile(); + + $this->assertWPError( $result ); + + $status = DB_Table::instance()->connect_sync_status(); + $this->assertTrue( $status['blocked'] ); + } + + /** + * Recovery still repairs an empty account while a block is recorded, e.g. + * right after an upgrade points the site at a fresh paid account. + */ + public function test_recovery_restarts_even_when_blocked() { + $this->enable_connect(); + $post = $this->indexed_post( true ); + update_option( + DB_Table::CONNECT_SYNC_OPTION, + [ + 'in_progress' => false, + 'blocked' => true, + ] + ); + + $this->intercept( + wp_json_encode( + [ + 'plan' => 'paid', + 'service' => 'ok', + 'kb' => [ 'state' => 'empty' ], + ] + ) + ); + + DB_Table::instance()->connect_check_recovery(); + + $this->assertSame( '', get_post_meta( $post, '_hyve_connect_synced_hash', true ) ); + $this->assertTrue( DB_Table::instance()->connect_sync_status()['in_progress'] ); + $this->assertNotFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); + } + + /** + * Seed a blocked sync plus a cached stats payload. + * + * @param int $used Storage chunks used. + * @param int $limit Storage chunk limit. + * @param array $windows Indexing windows ({remaining} each). + * @param array $snapshot Quota snapshot recorded at block time. + * + * @return void + */ + private function seed_blocked_with_stats( $used, $limit, $windows = [], $snapshot = [] ) { + update_option( + DB_Table::CONNECT_SYNC_OPTION, + [ + 'in_progress' => false, + 'blocked' => true, + 'message' => 'Over the limit', + 'quota' => $snapshot, + ] + ); + + set_transient( + 'hyve_connect_stats', + [ + 'kb' => [ + 'storage' => [ + 'used' => $used, + 'limit' => $limit, + ], + ], + 'indexing' => [ 'windows' => $windows ], + ], + 5 * MINUTE_IN_SECONDS + ); + } + + /** + * A blocked sync resumes by itself once cached stats show headroom. + */ + public function test_blocked_sync_resumes_when_stats_show_headroom() { + $this->enable_connect(); + $this->indexed_post( false ); + $this->seed_blocked_with_stats( + 100, + 1000, + [ + '24h' => [ 'remaining' => 500 ], + '30d' => [ 'remaining' => 500 ], + ] + ); + + DB_Table::instance()->connect_maybe_resume_blocked(); + + $status = DB_Table::instance()->connect_sync_status(); + $this->assertTrue( $status['in_progress'] ); + $this->assertEmpty( $status['blocked'] ); + $this->assertNotFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); + } + + /** + * No storage headroom -> the block stays. + */ + public function test_blocked_sync_stays_blocked_without_storage_headroom() { + $this->enable_connect(); + $this->indexed_post( false ); + $this->seed_blocked_with_stats( 1000, 1000 ); + + DB_Table::instance()->connect_maybe_resume_blocked(); + + $this->assertTrue( DB_Table::instance()->connect_sync_status()['blocked'] ); + $this->assertFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); + } + + /** + * Leftover room alone does not resume: with the numbers unchanged since + * the block, the knowledge base simply does not fit, and resuming would + * retry-loop against the cap. + */ + public function test_blocked_sync_stays_blocked_when_numbers_unchanged() { + $this->enable_connect(); + $this->indexed_post( false ); + $this->seed_blocked_with_stats( + 987, + 1000, + [ + '24h' => [ 'remaining' => 500 ], + '30d' => [ 'remaining' => 500 ], + ], + [ + 'kind' => 'storage', + 'limit' => 1000, + 'used' => 987, + ] + ); + + DB_Table::instance()->connect_maybe_resume_blocked(); + + $this->assertTrue( DB_Table::instance()->connect_sync_status()['blocked'] ); + $this->assertFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); + } + + /** + * A raised cap since the block (upgrade, server-side change) resumes. + */ + public function test_blocked_sync_resumes_when_limit_grew_since_block() { + $this->enable_connect(); + $this->indexed_post( false ); + $this->seed_blocked_with_stats( + 987, + 10000, + [ + '24h' => [ 'remaining' => 500 ], + '30d' => [ 'remaining' => 500 ], + ], + [ + 'kind' => 'storage', + 'limit' => 1000, + 'used' => 987, + ] + ); + + DB_Table::instance()->connect_maybe_resume_blocked(); + + $status = DB_Table::instance()->connect_sync_status(); + $this->assertTrue( $status['in_progress'] ); + $this->assertEmpty( $status['blocked'] ); + } + + /** + * Storage headroom but an exhausted indexing window -> the block stays + * (resuming would immediately re-block against the churn cap). + */ + public function test_blocked_sync_stays_blocked_when_indexing_window_exhausted() { + $this->enable_connect(); + $this->indexed_post( false ); + $this->seed_blocked_with_stats( + 100, + 1000, + [ + '24h' => [ 'remaining' => 500 ], + '30d' => [ 'remaining' => 0 ], + ] + ); + + DB_Table::instance()->connect_maybe_resume_blocked(); + + $this->assertTrue( DB_Table::instance()->connect_sync_status()['blocked'] ); + $this->assertFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); + } + + /** + * An import-mode disconnect unmarks sources that never reached the + * platform: nothing was exported for them, so keeping the KB markers + * would list them as indexed with no local chunks behind them. + */ + public function test_disconnect_import_drops_never_synced_sources() { + $this->enable_connect(); + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + + $synced = $this->indexed_post( true ); + $unsynced = $this->indexed_post( false ); + + $docs = self::factory()->post->create( [ 'post_type' => 'hyve_docs' ] ); + update_post_meta( $docs, '_hyve_added', 1 ); + + // Export returns only the synced post's chunk; delete succeeds. + add_filter( + 'pre_http_request', + function ( $response, $args ) use ( $synced ) { + $payload = json_decode( isset( $args['body'] ) ? (string) $args['body'] : '', true ); + + $data = 'export' === ( $payload['action'] ?? '' ) + ? [ + 'items' => [ + [ + 'id' => $synced, + 'content' => 'Synced chunk', + 'token_count' => 3, + 'embedding' => [ 0.1, 0.2 ], + ], + ], + 'next_cursor' => null, + ] + : [ 'deleted' => true ]; + + return [ + 'response' => [ 'code' => 200 ], + 'body' => $this->sse( [ [ 'job_complete', $data ] ] ), + ]; + }, + 10, + 2 + ); + + $request = new WP_REST_Request( 'POST', '/hyve/v1/connect' ); + $request->set_query_params( [ 'mode' => 'import' ] ); + $response = rest_do_request( $request ); + + $this->assertTrue( $response->get_data() ); + $this->assertSame( '1', get_post_meta( $synced, '_hyve_added', true ) ); + $this->assertSame( '', get_post_meta( $synced, '_hyve_connect_synced_hash', true ) ); + $this->assertSame( '', get_post_meta( $unsynced, '_hyve_added', true ) ); + $this->assertNull( get_post( $docs ) ); + } + + /** + * Import re-enters the local engine, where the local chunk limit applies: + * content over it is pruned (oldest first), mirroring Qdrant deactivation. + */ + public function test_disconnect_import_prunes_over_local_limit() { + $this->enable_connect(); + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + + $posts = [ + $this->indexed_post( true ), + $this->indexed_post( true ), + $this->indexed_post( true ), + ]; + + add_filter( + 'hyve_chunks_limit', + function () { + return 2; + } + ); + + add_filter( + 'pre_http_request', + function ( $response, $args ) use ( $posts ) { + $payload = json_decode( isset( $args['body'] ) ? (string) $args['body'] : '', true ); + + $data = 'export' === ( $payload['action'] ?? '' ) + ? [ + 'items' => array_map( + function ( $post_id ) { + return [ + 'id' => $post_id, + 'content' => 'Chunk for ' . $post_id, + 'token_count' => 3, + 'embedding' => [ 0.1 ], + ]; + }, + $posts + ), + 'next_cursor' => null, + ] + : [ 'deleted' => true ]; + + return [ + 'response' => [ 'code' => 200 ], + 'body' => $this->sse( [ [ 'job_complete', $data ] ] ), + ]; + }, + 10, + 2 + ); + + $request = new WP_REST_Request( 'POST', '/hyve/v1/connect' ); + $request->set_query_params( [ 'mode' => 'import' ] ); + rest_do_request( $request ); + + // Two newest chunks fit the limit; the oldest post's cleanup is scheduled. + $this->assertNotFalse( wp_next_scheduled( 'hyve_delete_posts', [ [ (string) $posts[0] ] ] ) ); + } + + /** + * Disconnect-clear removes the hosted copy with ONE platform call; the + * per-post local cleanup must not fire a platform delete for each source + * (a thousand-source KB would time out). + */ + public function test_disconnect_clear_deletes_hosted_copy_once() { + $this->enable_connect(); + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + + $docs = []; + + for ( $i = 0; $i < 3; $i++ ) { + $doc = self::factory()->post->create( [ 'post_type' => 'hyve_docs' ] ); + $docs[] = $doc; + update_post_meta( $doc, '_hyve_added', 1 ); + update_post_meta( $doc, '_hyve_connect_synced_hash', 'HASH' ); + } + + $requests = 0; + + add_filter( + 'pre_http_request', + function () use ( &$requests ) { + ++$requests; + + return [ + 'response' => [ 'code' => 200 ], + 'body' => $this->sse( + [ + [ + 'job_complete', + [ + 'deleted' => [], + 'all' => true, + ], + ], + ] + ), + ]; + } + ); + + $request = new WP_REST_Request( 'POST', '/hyve/v1/connect' ); + $request->set_query_params( [ 'mode' => 'clear' ] ); + $response = rest_do_request( $request ); + + $this->assertTrue( $response->get_data() ); + $this->assertSame( 1, $requests ); + + foreach ( $docs as $doc ) { + $this->assertNull( get_post( $doc ) ); + } + } +} diff --git a/tests/php/unit/tests/test-hyve-connect.php b/tests/php/unit/tests/test-hyve-connect.php new file mode 100644 index 00000000..8af0bbef --- /dev/null +++ b/tests/php/unit/tests/test-hyve-connect.php @@ -0,0 +1,551 @@ + + */ + private $captured = []; + + /** + * Reset options, transients and filters between tests. + */ + protected function tearDown(): void { + remove_all_filters( 'pre_http_request' ); + remove_all_filters( 'product_hyve_license_key' ); + remove_all_filters( 'hyve_connect_base_url' ); + remove_all_actions( 'before_delete_post' ); + delete_option( 'hyve_settings' ); + delete_option( Hyve_Connect::SITE_TOKEN_OPTION ); + delete_transient( 'hyve_connect_stats' ); + parent::tearDown(); + } + + /** + * Intercept the next HTTP request, capture it, and return a canned response. + * + * @param string $body Response body. + * @param int $code HTTP status. + * + * @return void + */ + private function intercept( $body, $code = 200 ) { + add_filter( + 'pre_http_request', + function ( $preempt, $args, $url ) use ( $body, $code ) { + $this->captured = [ + 'args' => $args, + 'url' => $url, + ]; + + return [ + 'response' => [ 'code' => $code ], + 'body' => $body, + ]; + }, + 10, + 3 + ); + } + + /** + * Build an SSE body from [event, data] frames. + * + * @param array}> $frames Frames. + * + * @return string + */ + private function sse( array $frames ) { + $out = ''; + + foreach ( $frames as $frame ) { + $out .= 'event: ' . $frame[0] . "\n" . 'data: ' . wp_json_encode( $frame[1] ) . "\n\n"; + } + + return $out; + } + + /** + * Connect is opt-in: with nothing chosen a site stays self-hosted. + */ + public function test_mode_defaults_to_self_hosted() { + delete_option( 'hyve_settings' ); + + $this->assertSame( Hyve_Connect::MODE_SELF, Hyve_Connect::get_mode() ); + $this->assertFalse( Hyve_Connect::is_active() ); + } + + /** + * Only an explicit hyve_connect opt-in turns Connect on. + */ + public function test_mode_connect_only_when_opted_in() { + update_option( 'hyve_settings', [ 'ai_mode' => Hyve_Connect::MODE_CONNECT ] ); + + $this->assertSame( Hyve_Connect::MODE_CONNECT, Hyve_Connect::get_mode() ); + $this->assertTrue( Hyve_Connect::is_active() ); + } + + /** + * Leaving Connect via a plain settings save is refused: it would strand the + * hosted KB and stale local markers, so it must go through disconnect. + */ + public function test_update_settings_refuses_leaving_connect() { + update_option( 'hyve_settings', [ 'ai_mode' => Hyve_Connect::MODE_CONNECT ] ); + + $request = new WP_REST_Request( 'POST', '/hyve/v1/settings' ); + $request->set_param( 'data', [ 'ai_mode' => Hyve_Connect::MODE_SELF ] ); + + $response = \ThemeIsle\HyveLite\API::instance()->update_settings( $request )->get_data(); + + $this->assertArrayHasKey( 'error', (array) $response ); + $this->assertSame( Hyve_Connect::MODE_CONNECT, \ThemeIsle\HyveLite\Main::get_settings()['ai_mode'] ); + } + + /** + * Parse_sse preserves event order and decodes each frame's data. + */ + public function test_parse_sse_orders_events_and_decodes_data() { + $body = $this->sse( + [ + [ 'stream_start', [] ], + [ 'delta', [ 'text' => 'Hi' ] ], + [ + 'job_complete', + [ + 'reply' => 'Hi', + 'answered' => true, + ], + ], + ] + ); + + $events = Hyve_Connect::parse_sse( $body ); + + $this->assertSame( [ 'stream_start', 'delta', 'job_complete' ], array_column( $events, 'event' ) ); + $this->assertSame( 'Hi', $events[1]['data']['text'] ); + } + + /** + * SSE comments (": connected") and blank frames are ignored. + */ + public function test_parse_sse_skips_comments_and_blanks() { + $body = ": connected\n\n" . $this->sse( [ [ 'job_complete', [ 'ok' => true ] ] ] ); + + $events = Hyve_Connect::parse_sse( $body ); + + $this->assertSame( [ 'job_complete' ], array_column( $events, 'event' ) ); + } + + /** + * Kb_aggregate must match the platform's aggregate byte for byte: sha256 over + * "id:hash" lines sorted by id as strings. This pins the string-sort ("10" + * before "2") and the empty-KB value, so the reconcile fast-path stays valid. + */ + public function test_kb_aggregate_matches_platform_formula() { + $manifest = [ + [ + 'id' => 2, + 'hash' => 'b', + ], + [ + 'id' => 10, + 'hash' => 'a', + ], + ]; + + // String sort orders "10" before "2". + $this->assertSame( + hash( 'sha256', "10:a\n2:b" ), + Hyve_Connect::kb_aggregate( $manifest ) + ); + + // Order-independent. + $this->assertSame( + Hyve_Connect::kb_aggregate( $manifest ), + Hyve_Connect::kb_aggregate( array_reverse( $manifest ) ) + ); + + // Empty KB. + $this->assertSame( + hash( 'sha256', '' ), + Hyve_Connect::kb_aggregate( [] ) + ); + } + + /** + * Kb_bucket_of must match the platform's bucketOf: low 8 bits of crc32. + */ + public function test_kb_bucket_of_matches_platform_formula() { + foreach ( [ '1', '2', '12345', 'custom-source' ] as $id ) { + $this->assertSame( + crc32( $id ) & 255, + Hyve_Connect::kb_bucket_of( $id ) + ); + $this->assertGreaterThanOrEqual( 0, Hyve_Connect::kb_bucket_of( $id ) ); + $this->assertLessThan( 256, Hyve_Connect::kb_bucket_of( $id ) ); + } + } + + /** + * Kb_bucket_hashes groups a manifest by bucket and aggregates each group, so + * a source's bucket hash equals aggregating just that source. + */ + public function test_kb_bucket_hashes_group_by_bucket() { + $manifest = [ + [ + 'id' => 1, + 'hash' => 'h1', + ], + [ + 'id' => 2, + 'hash' => 'h2', + ], + ]; + + $buckets = Hyve_Connect::kb_bucket_hashes( $manifest ); + + $b1 = Hyve_Connect::kb_bucket_of( 1 ); + $this->assertSame( + Hyve_Connect::kb_aggregate( + [ + [ + 'id' => 1, + 'hash' => 'h1', + ], + ] + ), + $buckets[ $b1 ] + ); + } + + /** + * Kb_upsert POSTs the contract-shaped payload and returns the job_complete data. + */ + public function test_kb_upsert_builds_request_and_returns_job_complete() { + $this->intercept( + $this->sse( + [ + [ + 'job_complete', + [ + 'results' => [ + [ + 'id' => 12, + 'status' => 'stored', + 'chunks' => 3, + ], + ], + 'kb' => [ 'chunks' => 3 ], + ], + ], + ] + ) + ); + + $result = Hyve_Connect::instance()->kb_upsert( + [ + [ + 'id' => 12, + 'type' => 'post', + 'title' => 'T', + 'url' => 'https://x', + 'content' => 'body', + ], + ] + ); + + $this->assertStringEndsWith( 'hyve-kb/start', $this->captured['url'] ); + + $body = json_decode( $this->captured['args']['body'], true ); + $this->assertSame( 'upsert', $body['action'] ); + $this->assertSame( 12, $body['documents'][0]['id'] ); + + $this->assertSame( get_site_url(), $this->captured['args']['headers']['X-Site-Url'] ); + $this->assertArrayNotHasKey( 'Authorization', $this->captured['args']['headers'] ); + + $this->assertSame( 'stored', $result['results'][0]['status'] ); + } + + /** + * Every request carries the per-site token, generated once and reused, so + * the platform can bind a free site's identity without a license key. + */ + public function test_site_token_is_sent_and_stable() { + delete_option( Hyve_Connect::SITE_TOKEN_OPTION ); + + $this->intercept( $this->sse( [ [ 'job_complete', [ 'kb' => [] ] ] ] ) ); + Hyve_Connect::instance()->kb_reconcile( [], null ); + + $token = $this->captured['args']['headers']['X-Site-Token']; + + $this->assertNotEmpty( $token ); + $this->assertSame( $token, get_option( Hyve_Connect::SITE_TOKEN_OPTION ) ); + + // A second request reuses the same stored token, not a fresh one. + $this->intercept( $this->sse( [ [ 'job_complete', [ 'kb' => [] ] ] ] ) ); + Hyve_Connect::instance()->kb_reconcile( [], null ); + + $this->assertSame( $token, $this->captured['args']['headers']['X-Site-Token'] ); + } + + /** + * A license key becomes a base64'd bearer; free installs send none. + */ + public function test_license_adds_base64_bearer() { + add_filter( 'product_hyve_license_key', fn() => 'LICENSE123' ); + $this->intercept( $this->sse( [ [ 'job_complete', [ 'kb' => [] ] ] ] ) ); + + Hyve_Connect::instance()->kb_reconcile( [], null ); + + $this->assertSame( + 'Bearer ' . base64_encode( 'LICENSE123' ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + $this->captured['args']['headers']['Authorization'] + ); + } + + /** + * The base URL is filterable (staging/local). + */ + public function test_base_url_is_filterable() { + add_filter( 'hyve_connect_base_url', fn() => 'https://staging.test/api/workflows/' ); + $this->intercept( $this->sse( [ [ 'job_complete', [] ] ] ) ); + + Hyve_Connect::instance()->kb_reconcile( [], null ); + + $this->assertStringStartsWith( 'https://staging.test/api/workflows/', $this->captured['url'] ); + } + + /** + * The custom instructions sent to the platform come from the same + * system_prompt setting that drives self-hosted chat. + */ + public function test_chat_settings_sends_system_prompt() { + update_option( 'hyve_settings', [ 'system_prompt' => 'Reply like a pirate.' ] ); + + $sent = Hyve_Connect::chat_settings(); + + $this->assertSame( 'Reply like a pirate.', $sent['instructions'] ); + } + + /** + * The prompt flows through hyve_system_prompt, so the Pro license gate + * (and any programmatic override) applies to Connect mode too. + */ + public function test_chat_settings_respects_system_prompt_filter() { + update_option( 'hyve_settings', [ 'system_prompt' => 'Reply like a pirate.' ] ); + + add_filter( 'hyve_system_prompt', '__return_empty_string' ); + $sent = Hyve_Connect::chat_settings(); + remove_filter( 'hyve_system_prompt', '__return_empty_string' ); + + $this->assertSame( '', $sent['instructions'] ); + } + + /** + * Instructions are capped to the platform's input limit so an oversized + * prompt cannot fail the whole chat request server-side. + */ + public function test_chat_settings_caps_instructions_length() { + update_option( 'hyve_settings', [ 'system_prompt' => str_repeat( 'a', 5000 ) ] ); + + $sent = Hyve_Connect::chat_settings(); + + $this->assertSame( 4000, strlen( $sent['instructions'] ) ); + } + + /** + * A terminal SSE error becomes a coded WP_Error. + */ + public function test_sse_error_maps_to_wp_error_with_code() { + $this->intercept( + $this->sse( + [ + [ + 'error', + [ + 'code' => 'quota_exceeded', + 'message' => 'Limit reached', + 'quota' => [ 'kind' => 'messages' ], + ], + ], + ] + ) + ); + + $result = Hyve_Connect::instance()->kb_upsert( + [ + [ + 'id' => 1, + 'title' => 'a', + 'content' => 'b', + ], + ] + ); + + $this->assertWPError( $result ); + $this->assertSame( 'hyve_connect_quota_exceeded', $result->get_error_code() ); + } + + /** + * A 429 HTTP response maps to quota_exceeded. + */ + public function test_http_429_maps_to_quota_exceeded() { + $this->intercept( wp_json_encode( [ 'message' => 'Too many requests' ] ), 429 ); + + $result = Hyve_Connect::instance()->kb_reconcile( [], null ); + + $this->assertWPError( $result ); + $this->assertSame( 'hyve_connect_quota_exceeded', $result->get_error_code() ); + } + + /** + * A stream with no terminal event is an error, not a silent empty result. + */ + public function test_missing_terminal_event_is_an_error() { + $this->intercept( $this->sse( [ [ 'stream_start', [] ], [ 'delta', [ 'text' => 'x' ] ] ] ) ); + + $result = Hyve_Connect::instance()->kb_reconcile( [], null ); + + $this->assertWPError( $result ); + $this->assertSame( 'hyve_connect_no_result', $result->get_error_code() ); + } + + /** + * User_message maps platform codes to visitor/admin wording. + */ + public function test_user_message_maps_codes() { + $quota = new WP_Error( 'hyve_connect_quota_exceeded', 'x', [ 'code' => 'quota_exceeded' ] ); + $kb = new WP_Error( 'hyve_connect_kb_unavailable', 'x', [ 'code' => 'kb_unavailable' ] ); + $unknown = new WP_Error( 'hyve_connect_whatever', 'x', [ 'code' => 'something_else' ] ); + + $this->assertStringContainsString( 'limit', strtolower( Hyve_Connect::user_message( $quota ) ) ); + $this->assertStringContainsString( 'knowledge base', strtolower( Hyve_Connect::user_message( $kb ) ) ); + // An unmapped code falls back to the generic unavailable message. + $this->assertStringContainsString( 'temporarily unavailable', strtolower( Hyve_Connect::user_message( $unknown ) ) ); + } + + /** + * Get_quota reads the plain-JSON aggregate over GET. + */ + public function test_get_quota_returns_decoded_json() { + $this->intercept( + wp_json_encode( + [ + 'plan' => 'free', + 'kb' => [ 'chunks' => 10 ], + ] + ) + ); + + $result = Hyve_Connect::instance()->get_quota(); + + $this->assertSame( 'free', $result['plan'] ); + $this->assertSame( 'GET', $this->captured['args']['method'] ); + $this->assertStringEndsWith( 'hyve/quota', $this->captured['url'] ); + } + + /** + * Stats() caches the aggregate so a second read does not hit the network. + */ + public function test_stats_are_cached_after_first_fetch() { + $this->intercept( + wp_json_encode( + [ + 'plan' => 'free', + 'service' => 'ok', + ] + ) + ); + + $first = Hyve_Connect::instance()->stats(); + $this->assertSame( 'free', $first['plan'] ); + + // Drop the interceptor; a cached second read still returns the data. + remove_all_filters( 'pre_http_request' ); + $second = Hyve_Connect::instance()->stats(); + $this->assertSame( 'free', $second['plan'] ); + } + + /** + * A degraded service is cached briefly as an error marker. + */ + public function test_stats_degraded_on_error() { + $this->intercept( wp_json_encode( [ 'message' => 'boom' ] ), 500 ); + + $stats = Hyve_Connect::instance()->stats(); + + $this->assertSame( 'error', $stats['service'] ); + } + + /** + * Deleting an indexed post in Connect mode removes it from the platform, so + * deleting local content (e.g. a sitemap's pages) never orphans it there. + * + * Goes through wp_delete_post to prove the real hook wiring: WordPress wipes + * the post meta before delete_post fires, so the handler must run earlier + * (before_delete_post) or _hyve_added is already gone and nothing propagates. + */ + public function test_deleting_indexed_post_removes_it_from_connect() { + update_option( 'hyve_settings', [ 'ai_mode' => Hyve_Connect::MODE_CONNECT ] ); + new \ThemeIsle\HyveLite\Main(); + + $post_id = self::factory()->post->create(); + update_post_meta( $post_id, '_hyve_added', 1 ); + + $this->intercept( + $this->sse( + [ + [ + 'job_complete', + [ + 'deleted' => [ $post_id ], + 'kb' => [], + ], + ], + ] + ) + ); + + wp_delete_post( $post_id, true ); + + $this->assertStringEndsWith( 'hyve-kb/start', $this->captured['url'] ); + + $body = json_decode( $this->captured['args']['body'], true ); + $this->assertSame( 'delete', $body['action'] ); + $this->assertSame( [ $post_id ], $body['ids'] ); + } + + /** + * Deleting a post that was never indexed makes no platform call, so routine + * deletions do not hit the API. + */ + public function test_deleting_non_indexed_post_skips_connect() { + update_option( 'hyve_settings', [ 'ai_mode' => Hyve_Connect::MODE_CONNECT ] ); + new \ThemeIsle\HyveLite\Main(); + + $post_id = self::factory()->post->create(); + + $this->intercept( $this->sse( [ [ 'job_complete', [] ] ] ) ); + $this->captured = []; + + wp_delete_post( $post_id, true ); + + $this->assertSame( [], $this->captured ); + } +} diff --git a/tests/php/unit/tests/test-indexed-content.php b/tests/php/unit/tests/test-indexed-content.php new file mode 100644 index 00000000..786a901f --- /dev/null +++ b/tests/php/unit/tests/test-indexed-content.php @@ -0,0 +1,157 @@ +user->create( [ 'role' => 'administrator' ] ) ); + } + + /** + * Create an indexed plugin-owned source. + * + * @param array $meta Extra post meta. + * @param string $title Post title. + * + * @return int + */ + private function docs_source( $meta = [], $title = 'Imported page' ) { + $post_id = self::factory()->post->create( + [ + 'post_type' => 'hyve_docs', + 'post_title' => $title, + ] + ); + + update_post_meta( $post_id, '_hyve_added', 1 ); + + foreach ( $meta as $key => $value ) { + update_post_meta( $post_id, $key, $value ); + } + + return $post_id; + } + + /** + * Fetch the unified included listing, keyed by post id. + * + * @return array> + */ + private function indexed_rows() { + $request = new WP_REST_Request( 'GET', '/hyve/v1/data' ); + $request->set_query_params( + [ + 'status' => 'included', + 'type' => 'any', + ] + ); + + $rows = []; + + foreach ( rest_do_request( $request )->get_data()['posts'] as $row ) { + $rows[ (int) $row['ID'] ] = $row; + } + + return $rows; + } + + /** + * The unified listing shows plugin-owned sources next to regular posts, + * labeled by origin and flagged as permanently removable. + */ + public function test_listing_includes_plugin_owned_sources() { + $post = self::factory()->post->create(); + update_post_meta( $post, '_hyve_added', 1 ); + + $sitemap = $this->docs_source( [ '_hyve_type' => 'sitemap' ], 'Imported: Pricing' ); + $custom = $this->docs_source( [], 'Refund policy' ); + + $rows = $this->indexed_rows(); + + $this->assertArrayHasKey( $post, $rows ); + $this->assertArrayNotHasKey( 'permanent', $rows[ $post ] ); + + $this->assertSame( 'Sitemap', $rows[ $sitemap ]['type'] ); + $this->assertTrue( $rows[ $sitemap ]['permanent'] ); + $this->assertSame( 'Custom Data', $rows[ $custom ]['type'] ); + } + + /** + * Link/sitemap sources without a title fall back to their source URL. + */ + public function test_untitled_source_lists_its_url() { + $link = $this->docs_source( + [ + '_hyve_type' => 'link', + '_hyve_source' => 'https://example.com/pricing', + ], + '' + ); + + $this->assertSame( 'https://example.com/pricing', $this->indexed_rows()[ $link ]['title'] ); + } + + /** + * In Connect mode each row reports whether it reached the platform, so + * the UI can flag over-limit sources instead of calling them indexed. + * Self-hosted rows carry no such flag. + */ + public function test_connect_mode_reports_sync_state() { + $self_hosted = $this->docs_source(); + $this->assertArrayNotHasKey( 'synced', $this->indexed_rows()[ $self_hosted ] ); + + update_option( 'hyve_settings', [ 'ai_mode' => \ThemeIsle\HyveLite\Hyve_Connect::MODE_CONNECT ] ); + // The Connect listing reads the platform stats; keep it off the network. + add_filter( + 'pre_http_request', + function () { + return new WP_Error( 'http_blocked', 'Blocked in tests.' ); + } + ); + + $synced = $this->docs_source( [ '_hyve_connect_synced_hash' => 'HASH' ] ); + + $rows = $this->indexed_rows(); + + $this->assertTrue( $rows[ $synced ]['synced'] ); + $this->assertFalse( $rows[ $self_hosted ]['synced'] ); + } + + /** + * Removing a plugin-owned source deletes it outright; a regular post only + * loses its Knowledge Base markers. + */ + public function test_delete_removes_plugin_owned_sources_permanently() { + $post = self::factory()->post->create(); + $sitemap = $this->docs_source( [ '_hyve_type' => 'sitemap' ] ); + update_post_meta( $post, '_hyve_added', 1 ); + + foreach ( [ $post, $sitemap ] as $id ) { + $request = new WP_REST_Request( 'DELETE', '/hyve/v1/data' ); + $request->set_query_params( [ 'id' => $id ] ); + rest_do_request( $request ); + } + + $this->assertInstanceOf( 'WP_Post', get_post( $post ) ); + $this->assertSame( '', get_post_meta( $post, '_hyve_added', true ) ); + $this->assertNull( get_post( $sitemap ) ); + } +}