text. Codes come from GenerationAdmin and the builder; unknown codes show the generic text. */ public static function messages(): array { return [ 'unavailable' => [__('Meaning-based search is not available in this build or environment, so it cannot be started, resumed or switched on here. Word matching keeps answering.', 'aiwend'), 'error'], 'not_semantic' => [__('Choose an embedding model first.', 'aiwend'), 'error'], 'build_in_progress' => [__('A build is already in progress for this site. Finish, pause or cancel it first.', 'aiwend'), 'error'], 'nothing_to_index' => [__('There are no public, ready knowledge sources to index.', 'aiwend'), 'error'], 'chunk_ceiling' => [__('This site has more content than the limit for the chosen model. Remove content or choose a model with a higher limit. Nothing was sent.', 'aiwend'), 'error'], 'unsupported_dimension' => [__('The chosen model is not supported. Nothing was sent.', 'aiwend'), 'error'], 'no_budget' => [__('Set an embedding token budget first. Indexing never uses the chat budget.', 'aiwend'), 'error'], 'budget_too_small' => [__('The available embedding budget is below the estimate for this site. Raise the budget or reduce the content.', 'aiwend'), 'error'], 'already_active' => [__('The index that is active already uses this model.', 'aiwend'), 'error'], 'confirm_required' => [__('Not done: tick the confirmation that describes the effect of this action.', 'aiwend'), 'error'], 'started' => [__('Build queued. It runs in the background in small steps; nothing is switched on until you activate it.', 'aiwend'), 'success'], 'paused' => [__('Pause requested. The step that is running finishes first, so a request that is already on its way to the provider still completes and can be charged. After that nothing more is sent until you resume.', 'aiwend'), 'success'], 'resumed' => [__('Build resumed. New requests will be sent to your provider and can be charged. Sources that are blocked stay blocked until you unblock them.', 'aiwend'), 'success'], 'cancelled' => [__('Build cancelled and its vectors removed. Cancelling does not undo charges already incurred or requests already sent; they stay on the usage record, and requests with an unknown outcome stay held until you resolve them.', 'aiwend'), 'success'], 'budget_saved' => [__('Embedding budget saved. Tokens are not money; this is a limit on indexing only.', 'aiwend'), 'success'], 'model_saved' => [__('Model saved. It applies to the next build.', 'aiwend'), 'success'], 'activated' => [__('The new index is active. You can roll back to the previous one for a limited time.', 'aiwend'), 'success'], 'rolled_back' => [__('Rolled back: the previous index answers again. No provider request was made.', 'aiwend'), 'success'], 'deactivated' => [__('Back to word matching. No provider request was made. The index is kept for a limited time so you can roll back to it.', 'aiwend'), 'success'], 'auto_sync_on' => [__('Automatic updates are on. Changed and new public sources are embedded in the background, within the embedding budget. Switch them off here at any time.', 'aiwend'), 'success'], 'auto_sync_off' => [__('Automatic updates are off. Nothing more will be sent automatically; the index is only marked out of date until you update it. This does not undo charges already incurred or a request that is already on its way.', 'aiwend'), 'success'], 'sync_queued' => [__('Update queued. It runs in the background within the embedding budget.', 'aiwend'), 'success'], 'nothing_to_update' => [__('The index already covers the current content.', 'aiwend'), 'error'], 'unblocked' => [__('Task unblocked. It will be built again with a new request when you resume.', 'aiwend'), 'success'], 'unresolved_hold' => [__('An earlier request for this source may still be charged. Resolve it on the Usage page first (charge it as an estimate, or enter the usage from the provider’s records), then unblock.', 'aiwend'), 'error'], 'not_ready' => [__('That index is not ready to be switched on.', 'aiwend'), 'error'], 'incomplete' => [__('The index no longer covers the current content. Resume the build to update it, then activate.', 'aiwend'), 'error'], 'config_mismatch' => [__('The index was built for a different model than the one chosen now.', 'aiwend'), 'error'], 'conflict' => [__('The index was changed at the same moment by someone else. Reload and try again.', 'aiwend'), 'error'], 'no_build' => [__('There is no build to do that with.', 'aiwend'), 'error'], 'already_paused' => [__('The build is already paused.', 'aiwend'), 'error'], 'no_rollback_target' => [__('There is no previous index to roll back to.', 'aiwend'), 'error'], 'rollback_expired' => [__('The previous index is no longer kept.', 'aiwend'), 'error'], 'nothing_active' => [__('No meaning-based index is active.', 'aiwend'), 'error'], 'not_found' => [__('That item was not found, or it was already handled.', 'aiwend'), 'error'], 'invalid' => [__('Some values were not valid.', 'aiwend'), 'error'], 'failed' => [__('That did not work. Nothing was changed.', 'aiwend'), 'error'], ]; } /** Notice codes for AdminView::notice(): prefixed so they cannot collide with other screens. @return array */ public static function notices(): array { $out = []; foreach (self::messages() as $code => $m) { $out['index_' . $code] = $m; } return $out; } /** The notice code for a GenerationAdmin result (unknown codes collapse to the generic one). @param array{ok:bool, code:string} $r */ public static function noticeFor(array $r): string { return array_key_exists($r['code'], self::messages()) ? 'index_' . $r['code'] : 'index_failed'; } /** Failure-state texts for a generation's stored code. */ private static function codeText(?string $code): string { return match ($code) { null => '', 'embedding_budget' => __('Paused: the embedding budget is used up or spending is paused. Raise the budget, then resume.', 'aiwend'), 'token_ceiling' => __('Paused: the planned token ceiling for this build was reached. Resume to continue within the available budget.', 'aiwend'), 'embedding_failed' => __('Paused: the provider request failed. Check the connection and usage, then resume.', 'aiwend'), 'embedder_unavailable', 'embedder_mismatch' => __('Paused: no suitable embedding connection is available.', 'aiwend'), 'uncertain_dispatch' => __('Paused: a request may have reached the provider without a known result. Resolve it on the Usage page, then unblock the source.', 'aiwend'), 'source_failed' => __('A step failed locally and will be retried.', 'aiwend'), 'chunk_ceiling' => __('Stopped: the content exceeds the limit for this model.', 'aiwend'), 'config_changed' => __('Stopped: the model was changed while the build was running.', 'aiwend'), 'owner_cancelled' => __('Cancelled by an administrator.', 'aiwend'), 'source_not_settling', 'build_retries_exhausted' => __('Stopped: some sources could not be processed. Check their status and start a new build.', 'aiwend'), default => __('Stopped. Start a new build after checking the sources and usage.', 'aiwend'), }; } public static function render(Plugin $plugin, bool $canActivate): void { $admin = $plugin->generationAdmin(); $site = get_current_blog_id(); $s = $admin->status($site); $pre = $s['preflight']; $b = $s['budget']; $a = $s['actions']; $why = static fn(array $action): string => $action['reason'] !== null ? ' ' . esc_html((self::messages()[$action['reason']][0] ?? '')) . '' : ''; echo '

' . esc_html__('Meaning-based search (preparation)', 'aiwend') . '

'; echo '

' . esc_html__('Answers use word matching today. Meaning-based search builds a second index with an embedding model, so paraphrases can match. Building it sends the text of your public knowledge sources to the embedding provider and uses tokens that can be charged by the provider. The embedding budget below is Aiwend\'s own limit on what it sends; your provider\'s final invoice can differ. Nothing is built or switched on without your confirmation. Automatic updates after a source changes happen only if you approve them below.', 'aiwend') . '

'; self::renderNotice(); if (!$s['execution_available']) { echo '

' . esc_html__('Not available in this build: building and switching on are disabled, so no text can be sent to a provider. You can prepare the model and the budget below.', 'aiwend') . '

'; } // Readiness echo '

' . esc_html__('Readiness', 'aiwend') . '

'; AdminView::form('index_save_model', 'aiwend-knowledge'); echo '

'; // Budget echo '

' . esc_html__('Embedding budget', 'aiwend') . '

'; if ($b['budget'] === null) { echo '

' . esc_html__('No embedding budget is set, so a build cannot start. This limit applies to indexing only; the chat budget is not used.', 'aiwend') . '

'; } else { echo ''; } echo '

' . esc_html__('This limit controls what Aiwend reserves and records for indexing, in tokens. It is not your provider\'s invoice: your provider may charge differently, for example for a request that fails after it was sent. Tokens are not money.', 'aiwend') . '

'; AdminView::form('index_save_budget', 'aiwend-knowledge'); echo wp_kses(sprintf('

', esc_html__('Token limit for indexing', 'aiwend'), GenerationAdmin::BUDGET_MAX, $b['budget']['limit'] ?? 0, esc_html__('Save budget', 'aiwend')), AdminView::allowedHtml()); // Build echo '

' . esc_html__('Build', 'aiwend') . '

'; $cur = $s['current']; if ($cur === null || $cur['state'] === 'failed') { if ($cur !== null) { echo wp_kses('

' . AdminView::badge(__('Stopped', 'aiwend'), 'error') . ' ' . esc_html(self::codeText($cur['code'])) . '

', AdminView::allowedHtml()); } if ($a['start']['reason'] === 'already_active') { echo '

' . esc_html__('No build is running. The active index already uses the chosen model. To build a new one, choose another model above and save it.', 'aiwend') . '

'; } else { AdminView::form('index_start', 'aiwend-knowledge'); echo ' '; printf('', $a['start']['allowed'] ? '' : ' disabled aria-describedby="aw-start-why"', esc_html__('Start build', 'aiwend')); if ($a['start']['reason'] !== null) { echo '

' . esc_html(self::messages()[$a['start']['reason']][0] ?? '') . '

'; } echo ''; } } else { $paused = (bool) $cur['paused_by_admin']; $attention = $cur['state'] === 'building' && !$paused && $cur['code'] !== null; $state = $cur['state'] === 'ready' ? __('Ready to activate', 'aiwend') : ($paused ? __('Paused by you', 'aiwend') : ($attention ? __('Needs attention', 'aiwend') : __('Building', 'aiwend'))); $progress = (int) $cur['sources_total'] > 0 /* translators: 1: number of sources indexed so far, 2: total number of sources */ ? sprintf(__('%1$d of %2$d sources indexed.', 'aiwend'), $cur['sources_built'], $cur['sources_total']) : ($paused ? __('No source has been indexed yet.', 'aiwend') : __('Not started yet: the first step runs in the background, usually within a minute.', 'aiwend')); echo wp_kses('

' . AdminView::badge($state, $cur['state'] === 'ready' ? 'success' : ($attention ? 'warn' : 'neutral')) . ' ' . esc_html($progress) . ' ' . esc_html(self::codeText($cur['code'])) . '

', AdminView::allowedHtml()); if ($paused) { echo '

' . esc_html__('Paused: nothing is sent to the provider until you resume.', 'aiwend') . '

'; } elseif ($cur['state'] === 'building' && !$attention) { echo '

' . esc_html__('This page does not update by itself. Reload it to see progress.', 'aiwend') . '

'; } $waiting = (int) ($s['jobs']['queued'] ?? 0) + (int) ($s['jobs']['running'] ?? 0); if ($waiting > 0 && !$paused && !$attention) { /* translators: %d: number of background steps waiting or running */ echo '

' . esc_html(sprintf(_n('%d background step is waiting or running.', '%d background steps are waiting or running.', $waiting, 'aiwend'), $waiting)) . '

'; } echo '
'; $show = ['pause' => !$paused && !$attention, 'resume' => $paused || $attention]; foreach (['pause' => __('Pause', 'aiwend'), 'resume' => __('Resume', 'aiwend')] as $do => $label) { if ($a[$do]['allowed'] && $show[$do]) { AdminView::form('index_' . $do, 'aiwend-knowledge'); echo ' '; } } if ($a['resume']['allowed'] && $show['resume']) { echo '' . esc_html__('Resume sends new requests to your provider and can incur charges. Blocked sources stay blocked until you unblock them.', 'aiwend') . ' '; } if ($a['cancel']['allowed']) { echo '
' . esc_html__('Cancel build…', 'aiwend') . ''; AdminView::form('index_cancel', 'aiwend-knowledge'); echo '

' . esc_html__('Removes the vectors built so far. This does not undo charges already incurred or requests already sent.', 'aiwend') . '

'; } echo '
'; foreach ($s['blocked'] as $blocked) { echo '

' . esc_html($blocked['title'] !== '' ? $blocked['title'] : __('A source', 'aiwend')) . ' — ' . esc_html(self::codeText('uncertain_dispatch')) . '

'; AdminView::form('index_unblock', 'aiwend-knowledge', ['generation' => (string) $cur['id'], 'source' => (string) $blocked['source_id']]); /* translators: %s: title of the blocked source */ printf('
', esc_attr(sprintf(__('Unblock %s', 'aiwend'), $blocked['title'] !== '' ? $blocked['title'] : __('this source', 'aiwend'))), esc_html__('Unblock', 'aiwend')); } } // Activation if ($canActivate) { echo '

' . esc_html__('Active index', 'aiwend') . '

'; /* translators: 1: embedding model name, 2: number of indexed sources */ echo '

' . ($s['active'] === null ? esc_html__('Word matching is answering. No meaning-based index is active.', 'aiwend') : esc_html(sprintf(__('Active: %1$s, %2$d sources.', 'aiwend'), $s['active']['model'], $s['active']['sources_built']))) . '

'; echo '
'; if ($cur !== null && $cur['state'] === 'ready') { AdminView::form('index_activate', 'aiwend-knowledge', ['generation' => (string) $cur['id']]); echo wp_kses('' . $why($a['activate']) . ' ', AdminView::allowedHtml()); } if ($s['previous'] !== null) { AdminView::form('index_rollback', 'aiwend-knowledge'); echo wp_kses('' . $why($a['rollback']) . ' ', AdminView::allowedHtml()); } if ($a['deactivate']['allowed']) { AdminView::form('index_deactivate', 'aiwend-knowledge'); echo ''; } echo '
'; if ($s['previous'] !== null || $a['deactivate']['allowed']) { $days = isset($s['previous']['retain_until']) && $s['previous']['retain_until'] !== null ? max(1, (int) ceil(((int) $s['previous']['retain_until'] - time()) / 86400)) : null; echo '

' . esc_html(sprintf( /* translators: %s: optional sentence saying how long the previous index is kept */ __('Rolling back or switching back changes at once which search answers questions. It makes no provider request and costs nothing.%s', 'aiwend'), /* translators: %d: number of days the previous index is kept */ $days !== null ? ' ' . sprintf(_n('The previous index is kept for about %d more day.', 'The previous index is kept for about %d more days.', $days, 'aiwend'), $days) : '' )) . '

'; } } self::renderKeeping($s); echo '
'; } /** The result of the action just taken, printed inside the panel (the page above it can be long); the redirect lands here. */ private static function renderNotice(): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation or filter parameter; no state is changed $code = isset($_GET['aiwend_notice']) ? sanitize_key(Input::get('aiwend_notice')) : ''; $text = str_starts_with($code, 'index_') ? (self::messages()[substr($code, 6)] ?? null) : null; if ($text !== null) { printf('

%3$s

', esc_attr($text[1]), $text[1] === 'error' ? 'alert' : 'status', esc_html($text[0])); } } /** * Keeping an index current after sources change: how many sources it does not cover yet, the administrator's own "update now" and the * standing approval for automatic updates. Both are costly actions and say so; neither exists without a chosen model. * * @param array $s */ private static function renderKeeping(array $s): void { $cur = $s['current']; if (!$s['config']['semantic'] || ($s['active'] === null && !($cur !== null && $cur['state'] === 'ready'))) { return; } $pending = (int) $s['pending']; $isActive = $s['active'] !== null; $can = $s['execution_available']; echo '

' . esc_html__('Keeping the index up to date', 'aiwend') . '

'; if ($pending > 0) { /* translators: %d: number of changed or new sources not yet covered by the index */ echo '

' . esc_html(sprintf(_n( '%d changed or new source is not covered by the index yet. Until the index is updated, word matching answers for it.', '%d changed or new sources are not covered by the index yet. Until the index is updated, word matching answers for them.', $pending, 'aiwend' ), $pending)) . '

'; } else { echo '

' . esc_html($isActive ? __('The active index covers all current content.', 'aiwend') : __('This index covers all current content.', 'aiwend')) . '

'; } AdminView::form('index_sync', 'aiwend-knowledge'); $syncOff = !$can || $pending === 0; printf(' ', $syncOff ? ' disabled aria-describedby="aw-sync-why"' : '', esc_html__('Update index now', 'aiwend')); echo '' . esc_html(!$can ? (self::messages()['unavailable'][0]) : ($pending === 0 ? __('Nothing to update.', 'aiwend') : __('Sends the text of the changed public sources to your embedding provider. Your provider may charge for this; Aiwend stops at your embedding budget.', 'aiwend'))) . ''; echo '

' . esc_html__('Automatic updates', 'aiwend') . '

'; echo '

' . esc_html($s['auto_sync'] ? __('On: when a source is added or changed, its text is sent to your embedding provider automatically, without asking again, and your provider may charge for each update, within your embedding budget. Turning this off stops future automatic sending; it does not undo charges already incurred or a request already on its way.', 'aiwend') : __('Off: source changes never send anything to the provider by themselves. Turn this on only if you accept that every later source change can cause provider charges without asking again.', 'aiwend')) . '

'; AdminView::form('index_auto', 'aiwend-knowledge', ['enabled' => $s['auto_sync'] ? '0' : '1']); if (!$s['auto_sync']) { echo ' '; } printf('', !$can && !$s['auto_sync'] ? ' disabled' : '', esc_html($s['auto_sync'] ? __('Turn automatic updates off', 'aiwend') : __('Turn automatic updates on', 'aiwend'))); echo ''; } }