message, 'code' => error code]. * * @return array */ public static function storeStatus(\WordPress\CloudflareWorkersAiProvider\Embeddings\Stores\VectorStore $store, bool $fetch = true): array { $status = get_transient(self::STATUS_TRANSIENT); if (!is_array($status) || ($status['index'] ?? '') !== $store->id() . ':' . $store->indexName()) { if (!$fetch) { return ['unchecked' => true]; } $s = $store->status(); $status = ['index' => $store->id() . ':' . $store->indexName(), 'result' => is_wp_error($s) ? ['error' => $s->get_error_message(), 'code' => $s->get_error_code()] : $s]; set_transient(self::STATUS_TRANSIENT, $status, MINUTE_IN_SECONDS); } return (array) $status['result']; } public static function renderStatusPanel(): void { echo '
'; self::renderStatus(); echo '
'; } /** * A move to another store: how far it is, and Cancel. * * @param array $move */ private static function renderMove(array $move): void { $back = ($move['serve'] ?? 'from') === 'to'; $total = max(0, (int) $move['vectors']); $left = IndexTable::signatureCounts(Settings::signatureOf((string) $move['from'], (string) $move['from_index']))['vectors']; $done = $total > 0 ? max(0, min($total, $total - $left)) : 0; echo '

'; echo esc_html($back /* translators: %s: store name */ ? sprintf(__('Going back to %s', 'aipcf-ai-provider-for-cloudflare'), Mover::label((string) $move['to'])) /* translators: 1: old store, 2: new store */ : sprintf(__('Moving from %1$s to %2$s', 'aipcf-ai-provider-for-cloudflare'), Mover::label((string) $move['from']), Mover::label((string) $move['to']))); echo '

'; printf('

%s

', (int) max(1, $total), (int) $done, esc_attr__('Vectors copied', 'aipcf-ai-provider-for-cloudflare'), esc_html(sprintf( /* translators: 1: vectors copied, 2: all vectors */ __('%1$s of %2$s vectors copied, no neurons.', 'aipcf-ai-provider-for-cloudflare'), number_format_i18n($done), number_format_i18n($total) ))); $notes = []; if ((int) $move['requeued'] > 0) { /* translators: %s: number of posts */ $notes[] = sprintf(_n('%s post is embedded again (edited since, or missing in the old store).', '%s posts are embedded again (edited since, or missing in the old store).', (int) $move['requeued'], 'aipcf-ai-provider-for-cloudflare'), number_format_i18n((int) $move['requeued'])); } $notes[] = $back ? __('Related posts and search never left this store.', 'aipcf-ai-provider-for-cloudflare') /* translators: %s: old store */ : sprintf(__('Related posts and search use %s until the move is done. It runs in the background; "Keep going until done" finishes it now.', 'aipcf-ai-provider-for-cloudflare'), Mover::label((string) $move['from'])); echo '

' . esc_html(implode(' ', $notes)) . '

'; if ((string) ($move['last_error'] ?? '') !== '') { /* translators: %s: error message */ echo '

' . esc_html(sprintf(__('Last problem: %s It is tried again on the next run.', 'aipcf-ai-provider-for-cloudflare'), (string) $move['last_error'])) . '

'; } if (!$back) { printf( '

%s

', esc_url(wp_nonce_url(admin_url('admin-post.php?action=aipcf_embed_move_cancel'), 'aipcf_embed_move_cancel')), /* translators: %s: old store */ esc_attr(sprintf(__('Cancel the move and go back to %s? What was copied is copied back, still without neurons.', 'aipcf-ai-provider-for-cloudflare'), Mover::label((string) $move['from']))), esc_attr__('Cancelling...', 'aipcf-ai-provider-for-cloudflare'), esc_html__('Cancel move', 'aipcf-ai-provider-for-cloudflare') ); } echo '
'; } /** * After a move: the vectors still in the old store, to delete or keep. * * @param array $left */ private static function renderLeftover(array $left): void { $link = static fn(string $action): string => esc_url(wp_nonce_url(admin_url('admin-post.php?action=' . $action), $action)); echo '

' . esc_html(sprintf( /* translators: 1: number of vectors, 2: store name, 3: index name */ _n('%1$s vector is still in %2$s (%3$s). Nothing uses it now.', '%1$s vectors are still in %2$s (%3$s). Nothing uses them now.', max(1, (int) $left['vectors']), 'aipcf-ai-provider-for-cloudflare'), number_format_i18n((int) $left['vectors']), Mover::label((string) $left['store']), (string) $left['index'] )) . '

'; printf( '%s ', $link('aipcf_embed_cleanup_old'), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in $link esc_attr(!empty($left['own']) /* translators: 1: store name, 2: index name */ ? sprintf(__('Delete the index %2$s from %1$s? It holds only this site\'s old vectors. This cannot be undone.', 'aipcf-ai-provider-for-cloudflare'), Mover::label((string) $left['store']), (string) $left['index']) /* translators: 1: store name, 2: index name */ : sprintf(__('Delete this site\'s vectors from %2$s in %1$s? Other sites\' vectors in it are not touched. This cannot be undone.', 'aipcf-ai-provider-for-cloudflare'), Mover::label((string) $left['store']), (string) $left['index'])), esc_attr__('Deleting...', 'aipcf-ai-provider-for-cloudflare'), esc_html__('Delete them', 'aipcf-ai-provider-for-cloudflare') ); printf('%s', $link('aipcf_embed_cleanup_keep'), esc_html__('Keep them', 'aipcf-ai-provider-for-cloudflare')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in $link echo '

'; } private static function renderStatus(): void { $state = State::get(); $c = IndexTable::counts(Settings::signature()); $left = $c['pending'] + $c['stale'] + $c['deleting']; if ($state['paused'] !== '') { /* translators: %s: why Embeddings are paused */ printf('

%s

%s

', esc_html(sprintf(__('Paused: %s', 'aipcf-ai-provider-for-cloudflare'), $state['paused'])), esc_html__('Saving settings does not resume it. Fix the cause, then click "Run one batch now" to resume.', 'aipcf-ai-provider-for-cloudflare')); } echo '

'; /* translators: %s: number of embedded items */ echo EmbeddingsSettings::count(__('%s embedded', 'aipcf-ai-provider-for-cloudflare'), number_format($c['indexed'])); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- count() escapes /* translators: %s: number of passages (parts of posts) in the vector store */ echo EmbeddingsSettings::count(_n('%s passage', '%s passages', $c['chunks'], 'aipcf-ai-provider-for-cloudflare'), number_format($c['chunks'])); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- count() escapes /* translators: %s: number of words in the embedded posts */ echo EmbeddingsSettings::count(_n('%s word', '%s words', $c['words'], 'aipcf-ai-provider-for-cloudflare'), number_format($c['words'])); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- count() escapes /* translators: %s: number of items waiting */ echo EmbeddingsSettings::count(__('%s waiting', 'aipcf-ai-provider-for-cloudflare'), number_format($left)); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- count() escapes /* translators: %s: number of failed items */ echo EmbeddingsSettings::count(__('%s failed', 'aipcf-ai-provider-for-cloudflare'), number_format($c['failed'])); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- count() escapes /* translators: %s: number of items with no text, or too short to embed */ echo EmbeddingsSettings::count(Settings::minWords() > 0 ? __('%s no text or too short', 'aipcf-ai-provider-for-cloudflare') : __('%s no text', 'aipcf-ai-provider-for-cloudflare'), number_format($c['skipped'])); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- count() escapes echo '

'; if ($state['move'] !== null) { self::renderMove($state['move']); } elseif ($state['moved_from'] !== null) { self::renderLeftover($state['moved_from']); } if ($state['move'] !== null) { // The move panel above says what is happening to the rows still in the old store. } elseif ($state['previous_index'] !== '') { // A real rebuild: items move to a new index and the old one is dropped at the end. $serving = $state['previous_model'] !== '' && $state['previous_model'] === \WordPress\CloudflareWorkersAiProvider\Embeddings\Models::current(); echo '

' . esc_html(sprintf( /* translators: %d: items */ _n('Rebuilding: %d item still to move to the new index; the old index is deleted when it is done.', 'Rebuilding: %d items still to move to the new index; the old index is deleted when they are done.', $c['stale'], 'aipcf-ai-provider-for-cloudflare'), $c['stale'] ) . ' ' . ($serving ? __('Related posts and search use the old index until then.', 'aipcf-ai-provider-for-cloudflare') : __('The model changed, so related posts and search use the new index as it fills.', 'aipcf-ai-provider-for-cloudflare'))) . '

'; } elseif ($c['stale'] > 0) { // Settings changed (model, passage size...): re-embedded in place, no index swap. echo '

' . esc_html(sprintf( /* translators: %d: items */ _n('%d item is waiting to be embedded again after a settings change.', '%d items are waiting to be embedded again after a settings change.', $c['stale'], 'aipcf-ai-provider-for-cloudflare'), $c['stale'] )) . '

'; } if ($state['reconcile'] !== null) { echo '

' . esc_html__('Re-checking the selection against the vector store...', 'aipcf-ai-provider-for-cloudflare') . '

'; } $lastRun = $state['last_run'] /* translators: %s: time since the last run, e.g. "5 mins" */ ? sprintf(__('Last run: %s ago.', 'aipcf-ai-provider-for-cloudflare'), human_time_diff($state['last_run'])) : __('Embeddings have not run yet.', 'aipcf-ai-provider-for-cloudflare'); // The pause notice above already shows the reason; don't repeat it here. $lastMessage = $state['last_message']; if ($state['paused'] !== '' && strpos($lastMessage, $state['paused']) !== false) { $lastMessage = ''; } printf( '

%s%s

', esc_html($lastRun), $lastMessage !== '' ? ' ' . esc_html($lastMessage) : '' ); $failures = IndexTable::exists() ? IndexTable::failures(10) : []; if ($failures !== []) { echo '
' . esc_html__('Failed items', 'aipcf-ai-provider-for-cloudflare') . '
    '; foreach ($failures as $f) { printf('
  • #%d %s: %s
  • ', esc_url((string) get_edit_post_link((int) $f['object_id'])), (int) $f['object_id'], esc_html(get_the_title((int) $f['object_id'])), esc_html((string) $f['last_error'])); } echo '
'; } $link = static fn(string $action): string => esc_url(wp_nonce_url(admin_url('admin-post.php?action=' . $action), $action)); echo '

'; if (Settings::enabled()) { printf('%s ', $link('aipcf_embed_run'), esc_attr__('Running...', 'aipcf-ai-provider-for-cloudflare'), esc_html__('Run one batch now', 'aipcf-ai-provider-for-cloudflare')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in $link } else { printf(' ', esc_html__('Run one batch now', 'aipcf-ai-provider-for-cloudflare')); } printf( ' ', esc_attr(wp_create_nonce('aipcf_embed_step')), esc_attr__('Embedding... (keep this tab open)', 'aipcf-ai-provider-for-cloudflare'), esc_attr__('Done', 'aipcf-ai-provider-for-cloudflare'), Settings::enabled() ? '' : ' disabled', esc_html__('Keep going until done', 'aipcf-ai-provider-for-cloudflare') ); printf('%s ', $link('aipcf_embed_reconcile'), esc_attr__('Checking...', 'aipcf-ai-provider-for-cloudflare'), esc_html__('Re-check selection', 'aipcf-ai-provider-for-cloudflare')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in $link printf('%s', $link('aipcf_embed_retry'), esc_attr__('Retrying...', 'aipcf-ai-provider-for-cloudflare'), esc_html__('Retry failed items', 'aipcf-ai-provider-for-cloudflare')); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in $link echo '

'; // Destructive actions, apart from the everyday ones. echo '
' . esc_html__('More actions', 'aipcf-ai-provider-for-cloudflare') . '
'; printf( '

%s

%s', $link('aipcf_embed_rebuild'), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in $link esc_attr__('Embed everything again? With the default index name this builds a new index, and the current one keeps answering related posts and search until it is complete (unless the model changed).', 'aipcf-ai-provider-for-cloudflare'), esc_attr__('Rebuilding...', 'aipcf-ai-provider-for-cloudflare'), esc_html__('Rebuild', 'aipcf-ai-provider-for-cloudflare'), FreeTierSettings::help(__('Embeds everything again (after changing the model, or to start clean). Costs the estimate below again.', 'aipcf-ai-provider-for-cloudflare')) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- help() escapes ); printf( '

%s

%s', $link('aipcf_embed_purge'), // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- escaped in $link esc_attr__('Switch Embeddings off and delete this site\'s vectors from the vector store? Your content is not touched.', 'aipcf-ai-provider-for-cloudflare'), esc_attr__('Deleting...', 'aipcf-ai-provider-for-cloudflare'), esc_html__('Delete vectors', 'aipcf-ai-provider-for-cloudflare'), FreeTierSettings::help(__('Switches Embeddings off and deletes this site\'s vectors from the vector store. Your content is not touched.', 'aipcf-ai-provider-for-cloudflare')) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- help() escapes ); echo '
'; // Test search: AJAX when JavaScript runs; otherwise the button posts the form to admin-post.php. wp_nonce_field('aipcf_embed_search', 'aipcf_embed_search_nonce', false); printf( '

', esc_attr__('Ask something your content answers', 'aipcf-ai-provider-for-cloudflare'), esc_attr__('Test search query', 'aipcf-ai-provider-for-cloudflare'), esc_url(admin_url('admin-post.php')), esc_attr(wp_create_nonce('aipcf_embed_search_ajax')), esc_attr__('Searching...', 'aipcf-ai-provider-for-cloudflare'), esc_attr__('No matches. Vectors written in the last minute are not searchable yet.', 'aipcf-ai-provider-for-cloudflare'), esc_html__('Test search', 'aipcf-ai-provider-for-cloudflare') ); echo '
'; $key = 'aipcf_embed_search_' . get_current_user_id(); $results = get_transient($key); if (is_array($results)) { delete_transient($key); self::renderResults($results); } echo '
'; } /** * Test-search results for display: one per post, best passage first. Shared by * the AJAX response and the no-JavaScript page. * * @return array{query: string, summary: string, matches: list}|\WP_Error */ public static function searchPayload(string $query) { $r = Ingest::search($query, 8); if (is_wp_error($r)) { return $r; } $matches = []; foreach ($r as $m) { $snippet = trim((string) preg_replace('/^Title:.*?\n(Section:.*?\n)?\n/s', '', $m['text'])); $matches[] = [ 'title' => $m['title'] !== '' ? $m['title'] : sprintf('#%d', $m['object_id']), 'url' => esc_url_raw($m['url'], ['http', 'https']), // metadata comes from the store: never trust its scheme 'section' => $m['section'], /* translators: %d: how closely the passage matches, as a percentage */ 'score' => sprintf(__('%d%% match', 'aipcf-ai-provider-for-cloudflare'), (int) round($m['score'] * 100)), 'snippet' => mb_substr($snippet, 0, 220) . (mb_strlen($snippet) > 220 ? '...' : ''), /* translators: %d: number of other matching passages */ 'more' => $m['more'] > 0 ? sprintf(_n('+%d more passage', '+%d more passages', $m['more'], 'aipcf-ai-provider-for-cloudflare'), $m['more']) : '', ]; } return [ 'query' => $query, /* translators: %s: the search query */ 'summary' => sprintf(__('Closest matches for "%s":', 'aipcf-ai-provider-for-cloudflare'), $query), 'matches' => $matches, ]; } /** * @param array $results searchPayload() output, or ['query', 'error']. */ private static function renderResults(array $results): void { if (isset($results['error'])) { echo '

' . esc_html((string) $results['error']) . '

'; return; } if ($results['matches'] === []) { echo '

' . esc_html__('No matches. Vectors written in the last minute are not searchable yet.', 'aipcf-ai-provider-for-cloudflare') . '

'; return; } echo '

' . esc_html((string) $results['summary']) . '

    '; foreach ($results['matches'] as $m) { printf( '
  1. %s%s (%s%s)
    %s
  2. ', esc_url($m['url']), esc_html($m['title']), $m['section'] !== '' ? ' › ' . esc_html($m['section']) : '', esc_html($m['score']), $m['more'] !== '' ? ', ' . esc_html($m['more']) : '', esc_html($m['snippet']) ); } echo '
'; } public static function ajaxSearch(): void { if (!current_user_can('manage_options') || !check_ajax_referer('aipcf_embed_search_ajax', 'nonce', false)) { wp_send_json_error(['message' => __('You do not have permission to do this.', 'aipcf-ai-provider-for-cloudflare')], 403); } $query = isset($_POST['query']) ? sanitize_text_field(wp_unslash((string) $_POST['query'])) : ''; $payload = self::searchPayload($query); if (is_wp_error($payload)) { wp_send_json_error(['message' => $payload->get_error_message()]); } wp_send_json_success($payload); } }