PluginProbe
MxChat – AI Chatbot & Content Generation for WordPress / 3.2.23
MxChat – AI Chatbot & Content Generation for WordPress v3.2.23
3.2.25 3.2.24 3.2.23 3.2.22 3.2.21 3.2.20 3.2.19 3.2.18 3.2.17 3.2.16 3.2.15 3.2.14 3.2.12 3.2.13 3.2.11 3.2.10 3.2.9 3.2.8 3.2.7 3.2.6 3.2.5 3.2.4 3.2.3 3.2.2 3.2.1 All 156 releases
← All changes | admin/class-pinecone-documents.php +784 -69 3.2.22 → 3.2.23 View file →
@@ -42,15 +42,21 @@
42 42
43 43 const API_VERSION = '2026-07';
44 44 const TEXT_FIELD = 'text';
45 45 const VECTOR_FIELD = 'embedding';
46 + const CODES_FIELD = 'codes'; // plan 3e83e4: normalised part-number tokens stored beside the text
47 + const CODES_MAX = 60; // per document — a long spec sheet must not bloat the 40 KB metadata cap
48 + const CODE_MIN_LEN = 4; // shorter tokens would match everything
49 + const CODE_MAX_LEN = 32;
46 50 const MIGRATION_KEY = 'mxchat_pinecone_docs_migration';
47 51 const UPSERT_BATCH = 40; // 1536-dim vectors are ~30 KB of JSON each; the request cap is 2 MB
48 52 const MIGRATE_BATCH = 40;
53 + const MIGRATE_STEP_SECONDS = 12; // pages copied per AJAX step until this much wall time is used (3e83e4)
54 + const MIGRATE_RETRIES = 3; // attempts per Pinecone call inside a step, 1 s / 2 s / 4 s apart
49 55 const OPTION = 'mxchat_pinecone_addon_options';
50 56
51 57 /** Fields the retrieval and listing paths ask for (never the vector unless needed). */
52 - const RECORD_FIELDS = array('text', 'source_url', 'type', 'bot_id', 'is_chunked', 'chunk_index', 'total_chunks', 'parent_url_hash', 'role_restriction', 'created_at', 'last_updated');
58 + const RECORD_FIELDS = array('text', 'source_url', 'title', 'type', 'bot_id', 'is_chunked', 'chunk_index', 'total_chunks', 'parent_url_hash', 'role_restriction', 'created_at', 'last_updated');
53 59
54 60 public static function clouds() {
55 61 return array('aws' => 'AWS', 'gcp' => 'GCP', 'azure' => 'Azure');
56 62 }
@@ -252,8 +258,17 @@
252 258 }
253 259 }
254 260 $doc[self::TEXT_FIELD] = (string) $text;
255 261 $doc[self::VECTOR_FIELD] = array_values(array_map('floatval', $embedding));
262 + // 3e83e4: the normalised code set travels with every write (new content
263 + // and copied records alike) so a part number typed without its
264 + // separators can still reach the record — see index_codes().
265 + $codes = self::index_codes((string) $text);
266 + if (!empty($codes)) {
267 + $doc[self::CODES_FIELD] = $codes;
268 + } else {
269 + unset($doc[self::CODES_FIELD]);
270 + }
256 271 return $doc;
257 272 }
258 273
259 274 /**
@@ -686,8 +701,12 @@
686 701 /**
687 702 * Text-match filter that requires at least one of the codes. Hyphenated or
688 703 * dotted codes are phrase-matched (the tokenizer splits them, and "20" on
689 704 * its own would match everything); single runs use $match_any.
705 + *
706 + * 3e83e4: a second leg matches the NORMALISED code set stored on each
707 + * document (codes $in …), which is what lets "R165321320" reach a record
708 + * that only ever says "R1653" — see query_codes() for the expansion.
690 709 */
691 710 public static function keyword_filter(array $tokens) {
692 711 $clauses = array();
693 712 foreach ($tokens as $token) {
@@ -693,8 +712,12 @@
693 712 foreach ($tokens as $token) {
694 713 $op = preg_match('/[.\-]/', $token) ? '$match_phrase' : '$match_any';
695 714 $clauses[] = array(self::TEXT_FIELD => array($op => $token));
696 715 }
716 + $query_codes = self::query_codes($tokens);
717 + if (!empty($query_codes)) {
718 + $clauses[] = array(self::CODES_FIELD => array('$in' => $query_codes));
719 + }
697 720 if (empty($clauses)) {
698 721 return null;
699 722 }
700 723 return count($clauses) === 1 ? $clauses[0] : array('$or' => $clauses);
@@ -699,8 +722,144 @@
699 722 }
700 723 return count($clauses) === 1 ? $clauses[0] : array('$or' => $clauses);
701 724 }
702 725
726 + /** Upper-case, separators stripped: "R1653 213 20" → "R165321320", "hgh25ca" → "HGH25CA". */
727 + public static function normalize_code($code) {
728 + return strtoupper(preg_replace('/[\s.\-_\/]+/u', '', (string) $code));
729 + }
730 +
731 + /**
732 + * Code-like runs in a piece of text, INCLUDING digit groups that follow a
733 + * code separated by a space, hyphen or dot ("R1653 213 20" is one number
734 + * the way Rexroth prints it). Returns [ [head, full], … ] where head is
735 + * the first run and full is the whole joined run, both un-normalised.
736 + */
737 + private static function code_runs($text) {
738 + $runs = array();
739 + $pattern = '/(?<![A-Za-z0-9])([A-Za-z0-9]+(?:[.\-][A-Za-z0-9]+)*)((?:[ .\-]\d{2,6}(?![A-Za-z0-9])){1,3})?(?![A-Za-z0-9])/u';
740 + if (!preg_match_all($pattern, (string) $text, $m, PREG_SET_ORDER)) {
741 + return $runs;
742 + }
743 + foreach ($m as $match) {
744 + $head = rtrim($match[1], '.');
745 + if (strlen($head) < self::CODE_MIN_LEN || !preg_match('/\d/', $head)) {
746 + continue;
747 + }
748 + $has_letter = (bool) preg_match('/[A-Za-z]/', $head);
749 + $separators = preg_match_all('/[.\-]/', $head);
750 + if (!$has_letter && $separators < 2) {
751 + continue; // 2024, 26.5, 10-20
752 + }
753 + $tail = isset($match[2]) ? $match[2] : '';
754 + $runs[] = array($head, $head . $tail);
755 + }
756 + return $runs;
757 + }
758 +
759 + /**
760 + * The normalised token set stored on a document at write time: for every
761 + * code-like run in the text, the separator-stripped form, the leading stem
762 + * (the run before the first separator) and the letters+digits stem
763 + * ("HGH25" of "HGH25CA"). Unique, minimum 4 characters, capped.
764 + */
765 + public static function index_codes($text) {
766 + $codes = array();
767 + foreach (self::code_runs($text) as $run) {
768 + list($head, $full) = $run;
769 + $candidates = array(self::normalize_code($full), self::normalize_code($head));
770 + $stem = preg_split('/[.\-\s]/', $head)[0];
771 + $candidates[] = self::normalize_code($stem);
772 + if (preg_match('/^([A-Za-z]+\d+)/', $head, $sm)) {
773 + $candidates[] = self::normalize_code($sm[1]);
774 + }
775 + foreach ($candidates as $c) {
776 + if (strlen($c) >= self::CODE_MIN_LEN && strlen($c) <= self::CODE_MAX_LEN && preg_match('/\d/', $c)) {
777 + $codes[$c] = true;
778 + }
779 + }
780 + if (count($codes) >= self::CODES_MAX) {
781 + break;
782 + }
783 + }
784 + return array_slice(array_keys($codes), 0, self::CODES_MAX);
785 + }
786 +
787 + /**
788 + * What a question's tokens expand to for the codes $in filter: the
789 + * stripped form, every prefix of it from 4 characters up (a stored stem
790 + * that is a prefix of what the visitor typed then matches — "R1653" for
791 + * "R165321320") and the letters+digits stem. Plain-language questions
792 + * carry no tokens and return nothing, so they are unchanged.
793 + */
794 + public static function query_codes(array $tokens) {
795 + $codes = array();
796 + foreach ($tokens as $token) {
797 + $full = self::normalize_code($token);
798 + if (strlen($full) < self::CODE_MIN_LEN || !preg_match('/\d/', $full)) {
799 + continue;
800 + }
801 + $full = substr($full, 0, self::CODE_MAX_LEN);
802 + $codes[$full] = true;
803 + for ($len = self::CODE_MIN_LEN; $len < strlen($full); $len++) {
804 + $codes[substr($full, 0, $len)] = true;
805 + }
806 + if (preg_match('/^([A-Z]+\d+)/', $full, $sm) && strlen($sm[1]) >= self::CODE_MIN_LEN) {
807 + $codes[$sm[1]] = true;
808 + }
809 + if (count($codes) >= 200) {
810 + break;
811 + }
812 + }
813 + return array_slice(array_keys($codes), 0, 200);
814 + }
815 +
816 + /**
817 + * Order keyword-leg hits so an exact code match outranks a prefix-only
818 + * match: a hit is exact when its stored code set (or its text) carries the
819 + * stripped question token itself. Dense order is kept inside each group.
820 + *
821 + * @return array{hits:array,exact:int,prefix:int}
822 + */
823 + public static function rank_keyword_hits(array $hits, array $tokens) {
824 + $exact_codes = array();
825 + foreach ($tokens as $token) {
826 + $n = self::normalize_code($token);
827 + if (strlen($n) >= self::CODE_MIN_LEN) {
828 + $exact_codes[$n] = true;
829 + }
830 + }
831 + $exact = array();
832 + $prefix = array();
833 + foreach ($hits as $hit) {
834 + $meta = is_array($hit['metadata'] ?? null) ? $hit['metadata'] : array();
835 + $stored = is_array($meta[self::CODES_FIELD] ?? null) ? $meta[self::CODES_FIELD] : array();
836 + $is_exact = false;
837 + foreach ($stored as $code) {
838 + if (isset($exact_codes[strtoupper((string) $code)])) {
839 + $is_exact = true;
840 + break;
841 + }
842 + }
843 + if (!$is_exact && !empty($tokens) && isset($meta[self::TEXT_FIELD])) {
844 + $haystack = self::normalize_code($meta[self::TEXT_FIELD]);
845 + foreach ($exact_codes as $code => $_) {
846 + if ($haystack !== '' && strpos($haystack, $code) !== false) {
847 + $is_exact = true;
848 + break;
849 + }
850 + }
851 + }
852 + $hit['code_match'] = $is_exact ? 'exact' : 'prefix';
853 + if ($is_exact) {
854 + $exact[] = $hit;
855 + } else {
856 + $prefix[] = $hit;
857 + }
858 + }
859 + return array('hits' => array_merge($exact, $prefix), 'exact' => count($exact), 'prefix' => count($prefix));
860 + }
861 +
703 862 /** AND two filters (either may be empty). */
704 863 public static function merge_filters($a, $b) {
705 864 $a = (!empty($a) && is_array($a)) ? $a : null;
706 865 $b = (!empty($b) && is_array($b)) ? $b : null;
@@ -825,8 +984,56 @@
825 984 return $base + array('ok' => true, 'message' => sprintf('"%s" is a document index with full-text search (%s, %d dimensions, %s). Ready to use.', $name, $schema['language'] !== '' ? 'language ' . $schema['language'] : 'full-text search', $schema['dimension'], $schema['metric'] ?: 'cosine'));
826 985 }
827 986
828 987 /**
988 + * The plain-language "that name is taken" message (3e83e4) — never a bare
989 + * HTTP 409. Carries a free suggestion so the UI can fill it in.
990 + */
991 + public static function name_taken_message($name, $suggested = '') {
992 + $message = sprintf(
993 + /* translators: %s: Pinecone index name */
994 + __('An index named "%s" already exists in this Pinecone project. Pick a different name, or paste that index\'s host above and press Check index instead of creating one.', 'mxchat'),
995 + $name
996 + );
997 + if ($suggested !== '') {
998 + /* translators: %s: suggested Pinecone index name */
999 + $message .= ' ' . sprintf(__('Suggested free name: %s.', 'mxchat'), $suggested);
1000 + }
1001 + return $message;
1002 + }
1003 +
1004 + /**
1005 + * A free index name derived from the current one: <base>-docs, then
1006 + * <base>-docs-2, -3 … (an existing -docs suffix is not doubled). Lists the
1007 + * project's indexes once. @return array{name:string,taken:bool,existing:array}
1008 + */
1009 + public static function suggest_index_name($base, $api_key) {
1010 + $base = strtolower(sanitize_text_field($base));
1011 + $base = preg_replace('/[^a-z0-9-]+/', '-', $base);
1012 + $base = trim(preg_replace('/-{2,}/', '-', $base), '-');
1013 + $base = preg_replace('/-docs(-\d+)?$/', '', $base);
1014 + if ($base === '') {
1015 + $base = 'mxchat';
1016 + }
1017 + $existing = array();
1018 + $res = self::control_request('GET', 'indexes', null, $api_key);
1019 + if ($res['error'] === '' && is_array($res['data'])) {
1020 + foreach (($res['data']['indexes'] ?? array()) as $index) {
1021 + if (isset($index['name'])) {
1022 + $existing[strtolower((string) $index['name'])] = true;
1023 + }
1024 + }
1025 + }
1026 + $stem = substr($base, 0, 45 - strlen('-docs-99'));
1027 + $stem = rtrim($stem, '-');
1028 + $candidate = $stem . '-docs';
1029 + for ($n = 2; isset($existing[$candidate]) && $n < 100; $n++) {
1030 + $candidate = $stem . '-docs-' . $n;
1031 + }
1032 + return array('name' => $candidate, 'taken' => isset($existing[$base]), 'existing' => array_keys($existing));
1033 + }
1034 +
1035 + /**
829 1036 * Create a document index for the active embedding dimension.
830 1037 * @return array|WP_Error the IndexModel
831 1038 */
832 1039 public static function create_document_index($name, $dimension, $cloud, $region, $language, $api_key) {
@@ -833,8 +1040,16 @@
833 1040 $name = strtolower(sanitize_text_field($name));
834 1041 if (!preg_match('/^[a-z0-9]([a-z0-9-]{0,43}[a-z0-9])?$/', $name)) {
835 1042 return new WP_Error('pinecone_index', 'Index names are 1-45 lowercase letters, digits and hyphens.');
836 1043 }
1044 + // 3e83e4: say so in plain words BEFORE Pinecone can answer 409 — the
1045 + // name field is prefilled with the vector index being migrated away
1046 + // from, so the first click used to collide for exactly that customer.
1047 + $taken = self::describe_index($name, $api_key);
1048 + if (!is_wp_error($taken)) {
1049 + $suggest = self::suggest_index_name($name, $api_key);
1050 + return new WP_Error('pinecone_exists', self::name_taken_message($name, $suggest['name']), array('code' => 409, 'suggested' => $suggest['name']));
1051 + }
837 1052 $cloud = array_key_exists($cloud, self::clouds()) ? $cloud : 'aws';
838 1053 $region = sanitize_text_field($region) ?: 'us-east-1';
839 1054 $language = array_key_exists($language, self::languages()) ? $language : 'en';
840 1055 $dimension = (int) $dimension;
@@ -849,8 +1064,12 @@
849 1064 self::VECTOR_FIELD => array('type' => 'dense_vector', 'dimension' => $dimension, 'metric' => 'cosine'),
850 1065 )),
851 1066 );
852 1067 $res = self::control_request('POST', 'indexes', $body, $api_key);
1068 + if ($res['code'] === 409) {
1069 + $suggest = self::suggest_index_name($name, $api_key);
1070 + return new WP_Error('pinecone_exists', self::name_taken_message($name, $suggest['name']), array('code' => 409, 'suggested' => $suggest['name']));
1071 + }
853 1072 if ($res['error'] !== '' || !is_array($res['data'])) {
854 1073 return new WP_Error('pinecone_api', $res['error'] !== '' ? $res['error'] : 'Empty create response', array('code' => $res['code']));
855 1074 }
856 1075 return $res['data'];
@@ -937,8 +1156,72 @@
937 1156 public static function migration_clear() {
938 1157 delete_transient(self::MIGRATION_KEY);
939 1158 }
940 1159
1160 + /**
1161 + * 3e83e4: is there an unfinished copy INTO this host? The settings save
1162 + * and the autosave refuse to switch the live chatbot onto a document index
1163 + * while its copy is still running (unless the owner ticks Switch anyway),
1164 + * so a production bot never answers from a half-filled index.
1165 + *
1166 + * @return array|null the running state, or null when nothing blocks
1167 + */
1168 + public static function migration_blocks_switch($host) {
1169 + $host = strtolower(trim(str_replace(array('https://', 'http://'), '', (string) $host), '/'));
1170 + $state = self::migration_get();
1171 + if ($host === '' || empty($state) || ($state['status'] ?? '') !== 'running') {
1172 + return null;
1173 + }
1174 + if (strtolower((string) ($state['target_host'] ?? '')) !== $host) {
1175 + return null;
1176 + }
1177 + return $state;
1178 + }
1179 +
1180 + /** Plain-language line for the switch gate: "The copy into X is still running (481 of 4,503 records)…". */
1181 + public static function switch_blocked_message(array $state) {
1182 + return sprintf(
1183 + /* translators: 1: document index name, 2: records copied, 3: records in total */
1184 + __('The copy into "%1$s" is still running (%2$s of %3$s records). Let it finish before switching the chatbot to the document index, or tick Switch anyway to move it now and copy the rest afterwards.', 'mxchat'),
1185 + (string) ($state['target_name'] ?: $state['target_host']),
1186 + number_format_i18n((int) ($state['copied'] ?? 0)),
1187 + number_format_i18n((int) ($state['total'] ?? 0))
1188 + );
1189 + }
1190 +
1191 + /**
1192 + * Run one Pinecone call up to MIGRATE_RETRIES times, 1 s / 2 s / 4 s apart,
1193 + * so a single dropped connection or a 5xx does not end the copy.
1194 + *
1195 + * @param callable $call returns array|true|WP_Error
1196 + * @return array{result:mixed,attempts:int}
1197 + */
1198 + private static function with_retry(callable $call) {
1199 + $result = null;
1200 + $attempts = 0;
1201 + for ($attempt = 1; $attempt <= self::MIGRATE_RETRIES; $attempt++) {
1202 + $attempts = $attempt;
1203 + $result = call_user_func($call);
1204 + if (!is_wp_error($result)) {
1205 + return array('result' => $result, 'attempts' => $attempts);
1206 + }
1207 + if ($attempt < self::MIGRATE_RETRIES) {
1208 + sleep((int) pow(2, $attempt - 1));
1209 + }
1210 + }
1211 + return array('result' => $result, 'attempts' => $attempts);
1212 + }
1213 +
1214 + /** Seconds one AJAX step may spend copying pages (bounded by PHP's own limit). */
1215 + private static function step_budget() {
1216 + $budget = (float) apply_filters('mxchat_pinecone_migrate_step_seconds', self::MIGRATE_STEP_SECONDS);
1217 + $limit = (int) ini_get('max_execution_time');
1218 + if ($limit > 0 && $limit - 8 < $budget) {
1219 + $budget = max(3, $limit - 8);
1220 + }
1221 + return $budget;
1222 + }
1223 +
941 1224 /** Classic GET /vectors/list page. @return array{ids:array,next:string}|WP_Error */
942 1225 public static function classic_list_page($host, $api_key, $namespace, $limit, $token) {
943 1226 $params = array('limit' => max(1, min(100, (int) $limit)));
944 1227 if ((string) $namespace !== '') {
@@ -1015,12 +1298,133 @@
1015 1298 }
1016 1299 return $data;
1017 1300 }
1018 1301
1302 + /** Display key of a namespace in the per-namespace map — Pinecone's console names the default one __default__. */
1303 + public static function ns_key($namespace) {
1304 + return (string) $namespace === '' ? '__default__' : (string) $namespace;
1305 + }
1306 +
1307 + /** Raw namespace for a display key (the default namespace is '' on the wire). */
1308 + public static function ns_raw($key) {
1309 + return (string) $key === '__default__' ? '' : (string) $key;
1310 + }
1311 +
1312 + /** Classic describe_index_stats → [raw namespace => record count], the default namespace as ''. */
1313 + private static function namespace_counts(array $stats) {
1314 + $counts = array();
1315 + foreach ((array) ($stats['namespaces'] ?? array()) as $ns_name => $ns_info) {
1316 + $counts[self::ns_raw($ns_name)] = (int) ($ns_info['vectorCount'] ?? $ns_info['recordCount'] ?? 0);
1317 + }
1318 + return $counts;
1319 + }
1320 +
1019 1321 /**
1322 + * e50d2e: the order namespaces are copied in when every namespace is
1323 + * asked for — the default namespace first, then the rest by name. Only
1324 + * namespaces that hold records are queued.
1325 + */
1326 + private static function namespace_queue(array $counts) {
1327 + $queue = array();
1328 + if (!empty($counts[''])) {
1329 + $queue[] = '';
1330 + }
1331 + $named = array();
1332 + foreach ($counts as $ns => $count) {
1333 + if ($ns !== '' && $count > 0) {
1334 + $named[] = (string) $ns;
1335 + }
1336 + }
1337 + sort($named, SORT_STRING);
1338 + return array_merge($queue, $named);
1339 + }
1340 +
1341 + private static function new_namespace_entry($namespace, $total, $status = 'queued') {
1342 + return array('namespace' => (string) $namespace, 'total' => (int) $total, 'copied' => 0, 'status' => $status, 'target_count' => null);
1343 + }
1344 +
1345 + /**
1346 + * e50d2e: the per-namespace map of a migration state — display key →
1347 + * namespace / total / copied / status / target_count. A state written
1348 + * before this build copied one namespace and carries no map, so one is
1349 + * synthesised from its single namespace; the Delete guard and the step
1350 + * loop read every state through here.
1351 + */
1352 + public static function per_namespace_map(array $state) {
1353 + if (!empty($state['per_namespace']) && is_array($state['per_namespace'])) {
1354 + return $state['per_namespace'];
1355 + }
1356 + $namespace = (string) ($state['namespace'] ?? '');
1357 + $status = ($state['status'] ?? '') === 'running' ? 'running' : (in_array($state['status'] ?? '', array('done', 'source_deleted'), true) ? 'done' : 'queued');
1358 + $entry = self::new_namespace_entry($namespace, (int) ($state['total'] ?? 0), $status);
1359 + $entry['copied'] = (int) ($state['copied'] ?? 0);
1360 + $entry['target_count'] = isset($state['target_count']) ? $state['target_count'] : null;
1361 + return array(self::ns_key($namespace) => $entry);
1362 + }
1363 +
1364 + /**
1365 + * e50d2e: widen a single-namespace copy — running or finished — to every
1366 + * namespace of the source that holds records. Namespaces already copied
1367 + * keep their entry, the rest queue behind the current one, the total
1368 + * becomes the sum, and "other namespaces" empties because none are left
1369 + * out any more. A finished copy with nothing left to queue stays done.
1370 + *
1371 + * @return array|WP_Error state
1372 + */
1373 + private static function extend_to_all_namespaces(array $state, $api_key) {
1374 + $stats = self::classic_stats((string) ($state['source_host'] ?? ''), $api_key);
1375 + if (is_wp_error($stats)) {
1376 + return $stats;
1377 + }
1378 + $ns_counts = self::namespace_counts($stats);
1379 + $map = self::per_namespace_map($state);
1380 + $queue = array_values((array) ($state['queue'] ?? array()));
1381 + $current = isset($state['current']) ? (string) $state['current'] : (string) ($state['namespace'] ?? '');
1382 + foreach (self::namespace_queue($ns_counts) as $ns) {
1383 + if (isset($map[self::ns_key($ns)]) || $ns === $current || in_array($ns, $queue, true)) {
1384 + continue;
1385 + }
1386 + $map[self::ns_key($ns)] = self::new_namespace_entry($ns, $ns_counts[$ns]);
1387 + $queue[] = $ns;
1388 + }
1389 + $total = 0;
1390 + foreach ($map as $entry) {
1391 + $total += (int) ($entry['total'] ?? 0);
1392 + }
1393 + $state['per_namespace'] = $map;
1394 + $state['queue'] = $queue;
1395 + $state['current'] = $current;
1396 + $state['namespace'] = $current;
1397 + $state['total'] = $total;
1398 + $state['total_all'] = (int) ($stats['totalVectorCount'] ?? $total);
1399 + $state['other_namespaces'] = array();
1400 + $state['all_namespaces'] = true;
1401 + if (($state['status'] ?? '') === 'done' && !empty($queue)) {
1402 + $next = array_shift($queue);
1403 + $state['queue'] = $queue;
1404 + $state['current'] = $next;
1405 + $state['namespace'] = $next;
1406 + $state['token'] = '';
1407 + $state['status'] = 'running';
1408 + $state['error'] = '';
1409 + $state['error_at'] = 0;
1410 + $state['per_namespace'][self::ns_key($next)]['status'] = 'running';
1411 + unset($state['finished']);
1412 + }
1413 + return $state;
1414 + }
1415 +
1416 + /**
1020 1417 * Validate and start (or resume) a migration. @return array|WP_Error state
1418 + *
1419 + * e50d2e: $all_namespaces copies every namespace of the source that holds
1420 + * records — the default namespace first, then the rest by name — each into
1421 + * the same-named namespace of the document index, one after another, in
1422 + * one run. Without it the copy is exactly 3e83e4's: the one configured
1423 + * namespace. Asking for every namespace on a copy that is already running
1424 + * or finished keeps what was copied and queues the rest.
1021 1425 */
1022 - public static function migrate_start($source_host, array $target_cfg, $restart = false) {
1426 + public static function migrate_start($source_host, array $target_cfg, $restart = false, $all_namespaces = false) {
1023 1427 $source_host = strtolower(trim(str_replace(array('https://', 'http://'), '', (string) $source_host), '/'));
1024 1428 if ($source_host === '') {
1025 1429 return new WP_Error('migrate', 'Enter the host of the vector index to copy from.');
1026 1430 }
@@ -1029,21 +1433,77 @@
1029 1433 }
1030 1434 if ($source_host === strtolower($target_cfg['host'])) {
1031 1435 return new WP_Error('migrate', 'The source and the document index are the same host.');
1032 1436 }
1033 - $existing = self::migration_get();
1034 - if (!$restart && !empty($existing) && ($existing['source_host'] ?? '') === $source_host && ($existing['target_host'] ?? '') === $target_cfg['host'] && ($existing['status'] ?? '') === 'running') {
1035 - return $existing; // resume
1437 + $existing = self::migration_get();
1438 + $same_copy = !empty($existing) && ($existing['source_host'] ?? '') === $source_host && strtolower((string) ($existing['target_host'] ?? '')) === strtolower($target_cfg['host']);
1439 + if (!$restart && $same_copy && ($existing['status'] ?? '') === 'running') {
1440 + // Resume from the recorded cursor — whether the last step ended
1441 + // cleanly or with an error (3e83e4: a second click continues,
1442 + // it never starts over unless asked).
1443 + $existing['error'] = '';
1444 + if ($all_namespaces && empty($existing['all_namespaces'])) {
1445 + $existing = self::extend_to_all_namespaces($existing, $target_cfg['api_key']);
1446 + if (is_wp_error($existing)) {
1447 + return $existing;
1448 + }
1449 + }
1450 + return self::migration_set($existing);
1036 1451 }
1452 + if (!$restart && $same_copy && $all_namespaces && ($existing['status'] ?? '') === 'done') {
1453 + $extended = self::extend_to_all_namespaces($existing, $target_cfg['api_key']);
1454 + if (is_wp_error($extended)) {
1455 + return $extended;
1456 + }
1457 + return self::migration_set($extended);
1458 + }
1037 1459 $stats = self::classic_stats($source_host, $target_cfg['api_key']);
1038 1460 if (is_wp_error($stats)) {
1039 1461 return $stats;
1040 1462 }
1041 1463 $namespace = (string) ($target_cfg['namespace'] ?? '');
1042 - if ($namespace !== '') {
1043 - $total = isset($stats['namespaces'][$namespace]['vectorCount']) ? (int) $stats['namespaces'][$namespace]['vectorCount'] : 0;
1464 + // 3e83e4: the total is the SOURCE NAMESPACE's count, never the whole
1465 + // index — the copy lists one namespace, so an index with records in
1466 + // other namespaces used to report "done" with copied < total and no
1467 + // explanation. The other namespaces are recorded for the UI instead.
1468 + $ns_counts = self::namespace_counts($stats);
1469 + $queue = array();
1470 + $per_namespace = array();
1471 + $other_namespaces = array();
1472 + if ($all_namespaces) {
1473 + $queue = self::namespace_queue($ns_counts);
1474 + if (empty($queue)) {
1475 + $queue = array($namespace); // stats listed no namespaces — copy the configured one, as before
1476 + }
1477 + $total = 0;
1478 + foreach ($queue as $ns) {
1479 + if (isset($ns_counts[$ns])) {
1480 + $count = (int) $ns_counts[$ns];
1481 + } elseif ($ns === '' && empty($ns_counts)) {
1482 + $count = (int) ($stats['totalVectorCount'] ?? 0);
1483 + } else {
1484 + $count = 0;
1485 + }
1486 + $per_namespace[self::ns_key($ns)] = self::new_namespace_entry($ns, $count);
1487 + $total += $count;
1488 + }
1489 + $current = (string) array_shift($queue);
1490 + $per_namespace[self::ns_key($current)]['status'] = 'running';
1044 1491 } else {
1045 - $total = (int) ($stats['totalVectorCount'] ?? 0);
1492 + $current = $namespace;
1493 + if (isset($ns_counts[$namespace])) {
1494 + $total = (int) $ns_counts[$namespace];
1495 + } elseif ($namespace === '' && empty($ns_counts)) {
1496 + $total = (int) ($stats['totalVectorCount'] ?? 0);
1497 + } else {
1498 + $total = 0;
1499 + }
1500 + $per_namespace[self::ns_key($namespace)] = self::new_namespace_entry($namespace, $total, 'running');
1501 + foreach ($ns_counts as $ns_name => $ns_count) {
1502 + if ($ns_name !== $namespace && $ns_count > 0) {
1503 + $other_namespaces[self::ns_key($ns_name)] = $ns_count;
1504 + }
1505 + }
1046 1506 }
1047 1507 $source_dimension = (int) ($stats['dimension'] ?? 0);
1048 1508 $target_index = self::find_index_by_host($target_cfg['host'], $target_cfg['api_key']);
1049 1509 if (is_wp_error($target_index)) {
@@ -1056,20 +1516,33 @@
1056 1516 if ($source_dimension > 0 && $schema['dimension'] > 0 && $source_dimension !== (int) $schema['dimension']) {
1057 1517 return new WP_Error('migrate', sprintf('Dimensions differ (source %d, document index %d) — the vectors cannot be copied. Re-import your content into the document index instead.', $source_dimension, $schema['dimension']));
1058 1518 }
1059 1519 $state = array(
1060 - 'status' => 'running',
1061 - 'source_host' => $source_host,
1062 - 'target_host' => $target_cfg['host'],
1063 - 'target_name' => (string) ($target_index['name'] ?? ''),
1064 - 'namespace' => $namespace,
1065 - 'total' => $total,
1066 - 'copied' => 0,
1067 - 'token' => '',
1068 - 'batches' => 0,
1069 - 'started' => time(),
1070 - 'error' => '',
1071 - 'source_name' => '',
1520 + 'status' => 'running',
1521 + 'source_host' => $source_host,
1522 + 'target_host' => $target_cfg['host'],
1523 + 'target_name' => (string) ($target_index['name'] ?? ''),
1524 + 'namespace' => $current,
1525 + 'total' => $total,
1526 + 'total_all' => (int) ($stats['totalVectorCount'] ?? $total),
1527 + 'other_namespaces' => $other_namespaces,
1528 + // e50d2e: the namespace being copied, the ones still queued and
1529 + // a per-namespace ledger — the Delete guard checks every entry.
1530 + 'all_namespaces' => (bool) $all_namespaces,
1531 + 'current' => $current,
1532 + 'queue' => $queue,
1533 + 'per_namespace' => $per_namespace,
1534 + 'copied' => 0,
1535 + 'skipped' => 0,
1536 + 'token' => '',
1537 + 'batches' => 0,
1538 + 'retries' => 0,
1539 + 'failed_steps' => 0,
1540 + 'started' => time(),
1541 + 'error' => '',
1542 + 'error_at' => 0,
1543 + 'target_count' => null,
1544 + 'source_name' => '',
1072 1545 );
1073 1546 $source_index = self::find_index_by_host($source_host, $target_cfg['api_key']);
1074 1547 if (!is_wp_error($source_index)) {
1075 1548 $state['source_name'] = (string) ($source_index['name'] ?? '');
@@ -1077,10 +1550,15 @@
1077 1550 return self::migration_set($state);
1078 1551 }
1079 1552
1080 1553 /**
1081 - * Copy one page: list → fetch (values + metadata) → documents/upsert.
1082 - * Idempotent — the same ids land in the same place on a re-run.
1554 + * Copy pages until the step's time budget is used or the source is
1555 + * exhausted: list → fetch (values + metadata) → documents/upsert, each
1556 + * call retried with backoff. Idempotent — the same ids land in the same
1557 + * place on a re-run. The cursor is persisted after EVERY page, so a step
1558 + * that dies mid-way (proxy timeout, killed request) loses at most one
1559 + * page of work and the next click continues from the recorded position.
1560 + *
1083 1561 * @return array|WP_Error state
1084 1562 */
1085 1563 public static function migrate_step(array $state, array $target_cfg, $batch = self::MIGRATE_BATCH) {
1086 1564 if (($state['status'] ?? '') !== 'running') {
@@ -1085,44 +1563,205 @@
1085 1563 public static function migrate_step(array $state, array $target_cfg, $batch = self::MIGRATE_BATCH) {
1086 1564 if (($state['status'] ?? '') !== 'running') {
1087 1565 return $state;
1088 1566 }
1089 - if (($state['target_host'] ?? '') !== ($target_cfg['host'] ?? '')) {
1567 + if (strtolower((string) ($state['target_host'] ?? '')) !== strtolower((string) ($target_cfg['host'] ?? ''))) {
1090 1568 return new WP_Error('migrate', 'The document index host changed since the migration started. Start it again.');
1091 1569 }
1092 - $page = self::classic_list_page($state['source_host'], $target_cfg['api_key'], $state['namespace'], $batch, $state['token']);
1093 - if (is_wp_error($page)) {
1094 - $state['error'] = $page->get_error_message();
1095 - return self::migration_set($state);
1570 + $started = microtime(true);
1571 + $budget = self::step_budget();
1572 + $pages = 0;
1573 + $api_key = $target_cfg['api_key'];
1574 + foreach (array('retries', 'skipped', 'failed_steps', 'error_at') as $counter) {
1575 + $state[$counter] = (int) ($state[$counter] ?? 0); // states written before 3e83e4 lack these
1096 1576 }
1097 - if (!empty($page['ids'])) {
1098 - $vectors = self::classic_fetch($state['source_host'], $target_cfg['api_key'], $state['namespace'], $page['ids']);
1099 - if (is_wp_error($vectors)) {
1100 - $state['error'] = $vectors->get_error_message();
1101 - return self::migration_set($state);
1577 + if (empty($state['per_namespace']) || !is_array($state['per_namespace'])) {
1578 + // e50d2e: a state written before this build copied one namespace and has no ledger
1579 + $state['per_namespace'] = self::per_namespace_map($state);
1580 + $state['current'] = (string) ($state['namespace'] ?? '');
1581 + $state['queue'] = array();
1582 + }
1583 + if (!isset($state['current'])) {
1584 + $state['current'] = (string) ($state['namespace'] ?? '');
1585 + }
1586 + do {
1587 + $source_host = $state['source_host'];
1588 + $namespace = (string) $state['current'];
1589 + $ns_key = self::ns_key($namespace);
1590 + $token = $state['token'];
1591 + // e50d2e: the same namespace on the document side — every bot keeps reading its own
1592 + $page_cfg = $target_cfg;
1593 + $page_cfg['namespace'] = $namespace;
1594 + $listed = self::with_retry(function () use ($source_host, $api_key, $namespace, $batch, $token) {
1595 + return self::classic_list_page($source_host, $api_key, $namespace, $batch, $token);
1596 + });
1597 + $state['retries'] += max(0, $listed['attempts'] - 1);
1598 + $page = $listed['result'];
1599 + if (is_wp_error($page)) {
1600 + return self::migration_fail($state, 'Listing the source index failed after ' . $listed['attempts'] . ' attempts: ' . $page->get_error_message());
1102 1601 }
1103 - $documents = array();
1104 - foreach ($vectors as $id => $vector) {
1105 - if (empty($vector['values']) || !is_array($vector['values'])) {
1106 - continue;
1602 + $ids = $page['ids'];
1603 + if (!empty($ids)) {
1604 + $fetched = self::with_retry(function () use ($source_host, $api_key, $namespace, $ids) {
1605 + return self::classic_fetch($source_host, $api_key, $namespace, $ids);
1606 + });
1607 + $state['retries'] += max(0, $fetched['attempts'] - 1);
1608 + $vectors = $fetched['result'];
1609 + if (is_wp_error($vectors)) {
1610 + return self::migration_fail($state, 'Reading ' . count($ids) . ' records from the source index failed after ' . $fetched['attempts'] . ' attempts: ' . $vectors->get_error_message());
1107 1611 }
1108 - $metadata = is_array($vector['metadata'] ?? null) ? $vector['metadata'] : array();
1109 - $documents[] = self::build_document((string) ($vector['id'] ?? $id), (string) ($metadata['text'] ?? ''), $vector['values'], $metadata);
1612 + $documents = array();
1613 + $skipped = 0;
1614 + foreach ($vectors as $id => $vector) {
1615 + if (empty($vector['values']) || !is_array($vector['values'])) {
1616 + $skipped++;
1617 + continue;
1618 + }
1619 + $metadata = is_array($vector['metadata'] ?? null) ? $vector['metadata'] : array();
1620 + $documents[] = self::build_document((string) ($vector['id'] ?? $id), (string) ($metadata['text'] ?? ''), $vector['values'], $metadata);
1621 + }
1622 + $skipped += max(0, count($ids) - count($vectors));
1623 + if (!empty($documents)) {
1624 + $upserted = self::with_retry(function () use ($documents, $page_cfg) {
1625 + return self::upsert($documents, $page_cfg);
1626 + });
1627 + $state['retries'] += max(0, $upserted['attempts'] - 1);
1628 + if (is_wp_error($upserted['result'])) {
1629 + return self::migration_fail($state, 'Writing ' . count($documents) . ' records to the document index failed after ' . $upserted['attempts'] . ' attempts: ' . $upserted['result']->get_error_message());
1630 + }
1631 + }
1632 + $state['copied'] = (int) $state['copied'] + count($documents);
1633 + $state['skipped'] = (int) ($state['skipped'] ?? 0) + $skipped;
1634 + $state['per_namespace'][$ns_key]['copied'] = (int) ($state['per_namespace'][$ns_key]['copied'] ?? 0) + count($documents);
1110 1635 }
1111 - $result = self::upsert($documents, $target_cfg);
1112 - if (is_wp_error($result)) {
1113 - $state['error'] = $result->get_error_message();
1114 - return self::migration_set($state);
1636 + $state['batches'] = (int) $state['batches'] + 1;
1637 + $state['token'] = $page['next'];
1638 + $state['error'] = '';
1639 + $state['error_at'] = 0;
1640 + $pages++;
1641 + if ($page['next'] === '') {
1642 + // This namespace is copied: record what the document index
1643 + // reports for it, then move on to the next queued namespace
1644 + // inside the same time budget (e50d2e), or finish.
1645 + $state['per_namespace'][$ns_key]['status'] = 'done';
1646 + $state['per_namespace'][$ns_key]['finished'] = time();
1647 + $ns_target = self::namespace_count($page_cfg);
1648 + $state['per_namespace'][$ns_key]['target_count'] = $ns_target > 0 ? $ns_target : null;
1649 + $queue = array_values((array) ($state['queue'] ?? array()));
1650 + if (!empty($queue)) {
1651 + $next = (string) array_shift($queue);
1652 + $state['queue'] = $queue;
1653 + $state['current'] = $next;
1654 + $state['namespace'] = $next;
1655 + $state['token'] = '';
1656 + if (!isset($state['per_namespace'][self::ns_key($next)])) {
1657 + $state['per_namespace'][self::ns_key($next)] = self::new_namespace_entry($next, 0);
1658 + }
1659 + $state['per_namespace'][self::ns_key($next)]['status'] = 'running';
1660 + self::migration_set($state);
1661 + continue; // re-checks the budget, then lists the next namespace from its first page
1662 + }
1663 + $state['status'] = 'done';
1664 + $state['finished'] = time();
1665 + // The finished state names what both sides report, so the UI
1666 + // can say "5,000 copied; the document index holds 5,000".
1667 + $target_count = 0;
1668 + foreach ($state['per_namespace'] as $entry) {
1669 + $target_count += (int) ($entry['target_count'] ?? 0);
1670 + }
1671 + $state['target_count'] = $target_count > 0 ? $target_count : null;
1672 + $state['target_count_at'] = time();
1673 + break;
1115 1674 }
1116 - $state['copied'] = (int) $state['copied'] + count($documents);
1675 + self::migration_set($state); // cursor persisted per page, not per step
1676 + } while ((microtime(true) - $started) < $budget);
1677 + $state['last_step_pages'] = $pages;
1678 + $state['last_step_seconds'] = round(microtime(true) - $started, 2);
1679 + return self::migration_set($state);
1680 + }
1681 +
1682 + /** Record count of ONE namespace on a document index (0 when not listed) — count() reads the whole index for the default namespace. */
1683 + private static function namespace_count(array $cfg) {
1684 + $stats = self::stats($cfg);
1685 + if (is_wp_error($stats)) {
1686 + return 0;
1117 1687 }
1118 - $state['batches'] = (int) $state['batches'] + 1;
1119 - $state['token'] = $page['next'];
1120 - $state['error'] = '';
1121 - if ($page['next'] === '') {
1122 - $state['status'] = 'done';
1123 - $state['finished'] = time();
1688 + $ns = (string) ($cfg['namespace'] ?? '');
1689 + return isset($stats['namespaces'][$ns]['vectorCount']) ? (int) $stats['namespaces'][$ns]['vectorCount'] : 0;
1690 + }
1691 +
1692 + /**
1693 + * e50d2e: namespaces of the OLD index that still hold records without a
1694 + * finished, count-matching copy — read from the live source stats, so a
1695 + * namespace filled after the copy counts too. Empty array = every record
1696 + * on the old index has a copy; the Delete old index button refuses on
1697 + * anything else and names what is missing.
1698 + *
1699 + * @return array|WP_Error display key → ['namespace','source','copied','status']
1700 + */
1701 + public static function uncopied_namespaces(array $state, $api_key) {
1702 + $stats = self::classic_stats((string) ($state['source_host'] ?? ''), $api_key);
1703 + if (is_wp_error($stats)) {
1704 + return $stats;
1124 1705 }
1706 + $counts = self::namespace_counts($stats);
1707 + if (empty($counts) && (int) ($stats['totalVectorCount'] ?? 0) > 0) {
1708 + $counts[''] = (int) $stats['totalVectorCount'];
1709 + }
1710 + $map = self::per_namespace_map($state);
1711 + $missing = array();
1712 + foreach ($counts as $ns => $count) {
1713 + if ($count <= 0) {
1714 + continue;
1715 + }
1716 + $key = self::ns_key($ns);
1717 + $entry = isset($map[$key]) && is_array($map[$key]) ? $map[$key] : null;
1718 + if ($entry === null || ($entry['status'] ?? '') !== 'done' || (int) ($entry['copied'] ?? 0) < $count) {
1719 + $missing[$key] = array(
1720 + 'namespace' => (string) $ns,
1721 + 'source' => (int) $count,
1722 + 'copied' => $entry ? (int) ($entry['copied'] ?? 0) : 0,
1723 + 'status' => $entry ? (string) ($entry['status'] ?? '') : 'not copied',
1724 + );
1725 + }
1726 + }
1727 + // Named in the copy order (default first, then by name) — Pinecone's stats object order varies call to call.
1728 + $ordered = array();
1729 + foreach (self::namespace_queue(array_combine(array_map(array(__CLASS__, 'ns_raw'), array_keys($missing)), array_column($missing, 'source')) ?: array()) as $ns) {
1730 + $ordered[self::ns_key($ns)] = $missing[self::ns_key($ns)];
1731 + }
1732 + return $ordered + $missing;
1733 + }
1734 +
1735 + /** Plain-language refusal naming every namespace the copy is missing. (Plain __(): the card's JS text-escapes it.) */
1736 + public static function uncopied_message(array $missing) {
1737 + $parts = array();
1738 + foreach ($missing as $key => $m) {
1739 + $parts[] = sprintf(
1740 + /* translators: 1: namespace name, 2: records copied, 3: records on the old index */
1741 + __('%1$s (%2$s of %3$s records copied)', 'mxchat'),
1742 + $key,
1743 + number_format_i18n((int) $m['copied']),
1744 + number_format_i18n((int) $m['source'])
1745 + );
1746 + }
1747 + return sprintf(
1748 + /* translators: %s: comma-separated namespace names with their counts */
1749 + _n(
1750 + 'Namespace %s on the old index has records that are not in the document index. Tick Copy every namespace and run Migrate again, then delete. The old index stays.',
1751 + 'Namespaces %s on the old index have records that are not in the document index. Tick Copy every namespace and run Migrate again, then delete. The old index stays.',
1752 + count($missing),
1753 + 'mxchat'
1754 + ),
1755 + implode(', ', $parts)
1756 + );
1757 + }
1758 +
1759 + /** Record a step failure without ending the copy: status stays running, the cursor stays put. */
1760 + private static function migration_fail(array $state, $message) {
1761 + $state['error'] = (string) $message;
1762 + $state['error_at'] = time();
1763 + $state['failed_steps'] = (int) ($state['failed_steps'] ?? 0) + 1;
1125 1764 return self::migration_set($state);
1126 1765 }
1127 1766
1128 1767 // =====================================================================
@@ -1131,12 +1770,47 @@
1131 1770
1132 1771 public static function boot() {
1133 1772 add_action('wp_ajax_mxchat_pinecone_docs_create_index', array(__CLASS__, 'ajax_create_index'));
1134 1773 add_action('wp_ajax_mxchat_pinecone_docs_check_index', array(__CLASS__, 'ajax_check_index'));
1774 + add_action('wp_ajax_mxchat_pinecone_docs_suggest_name', array(__CLASS__, 'ajax_suggest_name'));
1135 1775 add_action('wp_ajax_mxchat_pinecone_docs_migrate', array(__CLASS__, 'ajax_migrate'));
1136 1776 add_action('wp_ajax_mxchat_pinecone_docs_delete_old_index', array(__CLASS__, 'ajax_delete_old_index'));
1137 1777 }
1138 1778
1779 + /**
1780 + * Suggest a free document-index name for the name in the field (3e83e4).
1781 + * With a host that already answers in the project, the name is that
1782 + * index's own — the owner is pointing at an existing document index, not
1783 + * about to create one.
1784 + */
1785 + public static function ajax_suggest_name() {
1786 + self::ajax_guard();
1787 + $api_key = self::posted_api_key();
1788 + $base = isset($_POST['base']) ? sanitize_text_field(wp_unslash($_POST['base'])) : '';
1789 + $host = isset($_POST['host']) ? sanitize_text_field(wp_unslash($_POST['host'])) : '';
1790 + if ($api_key === '') {
1791 + wp_send_json_error(array('message' => esc_html__('Enter your Pinecone API key first.', 'mxchat')));
1792 + }
1793 + if ($host !== '') {
1794 + $index = self::find_index_by_host($host, $api_key);
1795 + if (!is_wp_error($index) && !empty($index['name'])) {
1796 + wp_send_json_success(array('name' => strtolower((string) $index['name']), 'resolved' => true, 'taken' => false, 'base' => strtolower($base)));
1797 + }
1798 + }
1799 + $suggest = self::suggest_index_name($base, $api_key);
1800 + wp_send_json_success(array('name' => $suggest['name'], 'resolved' => false, 'taken' => $suggest['taken'], 'base' => strtolower($base)));
1801 + }
1802 +
1803 + /** The document-index target for a migration call: the host in the form, else the saved one. */
1804 + private static function posted_target_host(array $options) {
1805 + $posted = isset($_POST['target_host']) ? sanitize_text_field(wp_unslash($_POST['target_host'])) : '';
1806 + $posted = strtolower(trim(str_replace(array('https://', 'http://'), '', $posted), '/'));
1807 + if ($posted !== '') {
1808 + return $posted;
1809 + }
1810 + return strtolower(trim((string) ($options['mxchat_pinecone_host'] ?? ''), '/'));
1811 + }
1812 +
1139 1813 private static function ajax_guard() {
1140 1814 check_ajax_referer('mxchat_prompts_setting_nonce', 'nonce');
1141 1815 if (!current_user_can('manage_options')) {
1142 1816 wp_send_json_error(array('message' => esc_html__('Permission denied.', 'mxchat')), 403);
@@ -1171,9 +1845,14 @@
1171 1845 }
1172 1846 $dimension = self::expected_dimension();
1173 1847 $index = self::create_document_index($name, $dimension, $cloud, $region, $language, $api_key);
1174 1848 if (is_wp_error($index)) {
1175 - wp_send_json_error(array('message' => $index->get_error_message()));
1849 + $data = $index->get_error_data();
1850 + wp_send_json_error(array(
1851 + 'message' => $index->get_error_message(),
1852 + 'code' => $index->get_error_code(),
1853 + 'suggested' => is_array($data) ? (string) ($data['suggested'] ?? '') : '',
1854 + ));
1176 1855 }
1177 1856 $host = (string) ($index['host'] ?? '');
1178 1857 // Give Pinecone a few seconds — small serverless indexes are usually Ready quickly.
1179 1858 $ready = !empty($index['status']['ready']);
@@ -1186,21 +1865,24 @@
1186 1865 }
1187 1866 }
1188 1867 $options = get_option(self::OPTION, array());
1189 1868 $options = is_array($options) ? $options : array();
1869 + // 3e83e4: creating the index no longer moves the live chatbot. The
1870 + // host and index type are only written when the owner saves the
1871 + // form; here we record that this host passed the check (the save
1872 + // gate) and the settings the index was created with.
1190 1873 $changes = array(
1191 - 'mxchat_pinecone_api_key' => $api_key,
1192 - 'mxchat_pinecone_host' => $host,
1193 - 'mxchat_pinecone_index' => strtolower($name),
1194 - 'mxchat_pinecone_index_type' => 'document',
1195 1874 'mxchat_pinecone_docs_verified_host' => $host,
1196 1875 'mxchat_pinecone_docs_cloud' => array_key_exists($cloud, self::clouds()) ? $cloud : 'aws',
1197 1876 'mxchat_pinecone_docs_region' => $region ?: 'us-east-1',
1198 1877 'mxchat_pinecone_docs_language' => array_key_exists($language, self::languages()) ? $language : 'en',
1199 1878 );
1879 + if (empty($options['mxchat_pinecone_api_key'])) {
1880 + $changes['mxchat_pinecone_api_key'] = $api_key;
1881 + }
1200 1882 // Remember the classic index so Migrate can copy from it.
1201 1883 $previous_host = (string) ($options['mxchat_pinecone_host'] ?? '');
1202 - if ($previous_host !== '' && $previous_host !== $host && self::options_index_type($options) === 'vector' && empty($options['mxchat_pinecone_vector_host'])) {
1884 + if ($previous_host !== '' && strtolower($previous_host) !== strtolower($host) && self::options_index_type($options) === 'vector' && empty($options['mxchat_pinecone_vector_host'])) {
1203 1885 $changes['mxchat_pinecone_vector_host'] = $previous_host;
1204 1886 $changes['mxchat_pinecone_vector_index'] = (string) ($options['mxchat_pinecone_index'] ?? '');
1205 1887 }
1206 1888 self::persist_options($changes);
@@ -1209,11 +1891,12 @@
1209 1891 'name' => strtolower($name),
1210 1892 'dimension' => $dimension,
1211 1893 'ready' => $ready,
1212 1894 'vector_host' => $changes['mxchat_pinecone_vector_host'] ?? ($options['mxchat_pinecone_vector_host'] ?? ''),
1895 + // Plain text: the card's JS escapes it on render (esc_html__ here double-escaped the quotes to a literal &quot;).
1213 1896 'message' => $ready
1214 - ? sprintf(esc_html__('Document index "%1$s" created (%2$d dimensions, cosine, full-text search on the chunk text). Host filled in and saved.', 'mxchat'), strtolower($name), $dimension)
1215 - : sprintf(esc_html__('Document index "%1$s" is being created (%2$d dimensions). The host is filled in and saved; run Check index in a minute to confirm it is ready.', 'mxchat'), strtolower($name), $dimension),
1897 + ? sprintf(__('Document index "%1$s" created (%2$d dimensions, cosine, full-text search on the chunk text). The host is filled in above. Your chatbot keeps answering from the current index until you save the Pinecone settings — copy your records across with Migrate first.', 'mxchat'), strtolower($name), $dimension)
1898 + : sprintf(__('Document index "%1$s" is being created (%2$d dimensions). The host is filled in above; run Check index in a minute to confirm it is ready. Your chatbot keeps answering from the current index until you save the Pinecone settings.', 'mxchat'), strtolower($name), $dimension),
1216 1899 ));
1217 1900 }
1218 1901
1219 1902 /** Check index: assert the host is a document index MxChat can use. */
@@ -1244,15 +1927,23 @@
1244 1927 $check['vector_host'] = $changes['mxchat_pinecone_vector_host'] ?? ($options['mxchat_pinecone_vector_host'] ?? '');
1245 1928 wp_send_json_success($check);
1246 1929 }
1247 1930
1248 - /** Migrate: mode start | step | status | reset. Each step copies one page. */
1931 + /**
1932 + * Migrate: mode start | step | status | reset.
1933 + *
1934 + * 3e83e4: the copy is DECOUPLED from the live retrieval path. The target
1935 + * is the document-index host in the form (posted as target_host) plus the
1936 + * API key, checked against Pinecone at start — the saved index type is
1937 + * never consulted, so the site keeps answering from its vector index for
1938 + * the whole copy and the owner switches by saving the settings afterwards.
1939 + * Each step copies pages for up to MIGRATE_STEP_SECONDS.
1940 + */
1249 1941 public static function ajax_migrate() {
1250 1942 self::ajax_guard();
1251 1943 $mode = isset($_POST['mode']) ? sanitize_key($_POST['mode']) : 'status';
1252 1944 $options = get_option(self::OPTION, array());
1253 1945 $options = is_array($options) ? $options : array();
1254 - $target = self::cfg_from_options($options);
1255 1946 if ($mode === 'reset') {
1256 1947 self::migration_clear();
1257 1948 wp_send_json_success(array('state' => array()));
1258 1949 }
@@ -1258,25 +1949,37 @@
1258 1949 }
1259 1950 if ($mode === 'status') {
1260 1951 wp_send_json_success(array('state' => self::migration_get()));
1261 1952 }
1262 - if ($target['index_type'] !== 'document') {
1263 - wp_send_json_error(array('message' => esc_html__('Save the Pinecone settings with the index type set to Document index first.', 'mxchat')));
1953 + $api_key = self::posted_api_key();
1954 + $namespace = (string) ($options['mxchat_pinecone_namespace'] ?? '');
1955 + if ($api_key === '') {
1956 + wp_send_json_error(array('message' => esc_html__('Enter your Pinecone API key first.', 'mxchat')));
1264 1957 }
1265 - if (strtolower((string) ($options['mxchat_pinecone_docs_verified_host'] ?? '')) !== strtolower($target['host'])) {
1266 - wp_send_json_error(array('message' => esc_html__('Run Check index on the document index host before migrating.', 'mxchat')));
1267 - }
1268 1958 if ($mode === 'start') {
1959 + $target_host = self::posted_target_host($options);
1960 + if ($target_host === '') {
1961 + wp_send_json_error(array('message' => esc_html__('Enter the host of the document index to copy into (or create one with Create index for me).', 'mxchat')));
1962 + }
1963 + $check = self::check_document_index($target_host, $api_key, self::expected_dimension());
1964 + if (!$check['ok']) {
1965 + wp_send_json_error(array('message' => $check['message']));
1966 + }
1967 + $target = array('api_key' => $api_key, 'host' => strtolower($check['host']), 'namespace' => $namespace, 'index_type' => 'document');
1269 1968 $source_host = isset($_POST['source_host']) ? sanitize_text_field(wp_unslash($_POST['source_host'])) : '';
1270 1969 $restart = !empty($_POST['restart']);
1271 - $state = self::migrate_start($source_host, $target, $restart);
1970 + $all_ns = !empty($_POST['all_namespaces']); // e50d2e: Copy every namespace
1971 + $state = self::migrate_start($source_host, $target, $restart, $all_ns);
1272 1972 if (is_wp_error($state)) {
1273 1973 wp_send_json_error(array('message' => $state->get_error_message()));
1274 1974 }
1275 - if ($source_host !== '' && $source_host !== (string) ($options['mxchat_pinecone_vector_host'] ?? '')) {
1276 - self::persist_options(array('mxchat_pinecone_vector_host' => $state['source_host'], 'mxchat_pinecone_vector_index' => $state['source_name']));
1975 + $changes = array('mxchat_pinecone_docs_verified_host' => $target['host']);
1976 + if ($source_host !== '' && strtolower($state['source_host']) !== strtolower((string) ($options['mxchat_pinecone_vector_host'] ?? ''))) {
1977 + $changes['mxchat_pinecone_vector_host'] = $state['source_host'];
1978 + $changes['mxchat_pinecone_vector_index'] = $state['source_name'];
1277 1979 }
1278 - wp_send_json_success(array('state' => $state));
1980 + self::persist_options($changes);
1981 + wp_send_json_success(array('state' => $state, 'live_index_type' => self::options_index_type($options)));
1279 1982 }
1280 1983 if ($mode === 'step') {
1281 1984 $state = self::migration_get();
1282 1985 if (empty($state)) {
@@ -1281,8 +1984,9 @@
1281 1984 $state = self::migration_get();
1282 1985 if (empty($state)) {
1283 1986 wp_send_json_error(array('message' => esc_html__('No migration in progress. Click Migrate to start one.', 'mxchat')));
1284 1987 }
1988 + $target = array('api_key' => $api_key, 'host' => (string) ($state['target_host'] ?? ''), 'namespace' => (string) ($state['current'] ?? ($state['namespace'] ?? $namespace)), 'index_type' => 'document');
1285 1989 $state = self::migrate_step($state, $target);
1286 1990 if (is_wp_error($state)) {
1287 1991 wp_send_json_error(array('message' => $state->get_error_message()));
1288 1992 }
@@ -1288,9 +1992,9 @@
1288 1992 }
1289 1993 if (!empty($state['error'])) {
1290 1994 wp_send_json_error(array('message' => $state['error'], 'state' => $state));
1291 1995 }
1292 - wp_send_json_success(array('state' => $state));
1996 + wp_send_json_success(array('state' => $state, 'live_index_type' => self::options_index_type($options)));
1293 1997 }
1294 1998 wp_send_json_error(array('message' => esc_html__('Unknown migration action.', 'mxchat')));
1295 1999 }
1296 2000
@@ -1312,8 +2016,19 @@
1312 2016 wp_send_json_error(array('message' => sprintf(esc_html__('The copy count does not match (%1$d of %2$d copied). The old index stays.', 'mxchat'), (int) $state['copied'], (int) $state['total'])));
1313 2017 }
1314 2018 if ($target['index_type'] !== 'document' || strtolower($target['host']) !== strtolower((string) $state['target_host'])) {
1315 2019 wp_send_json_error(array('message' => esc_html__('The saved document index no longer matches the migration. The old index stays.', 'mxchat')));
2020 + }
2021 + // e50d2e: EVERY namespace that holds records on the old index needs a
2022 + // finished copy whose count matches — not just the one copied last.
2023 + // Before this, a Multi-Bot or legacy-namespace site could delete
2024 + // records that were never copied.
2025 + $missing = self::uncopied_namespaces($state, $target['api_key']);
2026 + if (is_wp_error($missing)) {
2027 + wp_send_json_error(array('message' => sprintf(__('Could not read the old index before deleting it (%s). The old index stays.', 'mxchat'), $missing->get_error_message())));
2028 + }
2029 + if (!empty($missing)) {
2030 + wp_send_json_error(array('message' => self::uncopied_message($missing), 'uncopied' => $missing));
1316 2031 }
1317 2032 $source = self::find_index_by_host($state['source_host'], $target['api_key']);
1318 2033 if (is_wp_error($source)) {
1319 2034 wp_send_json_error(array('message' => $source->get_error_message()));