From 08e3961d6b5918958dfba63e3964b481c171744e Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Tue, 14 Jul 2026 16:27:43 +0530 Subject: [PATCH 01/17] wip --- inc/API.php | 351 ++++++++- inc/DB_Table.php | 531 +++++++++++++ inc/Hyve_Connect.php | 707 ++++++++++++++++++ inc/Main.php | 26 +- inc/Qdrant_API.php | 9 +- inc/Stream.php | 110 +++ src/backend/App.js | 9 +- src/backend/components/StatCard.js | 7 +- src/backend/router.js | 4 + src/backend/screens/AI.js | 32 +- src/backend/screens/Connect.js | 619 +++++++++++++++ src/backend/screens/Dashboard.js | 243 ++++-- src/backend/screens/KnowledgeBase.js | 27 +- src/backend/screens/Settings.js | 2 + src/backend/store.js | 59 +- src/backend/style.scss | 136 ++++ .../php/unit/tests/test-connect-migration.php | 347 +++++++++ tests/php/unit/tests/test-hyve-connect.php | 329 ++++++++ 18 files changed, 3466 insertions(+), 82 deletions(-) create mode 100644 inc/Hyve_Connect.php create mode 100644 src/backend/screens/Connect.js create mode 100644 tests/php/unit/tests/test-connect-migration.php create mode 100644 tests/php/unit/tests/test-hyve-connect.php diff --git a/inc/API.php b/inc/API.php index 56464d60..99862466 100644 --- a/inc/API.php +++ b/inc/API.php @@ -211,6 +211,21 @@ public function register_routes() { 'callback' => [ $this, 'qdrant_deactivate' ], ], ], + 'connect' => [ + [ + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'connect_disconnect' ], + 'args' => [ + 'action' => [ + 'type' => 'string', + ], + 'mode' => [ + 'type' => 'string', + 'enum' => [ 'import', 'clear' ], + ], + ], + ], + ], 'chat' => [ [ 'methods' => \WP_REST_Server::READABLE, @@ -333,6 +348,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 ); @@ -475,6 +496,22 @@ 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' ), + ] + ); + } + $api_warning = ''; $api_key_error = null; $key_validated = false; @@ -482,7 +519,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 @@ -511,7 +549,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(); @@ -522,6 +560,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_migration(); + } + // 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 @@ -733,12 +779,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 ); @@ -811,12 +865,19 @@ 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() ) { + $data['connect'] = Hyve_Connect::instance()->stats( true ); + $data['connectSync'] = $this->table->connect_migration_status(); + } + + return rest_ensure_response( $data ); } /** @@ -878,7 +939,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 ); @@ -1039,6 +1106,132 @@ public function qdrant_deactivate() { return rest_ensure_response( __( 'Qdrant deactivated.', 'hyve-lite' ) ); } + /** + * Disconnect Hyve Connect, on one of two user-chosen paths (D18). + * + * `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 ] ); + } + + 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 ) ] ); + } + + if ( 'clear' === $mode ) { + // Nothing is indexed anywhere now: drop the local bookkeeping too. + $this->connect_clear_local(); + } else { + // 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(); + } + + delete_option( DB_Table::CONNECT_SYNC_OPTION ); + + $settings['ai_mode'] = Hyve_Connect::MODE_SELF; + update_option( 'hyve_settings', $settings ); + 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, D5/D12). + * + * @return true|\WP_Error + */ + private function connect_import() { + $client = Hyve_Connect::instance(); + $cursor = null; + + do { + $batch = $client->kb_export( $cursor, 50 ); + + if ( is_wp_error( $batch ) ) { + return $batch; + } + + $items = isset( $batch['items'] ) && is_array( $batch['items'] ) ? $batch['items'] : []; + + foreach ( $items as $item ) { + $post_id = (int) ( $item['id'] ?? 0 ); + + $this->table->insert( + [ + 'post_id' => $post_id, + 'post_title' => $post_id ? get_the_title( $post_id ) : '', + 'post_content' => (string) ( $item['content'] ?? '' ), + '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; + } + + /** + * Clear local Knowledge Base bookkeeping when disconnecting with "clear". + * + * @return void + */ + private function connect_clear_local() { + $posts = get_posts( + [ + 'post_type' => 'any', + 'post_status' => 'any', + 'fields' => 'ids', + 'posts_per_page' => -1, + 'meta_key' => '_hyve_added', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + ] + ); + + foreach ( $posts as $post_id ) { + delete_post_meta( $post_id, '_hyve_added' ); + delete_post_meta( $post_id, '_hyve_connect_synced' ); + 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' ); + + do_action( 'hyve_data_deleted', (int) $post_id ); + } + } + /** * Get chat. * @@ -1047,6 +1240,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' ); @@ -1655,6 +1852,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 ); @@ -1677,6 +1880,120 @@ 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::user_message( $result ), + 'code' => $result->get_error_code(), + ] + ); + } + + $thread_id = isset( $result['thread_id'] ) ? $result['thread_id'] : $prepared['thread_id']; + $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'] : []; + $answered = ! empty( $result['answered'] ); + $reply = isset( $result['reply'] ) ? $result['reply'] : ''; + + if ( $answered ) { + $final = $reply; + + if ( ! empty( $settings['show_source_link'] ) && ! empty( $result['sources'] ) ) { + $final = $this->maybe_append_source_link( $final, array_column( $result['sources'], 'id' ) ); + } + } else { + $final = $settings['default_message']; + } + + $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. * @@ -1697,6 +2014,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..ae7817b6 100644 --- a/inc/DB_Table.php +++ b/inc/DB_Table.php @@ -48,6 +48,27 @@ class DB_Table { */ const MAX_PROCESS_ATTEMPTS = 5; + /** + * Source documents pushed to Hyve Connect per migration run. + * + * @var int + */ + const CONNECT_SYNC_BATCH = 10; + + /** + * The option key tracking the to-Connect sync job. + * + * @var string + */ + const CONNECT_SYNC_OPTION = 'hyve_connect_migration'; + + /** + * The cron hook that drives the to-Connect sync job. + * + * @var string + */ + const CONNECT_SYNC_HOOK = 'hyve_lite_connect_sync'; + /** * The single instance of the class. * @@ -363,6 +384,21 @@ public function get_by_storage( string $storage, int $limit = 100 ): array { return $results; } + /** + * Count rows held in a given storage backend. + * + * @since 1.4.3 + * + * @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. * @@ -558,6 +594,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'] ); @@ -677,6 +719,491 @@ public function ingest_document( $doc, $args = [] ) { return true; } + /** + * Ingest a document via Hyve Connect. + * + * 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 + */ + 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'] ); + + // 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; + } + + if ( ! $post_id ) { + return new \WP_Error( 'missing_post', __( 'Missing post reference.', 'hyve-lite' ) ); + } + + $result = Hyve_Connect::instance()->kb_upsert( [ $this->connect_document( (int) $post_id, $doc ) ] ); + + if ( is_wp_error( $result ) ) { + if ( $created_here ) { + wp_delete_post( $post_id, true ); + } + + return $result; + } + + $status = isset( $result['results'][0] ) && is_array( $result['results'][0] ) ? $result['results'][0] : []; + $state = $status['status'] ?? 'failed'; + + 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. + 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' ) ); + } + + update_post_meta( $post_id, '_hyve_added', 1 ); + // Already on the platform, so the to-Connect sync job must skip it. + update_post_meta( $post_id, '_hyve_connect_synced', 1 ); + + foreach ( $extra_meta as $meta_key => $meta_value ) { + update_post_meta( $post_id, $meta_key, $meta_value ); + } + + 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; + } + + /** + * Build the contract-shaped document for a Hyve Connect upsert. + * + * @param int $post_id The owning post id (site-scoped source id). + * @param array $doc The document, with `title` and `content`. + * + * @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 (D11). + 'content' => wp_strip_all_tags( (string) $doc['content'] ), + ]; + } + + /** + * Map a WordPress post type to a Hyve Connect source type. + * + * @param int $post_id The post id. + * + * @return string One of post|page|product|doc. + */ + private function connect_source_type( $post_id ) { + $map = [ + 'page' => 'page', + 'product' => 'product', + 'hyve_docs' => 'doc', + ]; + + $type = (string) get_post_type( $post_id ); + + return $map[ $type ] ?? 'post'; + } + + /** + * Translate a platform `rejected` result's categories into the local + * `_hyve_moderation_review` shape (category => score). + * + * @param array $status A single upsert result. + * + * @return array + */ + private function connect_moderation_review( $status ) { + $categories = isset( $status['moderation']['categories'] ) && is_array( $status['moderation']['categories'] ) + ? $status['moderation']['categories'] + : []; + + $review = []; + + foreach ( $categories as $category ) { + $review[ (string) $category ] = 1.0; + } + + return $review; + } + + /** + * 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 (`_hyve_connect_synced` unset): the existing self-hosted + * content to migrate on enable, or everything after an inactivity purge. + * + * @param int $limit Max posts to return (-1 for all). + * + * @return array Post ids. + */ + public function connect_pending_posts( $limit = self::CONNECT_SYNC_BATCH ) { + return get_posts( + [ + 'post_type' => 'any', + 'post_status' => 'any', + 'fields' => 'ids', + 'posts_per_page' => $limit, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- one-off migration read, not a hot path. + 'meta_query' => [ + [ + 'key' => '_hyve_added', + 'compare' => 'EXISTS', + ], + [ + 'key' => '_hyve_connect_synced', + 'compare' => 'NOT EXISTS', + ], + // A source the platform rejected on moderation stays out of the + // pending set so the batch never re-picks it forever. + [ + 'key' => '_hyve_moderation_failed', + 'compare' => 'NOT EXISTS', + ], + ], + ] + ); + } + + /** + * How many sources still need pushing to Hyve Connect. + * + * @return int + */ + public function connect_pending_count() { + return count( $this->connect_pending_posts( -1 ) ); + } + + /** + * Whether any source is currently believed to live on Hyve Connect. + * + * @return bool + */ + public function connect_has_synced() { + $synced = get_posts( + [ + 'post_type' => 'any', + 'post_status' => 'any', + 'fields' => 'ids', + 'posts_per_page' => 1, + 'meta_key' => '_hyve_connect_synced', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + ] + ); + + return ! empty( $synced ); + } + + /** + * 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_migration() { + $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' => '', + ] + ); + + Hyve_Connect::flush_stats(); + wp_schedule_single_event( time(), self::CONNECT_SYNC_HOOK ); + } + + /** + * 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_migrate_data() { + if ( ! Hyve_Connect::is_active() ) { + $this->connect_finish_migration(); + return; + } + + $post_ids = $this->connect_pending_posts(); + + if ( empty( $post_ids ) ) { + $this->connect_finish_migration(); + return; + } + + $documents = []; + + foreach ( $post_ids as $post_id ) { + $documents[] = $this->connect_document( + (int) $post_id, + [ + 'title' => get_the_title( $post_id ), + 'content' => get_post_field( 'post_content', $post_id ), + ] + ); + } + + $result = Hyve_Connect::instance()->kb_upsert( $documents ); + + if ( is_wp_error( $result ) ) { + // 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 ( false !== strpos( $result->get_error_code(), 'quota_exceeded' ) ) { + $this->connect_block_migration( $result->get_error_message() ); + return; + } + + // Transient failure (unreachable/provider): back off and retry. + wp_schedule_single_event( time() + 30, self::CONNECT_SYNC_HOOK ); + 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; + } + } + + foreach ( $post_ids as $post_id ) { + $row = $status_by_id[ (int) $post_id ] ?? []; + $state = isset( $row['status'] ) ? $row['status'] : 'stored'; + + // Either way the local chunk rows are redundant in Connect mode. + $this->delete_by_post_id( $post_id ); + + // A rejected source is not on the platform. Record why for the KB + // listing; the moderation marker keeps it out of the pending set + // without pretending it is synced (which would loop the purge-recovery + // check on a site whose whole KB is rejected). + 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; + } + + update_post_meta( $post_id, '_hyve_connect_synced', 1 ); + } + + $this->connect_advance_migration( count( $post_ids ) ); + } + + /** + * 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_migration( $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; + + update_option( self::CONNECT_SYNC_OPTION, $status ); + Hyve_Connect::flush_stats(); + + if ( $has_more ) { + wp_schedule_single_event( time() + 10, self::CONNECT_SYNC_HOOK ); + } + } + + /** + * Mark the sync job complete (nothing left to push). + * + * @return void + */ + private function connect_finish_migration() { + $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. + * + * @param string $message The platform's limit message. + * + * @return void + */ + private function connect_block_migration( $message ) { + $status = get_option( self::CONNECT_SYNC_OPTION, [] ); + + $status['in_progress'] = false; + $status['blocked'] = true; + $status['message'] = (string) $message; + + 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_migration_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() { + $posts = get_posts( + [ + 'post_type' => 'any', + 'post_status' => 'any', + 'fields' => 'ids', + 'posts_per_page' => -1, + 'meta_key' => '_hyve_connect_synced', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + ] + ); + + foreach ( $posts as $post_id ) { + delete_post_meta( $post_id, '_hyve_connect_synced' ); + } + } + + /** + * Re-push local content when Hyve Connect has lost it (inactivity purge, D13). + * + * 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_migration_status(); + + // Don't stack recovery on an in-flight or plan-blocked migration. + if ( ! empty( $status['in_progress'] ) || ! empty( $status['blocked'] ) ) { + 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_migration(); + } + } + /** * Process posts. * @@ -887,6 +1414,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..4f3c2400 --- /dev/null +++ b/inc/Hyve_Connect.php @@ -0,0 +1,707 @@ +> $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 ) ), + ] + ); + } + + /** + * 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, + ] + ); + } + + /** + * Fetch the hosted knowledge base state (used for purge auto-recovery, D13). + * + * @return array|\WP_Error + */ + public function kb_status() { + return $this->workflow( self::SLUG_KB, [ 'action' => 'status' ] ); + } + + /** + * Export a batch of stored chunks + vectors for local re-import on disconnect (D12). + * + * @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_remote_get( + $this->base_url() . self::PATH_QUOTA, + [ + 'headers' => $this->get_headers( 'application/json' ), + 'timeout' => 30, + ] + ); + + if ( is_wp_error( $response ) ) { + return $this->save_error( 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' ) ); + } + + delete_option( self::ERROR_OPTION_KEY ); + + 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' ) ); + } + + delete_option( self::ERROR_OPTION_KEY ); + + 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(); + } + + return apply_filters( + 'hyve_connect_chat_settings', + [ + 'instructions' => isset( $settings['instructions'] ) ? $settings['instructions'] : '', + ], + $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' ); + } + + /** + * Last recorded service error, for the dashboard notice. + * + * @return array + */ + public static function get_last_error() { + $error = get_option( self::ERROR_OPTION_KEY, [] ); + + return is_array( $error ) ? $error : []; + } + + /** + * 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' ), + 'moderation_flagged' => __( 'Message was flagged.', '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' ); + } + + /** + * 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, + 'timeout' => 60, + ] + ); + + if ( is_wp_error( $response ) ) { + return $this->save_error( 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'] ) { + delete_option( self::ERROR_OPTION_KEY ); + + 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-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; + } + + /** + * 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 ] ) ); + + // Persist only the errors that mean the service is degraded; a flagged + // message or unavailable KB is an expected per-request outcome. + if ( in_array( $code, [ 'provider_error', 'quota_exceeded' ], true ) ) { + $this->save_error( $error ); + } + + 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, + ] + ); + + $this->save_error( $error ); + + return $error; + } + + /** + * Persist a service error for the dashboard notice. + * + * @param \WP_Error $error The error. + * + * @return \WP_Error The same error, for chaining. + */ + private function save_error( $error ) { + update_option( + self::ERROR_OPTION_KEY, + [ + 'code' => $error->get_error_code(), + 'message' => $error->get_error_message(), + 'date' => wp_date( 'c' ), + 'provider' => 'Hyve Connect', + ] + ); + + return $error; + } +} diff --git a/inc/Main.php b/inc/Main.php index c157b58d..7b312349 100644 --- a/inc/Main.php +++ b/inc/Main.php @@ -57,7 +57,8 @@ public function __construct() { add_action( 'admin_menu', [ $this, 'register_menu_page' ] ); 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_migrate_data' ] ); add_filter( 'themeisle_sdk_enable_telemetry', '__return_true' ); add_filter( 'hyve_global_chat_enabled', [ $this, 'is_global_chat_enabled' ] ); @@ -177,6 +178,11 @@ public function menu_page() { public function admin_init() { $settings = self::get_settings(); + if ( Hyve_Connect::is_active() && false === get_transient( 'hyve_connect_recovery_check' ) ) { + set_transient( 'hyve_connect_recovery_check', 1, HOUR_IN_SECONDS ); + $this->table->connect_check_recovery(); + } + $post_types = get_post_types( [ 'public' => true ], 'objects' ); $post_types_for_js = []; @@ -212,6 +218,9 @@ function ( $data ) use ( $settings, $post_types_for_js ) { '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_migration_status() : null, 'isQdrantActive' => Qdrant_API::is_active(), 'assets' => [ 'images' => HYVE_LITE_URL . 'assets/images/', @@ -282,6 +291,7 @@ public static function get_default_settings() { return apply_filters( 'hyve_default_settings', [ + 'ai_mode' => 'self_hosted', 'api_key' => '', 'qdrant_api_key' => '', 'qdrant_endpoint' => '', @@ -727,10 +737,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, ]; } @@ -868,6 +887,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/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..e584ca7c 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,108 @@ 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 ) ) { + // An empty/purged KB is visitor-facing parity with self-hosted: show + // the site's default_message rather than an error. + if ( false !== strpos( $result->get_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::user_message( $result ) ] ); + } + + return; + } + + $answered = ! empty( $result['answered'] ); + $reply = isset( $result['reply'] ) ? $result['reply'] : ''; + $thread = isset( $result['thread_id'] ) ? $result['thread_id'] : $thread_id; + + if ( $answered ) { + $final = $reply; + + if ( ! empty( $settings['show_source_link'] ) && ! empty( $result['sources'] ) ) { + $final = API::instance()->maybe_append_source_link( $final, array_column( $result['sources'], 'id' ) ); + } + } else { + $final = esc_html( $default_message ); + } + + $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/src/backend/App.js b/src/backend/App.js index c711c8ca..54fd57bc 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( () => { @@ -112,7 +116,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/StatCard.js b/src/backend/components/StatCard.js index b303c9e4..2524bb6e 100644 --- a/src/backend/components/StatCard.js +++ b/src/backend/components/StatCard.js @@ -11,10 +11,15 @@ const StatCard = ( { meter, foot, chip, + compact = false, planned = false, } ) => { return ( -
+
{ icon && } { label } diff --git a/src/backend/router.js b/src/backend/router.js index fa715cdc..47174941 100644 --- a/src/backend/router.js +++ b/src/backend/router.js @@ -163,6 +163,10 @@ const ROUTES = { label: __( 'Advanced', 'hyve-lite' ), group: __( 'AI', 'hyve-lite' ), }, + 'hyve-connect': { + label: __( 'Hyve Connect', 'hyve-lite' ), + group: __( 'Integrations', 'hyve-lite' ), + }, qdrant: { label: __( 'Qdrant', 'hyve-lite' ), group: __( 'Integrations', 'hyve-lite' ), diff --git a/src/backend/screens/AI.js b/src/backend/screens/AI.js index 9e5c3705..c14b7b16 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. @@ -172,6 +173,10 @@ export const ProviderPanel = () => { const { setSetting, setHasAPI } = useDispatch( 'hyve' ); const { createNotice } = useDispatch( 'core/notices' ); + const isConnectActive = useSelect( ( select ) => + select( 'hyve' ).isConnectActive() + ); + 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' + ) }{ ' ' } + +
+
+ ) } + { + const percent = limit + ? Math.min( + 100, + Math.round( ( Number( used ) / Number( limit ) ) * 100 ) + ) + : 0; + + 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' ); + + 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 { + const stats = await apiFetch( { + path: `${ window.hyve.api }/stats`, + } ); + setConnect( stats?.connect ?? null ); + setConnectSync( stats?.connectSync ?? null ); + } catch { + // A transient poll failure just retries on the next tick. + } + }, 4000 ); + + return () => clearInterval( timer ); + }, [ isSyncing, setConnect, setConnectSync ] ); + + 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' ); + + const stats = await apiFetch( { + path: `${ window.hyve.api }/stats`, + } ); + setConnect( stats?.connect ?? null ); + setConnectSync( stats?.connectSync ?? null ); + + 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: { action: 'disconnect', 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 ); + }; + + // --- Connected --------------------------------------------------------- + if ( isConnectActive ) { + const kb = connect?.kb ?? {}; + const chat = connect?.chat ?? {}; + const storage = kb?.storage ?? {}; + + const kbUsed = Number( storage.used ?? kb.chunks ?? 0 ); + const kbLimit = Number( storage.limit ?? 0 ); + const chatUsed = Number( chat.used ?? 0 ); + const chatLimit = Number( chat.limit ?? 0 ); + const kbFull = kbLimit > 0 && kbUsed >= kbLimit; + const chatFull = chatLimit > 0 && chatUsed >= chatLimit; + const isExhausted = kbFull || chatFull; + + const isBlocked = Boolean( connectSync?.blocked ); + 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 statusText = __( + 'Running on the free plan. Connect a license to raise these limits.', + 'hyve-lite' + ); + if ( isOffline ) { + statusText = __( + "Can't reach hosted AI 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 + ) } +

+ +
+
+ ) } + + { isBlocked && ( +
+
+ + { __( + "Some content couldn't be synced.", + 'hyve-lite' + ) } + { ' ' } + { isPro + ? __( + 'It goes over your current plan limit.', + 'hyve-lite' + ) + : __( + 'It goes over the free plan limit. Upgrade to Pro to sync everything.', + 'hyve-lite' + ) } +
+
+ ) } + + +
+ { 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 hosted AI. Choose what happens to the content Hyve Connect 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 hosted AI.", + '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 Connect switches this site to hosted AI and stops using your key. Your indexed content is re-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..eaadf3ad 100644 --- a/src/backend/screens/Dashboard.js +++ b/src/backend/screens/Dashboard.js @@ -145,11 +145,14 @@ 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 ); @@ -161,7 +164,79 @@ const StatsGrid = () => { Math.round( ( chunks / chunksLimit ) * 100 ) ); - const needsStorage = ! isQdrantActive && 400 < chunks; + 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 connectKb = connect?.kb ?? {}; + const connectStorage = connectKb.storage ?? {}; + const connectChunks = Number( + connectStorage.used ?? connectKb.chunks ?? 0 + ); + const connectLimit = Number( connectStorage.limit ?? 0 ); + const connectPercent = connectLimit + ? Math.min( 100, Math.round( ( connectChunks / connectLimit ) * 100 ) ) + : 0; + + let kbCard; + + if ( isConnectActive ) { + const connectFull = connectLimit > 0 && connectChunks >= connectLimit; + + kbCard = { + value: connectChunks.toLocaleString(), + suffix: sprintf( + /* translators: %s: the chunk limit of the Hyve Connect plan. */ + __( '/ %s chunks', 'hyve-lite' ), + connectLimit.toLocaleString() + ), + meter: connectPercent, + foot: connectFull + ? __( 'Plan limit reached.', 'hyve-lite' ) + : sprintf( + /* translators: %d: percentage of the Hyve Connect plan used. */ + __( + '%d%% of your Hyve Connect plan used.', + 'hyve-lite' + ), + connectPercent + ), + }; + } 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 +263,114 @@ const StatsGrid = () => { + + { isConnectActive ? ( + ( () => { + const chat = connect?.chat ?? {}; + const used = Number( chat.used ?? 0 ); + const limit = Number( chat.limit ?? 0 ); + const percent = limit + ? Math.min( 100, Math.round( ( used / limit ) * 100 ) ) + : 0; + const offline = connect?.service === 'error'; + const full = limit > 0 && used >= limit; + + 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' ) } + + ) + } + /> + ); + } )() + ) : ( + + { __( 'Free', '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' - ) } - /> + } + /> + ) }
); }; diff --git a/src/backend/screens/KnowledgeBase.js b/src/backend/screens/KnowledgeBase.js index 04bce362..e033c270 100644 --- a/src/backend/screens/KnowledgeBase.js +++ b/src/backend/screens/KnowledgeBase.js @@ -133,6 +133,13 @@ const IndexedContent = () => { select( 'hyve' ).getTotalChunks() ); + // In Connect mode the platform owns the chunks; there are no local per-source + // rows to count, so the per-source Chunks column is dropped (the total still + // comes from the platform aggregate, and Status shows each source is indexed). + const isConnectActive = useSelect( ( select ) => + select( 'hyve' ).isConnectActive() + ); + const { setTotalChunks, setAttentionCount } = useDispatch( 'hyve' ); const { createNotice } = useDispatch( 'core/notices' ); @@ -308,13 +315,19 @@ 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' ), diff --git a/src/backend/screens/Settings.js b/src/backend/screens/Settings.js index 10ae220d..b1bfe1e1 100644 --- a/src/backend/screens/Settings.js +++ b/src/backend/screens/Settings.js @@ -14,12 +14,14 @@ import ChatAppearance from './ChatAppearance'; import { ProviderPanel, AdvancedPanel } 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 83508f35..7e170644 100644 --- a/src/backend/store.js +++ b/src/backend/store.js @@ -3,12 +3,18 @@ */ import { createReduxStore, register } from '@wordpress/data'; +const AI_MODE = window.hyve.aiMode || 'self_hosted'; +const IS_CONNECT = 'hyve_connect' === AI_MODE; + const DEFAULT_STATE = { route: 'home', hasLoaded: false, settings: {}, processed: [], - hasAPI: Boolean( window.hyve.hasAPIKey ), + aiMode: AI_MODE, + connect: window.hyve.connect || null, + connectSync: window.hyve.connectSync || null, + hasAPI: Boolean( window.hyve.hasAPIKey ) || IS_CONNECT, isQdrantActive: Boolean( window.hyve.isQdrantActive ), stats: window.hyve.stats || {}, chart: window.hyve.chart || null, @@ -72,6 +78,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', @@ -111,12 +135,25 @@ const selectors = { hasReachedLimit( state ) { return ( window.hyve.chunksLimit <= Number( state.totalChunks ) && - ! state.isQdrantActive + ! state.isQdrantActive && + 'hyve_connect' !== state.aiMode ); }, isQdrantActive( state ) { return state.isQdrantActive; }, + getAiMode( state ) { + return state.aiMode; + }, + isConnectActive( state ) { + return 'hyve_connect' === state.aiMode; + }, + getConnect( state ) { + return state.connect; + }, + getConnectSync( state ) { + return state.connectSync; + }, getAttentionCount( state ) { return state.attentionCount; }, @@ -175,6 +212,24 @@ const reducer = ( state = DEFAULT_STATE, action ) => { ...state, isQdrantActive: action.isQdrantActive, }; + case 'SET_AI_MODE': + return { + ...state, + aiMode: action.aiMode, + hasAPI: + Boolean( window.hyve.hasAPIKey ) || + 'hyve_connect' === 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 d4afc11e..ecdfabce 100644 --- a/src/backend/style.scss +++ b/src/backend/style.scss @@ -762,6 +762,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 +785,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 { @@ -970,6 +983,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 { @@ -1254,6 +1273,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; } @@ -1805,3 +1834,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/tests/php/unit/tests/test-connect-migration.php b/tests/php/unit/tests/test-connect-migration.php new file mode 100644 index 00000000..c1f42bb5 --- /dev/null +++ b/tests/php/unit/tests/test-connect-migration.php @@ -0,0 +1,347 @@ + 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 `_hyve_connect_synced`. + * + * @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', 1 ); + } + + 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() ); + } + + /** + * Starting the migration seeds the progress option and schedules the cron. + */ + public function test_start_migration_seeds_progress_and_schedules() { + $this->enable_connect(); + $this->indexed_post( false ); + $this->indexed_post( false ); + + DB_Table::instance()->connect_start_migration(); + + $status = DB_Table::instance()->connect_migration_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 migration is a no-op. + */ + public function test_start_migration_noop_when_nothing_pending() { + $this->enable_connect(); + + DB_Table::instance()->connect_start_migration(); + + $this->assertSame( [], DB_Table::instance()->connect_migration_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_migrate_data_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_migration(); + DB_Table::instance()->connect_migrate_data(); + + $this->assertSame( 1, (int) get_post_meta( $a, '_hyve_connect_synced', true ) ); + $this->assertSame( 1, (int) get_post_meta( $b, '_hyve_connect_synced', true ) ); + $this->assertSame( 0, DB_Table::instance()->connect_pending_count() ); + + $status = DB_Table::instance()->connect_migration_status(); + $this->assertFalse( $status['in_progress'] ); + $this->assertSame( 2, $status['current'] ); + } + + /** + * A rejected source is marked handled (so the batch advances) and flagged. + */ + public function test_migrate_data_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_migration(); + DB_Table::instance()->connect_migrate_data(); + + // 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', 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_migrate_data_blocks_on_quota_exceeded() { + $this->enable_connect(); + $this->indexed_post( false ); + + $this->intercept( + $this->sse( + [ + [ + 'error', + [ + 'code' => 'quota_exceeded', + 'message' => 'Over the free limit', + ], + ], + ] + ) + ); + + DB_Table::instance()->connect_start_migration(); + DB_Table::instance()->connect_migrate_data(); + + $status = DB_Table::instance()->connect_migration_status(); + $this->assertTrue( $status['blocked'] ); + $this->assertFalse( $status['in_progress'] ); + $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', true ) ); + + $status = DB_Table::instance()->connect_migration_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->assertSame( 1, (int) get_post_meta( $post, '_hyve_connect_synced', true ) ); + $this->assertSame( [], DB_Table::instance()->connect_migration_status() ); + } + + /** + * Regression: a direct ingest (e.g. a sitemap import) grows the KB without the + * migration 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->assertSame( 1, (int) get_post_meta( $post, '_hyve_connect_synced', true ) ); + $this->assertSame( [], DB_Table::instance()->connect_migration_status() ); + $this->assertFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); + } +} 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..b2c58459 --- /dev/null +++ b/tests/php/unit/tests/test-hyve-connect.php @@ -0,0 +1,329 @@ + + */ + 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::ERROR_OPTION_KEY ); + 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() ); + } + + /** + * 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_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'] ); + } + + /** + * 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_status(); + + $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_status(); + + $this->assertStringStartsWith( 'https://staging.test/api/workflows/', $this->captured['url'] ); + } + + /** + * 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_status(); + + $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_status(); + + $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() { + $flagged = new WP_Error( 'hyve_connect_moderation_flagged', 'x', [ 'code' => 'moderation_flagged' ] ); + $quota = new WP_Error( 'hyve_connect_quota_exceeded', 'x', [ 'code' => 'quota_exceeded' ] ); + + $this->assertSame( 'Message was flagged.', Hyve_Connect::user_message( $flagged ) ); + $this->assertStringContainsString( 'limit', strtolower( Hyve_Connect::user_message( $quota ) ) ); + } + + /** + * 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 ); + } +} From 0803732a0d45de9a575a42d2261fde07f30ba246 Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Wed, 15 Jul 2026 13:06:38 +0530 Subject: [PATCH 02/17] wip --- Gruntfile.js | 1 - src/backend/components/SetupChecklist.js | 76 +++++++++++------- src/backend/router.js | 12 +-- src/backend/screens/AI.js | 53 ++----------- src/backend/screens/Dashboard.js | 5 +- src/backend/screens/Settings.js | 3 +- src/backend/style.scss | 19 +++++ tests/e2e/specs/connect.spec.js | 98 ++++++++++++++++++++++++ tests/e2e/specs/dashboard.spec.js | 6 +- tests/e2e/specs/settings.spec.js | 12 +-- 10 files changed, 188 insertions(+), 97 deletions(-) create mode 100644 tests/e2e/specs/connect.spec.js 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/src/backend/components/SetupChecklist.js b/src/backend/components/SetupChecklist.js index 5ede5482..f09b218c 100644 --- a/src/backend/components/SetupChecklist.js +++ b/src/backend/components/SetupChecklist.js @@ -17,28 +17,36 @@ import { check } from '@wordpress/icons'; import { navigate } from '../router'; const SetupChecklist = () => { - const { hasAPI, chunks } = useSelect( ( select ) => ( { + const { hasAPI, isConnectActive, chunks } = useSelect( ( select ) => ( { hasAPI: select( 'hyve' ).hasAPI(), + isConnectActive: select( 'hyve' ).isConnectActive(), chunks: select( 'hyve' ).getTotalChunks(), } ) ); const totalChunks = Number( chunks ?? 0 ); + // AI is "connected" via hosted Hyve Connect or a personal OpenAI key. + const aiConnected = isConnectActive || 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 +57,7 @@ const SetupChecklist = () => { 'hyve-lite' ), done: 0 < totalChunks, - locked: ! hasAPI, + locked: ! aiConnected, action: { label: __( 'Add content', 'hyve-lite' ), onClick: () => navigate( 'kb' ), @@ -106,28 +114,40 @@ const SetupChecklist = () => {

{ step.description }

{ ! step.done && ( - +
+ + + { step.secondary && ! step.locked && ( + + ) } +
) }
) ) } diff --git a/src/backend/router.js b/src/backend/router.js index 47174941..e6669a19 100644 --- a/src/backend/router.js +++ b/src/backend/router.js @@ -155,18 +155,14 @@ 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' ), }, - 'hyve-connect': { - label: __( 'Hyve Connect', 'hyve-lite' ), - group: __( 'Integrations', 'hyve-lite' ), - }, qdrant: { label: __( 'Qdrant', 'hyve-lite' ), group: __( 'Integrations', 'hyve-lite' ), diff --git a/src/backend/screens/AI.js b/src/backend/screens/AI.js index c14b7b16..3b0af438 100644 --- a/src/backend/screens/AI.js +++ b/src/backend/screens/AI.js @@ -137,10 +137,6 @@ const MODEL_OPTIONS = [ }, ]; -const ADVANCED_DEFAULTS = { - similarity_score_threshold: 0.4, -}; - const getInitialApiStatus = () => { if ( window.hyve?.isApiKeyConnected ) { return 'connected'; @@ -177,6 +173,10 @@ export const ProviderPanel = () => { select( 'hyve' ).isConnectActive() ); + const hasStoredKey = Boolean( window.hyve?.hasAPIKey ); + const providerLocked = isConnectActive; + const apiKeyLocked = isConnectActive && ! hasStoredKey; + const onSave = async () => { const response = await save(); @@ -290,7 +290,7 @@ export const ProviderPanel = () => { label={ __( 'API key', 'hyve-lite' ) } type="password" value={ settings.api_key || '' } - disabled={ isSaving } + disabled={ isSaving || apiKeyLocked } onChange={ ( value ) => { setSetting( 'api_key', value ); setApiStatus( 'editing' ); @@ -298,7 +298,7 @@ export const ProviderPanel = () => { />
- { 'none' === apiStatus && ( + { 'none' === apiStatus && ! apiKeyLocked && (

{ __( 'Get an API key', 'hyve-lite' ) } @@ -324,7 +324,7 @@ export const ProviderPanel = () => { label, value, } ) ) } - disabled={ isSaving } + disabled={ isSaving || providerLocked } onChange={ ( value ) => setSetting( 'chat_model', value ) } /> { selectedModelOption?.description && ( @@ -338,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/Dashboard.js b/src/backend/screens/Dashboard.js index eaadf3ad..758c1975 100644 --- a/src/backend/screens/Dashboard.js +++ b/src/backend/screens/Dashboard.js @@ -569,6 +569,9 @@ const GetStarted = () => { const Dashboard = () => { const hasAPI = useSelect( ( select ) => select( 'hyve' ).hasAPI() ); + const isConnectActive = useSelect( ( select ) => + select( 'hyve' ).isConnectActive() + ); const settings = useSelect( ( select ) => select( 'hyve' ).getSettings() ); const chunks = useSelect( ( select ) => select( 'hyve' ).getTotalChunks() ); @@ -601,7 +604,7 @@ const Dashboard = () => { // Pro keeps the checklist up while its license step is incomplete. const showChecklist = applyFilters( 'hyve.setup-required', - ! hasAPI || 0 === totalChunks + ( ! hasAPI && ! isConnectActive ) || 0 === totalChunks ); return ( diff --git a/src/backend/screens/Settings.js b/src/backend/screens/Settings.js index b1bfe1e1..a59c539c 100644 --- a/src/backend/screens/Settings.js +++ b/src/backend/screens/Settings.js @@ -11,7 +11,7 @@ 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'; @@ -20,7 +20,6 @@ const PANELS = { 'chat-behavior': ChatBehavior, 'chat-appearance': ChatAppearance, 'ai-provider': ProviderPanel, - 'ai-advanced': AdvancedPanel, 'hyve-connect': ConnectPanel, qdrant: QdrantPanel, 'api-access': ApiAccessPanel, diff --git a/src/backend/style.scss b/src/backend/style.scss index ecdfabce..4d8582de 100644 --- a/src/backend/style.scss +++ b/src/backend/style.scss @@ -703,6 +703,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); @@ -901,6 +915,11 @@ 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; } } 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..fe57c36f 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 } ) => { From 7c6cb102c15e4a33685c504acb17e18322df9341 Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Thu, 16 Jul 2026 00:50:49 +0530 Subject: [PATCH 03/17] wip --- inc/API.php | 168 +++- inc/DB_Table.php | 409 +++++++++- inc/Hyve_Connect.php | 109 ++- inc/Main.php | 12 +- src/backend/screens/Connect.js | 43 + src/backend/screens/KnowledgeBase.js | 44 +- .../php/unit/tests/test-connect-migration.php | 746 +++++++++++++++++- tests/php/unit/tests/test-hyve-connect.php | 159 +++- tests/php/unit/tests/test-indexed-content.php | 157 ++++ 9 files changed, 1771 insertions(+), 76 deletions(-) create mode 100644 tests/php/unit/tests/test-indexed-content.php diff --git a/inc/API.php b/inc/API.php index 99862466..75666cb8 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' => [ @@ -201,7 +201,7 @@ public function register_routes() { 'callback' => [ $this, 'delete_thread' ], ], ], - 'qdrant' => [ + 'qdrant' => [ [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'qdrant_status' ], @@ -211,7 +211,7 @@ public function register_routes() { 'callback' => [ $this, 'qdrant_deactivate' ], ], ], - 'connect' => [ + 'connect' => [ [ 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'connect_disconnect' ], @@ -226,7 +226,13 @@ public function register_routes() { ], ], ], - 'chat' => [ + 'connect/reconcile' => [ + [ + 'methods' => \WP_REST_Server::CREATABLE, + 'callback' => [ $this, 'connect_reconcile' ], + ], + ], + 'chat' => [ [ 'methods' => \WP_REST_Server::READABLE, 'args' => [ @@ -717,6 +723,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. * @@ -751,6 +764,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 ); @@ -975,6 +1015,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 ); } @@ -1142,19 +1188,52 @@ public function connect_disconnect( $request ) { 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(); } 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 ] ); + } - $settings['ai_mode'] = Hyve_Connect::MODE_SELF; - update_option( 'hyve_settings', $settings ); Hyve_Connect::flush_stats(); return rest_ensure_response( true ); @@ -1204,6 +1283,37 @@ private function connect_import() { return true; } + /** + * Unmark sources that never reached the platform after an import. + * + * @return void + */ + private function connect_drop_unsynced() { + $posts = get_posts( + [ + 'post_type' => $this->table->connect_indexed_post_types(), + 'post_status' => 'any', + 'fields' => 'ids', + 'posts_per_page' => -1, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- one-off disconnect read. + 'meta_query' => [ + [ + 'key' => '_hyve_added', + 'compare' => 'EXISTS', + ], + [ + 'key' => '_hyve_connect_synced_hash', + 'compare' => 'NOT EXISTS', + ], + ], + ] + ); + + foreach ( $posts as $post_id ) { + $this->connect_forget_source( (int) $post_id ); + } + } + /** * Clear local Knowledge Base bookkeeping when disconnecting with "clear". * @@ -1212,7 +1322,7 @@ private function connect_import() { private function connect_clear_local() { $posts = get_posts( [ - 'post_type' => 'any', + 'post_type' => $this->table->connect_indexed_post_types(), 'post_status' => 'any', 'fields' => 'ids', 'posts_per_page' => -1, @@ -1221,15 +1331,37 @@ private function connect_clear_local() { ); foreach ( $posts as $post_id ) { - delete_post_meta( $post_id, '_hyve_added' ); - delete_post_meta( $post_id, '_hyve_connect_synced' ); - 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' ); + $this->connect_forget_source( (int) $post_id ); + } + } + + /** + * 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. + * + * @return void + */ + private function connect_forget_source( $post_id ) { + if ( 'hyve_docs' === get_post_type( $post_id ) ) { + wp_delete_post( $post_id, true ); + do_action( 'hyve_data_deleted', $post_id ); - do_action( 'hyve_data_deleted', (int) $post_id ); + return; } + + delete_post_meta( $post_id, '_hyve_added' ); + delete_post_meta( $post_id, '_hyve_connect_synced_hash' ); + delete_post_meta( $post_id, '_hyve_connect_synced_modified' ); + 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' ); + + do_action( 'hyve_data_deleted', $post_id ); } /** diff --git a/inc/DB_Table.php b/inc/DB_Table.php index ae7817b6..a6994023 100644 --- a/inc/DB_Table.php +++ b/inc/DB_Table.php @@ -779,7 +779,8 @@ private function ingest_document_connect( $doc, $args ) { return new \WP_Error( 'missing_post', __( 'Missing post reference.', 'hyve-lite' ) ); } - $result = Hyve_Connect::instance()->kb_upsert( [ $this->connect_document( (int) $post_id, $doc ) ] ); + $document = $this->connect_document( (int) $post_id, $doc ); + $result = Hyve_Connect::instance()->kb_upsert( [ $document ] ); if ( is_wp_error( $result ) ) { if ( $created_here ) { @@ -792,11 +793,26 @@ private function ingest_document_connect( $doc, $args ) { $status = isset( $result['results'][0] ) && is_array( $result['results'][0] ) ? $result['results'][0] : []; $state = $status['status'] ?? 'failed'; + // 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 ); + } + + 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 ); @@ -818,8 +834,9 @@ private function ingest_document_connect( $doc, $args ) { } update_post_meta( $post_id, '_hyve_added', 1 ); - // Already on the platform, so the to-Connect sync job must skip it. - update_post_meta( $post_id, '_hyve_connect_synced', 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'] ) ); foreach ( $extra_meta as $meta_key => $meta_value ) { update_post_meta( $post_id, $meta_key, $meta_value ); @@ -902,8 +919,8 @@ private function connect_moderation_review( $status ) { * 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 (`_hyve_connect_synced` unset): the existing self-hosted - * content to migrate on enable, or everything after an inactivity purge. + * not yet hold them (no synced hash): the existing self-hosted content to + * migrate on enable, or everything after an inactivity purge. * * @param int $limit Max posts to return (-1 for all). * @@ -912,7 +929,7 @@ private function connect_moderation_review( $status ) { public function connect_pending_posts( $limit = self::CONNECT_SYNC_BATCH ) { return get_posts( [ - 'post_type' => 'any', + 'post_type' => $this->connect_indexed_post_types(), 'post_status' => 'any', 'fields' => 'ids', 'posts_per_page' => $limit, @@ -923,15 +940,20 @@ public function connect_pending_posts( $limit = self::CONNECT_SYNC_BATCH ) { 'compare' => 'EXISTS', ], [ - 'key' => '_hyve_connect_synced', + 'key' => '_hyve_connect_synced_hash', 'compare' => 'NOT EXISTS', ], - // A source the platform rejected on moderation stays out of the - // pending set so the batch never re-picks it forever. + // 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', + ], ], ] ); @@ -954,11 +976,11 @@ public function connect_pending_count() { public function connect_has_synced() { $synced = get_posts( [ - 'post_type' => 'any', + 'post_type' => $this->connect_indexed_post_types(), 'post_status' => 'any', 'fields' => 'ids', 'posts_per_page' => 1, - 'meta_key' => '_hyve_connect_synced', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_key' => '_hyve_connect_synced_hash', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key ] ); @@ -1020,15 +1042,36 @@ public function connect_migrate_data() { } $documents = []; + $empty = 0; foreach ( $post_ids as $post_id ) { - $documents[] = $this->connect_document( + // 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_migration( $empty ); + return; } $result = Hyve_Connect::instance()->kb_upsert( $documents ); @@ -1037,7 +1080,12 @@ public function connect_migrate_data() { // 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 ( false !== strpos( $result->get_error_code(), 'quota_exceeded' ) ) { - $this->connect_block_migration( $result->get_error_message() ); + $data = $result->get_error_data(); + + $this->connect_block_migration( + $result->get_error_message(), + is_array( $data ) && isset( $data['quota'] ) && is_array( $data['quota'] ) ? $data['quota'] : [] + ); return; } @@ -1054,27 +1102,75 @@ public function connect_migrate_data() { } } - foreach ( $post_ids as $post_id ) { + $handled = 0; + $storage_skip = false; + + foreach ( array_keys( $documents ) as $post_id ) { $row = $status_by_id[ (int) $post_id ] ?? []; $state = isset( $row['status'] ) ? $row['status'] : 'stored'; + // 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 ); - // A rejected source is not on the platform. Record why for the KB - // listing; the moderation marker keeps it out of the pending set - // without pretending it is synced (which would loop the purge-recovery - // check on a site whose whole KB is rejected). + // Record the rejection for the KB listing. If the platform retained + // a previously-synced copy it is still on the cloud, and the synced + // hash from that sync already describes it; 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; } - update_post_meta( $post_id, '_hyve_connect_synced', 1 ); + // 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 ) ); } - $this->connect_advance_migration( count( $post_ids ) ); + // 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_migration( + $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_migration( $handled + $empty ); } /** @@ -1121,18 +1217,22 @@ private function connect_finish_migration() { } /** - * Stop the sync job because the plan's limit was hit, recording the reason. + * 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 string $message The platform's limit message. + * @param array $quota The platform's quota snapshot ({kind, limit, used}). * * @return void */ - private function connect_block_migration( $message ) { + private function connect_block_migration( $message, $quota = [] ) { $status = get_option( self::CONNECT_SYNC_OPTION, [] ); $status['in_progress'] = false; $status['blocked'] = true; $status['message'] = (string) $message; + $status['quota'] = is_array( $quota ) ? $quota : []; update_option( self::CONNECT_SYNC_OPTION, $status ); Hyve_Connect::flush_stats(); @@ -1157,17 +1257,70 @@ public function connect_migration_status() { public function connect_reset_sync_markers() { $posts = get_posts( [ - 'post_type' => 'any', + 'post_type' => $this->connect_indexed_post_types(), 'post_status' => 'any', 'fields' => 'ids', 'posts_per_page' => -1, - 'meta_key' => '_hyve_connect_synced', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + 'meta_key' => '_hyve_connect_synced_hash', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key ] ); foreach ( $posts as $post_id ) { - delete_post_meta( $post_id, '_hyve_connect_synced' ); + delete_post_meta( $post_id, '_hyve_connect_synced_hash' ); + delete_post_meta( $post_id, '_hyve_connect_synced_modified' ); + } + } + + /** + * 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_migration_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_migration(); } /** @@ -1186,8 +1339,10 @@ public function connect_check_recovery() { $status = $this->connect_migration_status(); - // Don't stack recovery on an in-flight or plan-blocked migration. - if ( ! empty( $status['in_progress'] ) || ! empty( $status['blocked'] ) ) { + // Don't stack recovery on an in-flight migration. 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; } @@ -1204,6 +1359,204 @@ public function connect_check_recovery() { } } + /** + * 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 ); + } + + /** + * 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 = get_posts( + [ + 'post_type' => $this->connect_indexed_post_types(), + 'post_status' => 'any', + 'fields' => 'ids', + 'posts_per_page' => -1, + 'meta_key' => '_hyve_added', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- reconcile read, not a hot path. + ] + ); + + $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 migration 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_migration_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. + if ( ! empty( $status['blocked'] ) ) { + delete_option( self::CONNECT_SYNC_OPTION ); + } + + $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'] ) ) { + 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 ) ) { + 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 + // migration 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 ) { + delete_post_meta( (int) $id, '_hyve_connect_synced_hash' ); + delete_post_meta( (int) $id, '_hyve_connect_synced_modified' ); + } + + if ( ! empty( $to_push ) ) { + $this->connect_start_migration(); + } + + return true; + } + /** * Process posts. * diff --git a/inc/Hyve_Connect.php b/inc/Hyve_Connect.php index 4f3c2400..6f8ff5c1 100644 --- a/inc/Hyve_Connect.php +++ b/inc/Hyve_Connect.php @@ -154,6 +154,109 @@ public function kb_delete( $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). * @@ -598,9 +701,9 @@ private static function parse_frame( $frame ) { */ private function get_headers( $accept = 'text/event-stream' ) { $headers = [ - 'Content-Type' => 'application/json', - 'Accept' => $accept, - 'X-Site-Url' => get_site_url(), + 'Content-Type' => 'application/json', + 'Accept' => $accept, + 'X-Site-Url' => get_site_url(), 'X-Hyve-Version' => defined( 'HYVE_LITE_VERSION' ) ? HYVE_LITE_VERSION : '', ]; diff --git a/inc/Main.php b/inc/Main.php index 7b312349..3eebe221 100644 --- a/inc/Main.php +++ b/inc/Main.php @@ -178,9 +178,13 @@ public function menu_page() { public function admin_init() { $settings = self::get_settings(); - if ( Hyve_Connect::is_active() && false === get_transient( 'hyve_connect_recovery_check' ) ) { - set_transient( 'hyve_connect_recovery_check', 1, HOUR_IN_SECONDS ); - $this->table->connect_check_recovery(); + 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_maybe_resume_blocked(); } $post_types = get_post_types( [ 'public' => true ], 'objects' ); @@ -869,6 +873,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' ); } diff --git a/src/backend/screens/Connect.js b/src/backend/screens/Connect.js index 0e1a11c4..a4a59a8c 100644 --- a/src/backend/screens/Connect.js +++ b/src/backend/screens/Connect.js @@ -218,6 +218,41 @@ export const ConnectPanel = () => { 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. + const stats = await apiFetch( { + path: `${ window.hyve.api }/stats`, + } ); + setConnect( stats?.connect ?? null ); + setConnectSync( stats?.connectSync ?? null ); + + 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 kb = connect?.kb ?? {}; @@ -451,6 +486,14 @@ export const ConnectPanel = () => { ) }
+
) } + { 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 && (
@@ -346,15 +380,7 @@ export const ConnectPanel = () => { 'hyve-lite' ) } { ' ' } - { isPro - ? __( - 'It goes over your current plan limit.', - 'hyve-lite' - ) - : __( - 'It goes over the free plan limit. Upgrade to Pro to sync everything.', - 'hyve-lite' - ) } + { blockedReason }
) } From c35f120571c1ce2603308fa30e29ebf3ee64e6b8 Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Thu, 16 Jul 2026 15:13:25 +0530 Subject: [PATCH 05/17] wip --- inc/API.php | 8 +++ .../php/unit/tests/test-connect-migration.php | 60 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/inc/API.php b/inc/API.php index 75666cb8..6c6c6644 100644 --- a/inc/API.php +++ b/inc/API.php @@ -1206,6 +1206,14 @@ public function connect_disconnect( $request ) { // 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 ); diff --git a/tests/php/unit/tests/test-connect-migration.php b/tests/php/unit/tests/test-connect-migration.php index e5254791..095a8864 100644 --- a/tests/php/unit/tests/test-connect-migration.php +++ b/tests/php/unit/tests/test-connect-migration.php @@ -1021,6 +1021,66 @@ function ( $response, $args ) use ( $synced ) { $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 From 7f8e6ba2488c34ba3c818e35005f2d34e7c2979a Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Thu, 16 Jul 2026 21:47:40 +0530 Subject: [PATCH 06/17] wip --- inc/API.php | 4 ++-- inc/DB_Table.php | 12 ++++-------- inc/Hyve_Connect.php | 6 +++--- src/backend/screens/KnowledgeBase.js | 6 ++---- tests/php/unit/tests/test-hyve-connect.php | 2 +- 5 files changed, 12 insertions(+), 18 deletions(-) diff --git a/inc/API.php b/inc/API.php index 6c6c6644..6d2b5be1 100644 --- a/inc/API.php +++ b/inc/API.php @@ -1153,7 +1153,7 @@ public function qdrant_deactivate() { } /** - * Disconnect Hyve Connect, on one of two user-chosen paths (D18). + * 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 @@ -1252,7 +1252,7 @@ public function connect_reconcile( $request ) { * * 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, D5/D12). + * re-embedding is needed (the export model matches the local model). * * @return true|\WP_Error */ diff --git a/inc/DB_Table.php b/inc/DB_Table.php index a6994023..8fe08025 100644 --- a/inc/DB_Table.php +++ b/inc/DB_Table.php @@ -387,8 +387,6 @@ public function get_by_storage( string $storage, int $limit = 100 ): array { /** * Count rows held in a given storage backend. * - * @since 1.4.3 - * * @param string $storage Storage backend (e.g. WordPress|Qdrant). * * @return int @@ -869,7 +867,7 @@ private function connect_document( $post_id, $doc ) { 'type' => $this->connect_source_type( $post_id ), 'title' => (string) $doc['title'], 'url' => $url ? $url : null, - // The plugin extracts text; the platform chunks it (D11). + // The plugin extracts text; the platform chunks it. 'content' => wp_strip_all_tags( (string) $doc['content'] ), ]; } @@ -1121,10 +1119,8 @@ public function connect_migrate_data() { // Either way the local chunk rows are redundant in Connect mode. $this->delete_by_post_id( $post_id ); - // Record the rejection for the KB listing. If the platform retained - // a previously-synced copy it is still on the cloud, and the synced - // hash from that sync already describes it; a brand-new rejected - // source stays unsynced. + // 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 ) ); @@ -1324,7 +1320,7 @@ public function connect_maybe_resume_blocked() { } /** - * Re-push local content when Hyve Connect has lost it (inactivity purge, D13). + * 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, diff --git a/inc/Hyve_Connect.php b/inc/Hyve_Connect.php index 6f8ff5c1..11f89ae5 100644 --- a/inc/Hyve_Connect.php +++ b/inc/Hyve_Connect.php @@ -120,7 +120,7 @@ public function base_url() { * Upsert documents into the hosted knowledge base. * * The platform chunks, moderates, embeds, and stores; the plugin sends - * whole documents (D11). Unchanged content is cached server-side and costs + * whole documents. Unchanged content is cached server-side and costs * no quota. * * @param array> $documents Normalized documents ({id,type,title,url,content,meta}). @@ -273,7 +273,7 @@ public function kb_delete_all() { } /** - * Fetch the hosted knowledge base state (used for purge auto-recovery, D13). + * Fetch the hosted knowledge base state via the dedicated status action. * * @return array|\WP_Error */ @@ -282,7 +282,7 @@ public function kb_status() { } /** - * Export a batch of stored chunks + vectors for local re-import on disconnect (D12). + * 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. diff --git a/src/backend/screens/KnowledgeBase.js b/src/backend/screens/KnowledgeBase.js index b0499df4..74d1c2e7 100644 --- a/src/backend/screens/KnowledgeBase.js +++ b/src/backend/screens/KnowledgeBase.js @@ -133,10 +133,8 @@ const IndexedContent = () => { select( 'hyve' ).getTotalChunks() ); - // In Connect mode the platform owns the chunks; there are no local per-source - // rows to count, so the per-source Chunks column is dropped (the total still - // comes from the platform aggregate, and Status reflects each source's - // actual sync state). + // 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() ); diff --git a/tests/php/unit/tests/test-hyve-connect.php b/tests/php/unit/tests/test-hyve-connect.php index c67ede28..61e254bb 100644 --- a/tests/php/unit/tests/test-hyve-connect.php +++ b/tests/php/unit/tests/test-hyve-connect.php @@ -1,6 +1,6 @@ Date: Thu, 16 Jul 2026 22:07:01 +0530 Subject: [PATCH 07/17] Send per-site token with Hyve Connect requests Generate a persistent per-site token and send it as X-Site-Token so the platform can bind a free site's identity without a license key. --- inc/Hyve_Connect.php | 29 ++++++++++++++++++++++ tests/php/unit/tests/test-hyve-connect.php | 23 +++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/inc/Hyve_Connect.php b/inc/Hyve_Connect.php index 11f89ae5..26b365e3 100644 --- a/inc/Hyve_Connect.php +++ b/inc/Hyve_Connect.php @@ -62,6 +62,15 @@ class Hyve_Connect { */ public const ERROR_OPTION_KEY = 'hyve_connect_api_error'; + /** + * The per-site token option key for `wp_options`. Free sites have no license + * key, so this random token is the secret that binds this site's identity on + * the platform. Persisted (not autoloaded) and never sent to the front end. + * + * @var string + */ + public const SITE_TOKEN_OPTION = 'hyve_connect_site_token'; + /** * The single instance of the class. * @@ -704,6 +713,7 @@ private function get_headers( $accept = 'text/event-stream' ) { '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 : '', ]; @@ -717,6 +727,25 @@ private function get_headers( $accept = 'text/event-stream' ) { 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. * diff --git a/tests/php/unit/tests/test-hyve-connect.php b/tests/php/unit/tests/test-hyve-connect.php index 61e254bb..a02752d9 100644 --- a/tests/php/unit/tests/test-hyve-connect.php +++ b/tests/php/unit/tests/test-hyve-connect.php @@ -33,6 +33,7 @@ protected function tearDown(): void { remove_all_actions( 'before_delete_post' ); delete_option( 'hyve_settings' ); delete_option( Hyve_Connect::ERROR_OPTION_KEY ); + delete_option( Hyve_Connect::SITE_TOKEN_OPTION ); delete_transient( 'hyve_connect_stats' ); parent::tearDown(); } @@ -266,6 +267,28 @@ public function test_kb_upsert_builds_request_and_returns_job_complete() { $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_status(); + + $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_status(); + + $this->assertSame( $token, $this->captured['args']['headers']['X-Site-Token'] ); + } + /** * A license key becomes a base64'd bearer; free installs send none. */ From 1532bb325736740b7786a872130e54fbae1e77f7 Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Fri, 17 Jul 2026 11:56:29 +0530 Subject: [PATCH 08/17] wip --- inc/API.php | 113 ++++++----- inc/DB_Table.php | 179 ++++++++++-------- inc/Hyve_Connect.php | 64 +------ inc/Main.php | 4 +- inc/Stream.php | 16 +- src/backend/components/SetupChecklist.js | 5 +- src/backend/components/StatCard.js | 7 +- src/backend/screens/Connect.js | 74 +++----- src/backend/screens/Dashboard.js | 49 ++--- src/backend/store.js | 14 +- src/backend/style.scss | 19 -- src/backend/utils.js | 41 ++++ ...ct-migration.php => test-connect-sync.php} | 100 +++++----- tests/php/unit/tests/test-hyve-connect.php | 13 +- 14 files changed, 323 insertions(+), 375 deletions(-) rename tests/php/unit/tests/{test-connect-migration.php => test-connect-sync.php} (89%) diff --git a/inc/API.php b/inc/API.php index 6d2b5be1..151f35e5 100644 --- a/inc/API.php +++ b/inc/API.php @@ -216,10 +216,7 @@ public function register_routes() { 'methods' => \WP_REST_Server::CREATABLE, 'callback' => [ $this, 'connect_disconnect' ], 'args' => [ - 'action' => [ - 'type' => 'string', - ], - 'mode' => [ + 'mode' => [ 'type' => 'string', 'enum' => [ 'import', 'clear' ], ], @@ -571,7 +568,7 @@ public function update_settings( $request ) { // 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_migration(); + $this->table->connect_start_sync(); } // Reconcile the dashboard service-error notice with the key that was just @@ -914,7 +911,7 @@ public function get_stats() { // after connecting or activating a license), bypassing the page-load cache. if ( Hyve_Connect::is_active() ) { $data['connect'] = Hyve_Connect::instance()->stats( true ); - $data['connectSync'] = $this->table->connect_migration_status(); + $data['connectSync'] = $this->table->connect_sync_status(); } return rest_ensure_response( $data ); @@ -1297,29 +1294,18 @@ private function connect_import() { * @return void */ private function connect_drop_unsynced() { - $posts = get_posts( + $this->connect_forget_sources( [ - 'post_type' => $this->table->connect_indexed_post_types(), - 'post_status' => 'any', - 'fields' => 'ids', - 'posts_per_page' => -1, - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- one-off disconnect read. - 'meta_query' => [ - [ - 'key' => '_hyve_added', - 'compare' => 'EXISTS', - ], - [ - 'key' => '_hyve_connect_synced_hash', - 'compare' => 'NOT EXISTS', - ], + [ + 'key' => '_hyve_added', + 'compare' => 'EXISTS', + ], + [ + 'key' => '_hyve_connect_synced_hash', + 'compare' => 'NOT EXISTS', ], ] ); - - foreach ( $posts as $post_id ) { - $this->connect_forget_source( (int) $post_id ); - } } /** @@ -1328,17 +1314,25 @@ private function connect_drop_unsynced() { * @return void */ private function connect_clear_local() { - $posts = get_posts( + $this->connect_forget_sources( [ - 'post_type' => $this->table->connect_indexed_post_types(), - 'post_status' => 'any', - 'fields' => 'ids', - 'posts_per_page' => -1, - 'meta_key' => '_hyve_added', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + [ + 'key' => '_hyve_added', + 'compare' => 'EXISTS', + ], ] ); + } - foreach ( $posts as $post_id ) { + /** + * Forget every indexed source matching a meta query (disconnect cleanup). + * + * @param array> $meta_query A meta_query clause list. + * + * @return void + */ + private function connect_forget_sources( array $meta_query ) { + foreach ( $this->table->connect_source_ids( $meta_query ) as $post_id ) { $this->connect_forget_source( (int) $post_id ); } } @@ -1362,8 +1356,7 @@ private function connect_forget_source( $post_id ) { } delete_post_meta( $post_id, '_hyve_added' ); - delete_post_meta( $post_id, '_hyve_connect_synced_hash' ); - delete_post_meta( $post_id, '_hyve_connect_synced_modified' ); + $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' ); @@ -1845,6 +1838,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. * @@ -2101,18 +2130,10 @@ private function get_chat_connect( $request ) { $settings = Main::get_settings(); $result = is_array( $job['result'] ) ? $job['result'] : []; - $answered = ! empty( $result['answered'] ); - $reply = isset( $result['reply'] ) ? $result['reply'] : ''; - - if ( $answered ) { - $final = $reply; - - if ( ! empty( $settings['show_source_link'] ) && ! empty( $result['sources'] ) ) { - $final = $this->maybe_append_source_link( $final, array_column( $result['sources'], 'id' ) ); - } - } else { - $final = $settings['default_message']; - } + $resolved = $this->connect_reply_final( $result, $settings, $settings['default_message'] ); + $answered = $resolved['answered']; + $reply = $resolved['reply']; + $final = $resolved['final']; $payload = [ 'success' => $answered, diff --git a/inc/DB_Table.php b/inc/DB_Table.php index 8fe08025..080ba248 100644 --- a/inc/DB_Table.php +++ b/inc/DB_Table.php @@ -49,7 +49,7 @@ class DB_Table { const MAX_PROCESS_ATTEMPTS = 5; /** - * Source documents pushed to Hyve Connect per migration run. + * Source documents pushed to Hyve Connect per sync batch. * * @var int */ @@ -60,7 +60,7 @@ class DB_Table { * * @var string */ - const CONNECT_SYNC_OPTION = 'hyve_connect_migration'; + const CONNECT_SYNC_OPTION = 'hyve_connect_sync'; /** * The cron hook that drives the to-Connect sync job. @@ -918,42 +918,36 @@ private function connect_moderation_review( $status ) { * * 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 - * migrate on enable, or everything after an inactivity purge. + * 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 connect_pending_posts( $limit = self::CONNECT_SYNC_BATCH ) { - return get_posts( + return $this->connect_source_ids( [ - 'post_type' => $this->connect_indexed_post_types(), - 'post_status' => 'any', - 'fields' => 'ids', - 'posts_per_page' => $limit, - // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- one-off migration read, not a hot path. - 'meta_query' => [ - [ - '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', - ], + [ + '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', + ], + ], + $limit ); } @@ -972,17 +966,17 @@ public function connect_pending_count() { * @return bool */ public function connect_has_synced() { - $synced = get_posts( - [ - 'post_type' => $this->connect_indexed_post_types(), - 'post_status' => 'any', - 'fields' => 'ids', - 'posts_per_page' => 1, - 'meta_key' => '_hyve_connect_synced_hash', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key - ] + return ! empty( + $this->connect_source_ids( + [ + [ + 'key' => '_hyve_connect_synced_hash', + 'compare' => 'EXISTS', + ], + ], + 1 + ) ); - - return ! empty( $synced ); } /** @@ -994,7 +988,7 @@ public function connect_has_synced() { * * @return void */ - public function connect_start_migration() { + public function connect_start_sync() { $pending = $this->connect_pending_count(); if ( 0 === $pending ) { @@ -1026,16 +1020,16 @@ public function connect_start_migration() { * * @return void */ - public function connect_migrate_data() { + public function connect_run_sync() { if ( ! Hyve_Connect::is_active() ) { - $this->connect_finish_migration(); + $this->connect_finish_sync(); return; } $post_ids = $this->connect_pending_posts(); if ( empty( $post_ids ) ) { - $this->connect_finish_migration(); + $this->connect_finish_sync(); return; } @@ -1068,7 +1062,7 @@ public function connect_migrate_data() { } if ( empty( $documents ) ) { - $this->connect_advance_migration( $empty ); + $this->connect_advance_sync( $empty ); return; } @@ -1080,7 +1074,7 @@ public function connect_migrate_data() { if ( false !== strpos( $result->get_error_code(), 'quota_exceeded' ) ) { $data = $result->get_error_data(); - $this->connect_block_migration( + $this->connect_block_sync( $result->get_error_message(), is_array( $data ) && isset( $data['quota'] ) && is_array( $data['quota'] ) ? $data['quota'] : [] ); @@ -1151,7 +1145,7 @@ public function connect_migrate_data() { if ( 0 === $handled && 0 === $empty ) { $storage = isset( $result['kb']['storage'] ) && is_array( $result['kb']['storage'] ) ? $result['kb']['storage'] : []; - $this->connect_block_migration( + $this->connect_block_sync( $storage_skip ? __( 'Knowledge base storage quota exceeded.', 'hyve-lite' ) : __( 'Knowledge base indexing limit reached for this period.', 'hyve-lite' ), @@ -1166,7 +1160,7 @@ public function connect_migrate_data() { return; } - $this->connect_advance_migration( $handled + $empty ); + $this->connect_advance_sync( $handled + $empty ); } /** @@ -1176,7 +1170,7 @@ public function connect_migrate_data() { * * @return void */ - private function connect_advance_migration( $done ) { + private function connect_advance_sync( $done ) { $status = get_option( self::CONNECT_SYNC_OPTION, [] ); if ( empty( $status ) ) { @@ -1200,7 +1194,7 @@ private function connect_advance_migration( $done ) { * * @return void */ - private function connect_finish_migration() { + private function connect_finish_sync() { $status = get_option( self::CONNECT_SYNC_OPTION, [] ); if ( empty( $status ) ) { @@ -1222,7 +1216,7 @@ private function connect_finish_migration() { * * @return void */ - private function connect_block_migration( $message, $quota = [] ) { + private function connect_block_sync( $message, $quota = [] ) { $status = get_option( self::CONNECT_SYNC_OPTION, [] ); $status['in_progress'] = false; @@ -1239,7 +1233,7 @@ private function connect_block_migration( $message, $quota = [] ) { * * @return array */ - public function connect_migration_status() { + public function connect_sync_status() { $status = get_option( self::CONNECT_SYNC_OPTION, [] ); return is_array( $status ) ? $status : []; @@ -1251,19 +1245,17 @@ public function connect_migration_status() { * @return void */ public function connect_reset_sync_markers() { - $posts = get_posts( + $synced = $this->connect_source_ids( [ - 'post_type' => $this->connect_indexed_post_types(), - 'post_status' => 'any', - 'fields' => 'ids', - 'posts_per_page' => -1, - 'meta_key' => '_hyve_connect_synced_hash', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key + [ + 'key' => '_hyve_connect_synced_hash', + 'compare' => 'EXISTS', + ], ] ); - foreach ( $posts as $post_id ) { - delete_post_meta( $post_id, '_hyve_connect_synced_hash' ); - delete_post_meta( $post_id, '_hyve_connect_synced_modified' ); + foreach ( $synced as $post_id ) { + $this->connect_forget_sync( $post_id ); } } @@ -1281,7 +1273,7 @@ public function connect_reset_sync_markers() { * @return void */ public function connect_maybe_resume_blocked() { - $status = $this->connect_migration_status(); + $status = $this->connect_sync_status(); if ( empty( $status['blocked'] ) ) { return; @@ -1316,7 +1308,7 @@ public function connect_maybe_resume_blocked() { } delete_option( self::CONNECT_SYNC_OPTION ); - $this->connect_start_migration(); + $this->connect_start_sync(); } /** @@ -1333,9 +1325,9 @@ public function connect_check_recovery() { return; } - $status = $this->connect_migration_status(); + $status = $this->connect_sync_status(); - // Don't stack recovery on an in-flight migration. A plan-blocked one + // 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'] ) ) { @@ -1351,7 +1343,7 @@ public function connect_check_recovery() { if ( in_array( $state, [ 'empty', 'purged' ], true ) ) { $this->connect_reset_sync_markers(); - $this->connect_start_migration(); + $this->connect_start_sync(); } } @@ -1402,6 +1394,39 @@ public function connect_indexed_post_types() { 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 @@ -1412,13 +1437,12 @@ public function connect_indexed_post_types() { * @return array */ public function connect_local_manifest() { - $post_ids = get_posts( + $post_ids = $this->connect_source_ids( [ - 'post_type' => $this->connect_indexed_post_types(), - 'post_status' => 'any', - 'fields' => 'ids', - 'posts_per_page' => -1, - 'meta_key' => '_hyve_added', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- reconcile read, not a hot path. + [ + 'key' => '_hyve_added', + 'compare' => 'EXISTS', + ], ] ); @@ -1464,7 +1488,7 @@ public function connect_local_manifest() { * * 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 migration job. + * plugin re-pushes stale/missing sources through the sync job. * * @return true|\WP_Error True when reconciled (or already in sync). */ @@ -1473,7 +1497,7 @@ public function connect_reconcile() { return new \WP_Error( 'connect_inactive', __( 'Hyve Connect is not active.', 'hyve-lite' ) ); } - $status = $this->connect_migration_status(); + $status = $this->connect_sync_status(); if ( ! empty( $status['in_progress'] ) ) { return new \WP_Error( 'connect_busy', __( 'A sync is already in progress.', 'hyve-lite' ) ); @@ -1535,19 +1559,18 @@ function ( $entry ) use ( $scope ) { } // Stale (changed) + missing (never landed) -> re-push. Unmark them so the - // migration job picks exactly these up, then kick it. + // 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 ) { - delete_post_meta( (int) $id, '_hyve_connect_synced_hash' ); - delete_post_meta( (int) $id, '_hyve_connect_synced_modified' ); + $this->connect_forget_sync( $id ); } if ( ! empty( $to_push ) ) { - $this->connect_start_migration(); + $this->connect_start_sync(); } return true; diff --git a/inc/Hyve_Connect.php b/inc/Hyve_Connect.php index 26b365e3..7a7edfa8 100644 --- a/inc/Hyve_Connect.php +++ b/inc/Hyve_Connect.php @@ -55,13 +55,6 @@ class Hyve_Connect { */ const PATH_QUOTA = 'hyve/quota'; - /** - * The service error option key for `wp_options`. - * - * @var string - */ - public const ERROR_OPTION_KEY = 'hyve_connect_api_error'; - /** * The per-site token option key for `wp_options`. Free sites have no license * key, so this random token is the secret that binds this site's identity on @@ -281,15 +274,6 @@ public function kb_delete_all() { ); } - /** - * Fetch the hosted knowledge base state via the dedicated status action. - * - * @return array|\WP_Error - */ - public function kb_status() { - return $this->workflow( self::SLUG_KB, [ 'action' => 'status' ] ); - } - /** * Export a batch of stored chunks + vectors for local re-import on disconnect. * @@ -326,7 +310,7 @@ public function get_quota() { ); if ( is_wp_error( $response ) ) { - return $this->save_error( new \WP_Error( 'hyve_connect_unreachable', $response->get_error_message() ) ); + return new \WP_Error( 'hyve_connect_unreachable', $response->get_error_message() ); } $code = (int) wp_remote_retrieve_response_code( $response ); @@ -342,8 +326,6 @@ public function get_quota() { return new \WP_Error( 'hyve_connect_bad_response', __( 'Unexpected response from the hosted AI.', 'hyve-lite' ) ); } - delete_option( self::ERROR_OPTION_KEY ); - return $decoded; } @@ -445,8 +427,6 @@ public function stream_chat( $payload, $on_event ) { return new \WP_Error( 'hyve_connect_no_result', __( 'The hosted AI did not return a result.', 'hyve-lite' ) ); } - delete_option( self::ERROR_OPTION_KEY ); - return $result; } @@ -532,17 +512,6 @@ public static function flush_stats() { delete_transient( 'hyve_connect_stats' ); } - /** - * Last recorded service error, for the dashboard notice. - * - * @return array - */ - public static function get_last_error() { - $error = get_option( self::ERROR_OPTION_KEY, [] ); - - return is_array( $error ) ? $error : []; - } - /** * Turn a Connect WP_Error into a visitor/admin-facing message. * @@ -595,7 +564,7 @@ private function workflow( $slug, $payload ) { ); if ( is_wp_error( $response ) ) { - return $this->save_error( new \WP_Error( 'hyve_connect_unreachable', $response->get_error_message() ) ); + return new \WP_Error( 'hyve_connect_unreachable', $response->get_error_message() ); } $code = (int) wp_remote_retrieve_response_code( $response ); @@ -625,7 +594,6 @@ private function terminal_result( $events ) { } if ( 'job_complete' === $event['event'] ) { - delete_option( self::ERROR_OPTION_KEY ); return $event['data']; } @@ -773,12 +741,6 @@ private function map_sse_error( $data ) { $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 ] ) ); - // Persist only the errors that mean the service is degraded; a flagged - // message or unavailable KB is an expected per-request outcome. - if ( in_array( $code, [ 'provider_error', 'quota_exceeded' ], true ) ) { - $this->save_error( $error ); - } - return $error; } @@ -811,28 +773,6 @@ private function map_http_error( $code, $body ) { ] ); - $this->save_error( $error ); - - return $error; - } - - /** - * Persist a service error for the dashboard notice. - * - * @param \WP_Error $error The error. - * - * @return \WP_Error The same error, for chaining. - */ - private function save_error( $error ) { - update_option( - self::ERROR_OPTION_KEY, - [ - 'code' => $error->get_error_code(), - 'message' => $error->get_error_message(), - 'date' => wp_date( 'c' ), - 'provider' => 'Hyve Connect', - ] - ); return $error; } diff --git a/inc/Main.php b/inc/Main.php index 3eebe221..63f5929c 100644 --- a/inc/Main.php +++ b/inc/Main.php @@ -58,7 +58,7 @@ public function __construct() { add_action( 'admin_menu', [ $this, 'register_menu_page' ] ); add_action( 'save_post', [ $this, 'update_meta' ], 10, 3 ); add_action( 'before_delete_post', [ $this, 'delete_post' ] ); - add_action( DB_Table::CONNECT_SYNC_HOOK, [ $this->table, 'connect_migrate_data' ] ); + 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' ] ); @@ -224,7 +224,7 @@ function ( $data ) use ( $settings, $post_types_for_js ) { '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_migration_status() : null, + 'connectSync' => Hyve_Connect::is_active() ? $this->table->connect_sync_status() : null, 'isQdrantActive' => Qdrant_API::is_active(), 'assets' => [ 'images' => HYVE_LITE_URL . 'assets/images/', diff --git a/inc/Stream.php b/inc/Stream.php index e584ca7c..f98c874b 100644 --- a/inc/Stream.php +++ b/inc/Stream.php @@ -357,20 +357,12 @@ private function stream_connect( $message, $thread_id, $record_id, $is_test, $se return; } - $answered = ! empty( $result['answered'] ); - $reply = isset( $result['reply'] ) ? $result['reply'] : ''; + $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; - if ( $answered ) { - $final = $reply; - - if ( ! empty( $settings['show_source_link'] ) && ! empty( $result['sources'] ) ) { - $final = API::instance()->maybe_append_source_link( $final, array_column( $result['sources'], 'id' ) ); - } - } else { - $final = esc_html( $default_message ); - } - $payload = [ 'success' => $answered, 'response' => $answered ? $reply : '', diff --git a/src/backend/components/SetupChecklist.js b/src/backend/components/SetupChecklist.js index f09b218c..93d3f8df 100644 --- a/src/backend/components/SetupChecklist.js +++ b/src/backend/components/SetupChecklist.js @@ -17,16 +17,15 @@ import { check } from '@wordpress/icons'; import { navigate } from '../router'; const SetupChecklist = () => { - const { hasAPI, isConnectActive, chunks } = useSelect( ( select ) => ( { + const { hasAPI, chunks } = useSelect( ( select ) => ( { hasAPI: select( 'hyve' ).hasAPI(), - isConnectActive: select( 'hyve' ).isConnectActive(), chunks: select( 'hyve' ).getTotalChunks(), } ) ); const totalChunks = Number( chunks ?? 0 ); // AI is "connected" via hosted Hyve Connect or a personal OpenAI key. - const aiConnected = isConnectActive || hasAPI; + const aiConnected = hasAPI; /** * Setup steps. Pro prepends its license step (and locks the rest while diff --git a/src/backend/components/StatCard.js b/src/backend/components/StatCard.js index 2524bb6e..783bb60b 100644 --- a/src/backend/components/StatCard.js +++ b/src/backend/components/StatCard.js @@ -12,14 +12,9 @@ const StatCard = ( { foot, chip, compact = false, - planned = false, } ) => { return ( -
+
{ icon && } { label } diff --git a/src/backend/screens/Connect.js b/src/backend/screens/Connect.js index cf979c07..521e3913 100644 --- a/src/backend/screens/Connect.js +++ b/src/backend/screens/Connect.js @@ -9,12 +9,12 @@ import { Button, Modal } from '@wordpress/components'; import { useDispatch, useSelect } from '@wordpress/data'; -import { useEffect, useState } from '@wordpress/element'; +import { useCallback, useEffect, useState } from '@wordpress/element'; /** * Internal dependencies. */ -import { setUtm } from '../utils'; +import { setUtm, percentOf, quotaOf } from '../utils'; import Card from '../components/Card'; import Chip from '../components/Chip'; import FieldRow from '../components/FieldRow'; @@ -32,12 +32,7 @@ import FieldRow from '../components/FieldRow'; * @return {Element} The row. */ const QuotaRow = ( { label, used, limit, unit, sub } ) => { - const percent = limit - ? Math.min( - 100, - Math.round( ( Number( used ) / Number( limit ) ) * 100 ) - ) - : 0; + const percent = percentOf( used, limit ); return (
@@ -109,6 +104,13 @@ export const ConnectPanel = () => { 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 @@ -120,18 +122,14 @@ export const ConnectPanel = () => { const timer = setInterval( async () => { try { - const stats = await apiFetch( { - path: `${ window.hyve.api }/stats`, - } ); - setConnect( stats?.connect ?? null ); - setConnectSync( stats?.connectSync ?? null ); + await refreshConnectStats(); } catch { // A transient poll failure just retries on the next tick. } }, 4000 ); return () => clearInterval( timer ); - }, [ isSyncing, setConnect, setConnectSync ] ); + }, [ isSyncing, refreshConnectStats ] ); const hasKey = Boolean( window.hyve?.hasAPIKey ); const isPro = Boolean( window.hyve?.license ); @@ -155,11 +153,7 @@ export const ConnectPanel = () => { setSetting( 'ai_mode', 'hyve_connect' ); setAiMode( 'hyve_connect' ); - const stats = await apiFetch( { - path: `${ window.hyve.api }/stats`, - } ); - setConnect( stats?.connect ?? null ); - setConnectSync( stats?.connectSync ?? null ); + await refreshConnectStats(); createNotice( 'success', __( 'Hyve Connect is on.', 'hyve-lite' ), { type: 'snackbar', @@ -182,7 +176,7 @@ export const ConnectPanel = () => { const response = await apiFetch( { path: `${ window.hyve.api }/connect`, method: 'POST', - data: { action: 'disconnect', mode: disconnectMode }, + data: { mode: disconnectMode }, } ); if ( response.error ) { @@ -232,11 +226,7 @@ export const ConnectPanel = () => { } // Reconcile may have queued a re-push; refresh so progress shows. - const stats = await apiFetch( { - path: `${ window.hyve.api }/stats`, - } ); - setConnect( stats?.connect ?? null ); - setConnectSync( stats?.connectSync ?? null ); + await refreshConnectStats(); createNotice( 'success', @@ -255,17 +245,9 @@ export const ConnectPanel = () => { // --- Connected --------------------------------------------------------- if ( isConnectActive ) { - const kb = connect?.kb ?? {}; - const chat = connect?.chat ?? {}; - const storage = kb?.storage ?? {}; - - const kbUsed = Number( storage.used ?? kb.chunks ?? 0 ); - const kbLimit = Number( storage.limit ?? 0 ); - const chatUsed = Number( chat.used ?? 0 ); - const chatLimit = Number( chat.limit ?? 0 ); - const kbFull = kbLimit > 0 && kbUsed >= kbLimit; - const chatFull = chatLimit > 0 && chatUsed >= chatLimit; - const isExhausted = kbFull || chatFull; + 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; @@ -300,7 +282,7 @@ export const ConnectPanel = () => { ); if ( isOffline ) { statusText = __( - "Can't reach hosted AI right now. We'll keep retrying.", + "Can't reach Hyve Connect right now. We'll keep retrying.", 'hyve-lite' ); } else if ( isPro ) { @@ -426,11 +408,11 @@ export const ConnectPanel = () => { 'Knowledge base', 'hyve-lite' ) } - used={ kbUsed } - limit={ kbLimit } + used={ kbQuota.used } + limit={ kbQuota.limit } unit={ __( 'chunks', 'hyve-lite' ) } sub={ - kbFull + kbQuota.full ? __( 'Limit reached.', 'hyve-lite' @@ -440,11 +422,11 @@ export const ConnectPanel = () => { /> { >

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

@@ -611,7 +593,7 @@ export const ConnectPanel = () => { { __( 'Qdrant is connected.', 'hyve-lite' ) } { ' ' } { __( - "Qdrant and Hyve Connect can't run at the same time. Disconnect Qdrant first, then switch this site to hosted AI.", + "Qdrant and Hyve Connect can't run at the same time. Disconnect Qdrant first, then switch this site to Hyve Connect.", 'hyve-lite' ) }
@@ -627,7 +609,7 @@ export const ConnectPanel = () => { ) } { ' ' } { __( - "Hyve Connect and a self-hosted key can't run at the same time. Enabling Connect switches this site to hosted AI and stops using your key. Your indexed content is re-synced to Hyve Connect.", + "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' ) }
diff --git a/src/backend/screens/Dashboard.js b/src/backend/screens/Dashboard.js index 758c1975..895ddc9e 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 } from '../utils'; import Card from '../components/Card'; import Chip from '../components/Chip'; import SetupChecklist from '../components/SetupChecklist'; @@ -159,39 +159,26 @@ const StatsGrid = () => { const chunks = Number( totalChunks ?? 0 ); const chunksLimit = Number( window.hyve?.chunksLimit ?? 500 ); - const usedPercent = Math.min( - 100, - Math.round( ( chunks / chunksLimit ) * 100 ) - ); + 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 connectKb = connect?.kb ?? {}; - const connectStorage = connectKb.storage ?? {}; - const connectChunks = Number( - connectStorage.used ?? connectKb.chunks ?? 0 - ); - const connectLimit = Number( connectStorage.limit ?? 0 ); - const connectPercent = connectLimit - ? Math.min( 100, Math.round( ( connectChunks / connectLimit ) * 100 ) ) - : 0; + const kbQuota = quotaOf( connect, 'kb' ); let kbCard; if ( isConnectActive ) { - const connectFull = connectLimit > 0 && connectChunks >= connectLimit; - kbCard = { - value: connectChunks.toLocaleString(), + value: kbQuota.used.toLocaleString(), suffix: sprintf( /* translators: %s: the chunk limit of the Hyve Connect plan. */ __( '/ %s chunks', 'hyve-lite' ), - connectLimit.toLocaleString() + kbQuota.limit.toLocaleString() ), - meter: connectPercent, - foot: connectFull + meter: kbQuota.percent, + foot: kbQuota.full ? __( 'Plan limit reached.', 'hyve-lite' ) : sprintf( /* translators: %d: percentage of the Hyve Connect plan used. */ @@ -199,7 +186,7 @@ const StatsGrid = () => { '%d%% of your Hyve Connect plan used.', 'hyve-lite' ), - connectPercent + kbQuota.percent ), }; } else if ( isQdrantActive ) { @@ -271,14 +258,11 @@ const StatsGrid = () => { { isConnectActive ? ( ( () => { - const chat = connect?.chat ?? {}; - const used = Number( chat.used ?? 0 ); - const limit = Number( chat.limit ?? 0 ); - const percent = limit - ? Math.min( 100, Math.round( ( used / limit ) * 100 ) ) - : 0; + const { used, limit, percent, full } = quotaOf( + connect, + 'chat' + ); const offline = connect?.service === 'error'; - const full = limit > 0 && used >= limit; let connectChip = ( @@ -313,7 +297,7 @@ const StatsGrid = () => { ? undefined : sprintf( /* translators: %s: monthly message limit. */ - __( '/ %s msgs', 'hyve-lite' ), + __( '/ %s messages', 'hyve-lite' ), limit.toLocaleString() ) } @@ -322,7 +306,7 @@ const StatsGrid = () => { foot={ offline ? ( __( - "Can't reach hosted AI right now.", + "Can't reach Hyve Connect right now.", 'hyve-lite' ) ) : ( @@ -569,9 +553,6 @@ const GetStarted = () => { const Dashboard = () => { const hasAPI = useSelect( ( select ) => select( 'hyve' ).hasAPI() ); - const isConnectActive = useSelect( ( select ) => - select( 'hyve' ).isConnectActive() - ); const settings = useSelect( ( select ) => select( 'hyve' ).getSettings() ); const chunks = useSelect( ( select ) => select( 'hyve' ).getTotalChunks() ); @@ -604,7 +585,7 @@ const Dashboard = () => { // Pro keeps the checklist up while its license step is incomplete. const showChecklist = applyFilters( 'hyve.setup-required', - ( ! hasAPI && ! isConnectActive ) || 0 === totalChunks + ! hasAPI || 0 === totalChunks ); return ( diff --git a/src/backend/store.js b/src/backend/store.js index 7e170644..36adf191 100644 --- a/src/backend/store.js +++ b/src/backend/store.js @@ -4,7 +4,6 @@ import { createReduxStore, register } from '@wordpress/data'; const AI_MODE = window.hyve.aiMode || 'self_hosted'; -const IS_CONNECT = 'hyve_connect' === AI_MODE; const DEFAULT_STATE = { route: 'home', @@ -14,7 +13,7 @@ const DEFAULT_STATE = { aiMode: AI_MODE, connect: window.hyve.connect || null, connectSync: window.hyve.connectSync || null, - hasAPI: Boolean( window.hyve.hasAPIKey ) || IS_CONNECT, + hasKey: Boolean( window.hyve.hasAPIKey ), isQdrantActive: Boolean( window.hyve.isQdrantActive ), stats: window.hyve.stats || {}, chart: window.hyve.chart || null, @@ -121,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; @@ -142,9 +142,6 @@ const selectors = { isQdrantActive( state ) { return state.isQdrantActive; }, - getAiMode( state ) { - return state.aiMode; - }, isConnectActive( state ) { return 'hyve_connect' === state.aiMode; }, @@ -190,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 { @@ -216,9 +213,6 @@ const reducer = ( state = DEFAULT_STATE, action ) => { return { ...state, aiMode: action.aiMode, - hasAPI: - Boolean( window.hyve.hasAPIKey ) || - 'hyve_connect' === action.aiMode, }; case 'SET_CONNECT': return { diff --git a/src/backend/style.scss b/src/backend/style.scss index 4d8582de..80d4ac94 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; @@ -839,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 { @@ -874,9 +858,6 @@ p.hyve-next-notice__text { font-weight: 500; } - .is-planned & { - color: var(--hyve-faint); - } } .hyve-next-stat__meter { diff --git a/src/backend/utils.js b/src/backend/utils.js index 7225df7a..bc1b2e22 100644 --- a/src/backend/utils.js +++ b/src/backend/utils.js @@ -210,6 +210,47 @@ 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, + }; +}; + export const getChatIcons = () => [ { icon: ChatBubbleLeftEllipsisIcon, diff --git a/tests/php/unit/tests/test-connect-migration.php b/tests/php/unit/tests/test-connect-sync.php similarity index 89% rename from tests/php/unit/tests/test-connect-migration.php rename to tests/php/unit/tests/test-connect-sync.php index 095a8864..8a137822 100644 --- a/tests/php/unit/tests/test-connect-migration.php +++ b/tests/php/unit/tests/test-connect-sync.php @@ -1,6 +1,6 @@ enable_connect(); $this->indexed_post( false ); $this->indexed_post( false ); - DB_Table::instance()->connect_start_migration(); + DB_Table::instance()->connect_start_sync(); - $status = DB_Table::instance()->connect_migration_status(); + $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 migration is a no-op. + * With nothing indexed, starting the sync is a no-op. */ - public function test_start_migration_noop_when_nothing_pending() { + public function test_start_sync_noop_when_nothing_pending() { $this->enable_connect(); - DB_Table::instance()->connect_start_migration(); + DB_Table::instance()->connect_start_sync(); - $this->assertSame( [], DB_Table::instance()->connect_migration_status() ); + $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_migrate_data_syncs_batch_and_finishes() { + public function test_run_sync_syncs_batch_and_finishes() { $this->enable_connect(); $a = $this->indexed_post( false ); $b = $this->indexed_post( false ); @@ -172,14 +172,14 @@ public function test_migrate_data_syncs_batch_and_finishes() { ) ); - DB_Table::instance()->connect_start_migration(); - DB_Table::instance()->connect_migrate_data(); + 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_migration_status(); + $status = DB_Table::instance()->connect_sync_status(); $this->assertFalse( $status['in_progress'] ); $this->assertSame( 2, $status['current'] ); } @@ -187,7 +187,7 @@ public function test_migrate_data_syncs_batch_and_finishes() { /** * A rejected source is marked handled (so the batch advances) and flagged. */ - public function test_migrate_data_records_rejection_and_advances() { + public function test_run_sync_records_rejection_and_advances() { $this->enable_connect(); $post = $this->indexed_post( false ); @@ -213,8 +213,8 @@ public function test_migrate_data_records_rejection_and_advances() { ) ); - DB_Table::instance()->connect_start_migration(); - DB_Table::instance()->connect_migrate_data(); + 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). @@ -227,7 +227,7 @@ public function test_migrate_data_records_rejection_and_advances() { * 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_migrate_data_marks_empty_content_sources_failed() { + public function test_run_sync_marks_empty_content_sources_failed() { $this->enable_connect(); $empty = self::factory()->post->create( [ 'post_content' => '' ] ); @@ -252,21 +252,21 @@ public function test_migrate_data_marks_empty_content_sources_failed() { ) ); - DB_Table::instance()->connect_start_migration(); - DB_Table::instance()->connect_migrate_data(); + 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_migration_status()['in_progress'] ); + $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_migrate_data_records_platform_failure_and_advances() { + public function test_run_sync_records_platform_failure_and_advances() { $this->enable_connect(); $post = $this->indexed_post( false ); @@ -289,8 +289,8 @@ public function test_migrate_data_records_platform_failure_and_advances() { ) ); - DB_Table::instance()->connect_start_migration(); - DB_Table::instance()->connect_migrate_data(); + 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 ) ); @@ -301,7 +301,7 @@ public function test_migrate_data_records_platform_failure_and_advances() { * 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_migrate_data_blocks_on_quota_exceeded() { + public function test_run_sync_blocks_on_quota_exceeded() { $this->enable_connect(); $this->indexed_post( false ); @@ -324,10 +324,10 @@ public function test_migrate_data_blocks_on_quota_exceeded() { ) ); - DB_Table::instance()->connect_start_migration(); - DB_Table::instance()->connect_migrate_data(); + DB_Table::instance()->connect_start_sync(); + DB_Table::instance()->connect_run_sync(); - $status = DB_Table::instance()->connect_migration_status(); + $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". @@ -339,7 +339,7 @@ public function test_migrate_data_blocks_on_quota_exceeded() { * A source skipped for quota stays pending while the stored ones advance; * the job keeps going as long as something fits. */ - public function test_migrate_data_keeps_skipped_sources_pending() { + public function test_run_sync_keeps_skipped_sources_pending() { $this->enable_connect(); $a = $this->indexed_post( false ); $b = $this->indexed_post( false ); @@ -373,20 +373,20 @@ public function test_migrate_data_keeps_skipped_sources_pending() { ) ); - DB_Table::instance()->connect_start_migration(); - DB_Table::instance()->connect_migrate_data(); + 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_migration_status()['blocked'] ); + $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_migrate_data_blocks_when_nothing_fits() { + public function test_run_sync_blocks_when_nothing_fits() { $this->enable_connect(); $post = $this->indexed_post( false ); @@ -415,10 +415,10 @@ public function test_migrate_data_blocks_when_nothing_fits() { ) ); - DB_Table::instance()->connect_start_migration(); - DB_Table::instance()->connect_migrate_data(); + DB_Table::instance()->connect_start_sync(); + DB_Table::instance()->connect_run_sync(); - $status = DB_Table::instance()->connect_migration_status(); + $status = DB_Table::instance()->connect_sync_status(); $this->assertTrue( $status['blocked'] ); $this->assertSame( 'storage', $status['quota']['kind'] ); $this->assertSame( 1000, $status['quota']['limit'] ); @@ -448,7 +448,7 @@ public function test_recovery_restarts_sync_when_platform_empty() { $this->assertSame( '', get_post_meta( $post, '_hyve_connect_synced_hash', true ) ); - $status = DB_Table::instance()->connect_migration_status(); + $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 ) ); @@ -474,12 +474,12 @@ public function test_recovery_noop_when_platform_has_content() { DB_Table::instance()->connect_check_recovery(); $this->assertNotSame( '', get_post_meta( $post, '_hyve_connect_synced_hash', true ) ); - $this->assertSame( [], DB_Table::instance()->connect_migration_status() ); + $this->assertSame( [], DB_Table::instance()->connect_sync_status() ); } /** * Regression: a direct ingest (e.g. a sitemap import) grows the KB without the - * migration bookkeeping, so a stale "empty" aggregate can linger in the cache. + * 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. */ @@ -512,7 +512,7 @@ public function test_recovery_ignores_stale_empty_cache() { DB_Table::instance()->connect_check_recovery(); $this->assertNotSame( '', get_post_meta( $post, '_hyve_connect_synced_hash', true ) ); - $this->assertSame( [], DB_Table::instance()->connect_migration_status() ); + $this->assertSame( [], DB_Table::instance()->connect_sync_status() ); $this->assertFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); } @@ -569,7 +569,7 @@ public function test_reconcile_stops_when_root_in_sync() { /** * The drill-down (root -> buckets -> scoped detail) re-pushes a stale source: - * it is unmarked so the migration job re-sends it. + * it is unmarked so the sync job re-sends it. */ public function test_reconcile_drilldown_repushes_stale_source() { $this->enable_connect(); @@ -621,7 +621,7 @@ public function test_reconcile_drilldown_repushes_stale_source() { $this->assertTrue( DB_Table::instance()->connect_reconcile() ); - // Unmarked for re-push, and the migration job is scheduled to send it. + // 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 ) ); } @@ -781,7 +781,7 @@ public function test_reconcile_retries_a_blocked_sync() { ); $this->assertTrue( DB_Table::instance()->connect_reconcile() ); - $this->assertSame( [], DB_Table::instance()->connect_migration_status() ); + $this->assertSame( [], DB_Table::instance()->connect_sync_status() ); } /** @@ -812,7 +812,7 @@ public function test_recovery_restarts_even_when_blocked() { DB_Table::instance()->connect_check_recovery(); $this->assertSame( '', get_post_meta( $post, '_hyve_connect_synced_hash', true ) ); - $this->assertTrue( DB_Table::instance()->connect_migration_status()['in_progress'] ); + $this->assertTrue( DB_Table::instance()->connect_sync_status()['in_progress'] ); $this->assertNotFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); } @@ -869,7 +869,7 @@ public function test_blocked_sync_resumes_when_stats_show_headroom() { DB_Table::instance()->connect_maybe_resume_blocked(); - $status = DB_Table::instance()->connect_migration_status(); + $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 ) ); @@ -885,7 +885,7 @@ public function test_blocked_sync_stays_blocked_without_storage_headroom() { DB_Table::instance()->connect_maybe_resume_blocked(); - $this->assertTrue( DB_Table::instance()->connect_migration_status()['blocked'] ); + $this->assertTrue( DB_Table::instance()->connect_sync_status()['blocked'] ); $this->assertFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); } @@ -913,7 +913,7 @@ public function test_blocked_sync_stays_blocked_when_numbers_unchanged() { DB_Table::instance()->connect_maybe_resume_blocked(); - $this->assertTrue( DB_Table::instance()->connect_migration_status()['blocked'] ); + $this->assertTrue( DB_Table::instance()->connect_sync_status()['blocked'] ); $this->assertFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); } @@ -939,7 +939,7 @@ public function test_blocked_sync_resumes_when_limit_grew_since_block() { DB_Table::instance()->connect_maybe_resume_blocked(); - $status = DB_Table::instance()->connect_migration_status(); + $status = DB_Table::instance()->connect_sync_status(); $this->assertTrue( $status['in_progress'] ); $this->assertEmpty( $status['blocked'] ); } @@ -962,7 +962,7 @@ public function test_blocked_sync_stays_blocked_when_indexing_window_exhausted() DB_Table::instance()->connect_maybe_resume_blocked(); - $this->assertTrue( DB_Table::instance()->connect_migration_status()['blocked'] ); + $this->assertTrue( DB_Table::instance()->connect_sync_status()['blocked'] ); $this->assertFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); } diff --git a/tests/php/unit/tests/test-hyve-connect.php b/tests/php/unit/tests/test-hyve-connect.php index a02752d9..085421a5 100644 --- a/tests/php/unit/tests/test-hyve-connect.php +++ b/tests/php/unit/tests/test-hyve-connect.php @@ -32,7 +32,6 @@ protected function tearDown(): void { remove_all_filters( 'hyve_connect_base_url' ); remove_all_actions( 'before_delete_post' ); delete_option( 'hyve_settings' ); - delete_option( Hyve_Connect::ERROR_OPTION_KEY ); delete_option( Hyve_Connect::SITE_TOKEN_OPTION ); delete_transient( 'hyve_connect_stats' ); parent::tearDown(); @@ -275,7 +274,7 @@ 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_status(); + Hyve_Connect::instance()->kb_reconcile( [], null ); $token = $this->captured['args']['headers']['X-Site-Token']; @@ -284,7 +283,7 @@ public function test_site_token_is_sent_and_stable() { // A second request reuses the same stored token, not a fresh one. $this->intercept( $this->sse( [ [ 'job_complete', [ 'kb' => [] ] ] ] ) ); - Hyve_Connect::instance()->kb_status(); + Hyve_Connect::instance()->kb_reconcile( [], null ); $this->assertSame( $token, $this->captured['args']['headers']['X-Site-Token'] ); } @@ -296,7 +295,7 @@ 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_status(); + Hyve_Connect::instance()->kb_reconcile( [], null ); $this->assertSame( 'Bearer ' . base64_encode( 'LICENSE123' ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode @@ -311,7 +310,7 @@ 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_status(); + Hyve_Connect::instance()->kb_reconcile( [], null ); $this->assertStringStartsWith( 'https://staging.test/api/workflows/', $this->captured['url'] ); } @@ -355,7 +354,7 @@ public function test_sse_error_maps_to_wp_error_with_code() { public function test_http_429_maps_to_quota_exceeded() { $this->intercept( wp_json_encode( [ 'message' => 'Too many requests' ] ), 429 ); - $result = Hyve_Connect::instance()->kb_status(); + $result = Hyve_Connect::instance()->kb_reconcile( [], null ); $this->assertWPError( $result ); $this->assertSame( 'hyve_connect_quota_exceeded', $result->get_error_code() ); @@ -367,7 +366,7 @@ public function test_http_429_maps_to_quota_exceeded() { public function test_missing_terminal_event_is_an_error() { $this->intercept( $this->sse( [ [ 'stream_start', [] ], [ 'delta', [ 'text' => 'x' ] ] ] ) ); - $result = Hyve_Connect::instance()->kb_status(); + $result = Hyve_Connect::instance()->kb_reconcile( [], null ); $this->assertWPError( $result ); $this->assertSame( 'hyve_connect_no_result', $result->get_error_code() ); From 255281b9e769fff94fdd0028659679da9385b326 Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Fri, 17 Jul 2026 14:35:33 +0530 Subject: [PATCH 09/17] wip --- inc/API.php | 147 ++++++++++-- inc/DB_Table.php | 160 +++++++++++-- inc/Hyve_Connect.php | 11 +- inc/Main.php | 2 + inc/OpenAI.php | 9 +- inc/Stream.php | 2 +- inc/Threads.php | 4 +- tests/php/unit/tests/test-connect-chat.php | 83 +++++++ tests/php/unit/tests/test-connect-import.php | 192 +++++++++++++++ tests/php/unit/tests/test-connect-sync.php | 234 +++++++++++++++++++ tests/php/unit/tests/test-hyve-connect.php | 23 +- 11 files changed, 833 insertions(+), 34 deletions(-) create mode 100644 tests/php/unit/tests/test-connect-chat.php create mode 100644 tests/php/unit/tests/test-connect-import.php diff --git a/inc/API.php b/inc/API.php index 151f35e5..8d6b9abf 100644 --- a/inc/API.php +++ b/inc/API.php @@ -515,6 +515,14 @@ public function update_settings( $request ) { ); } + 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; @@ -1170,6 +1178,8 @@ public function connect_disconnect( $request ) { return rest_ensure_response( [ 'error' => $blocked ] ); } + wp_clear_scheduled_hook( DB_Table::CONNECT_SYNC_HOOK ); + if ( 'import' === $mode ) { $imported = $this->connect_import(); @@ -1256,6 +1266,17 @@ public function connect_reconcile( $request ) { 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 ); @@ -1264,20 +1285,47 @@ private function connect_import() { 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' => $post_id ? get_the_title( $post_id ) : '', - 'post_content' => (string) ( $item['content'] ?? '' ), - 'token_count' => (int) ( $item['token_count'] ?? 0 ), - 'embeddings' => wp_json_encode( $item['embedding'] ?? [] ), - 'post_status' => 'processed', - 'storage' => 'WordPress', + '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', ] ); } @@ -1320,20 +1368,22 @@ private function connect_clear_local() { '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 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 ) { + 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 ); + $this->connect_forget_source( (int) $post_id, $delete_chunks ); } } @@ -1343,11 +1393,12 @@ private function connect_forget_sources( array $meta_query ) { * 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 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 ) { + 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 ); @@ -1362,6 +1413,14 @@ private function connect_forget_source( $post_id ) { 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 ); } @@ -1969,6 +2028,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__ + $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. * @@ -1977,6 +2077,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 ) ) { @@ -2075,14 +2187,19 @@ private function send_chat_connect( $prepared, $is_test, $record_id ) { if ( is_wp_error( $result ) ) { return rest_ensure_response( [ - 'error' => Hyve_Connect::user_message( $result ), + 'error' => Hyve_Connect::visitor_message(), 'code' => $result->get_error_code(), ] ); } $thread_id = isset( $result['thread_id'] ) ? $result['thread_id'] : $prepared['thread_id']; - $token = wp_generate_password( 24, false ); + + 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, diff --git a/inc/DB_Table.php b/inc/DB_Table.php index 080ba248..bbb4167c 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. @@ -62,6 +62,14 @@ class DB_Table { */ 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. * @@ -69,6 +77,15 @@ class DB_Table { */ 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. * @@ -139,6 +156,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", @@ -177,6 +195,7 @@ public function get_columns() { 'post_title' => '%s', 'post_content' => '%s', 'embeddings' => '%s', + 'embedding_model' => '%s', 'token_count' => '%d', 'post_status' => '%s', 'storage' => '%s', @@ -198,6 +217,7 @@ public function get_column_defaults() { 'post_title' => '', 'post_content' => '', 'embeddings' => '', + 'embedding_model' => '', 'token_count' => 0, 'post_status' => 'scheduled', 'storage' => 'WordPress', @@ -534,10 +554,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', @@ -796,6 +820,15 @@ private function ingest_document_connect( $doc, $args ) { 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 ); + + $status = $this->connect_sync_status(); + + if ( empty( $status['in_progress'] ) && empty( $status['blocked'] ) ) { + $this->connect_start_sync(); + } } return new \WP_Error( @@ -1003,6 +1036,7 @@ public function connect_start_sync() { 'in_progress' => true, 'blocked' => false, 'message' => '', + 'heartbeat' => time(), ] ); @@ -1086,6 +1120,11 @@ public function connect_run_sync() { return; } + if ( ! Hyve_Connect::is_active() ) { + $this->connect_finish_sync(); + return; + } + $status_by_id = []; foreach ( ( isset( $result['results'] ) && is_array( $result['results'] ) ? $result['results'] : [] ) as $row ) { @@ -1098,8 +1137,11 @@ public function connect_run_sync() { $storage_skip = false; foreach ( array_keys( $documents ) as $post_id ) { - $row = $status_by_id[ (int) $post_id ] ?? []; - $state = isset( $row['status'] ) ? $row['status'] : 'stored'; + $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. @@ -1180,6 +1222,7 @@ private function connect_advance_sync( $done ) { $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(); @@ -1189,6 +1232,38 @@ private function connect_advance_sync( $done ) { } } + /** + * 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). * @@ -1347,6 +1422,45 @@ public function connect_check_recovery() { } } + /** + * 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. @@ -1504,12 +1618,11 @@ public function connect_reconcile() { } // 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. - if ( ! empty( $status['blocked'] ) ) { - delete_option( self::CONNECT_SYNC_OPTION ); - } + // 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(); @@ -1522,6 +1635,7 @@ public function connect_reconcile() { } if ( ! empty( $root['in_sync'] ) ) { + $this->connect_clear_blocked_state( $was_blocked ); return true; } @@ -1537,6 +1651,7 @@ public function connect_reconcile() { : []; if ( empty( $differing ) ) { + $this->connect_clear_blocked_state( $was_blocked ); return true; } @@ -1570,12 +1685,30 @@ function ( $entry ) use ( $scope ) { } 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. * @@ -1644,9 +1777,10 @@ public function process_post( $id, $allow_retry = true ) { $this->update( $id, [ - 'embeddings' => $embeddings, - 'post_status' => 'processed', - 'storage' => $storage, + 'embeddings' => $embeddings, + 'embedding_model' => OpenAI::EMBEDDING_MODEL, + 'post_status' => 'processed', + 'storage' => $storage, ] ); diff --git a/inc/Hyve_Connect.php b/inc/Hyve_Connect.php index 7a7edfa8..5144f500 100644 --- a/inc/Hyve_Connect.php +++ b/inc/Hyve_Connect.php @@ -525,7 +525,6 @@ public static function user_message( $error ) { $messages = [ 'quota_exceeded' => __( 'You have reached your Hyve Connect limit for now. Upgrade your plan for more.', 'hyve-lite' ), - 'moderation_flagged' => __( 'Message was flagged.', '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' ), ]; @@ -539,6 +538,16 @@ public static function user_message( $error ) { 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. * diff --git a/inc/Main.php b/inc/Main.php index 63f5929c..9c28be39 100644 --- a/inc/Main.php +++ b/inc/Main.php @@ -184,7 +184,9 @@ public function admin_init() { $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' ); diff --git a/inc/OpenAI.php b/inc/OpenAI.php index 485202b6..6e7d4561 100644 --- a/inc/OpenAI.php +++ b/inc/OpenAI.php @@ -65,6 +65,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). * @@ -224,7 +231,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/Stream.php b/inc/Stream.php index f98c874b..c0332391 100644 --- a/inc/Stream.php +++ b/inc/Stream.php @@ -351,7 +351,7 @@ private function stream_connect( $message, $thread_id, $record_id, $is_test, $se ] ); } else { - $this->send_event( 'error', [ 'message' => Hyve_Connect::user_message( $result ) ] ); + $this->send_event( 'error', [ 'message' => Hyve_Connect::visitor_message() ] ); } return; 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/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..9bba9e5e --- /dev/null +++ b/tests/php/unit/tests/test-connect-import.php @@ -0,0 +1,192 @@ + $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'] ); + } +} diff --git a/tests/php/unit/tests/test-connect-sync.php b/tests/php/unit/tests/test-connect-sync.php index 8a137822..6703560b 100644 --- a/tests/php/unit/tests/test-connect-sync.php +++ b/tests/php/unit/tests/test-connect-sync.php @@ -30,8 +30,10 @@ protected function setUp(): void { */ protected function tearDown(): void { remove_all_filters( 'pre_http_request' ); + remove_all_filters( 'product_hyve_license_key' ); delete_option( 'hyve_settings' ); delete_option( DB_Table::CONNECT_SYNC_OPTION ); + delete_option( DB_Table::CONNECT_IDENTITY_OPTION ); delete_transient( 'hyve_connect_stats' ); delete_transient( 'hyve_connect_recovery_check' ); wp_clear_scheduled_hook( DB_Table::CONNECT_SYNC_HOOK ); @@ -184,6 +186,212 @@ public function test_run_sync_syncs_batch_and_finishes() { $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. */ @@ -784,6 +992,32 @@ public function test_reconcile_retries_a_blocked_sync() { $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. diff --git a/tests/php/unit/tests/test-hyve-connect.php b/tests/php/unit/tests/test-hyve-connect.php index 085421a5..d07bec95 100644 --- a/tests/php/unit/tests/test-hyve-connect.php +++ b/tests/php/unit/tests/test-hyve-connect.php @@ -101,6 +101,22 @@ public function test_mode_connect_only_when_opted_in() { $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. */ @@ -376,11 +392,14 @@ public function test_missing_terminal_event_is_an_error() { * user_message maps platform codes to visitor/admin wording. */ public function test_user_message_maps_codes() { - $flagged = new WP_Error( 'hyve_connect_moderation_flagged', 'x', [ 'code' => 'moderation_flagged' ] ); $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->assertSame( 'Message was flagged.', Hyve_Connect::user_message( $flagged ) ); $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 ) ) ); } /** From 0d467113e43eac67477d870dc0a937fa2d5f9fd7 Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Fri, 17 Jul 2026 17:06:54 +0530 Subject: [PATCH 10/17] wip --- inc/API.php | 2 +- inc/DB_Table.php | 47 ++++++++++++---------- inc/Hyve_Connect.php | 14 ++++--- inc/Stream.php | 4 +- tests/e2e/specs/settings.spec.js | 2 +- tests/php/unit/tests/test-hyve-connect.php | 16 ++++---- 6 files changed, 47 insertions(+), 38 deletions(-) diff --git a/inc/API.php b/inc/API.php index 8d6b9abf..dc569f69 100644 --- a/inc/API.php +++ b/inc/API.php @@ -2038,7 +2038,7 @@ private function build_source_link( $post_id, $number ) { 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__ + $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 ) { diff --git a/inc/DB_Table.php b/inc/DB_Table.php index bbb4167c..c0f7c59b 100644 --- a/inc/DB_Table.php +++ b/inc/DB_Table.php @@ -189,16 +189,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', + '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', + 'token_count' => '%d', + 'post_status' => '%s', + 'storage' => '%s', ]; } @@ -211,16 +211,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' => '', + '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', + 'token_count' => 0, + 'post_status' => 'scheduled', + 'storage' => 'WordPress', ]; } @@ -1103,9 +1103,11 @@ public function connect_run_sync() { $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 ( false !== strpos( $result->get_error_code(), 'quota_exceeded' ) ) { + if ( is_string( $error_code ) && false !== strpos( $error_code, 'quota_exceeded' ) ) { $data = $result->get_error_data(); $this->connect_block_sync( @@ -1120,7 +1122,10 @@ public function connect_run_sync() { return; } - if ( ! Hyve_Connect::is_active() ) { + $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; } @@ -1297,7 +1302,7 @@ private function connect_block_sync( $message, $quota = [] ) { $status['in_progress'] = false; $status['blocked'] = true; $status['message'] = (string) $message; - $status['quota'] = is_array( $quota ) ? $quota : []; + $status['quota'] = $quota; update_option( self::CONNECT_SYNC_OPTION, $status ); Hyve_Connect::flush_stats(); diff --git a/inc/Hyve_Connect.php b/inc/Hyve_Connect.php index 5144f500..3d3247f5 100644 --- a/inc/Hyve_Connect.php +++ b/inc/Hyve_Connect.php @@ -301,10 +301,11 @@ public function kb_export( $cursor = null, $batch_size = 50 ) { * @return array|\WP_Error */ public function get_quota() { - $response = wp_remote_get( + $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, ] ); @@ -338,8 +339,8 @@ public function get_quota() { * 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). + * @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. */ @@ -524,9 +525,9 @@ public static function user_message( $error ) { $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' ), + '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 ) { @@ -568,6 +569,7 @@ private function workflow( $slug, $payload ) { [ 'headers' => $this->get_headers(), 'body' => $body, + // phpcs:ignore WordPressVIPMinimum.Performance.RemoteRequestTimeout.timeout_timeout -- Buffered SSE workflow (moderation + embedding + storage) legitimately runs longer. 'timeout' => 60, ] ); diff --git a/inc/Stream.php b/inc/Stream.php index c0332391..589428fa 100644 --- a/inc/Stream.php +++ b/inc/Stream.php @@ -339,9 +339,11 @@ private function stream_connect( $message, $thread_id, $record_id, $is_test, $se ); 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 ( false !== strpos( $result->get_error_code(), 'kb_unavailable' ) ) { + if ( is_string( $error_code ) && false !== strpos( $error_code, 'kb_unavailable' ) ) { $this->send_event( 'done', [ diff --git a/tests/e2e/specs/settings.spec.js b/tests/e2e/specs/settings.spec.js index fe57c36f..ced5eb14 100644 --- a/tests/e2e/specs/settings.spec.js +++ b/tests/e2e/specs/settings.spec.js @@ -247,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-hyve-connect.php b/tests/php/unit/tests/test-hyve-connect.php index d07bec95..be61a3d8 100644 --- a/tests/php/unit/tests/test-hyve-connect.php +++ b/tests/php/unit/tests/test-hyve-connect.php @@ -118,7 +118,7 @@ public function test_update_settings_refuses_leaving_connect() { } /** - * parse_sse preserves event order and decodes each frame's data. + * Parse_sse preserves event order and decodes each frame's data. */ public function test_parse_sse_orders_events_and_decodes_data() { $body = $this->sse( @@ -153,7 +153,7 @@ public function test_parse_sse_skips_comments_and_blanks() { } /** - * kb_aggregate must match the platform's aggregate byte for byte: sha256 over + * 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. */ @@ -189,7 +189,7 @@ public function test_kb_aggregate_matches_platform_formula() { } /** - * kb_bucket_of must match the platform's bucketOf: low 8 bits of crc32. + * 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 ) { @@ -203,7 +203,7 @@ public function test_kb_bucket_of_matches_platform_formula() { } /** - * kb_bucket_hashes groups a manifest by bucket and aggregates each group, so + * 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() { @@ -235,7 +235,7 @@ public function test_kb_bucket_hashes_group_by_bucket() { } /** - * kb_upsert POSTs the contract-shaped payload and returns the job_complete data. + * 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( @@ -389,7 +389,7 @@ public function test_missing_terminal_event_is_an_error() { } /** - * user_message maps platform codes to visitor/admin wording. + * 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' ] ); @@ -403,7 +403,7 @@ public function test_user_message_maps_codes() { } /** - * get_quota reads the plain-JSON aggregate over GET. + * Get_quota reads the plain-JSON aggregate over GET. */ public function test_get_quota_returns_decoded_json() { $this->intercept( @@ -423,7 +423,7 @@ public function test_get_quota_returns_decoded_json() { } /** - * stats() caches the aggregate so a second read does not hit the network. + * Stats() caches the aggregate so a second read does not hit the network. */ public function test_stats_are_cached_after_first_fetch() { $this->intercept( From d89b9cdc46cbf26216f9844173da256a1058a954 Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Sat, 18 Jul 2026 12:28:13 +0530 Subject: [PATCH 11/17] chore: gate pro features --- src/backend/screens/ChatAppearance.js | 4 ++-- src/backend/screens/ChatBehavior.js | 6 +++--- src/backend/screens/Dashboard.js | 4 ++-- src/backend/screens/Integrations.js | 4 ++-- src/backend/screens/KnowledgeBase.js | 8 ++++---- src/backend/screens/Messages.js | 4 ++-- src/backend/utils.js | 18 ++++++++++++++++++ 7 files changed, 33 insertions(+), 15 deletions(-) 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/Dashboard.js b/src/backend/screens/Dashboard.js index 2f65bf0a..c5a2fc4c 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 { isLicenseActive, setUtm } from '../utils'; import Card from '../components/Card'; import Chip from '../components/Chip'; import SetupChecklist from '../components/SetupChecklist'; @@ -294,7 +294,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..f36d971c 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 ( @@ -1493,7 +1493,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 +1589,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/utils.js b/src/backend/utils.js index 7225df7a..660ce413 100644 --- a/src/backend/utils.js +++ b/src/backend/utils.js @@ -210,6 +210,24 @@ export const setUtm = ( urlAdress, linkArea ) => { return urlLink.toString(); }; +/** + * 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, From 70b253d571a92d82b206deb416867f5748a5785d Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Sat, 18 Jul 2026 13:12:08 +0530 Subject: [PATCH 12/17] fix: add instruction param --- inc/Hyve_Connect.php | 4 ++- tests/php/unit/tests/test-hyve-connect.php | 38 ++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/inc/Hyve_Connect.php b/inc/Hyve_Connect.php index 3d3247f5..05754d4a 100644 --- a/inc/Hyve_Connect.php +++ b/inc/Hyve_Connect.php @@ -459,10 +459,12 @@ public static function chat_settings( $settings = null ) { $settings = Main::get_settings(); } + $instructions = trim( (string) apply_filters( 'hyve_system_prompt', $settings['system_prompt'] ?? '' ) ); + return apply_filters( 'hyve_connect_chat_settings', [ - 'instructions' => isset( $settings['instructions'] ) ? $settings['instructions'] : '', + 'instructions' => mb_substr( $instructions, 0, 4000 ), ], $settings ); diff --git a/tests/php/unit/tests/test-hyve-connect.php b/tests/php/unit/tests/test-hyve-connect.php index be61a3d8..8af0bbef 100644 --- a/tests/php/unit/tests/test-hyve-connect.php +++ b/tests/php/unit/tests/test-hyve-connect.php @@ -331,6 +331,44 @@ public function test_base_url_is_filterable() { $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. */ From 882052e75a5ffeffed889dc8668c42a6798fbbea Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Sun, 19 Jul 2026 01:21:44 +0530 Subject: [PATCH 13/17] fix: keep unsynced sources with local chunks when cancelling a Connect migration --- inc/API.php | 15 +++++++- tests/php/unit/tests/test-connect-import.php | 40 ++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/inc/API.php b/inc/API.php index fdcc1b0b..21f2d457 100644 --- a/inc/API.php +++ b/inc/API.php @@ -1349,7 +1349,7 @@ private function connect_import() { * @return void */ private function connect_drop_unsynced() { - $this->connect_forget_sources( + $candidates = $this->table->connect_source_ids( [ [ 'key' => '_hyve_added', @@ -1361,6 +1361,19 @@ private function connect_drop_unsynced() { ], ] ); + + 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 ); + } + } } /** diff --git a/tests/php/unit/tests/test-connect-import.php b/tests/php/unit/tests/test-connect-import.php index 9bba9e5e..42dbe5bb 100644 --- a/tests/php/unit/tests/test-connect-import.php +++ b/tests/php/unit/tests/test-connect-import.php @@ -189,4 +189,44 @@ public function test_import_rejects_a_mismatched_model() { $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 ) ); + } } From 8b94f4a2ea117fdbd317da9d7a2ae8e2cb1b8d97 Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Thu, 23 Jul 2026 17:21:47 +0530 Subject: [PATCH 14/17] fix: qa issues --- inc/API.php | 2 + inc/DB_Table.php | 37 ++++++++++++++++ inc/Main.php | 12 +++--- src/backend/screens/Dashboard.js | 4 +- tests/php/unit/tests/test-connect-sync.php | 50 ++++++++++++++++++++++ 5 files changed, 96 insertions(+), 9 deletions(-) diff --git a/inc/API.php b/inc/API.php index 21f2d457..0078fe8c 100644 --- a/inc/API.php +++ b/inc/API.php @@ -925,6 +925,8 @@ public function get_stats() { // 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(); } diff --git a/inc/DB_Table.php b/inc/DB_Table.php index c0f7c59b..c1506813 100644 --- a/inc/DB_Table.php +++ b/inc/DB_Table.php @@ -118,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 ); }, @@ -1022,6 +1026,8 @@ public function connect_has_synced() { * @return void */ public function connect_start_sync() { + $this->connect_clear_processing_errors(); + $pending = $this->connect_pending_count(); if ( 0 === $pending ) { @@ -1044,6 +1050,37 @@ public function connect_start_sync() { 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. * diff --git a/inc/Main.php b/inc/Main.php index f7706b51..c53c9d0c 100644 --- a/inc/Main.php +++ b/inc/Main.php @@ -81,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' ] ); } @@ -731,17 +734,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; } diff --git a/src/backend/screens/Dashboard.js b/src/backend/screens/Dashboard.js index 38b4f3f8..9984d92e 100644 --- a/src/backend/screens/Dashboard.js +++ b/src/backend/screens/Dashboard.js @@ -333,8 +333,8 @@ const StatsGrid = () => { label={ __( 'Hyve Connect', 'hyve-lite' ) } value={ __( 'Hosted AI, no API key', 'hyve-lite' ) } chip={ - - { __( 'Free', 'hyve-lite' ) } + + { __( 'Not connected', 'hyve-lite' ) } } foot={ diff --git a/tests/php/unit/tests/test-connect-sync.php b/tests/php/unit/tests/test-connect-sync.php index 6703560b..6dfe671d 100644 --- a/tests/php/unit/tests/test-connect-sync.php +++ b/tests/php/unit/tests/test-connect-sync.php @@ -115,6 +115,56 @@ public function test_pending_posts_excludes_already_synced() { $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. */ From ef1a49d85c62d8756c3d5fd2ef5a87f38ee99aa4 Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Thu, 23 Jul 2026 17:49:08 +0530 Subject: [PATCH 15/17] fix: qa issues --- src/frontend/App.js | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) 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; } From f98e5af8bd1fdcf8f9d3a65b5d0c82d3d54cec05 Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Fri, 24 Jul 2026 20:59:00 +0530 Subject: [PATCH 16/17] fix: qa issues --- inc/Main.php | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/inc/Main.php b/inc/Main.php index c53c9d0c..1511c36b 100644 --- a/inc/Main.php +++ b/inc/Main.php @@ -112,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 * @@ -128,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' ) . '

'; + '

' . __( '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' ) . '

'; - // 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' ) . '

'; + // 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 ) ); From 238aa3b42321800eff21bb8b673cb22de29688b1 Mon Sep 17 00:00:00 2001 From: Hardeep Asrani Date: Wed, 29 Jul 2026 13:56:53 +0530 Subject: [PATCH 17/17] fix: posts queue --- inc/API.php | 48 +++++++++++++ inc/DB_Table.php | 82 +++++++++++++++++++++- inc/Main.php | 14 +++- src/backend/screens/KnowledgeBase.js | 74 ++++++++++++++++++- tests/php/unit/tests/test-connect-sync.php | 64 +++++++++++++++++ 5 files changed, 277 insertions(+), 5 deletions(-) diff --git a/inc/API.php b/inc/API.php index 0078fe8c..7ad91e78 100644 --- a/inc/API.php +++ b/inc/API.php @@ -166,6 +166,19 @@ public function register_routes() { 'callback' => [ $this, 'delete_data' ], ], ], + 'data/enqueue' => [ + [ + 'methods' => \WP_REST_Server::CREATABLE, + 'args' => [ + 'ids' => [ + 'required' => true, + 'type' => 'array', + 'items' => [ 'type' => 'integer' ], + ], + ], + 'callback' => [ $this, 'enqueue_data' ], + ], + ], 'data/counts' => [ [ 'methods' => \WP_REST_Server::READABLE, @@ -982,6 +995,41 @@ public function add_data( $request ) { return rest_ensure_response( true ); } + /** + * Queue a whole selection for Hyve Connect in one request. + * + * Unlike {@see add_data()}, which pushes a single post synchronously, this + * persists every selected post as pending up front and lets the sync cron + * drain them in the background. A refresh mid-add therefore cannot lose the + * items still waiting: they are already durably queued server-side. + * + * @param \WP_REST_Request> $request Request object. + * + * @return \WP_REST_Response + */ + public function enqueue_data( $request ) { + if ( ! Hyve_Connect::is_active() ) { + return rest_ensure_response( [ 'error' => __( 'Bulk queueing is only available with Hyve Connect.', 'hyve-lite' ) ] ); + } + + $ids = $request->get_param( 'ids' ); + $ids = is_array( $ids ) ? array_filter( array_map( 'intval', $ids ) ) : []; + + if ( empty( $ids ) ) { + return rest_ensure_response( [ 'error' => __( 'No content to queue.', 'hyve-lite' ) ] ); + } + + $queued = $this->table->connect_enqueue_posts( $ids ); + + return rest_ensure_response( + [ + 'success' => true, + 'queued' => $queued, + 'connectSync' => $this->table->connect_sync_status(), + ] + ); + } + /** * Delete data. * diff --git a/inc/DB_Table.php b/inc/DB_Table.php index c1506813..c3193302 100644 --- a/inc/DB_Table.php +++ b/inc/DB_Table.php @@ -86,6 +86,15 @@ class DB_Table { */ const CONNECT_SYNC_STALL = 300; + /** + * How long a post may carry the `_hyve_post_processing` flag before it is + * treated as leaked (an add interrupted mid-request) and ignored. Comfortably + * beyond a single synchronous add, so a live one is never mistaken for stale. + * + * @var int + */ + const PROCESSING_STALL = 300; + /** * The single instance of the class. * @@ -556,7 +565,9 @@ public function get_posts_over_limit() { * @throws \Exception If Qdrant API fails. */ public function add_post( $post_id, $action = 'add' ) { - update_post_meta( $post_id, '_hyve_post_processing', 1 ); + // Stamped with the start time so an add interrupted before the matching + // delete below leaves a flag that can be aged out instead of sticking. + update_post_meta( $post_id, '_hyve_post_processing', time() ); $content = Hyve_Connect::is_active() ? get_post_field( 'post_content', $post_id ) @@ -1050,6 +1061,75 @@ public function connect_start_sync() { wp_schedule_single_event( time(), self::CONNECT_SYNC_HOOK ); } + /** + * Durably queue posts for the to-Connect sync job in a single pass. + * + * Each post is marked as belonging to the Knowledge Base (`_hyve_added`) + * without a synced hash, which is exactly the pending state the sync drain + * looks for, and then the cron is kicked. Because the whole selection is + * persisted up front (rather than one REST call per post), a page refresh + * mid-add can no longer drop the items still waiting to be pushed. + * + * @param array $post_ids Post ids to queue. + * + * @return int How many posts were newly queued. + */ + public function connect_enqueue_posts( $post_ids ) { + $queued = 0; + + foreach ( $post_ids as $post_id ) { + $post_id = (int) $post_id; + + if ( $post_id <= 0 || ! get_post( $post_id ) ) { + continue; + } + + // Already on the platform: nothing to push. + if ( '' !== (string) get_post_meta( $post_id, '_hyve_connect_synced_hash', true ) ) { + continue; + } + + // A fresh queueing clears the terminal markers a previous interrupted + // or rejected attempt may have left, so the source re-enters the + // pending set instead of being skipped forever. + delete_post_meta( $post_id, '_hyve_moderation_failed' ); + delete_post_meta( $post_id, '_hyve_moderation_review' ); + delete_post_meta( $post_id, '_hyve_processing_error' ); + delete_post_meta( $post_id, '_hyve_post_processing' ); + + update_post_meta( $post_id, '_hyve_added', 1 ); + ++$queued; + } + + if ( 0 === $queued ) { + return 0; + } + + $status = $this->connect_sync_status(); + + // Fold the new work into a run already in flight (e.g. a mode-switch + // sync): bump its total and make sure a pass is queued to carry it. The + // drain re-derives the pending set each pass, so it picks the posts up. + if ( ! empty( $status['in_progress'] ) ) { + // total tracks the whole job (done + still pending), not just what + // is left, so the progress bar stays honest as new work folds in. + $status['total'] = (int) ( $status['current'] ?? 0 ) + $this->connect_pending_count(); + $status['heartbeat'] = time(); + update_option( self::CONNECT_SYNC_OPTION, $status ); + Hyve_Connect::flush_stats(); + + if ( ! wp_next_scheduled( self::CONNECT_SYNC_HOOK ) ) { + wp_schedule_single_event( time(), self::CONNECT_SYNC_HOOK ); + } + + return $queued; + } + + $this->connect_start_sync(); + + return $queued; + } + /** * Forget per-source processing errors so a new sync round re-attempts them. * diff --git a/inc/Main.php b/inc/Main.php index 1511c36b..999b98a0 100644 --- a/inc/Main.php +++ b/inc/Main.php @@ -866,9 +866,17 @@ public function register_row_action_filter_shortcut( $post_type ) { * @return array */ public function add_to_knowledge_base_row_action( $actions, $post ) { - if ( get_post_meta( $post->ID, '_hyve_post_processing', true ) ) { - $actions['hyve_knowledge_base_processing'] = __( 'Hyve is processing the post', 'hyve-lite' ); - return $actions; + $processing = (int) get_post_meta( $post->ID, '_hyve_post_processing', true ); + + if ( $processing ) { + if ( ( time() - $processing ) < DB_Table::PROCESSING_STALL ) { + $actions['hyve_knowledge_base_processing'] = __( 'Hyve is processing the post', 'hyve-lite' ); + return $actions; + } + + // A leaked flag from an add interrupted mid-request; clear it and + // fall through to the normal add/remove action. + delete_post_meta( $post->ID, '_hyve_post_processing' ); } $label = __( 'Add to Hyve', 'hyve-lite' ); diff --git a/src/backend/screens/KnowledgeBase.js b/src/backend/screens/KnowledgeBase.js index a4e692ff..ea0bdfff 100644 --- a/src/backend/screens/KnowledgeBase.js +++ b/src/backend/screens/KnowledgeBase.js @@ -520,11 +520,15 @@ const WordPressDrill = () => { // Page size comes from the endpoint. const perPageRef = useRef( 20 ); - const { setTotalChunks, setAttentionCount } = useDispatch( 'hyve' ); + const { setTotalChunks, setAttentionCount, setConnectSync } = + useDispatch( 'hyve' ); const { createNotice } = useDispatch( 'core/notices' ); const hasReachedLimit = useSelect( ( select ) => select( 'hyve' ).hasReachedLimit() ); + const isConnectActive = useSelect( ( select ) => + select( 'hyve' ).isConnectActive() + ); useEffect( () => { const request = ++requestRef.current; @@ -661,7 +665,75 @@ const WordPressDrill = () => { } ); }; + // Connect mode drains a durable server-side queue, so the whole selection is + // enqueued in one request and synced in the background. Persisting it up + // front is what makes a page refresh mid-add safe: nothing is left to a + // client loop that a refresh could interrupt. + const runConnectQueue = async ( items ) => { + setBulk( { done: 0, total: items.length } ); + + try { + const response = await apiFetch( { + path: `${ window.hyve.api }/data/enqueue`, + method: 'POST', + data: { ids: items.map( ( item ) => item.ID ) }, + } ); + + if ( response.error ) { + throw new Error( response.error ); + } + + const queued = response.queued ?? items.length; + + // Drop the queued posts from the "available" list and reflect the + // sync progress the background job will advance from here. + setProcessedPosts( ( prev ) => [ + ...prev, + ...items.map( ( item ) => item.ID ), + ] ); + setSelected( ( prev ) => { + const next = { ...prev }; + items.forEach( ( item ) => delete next[ item.ID ] ); + return next; + } ); + setConnectSync( response.connectSync ?? null ); + + window.hyveTrk?.add?.( { + feature: 'knowledge-base', + featureComponent: 'add-data', + featureValue: 'import-wordpress-data', + } ); + + createNotice( + 'success', + sprintf( + /* translators: %s: number of items queued. */ + _n( + '%s item queued. It will sync to Hyve Connect in the background.', + '%s items queued. They will sync to Hyve Connect in the background.', + queued, + 'hyve-lite' + ), + queued + ), + { type: 'snackbar', isDismissible: true } + ); + } catch ( error ) { + createNotice( 'error', error?.message ?? String( error ), { + type: 'snackbar', + isDismissible: true, + } ); + } + + setBulk( null ); + fetchAttentionCount( setAttentionCount ); + }; + const runBulk = async ( items ) => { + if ( isConnectActive ) { + return runConnectQueue( items ); + } + setBulk( { done: 0, total: items.length } ); let added = 0; diff --git a/tests/php/unit/tests/test-connect-sync.php b/tests/php/unit/tests/test-connect-sync.php index 6dfe671d..ece857a6 100644 --- a/tests/php/unit/tests/test-connect-sync.php +++ b/tests/php/unit/tests/test-connect-sync.php @@ -193,6 +193,70 @@ public function test_start_sync_noop_when_nothing_pending() { $this->assertFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); } + /** + * Bulk enqueue durably marks the whole selection pending in one pass and + * kicks the sync, so a refresh mid-add cannot drop the waiting items. + */ + public function test_enqueue_marks_selection_pending_and_schedules() { + $this->enable_connect(); + $a = self::factory()->post->create(); + $b = self::factory()->post->create(); + + $queued = DB_Table::instance()->connect_enqueue_posts( [ $a, $b ] ); + + $this->assertSame( 2, $queued ); + $this->assertSame( '1', get_post_meta( $a, '_hyve_added', true ) ); + $this->assertSame( '1', get_post_meta( $b, '_hyve_added', true ) ); + $this->assertSame( 2, DB_Table::instance()->connect_pending_count() ); + $this->assertSame( 2, DB_Table::instance()->connect_sync_status()['total'] ); + $this->assertNotFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); + } + + /** + * Enqueue skips a source already on the platform and clears the terminal + * markers a previous rejected/failed attempt left, so it re-enters the queue. + */ + public function test_enqueue_skips_synced_and_clears_stale_markers() { + $this->enable_connect(); + $synced = $this->indexed_post( true ); + $failed = self::factory()->post->create(); + update_post_meta( $failed, '_hyve_moderation_failed', 1 ); + update_post_meta( $failed, '_hyve_processing_error', 'stale' ); + + $queued = DB_Table::instance()->connect_enqueue_posts( [ $synced, $failed ] ); + + $this->assertSame( 1, $queued ); + $this->assertSame( '', get_post_meta( $failed, '_hyve_moderation_failed', true ) ); + $this->assertSame( '', get_post_meta( $failed, '_hyve_processing_error', true ) ); + $this->assertContains( $failed, DB_Table::instance()->connect_pending_posts( -1 ) ); + $this->assertNotContains( $synced, DB_Table::instance()->connect_pending_posts( -1 ) ); + } + + /** + * Enqueuing into a sync already in flight folds the new work into its total + * without resetting the progress already made. + */ + public function test_enqueue_folds_into_running_sync() { + $this->enable_connect(); + update_option( + DB_Table::CONNECT_SYNC_OPTION, + [ + 'total' => 5, + 'current' => 3, + 'in_progress' => true, + 'blocked' => false, + ] + ); + $post = self::factory()->post->create(); + + DB_Table::instance()->connect_enqueue_posts( [ $post ] ); + + $status = DB_Table::instance()->connect_sync_status(); + $this->assertSame( 3, $status['current'] ); + $this->assertSame( 4, $status['total'] ); + $this->assertNotFalse( wp_next_scheduled( DB_Table::CONNECT_SYNC_HOOK ) ); + } + /** * A run pushes the batch, marks each source synced, and finishes when drained. */