[policy operation, handler method] */ private const ACTIONS = [ 'save_budget' => ['settings.manage', 'saveBudget'], 'save_price' => ['settings.manage', 'savePrice'], 'retry_job' => ['settings.manage', 'retryJob'], 'retry_upgrade' => ['settings.manage', 'retryUpgrade'], 'run_repair' => ['knowledge.manage', 'runRepair'], 'process_now' => ['knowledge.manage', 'processNow'], 'remove_price' => ['settings.manage', 'removePrice'], 'save_retention' => ['settings.manage', 'saveRetention'], 'save_links' => ['settings.manage', 'saveLinks'], 'save_demo' => ['settings.manage', 'saveDemo'], 'resolve_usage' => ['settings.manage', 'resolveUsage'], 'recover_hold' => ['settings.manage', 'recoverHold'], 'save_uninstall' => ['settings.manage', 'saveUninstall'], 'toggle_module' => ['modules.manage', 'toggleModule'], 'create_bot' => ['bots.manage', 'createBot'], 'update_bot' => ['bots.manage', 'updateBot'], 'publish_bot' => ['bots.manage', 'publishBot'], 'delete_bot' => ['bots.delete', 'deleteBot'], 'save_appearance' => ['bots.manage', 'saveAppearance'], 'save_ai' => ['bots.manage', 'saveAi'], 'bot_sources' => ['bots.manage', 'botSources'], 'attach_sources' => ['bots.manage', 'attachSources'], 'detach_source' => ['bots.manage', 'detachSource'], 'create_test_page' => ['bots.manage', 'createTestPage'], 'add_post_source' => ['knowledge.manage', 'addPostSource'], 'upload_source' => ['knowledge.manage', 'uploadSource'], 'replace_source' => ['knowledge.manage', 'replaceSource'], 'add_sample_knowledge' => ['knowledge.manage', 'addSampleKnowledge'], 'source_visibility' => ['knowledge.manage', 'sourceVisibility'], 'publish_source' => ['knowledge.publish', 'publishSource'], 'reindex_source' => ['knowledge.manage', 'reindexSource'], 'delete_source' => ['knowledge.manage', 'deleteSource'], 'index_save_budget' => ['index.manage', 'indexSaveBudget'], 'index_save_model' => ['index.manage', 'indexSaveModel'], 'index_start' => ['index.manage', 'indexStart'], 'index_pause' => ['index.manage', 'indexPause'], 'index_resume' => ['index.manage', 'indexResume'], 'index_cancel' => ['index.manage', 'indexCancel'], 'index_unblock' => ['index.manage', 'indexUnblock'], 'index_activate' => ['index.activate', 'indexActivate'], 'index_rollback' => ['index.activate', 'indexRollback'], 'index_deactivate' => ['index.activate', 'indexDeactivate'], 'index_sync' => ['index.manage', 'indexSync'], 'index_auto' => ['index.manage', 'indexAuto'], // Phase 117: the guided setup on the Overview. Thin wrappers that re-check the order of the steps on the server. 'setup_enable' => ['modules.manage', 'setupEnable'], 'setup_assistant' => ['bots.manage', 'setupAssistant'], 'setup_budget' => ['settings.manage', 'setupBudget'], 'setup_publish' => ['bots.manage', 'setupPublish'], ]; /** Screens a form may return to. The value comes from the request, so it is never used as an address on its own. */ private const RETURN_PAGES = ['aiwend', 'aiwend-connections', 'aiwend-bots', 'aiwend-knowledge', 'aiwend-conversations', 'aiwend-usage', 'aiwend-settings', 'aiwend-publish', 'aiwend-diagnostics']; /** Synthetic sample knowledge offered on the Overview (explicit click only). */ private const SAMPLE = "# Demo Store FAQ\n\nDemo Store offers a 30-day return period.\nStandard delivery takes 3 to 5 business days.\nCustomer support is available Monday to Friday, from 9 AM to 5 PM.\n"; public function __construct(Plugin $plugin) { $this->plugin = $plugin; } public function register(): void { add_action('admin_menu', [$this, 'menu']); add_action('admin_post_aiwend', [$this, 'handle']); add_action('admin_enqueue_scripts', [$this, 'assets']); (new ContentPicker())->register(); add_action('admin_enqueue_scripts', [$this, 'menuIconStyle']); add_filter('admin_title', [$this, 'hiddenPageTitle']); } public function menu(): void { $any = current_user_can(Capabilities::MANAGE) ? Capabilities::MANAGE : (current_user_can(Capabilities::MANAGE_KNOWLEDGE) ? Capabilities::MANAGE_KNOWLEDGE : Capabilities::MANAGE); // The Aiwend logo (raster, when included in this build): WordPress shows a URL icon as an image; sized in menuIconStyle(). $icon = Brand::url(64) ?? 'dashicons-format-chat'; // the logo may not be part of this build (see Brand) add_menu_page(__('Aiwend', 'aiwend'), __('Aiwend', 'aiwend'), $any, self::SLUG, [$this, 'pageOverview'], $icon, 58); add_submenu_page(self::SLUG, __('Overview', 'aiwend'), __('Overview', 'aiwend'), Capabilities::MANAGE, self::SLUG, [$this, 'pageOverview']); // Existing slugs are kept, so earlier links keep working. add_submenu_page(self::SLUG, __('Assistants', 'aiwend'), __('Assistants', 'aiwend'), Capabilities::MANAGE_BOTS, self::SLUG . '-bots', [$this, 'pageAssistants']); add_submenu_page(self::SLUG, __('Knowledge', 'aiwend'), __('Knowledge', 'aiwend'), Capabilities::MANAGE_KNOWLEDGE, self::SLUG . '-knowledge', [$this, 'pageKnowledge']); add_submenu_page(self::SLUG, __('Usage', 'aiwend'), __('Usage', 'aiwend'), Capabilities::VIEW_USAGE, self::SLUG . '-usage', [$this, 'pageUsage']); add_submenu_page(self::SLUG, __('Settings', 'aiwend'), __('Settings', 'aiwend'), Capabilities::MANAGE, self::SLUG . '-settings', [$this, 'pageSettings']); // Diagnostics now lives under Settings; the old address still works (hidden page). // Phase 96: the confirmation screen for making a private source public (hidden page; administrators only). $publishHook = add_submenu_page('', __('Use knowledge in answers', 'aiwend'), __('Use knowledge in answers', 'aiwend'), Capabilities::PUBLISH_KNOWLEDGE, self::SLUG . '-publish', [$this, 'pagePublish']); if (is_string($publishHook)) { // Checked on load, before WordPress prints the admin header, so a refusal is a real HTTP 403 rather than a 200 page with an error inside. add_action('load-' . $publishHook, [$this, 'guardPublishScreen']); } add_submenu_page('', __('Diagnostics', 'aiwend'), __('Diagnostics', 'aiwend'), Capabilities::VIEW_USAGE, self::SLUG . '-diagnostics', [$this, 'pageDiagnostics']); } /** * WordPress finds a page title through the menu, and a page registered without a parent (the two hidden screens above) has none, so * its document title would be just the site name. Name those two screens; every other title is left as WordPress made it. */ public function hiddenPageTitle(string $adminTitle): string { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only: only chooses a title from the page slug $page = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : ''; $names = [ self::SLUG . '-publish' => __('Use knowledge in answers', 'aiwend'), self::SLUG . '-diagnostics' => __('Diagnostics', 'aiwend'), ]; return isset($names[$page]) ? $names[$page] . ' ' . ltrim($adminTitle) : $adminTitle; } /** Sizes the logo in the WordPress admin menu without altering it (full opacity, no filter). */ public function menuIconStyle(): void { if (!Brand::available()) { return; } wp_register_style('aiwend-menu-icon', false, [], AIWEND_VERSION); wp_enqueue_style('aiwend-menu-icon'); wp_add_inline_style('aiwend-menu-icon', '#adminmenu .toplevel_page_aiwend .wp-menu-image img{width:20px;height:20px;padding:7px 0 0;opacity:1;object-fit:contain}'); } /** Admin assets only on Aiwend screens. */ public function assets(string $hook): void { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation or filter parameter; no state is changed $page = isset($_GET['page']) ? sanitize_key(Input::get('page')) : ''; if ($page !== self::SLUG && !str_starts_with($page, self::SLUG . '-')) { return; } $base = plugins_url('', AIWEND_FILE); wp_enqueue_style('aiwend-admin', $base . '/assets/admin.css', [], AIWEND_VERSION); $deps = []; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation or filter parameter; no state is changed if ($page === self::SLUG . '-bots' && sanitize_key(Input::get('tab')) === 'appearance' && current_user_can('upload_files')) { wp_enqueue_media(); // Media Library picker for the custom avatar $deps[] = 'media-editor'; } wp_enqueue_script('aiwend-admin', $base . '/assets/admin.js', $deps, AIWEND_VERSION, ['in_footer' => true]); wp_localize_script('aiwend-admin', 'aiwendAdmin', [ 'unsaved' => __('You have unsaved changes. Leave without saving?', 'aiwend'), 'unsavedLabel' => __('Unsaved changes', 'aiwend'), 'copied' => __('Shortcode copied.', 'aiwend'), 'copyFailed' => __('Copy failed. Select the text and copy it manually.', 'aiwend'), 'picker' => [ 'remove' => __('Remove', 'aiwend'), /* translators: %s: page or post title */ 'removeNamed' => __('Remove %s', 'aiwend'), 'removed' => __('Removed.', 'aiwend'), /* translators: %s: page or post title */ 'added' => __('Added: %s', 'aiwend'), /* translators: %d: number of search results */ 'found' => __('%d results. Choose one to add it.', 'aiwend'), 'none' => __('No matching pages or posts.', 'aiwend'), 'failed' => __('The search failed. Open Advanced to enter IDs.', 'aiwend'), 'limit' => __('The list is full. Remove an item first.', 'aiwend'), ], 'noMatches' => __('No matches.', 'aiwend'), /* translators: %d: number of items ticked in a list */ 'selected' => __('%d selected', 'aiwend'), /* translators: %d: number of ticked items that a search filter is currently hiding */ 'selectedHidden' => __('(%d hidden by the filter)', 'aiwend'), 'badType' => \Aiwend\Knowledge\PdfSupport::available() ? __('This file type is not supported. Choose a TXT, Markdown or PDF file.', 'aiwend') : __('This file type is not supported. Choose a TXT or Markdown file (PDF is not available in this edition).', 'aiwend'), 'tooBig' => __('The file is larger than the 2 MB limit.', 'aiwend'), /* translators: 1: HEX colour, 2: contrast ratio */ 'contrastAccent' => __('White text on %1$s: %2$s:1, below the recommended 4.5:1. Choose a darker brand color, or set a dark primary button text color under Advanced colors.', 'aiwend'), /* translators: 1: text colour name, 2: background colour name, 3: ratio, 4: minimum ratio */ 'contrastPair' => __('%1$s on %2$s: %3$s:1, below the recommended %4$s:1.', 'aiwend'), /* translators: %s: HEX colour */ 'useColor' => __('Use %s for the text', 'aiwend'), 'hexInvalid' => __('Use a 6-digit HEX color such as #1a2b3c, or leave empty.', 'aiwend'), ]); } // ---------------------------------------------------------------- actions public function handle(): void { $do = isset($_POST['do']) ? sanitize_key(Input::post('do')) : ''; if (!isset(self::ACTIONS[$do])) { wp_die(esc_html__('Unknown action.', 'aiwend'), '', ['response' => 400]); } check_admin_referer('aiwend_' . $do); [$operation, $method] = self::ACTIONS[$do]; $decision = $this->plugin->policy->check($this->plugin->currentPrincipal(), $operation); if (!$decision->allowed) { wp_die(esc_html__('You do not have permission to do that.', 'aiwend'), '', ['response' => 403]); } $back = isset($_POST['back']) ? sanitize_key(Input::post('back')) : self::SLUG; if (!in_array($back, self::RETURN_PAGES, true)) { $back = self::SLUG; } $args = []; if (isset($_POST['assistant']) && (int) $_POST['assistant'] > 0) { $args['assistant'] = (int) $_POST['assistant']; } if (isset($_POST['tab'])) { $args['tab'] = sanitize_key(Input::post('tab')); } // Opened from the guided setup: the way back is carried through the existing AI screens (a whole number, never an address). $marker = AdminView::setupMarker(); if ($marker > 0 && in_array($back, AdminView::MARKED_PAGES, true)) { $args['aiwend_setup'] = $marker; } // The guided setup step to come back to: only a name from the fixed list, and only ever as a query argument of an Aiwend screen. $step = isset($_POST['step']) ? sanitize_key(Input::post('step')) : ''; if (in_array($step, SetupProgress::STEPS, true)) { $args['step'] = $step; } $result = $this->{$method}(); if (is_array($result)) { $notice = (string) $result['notice']; $back = (string) ($result['page'] ?? $back); $args = array_merge($args, (array) ($result['args'] ?? [])); if (($args['assistant'] ?? null) === 0) { unset($args['assistant']); // an assistant that no longer exists is not part of the address } } else { $notice = $result; } $this->plugin->audit->record(get_current_blog_id(), $this->plugin->currentPrincipal()->actorRef(), 'admin.' . $do, 'admin', $notice); // Index panel results land on the panel itself (its notice is printed there), not at the top of a long page. wp_safe_redirect(add_query_arg(array_merge(['page' => $back, 'aiwend_notice' => $notice], $args), admin_url('admin.php')) . (str_starts_with($notice, 'index_') ? '#aw-index' : '')); exit; } private function post(string $key, string $default = ''): string { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce and capability are verified in handle() (check_admin_referer) before any action method runs return isset($_POST[$key]) ? trim(sanitize_textarea_field(Input::post($key))) : $default; } /** * Free text typed by the administrator (instructions, welcome message, suggested questions): kept as typed. sanitize_textarea_field() * would delete everything that looks like a tag ("under <50 words" became "under") and encode what follows a stray "<"; here "<" and ">" * are ordinary characters, and the text is escaped wherever it is shown. Invalid UTF-8, control characters and carriage returns are removed. */ private function postText(string $key, string $default = ''): string { // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce and capability are verified in handle() (check_admin_referer); the value is cleaned below and escaped on output if (!isset($_POST[$key])) { return $default; } // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- as above $value = wp_check_invalid_utf8(Input::post($key), true); return trim((string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', str_replace(["\r\n", "\r"], "\n", $value))); } private function can(string $operation): bool { return $this->plugin->policy->check($this->plugin->currentPrincipal(), $operation)->allowed; } private function saveBudget(): string { $site = get_current_blog_id(); $limit = max(0, (int) $this->post('site_limit', '0')); $this->plugin->ledger->setLimit($site, 'site', $limit); // The conversation limit is never silently adjusted: a value no answer can fit is refused (the saved value stays) and said so, // and a value that is valid but tight is saved with a warning. The other limits below are saved either way. $saved = (int) $this->plugin->options->get(ConversationBudget::OPTION, ConversationBudget::DEFAULT_UNITS); $session = ConversationBudget::assess($this->post('session_limit', (string) $saved), $this->answerLengths()); if (!in_array($session['status'], [ConversationBudget::REFUSED, ConversationBudget::INVALID], true)) { $this->plugin->options->set(ConversationBudget::OPTION, $session['value']); } // Out-of-range or malformed values fall back to the defaults (ClientBudget); they never switch the limit off. $this->plugin->options->set(ClientBudget::OPTION_DAILY, ClientBudget::dailyUnits($this->post('client_limit', (string) ClientBudget::DEFAULT_DAILY_UNITS))); $this->plugin->options->set(ClientBudget::OPTION_SHARE, ClientBudget::sharePercent($this->post('client_share', (string) ClientBudget::DEFAULT_SHARE_PERCENT))); $this->plugin->ledger->halt($this->post('halted') === '1'); $this->plugin->alerts->clearSite(); switch (true) { case $session['status'] === ConversationBudget::REFUSED: return 'budget_session_refused'; case $session['status'] === ConversationBudget::INVALID: return 'budget_session_invalid'; case $session['status'] === ConversationBudget::TOO_LOW_FOR_PROVIDER: return 'budget_session_low'; case $session['status'] === ConversationBudget::TIGHT: return 'budget_session_tight'; default: return 'saved'; } } /** @return list the maximum answer length of every assistant of this site (what one answer can reserve at most) */ private function answerLengths(): array { return array_values(array_map(static fn(array $b): int => (int) ($b['max_output_tokens'] ?? ConversationBudget::DEFAULT_MAX_ANSWER), $this->plugin->bots->all(get_current_blog_id()))); } /** * A short "how to choose" for new administrators, with figures taken from the real reservation rules (ConversationBudget) for this site's * assistants. It states limits of the figures and promises no cost or number of conversations. */ private function limitsGuide(): void { $max = ConversationBudget::largestAnswer($this->answerLengths()); $provider = ConversationBudget::providerFloorUnits($max); $n = static fn(int $v): string => number_format_i18n($v); echo '
' . esc_html__('Choosing starting limits', 'aiwend') . '
'; } /** * The conversation-limit field with the figures that apply to it, so the saved value and what it can do are visible. The input's minimum * is the same floor the server enforces (ConversationBudget), so the browser and the server agree. */ private function conversationLimitField(): void { $saved = (int) $this->plugin->options->get(ConversationBudget::OPTION, ConversationBudget::DEFAULT_UNITS); $max = ConversationBudget::largestAnswer($this->answerLengths()); $floor = ConversationBudget::floorUnits($max); $provider = ConversationBudget::providerFloorUnits($max); $n = static fn(int $v): string => number_format_i18n($v); $standing = ConversationBudget::standing($saved, [$max]); if ($standing !== ConversationBudget::OK) { switch (true) { case $standing === ConversationBudget::REFUSED: $warning = sprintf(/* translators: 1: saved limit, 2: least units one answer can reserve; both are numbers */ __('The conversation limit now saved (%1$s units) is lower than the least one answer can reserve (%2$s units), so no conversation can be answered. Raise it below.', 'aiwend'), $n($saved), $n($floor)); break; case $standing === ConversationBudget::TOO_LOW_FOR_PROVIDER: $warning = sprintf(/* translators: 1: saved limit, 2: least units one answer from an AI provider reserves; both are numbers */ __('The conversation limit now saved (%1$s units) is lower than what one answer from an AI provider reserves (at least %2$s units), so only the simulated demo can answer within it. Raise it below.', 'aiwend'), $n($saved), $n($provider)); break; default: $warning = sprintf(/* translators: 1: saved limit, 2: least units one answer from an AI provider reserves; both are numbers */ __('The conversation limit now saved (%1$s units) allows fewer than two answers from an AI provider (one reserves at least %2$s units), so conversations will end quickly.', 'aiwend'), $n($saved), $n($provider)); break; } echo '

' . esc_html($warning) . '

'; } echo '

' . esc_html(sprintf( /* translators: 1: saved limit, 2: largest maximum answer of the assistants, 3: least reservation of an answer from an AI provider, 4: least reservation of any answer; all are numbers */ __('Saved: %1$s units. It applies to conversations that start after you save; a conversation already open keeps the limit it started with. Before each answer Aiwend reserves the text it sends plus the longest answer allowed (your largest assistant: %2$s units). One answer from an AI provider reserves at least %3$s units before the text is counted, and any answer at least %4$s, so a lower limit is not saved.', 'aiwend'), $n($saved), $n($max), $n($provider), $n($floor) )) . '

'; } private function savePrice(): string { $result = (new PriceOverrides($this->plugin->options, $this->plugin->clock))->save([ 'provider' => $this->post('provider'), 'model' => $this->post('model'), 'currency' => strtoupper($this->post('currency')), 'input' => $this->post('input'), 'cached_input' => $this->post('cached_input'), 'output' => $this->post('output'), 'confirm' => $this->post('confirm'), ]); return $result['ok'] ? 'rate_saved' : 'rate_' . $result['error']; } /** * Moves ONE dead background job back to the queue. Only the internal, idempotent kinds of RetryPolicy are accepted, and the type, the * site and the "dead right now" condition are part of the UPDATE itself (Queue::retryDeadOfTypes), so a forged or repeated request * changes nothing. Nothing is dispatched here; a worker runs the job later. Chat requests are never involved. */ private function retryJob(): string { $id = (int) $this->post('job'); return $id > 0 && $this->plugin->queue->retryDeadOfTypes(get_current_blog_id(), $id, \Aiwend\Queue\RetryPolicy::types()) ? 'job_retried' : 'job_not_retryable'; } /** "Try again now" for a failed database update: the same re-runnable, locked upgrade, without the one-minute wait. */ private function retryUpgrade(): string { $result = (new \Aiwend\Db\UpgradeGuard($this->plugin->db, $this->plugin->options, $this->plugin->clock))->run(true); return in_array($result, [\Aiwend\Db\UpgradeGuard::DONE, \Aiwend\Db\UpgradeGuard::CURRENT], true) ? 'upgrade_retried' : 'upgrade_still_failing'; } /** * Honest guidance while sources wait for indexing: indexing is run by WordPress's scheduler (WP-Cron), which only starts when the site is * visited and can be late or blocked on some hosts; "Process now" runs a few waiting jobs right away. Shown only to people who can use it. * * @param list> $sources */ private function processNowCallout(array $sources, string $back, array $hidden = []): void { $waiting = count(array_filter($sources, static fn(array $s): bool => in_array($s['status'], ['queued', 'processing'], true))); if ($waiting === 0 || !$this->can('knowledge.manage')) { return; } echo '

' . esc_html(sprintf( /* translators: %d: number of sources waiting for indexing */ _n('%d source is waiting to be indexed.', '%d sources are waiting to be indexed.', $waiting, 'aiwend'), $waiting )) . ' ' . esc_html__('Indexing runs in the background when WordPress’s scheduler runs, usually within a minute of a visit to your site; on a quiet site, or where the scheduler is blocked, it can wait longer. Press Process now to index a few waiting sources immediately, or see Diagnostics under Settings.', 'aiwend') . '

'; AdminView::form('process_now', $back, $hidden); echo '

'; } /** * "Process now" on the Knowledge screen: a bounded run of the waiting indexing jobs (Plugin::processKnowledgeNow). Nonce and the knowledge.manage * capability were checked in handle(); the run only touches this site's knowledge.ingest jobs. */ private function processNow(): string { $result = $this->plugin->processKnowledgeNow(); switch (true) { case $result['status'] === 'ran': return 'process_done'; case $result['status'] === 'partial': return 'process_partial'; case $result['status'] === 'busy': return 'process_busy'; default: return 'process_none'; } } /** Queues the existing daily knowledge repair once (a second click within five minutes finds the same job). It runs on the next background run. */ private function runRepair(): string { if (!$this->plugin->modules->isEnabled('knowledge')) { return 'repair_unavailable'; } try { $this->plugin->queue->enqueue(get_current_blog_id(), KnowledgeModule::REPAIR, [], 'repair-manual:' . intdiv($this->plugin->clock->now(), 300)); } catch (\Aiwend\Queue\JobRejectedException $exception) { return 'repair_unavailable'; } return 'repair_queued'; } private function removePrice(): string { return (new PriceOverrides($this->plugin->options, $this->plugin->clock))->remove($this->post('provider'), $this->post('model')) ? 'rate_removed' : 'rate_missing'; } private function saveRetention(): string { $this->plugin->options->set('aiwend_chat_retention_days', max(1, min(3650, (int) $this->post('chat_days', '30')))); $this->plugin->options->set('aiwend_audit_retention_days', max(1, min(3650, (int) $this->post('audit_days', '30')))); return 'saved'; } private function saveLinks(): string { $hosts = []; foreach (preg_split('/[\s,]+/', strtolower($this->post('hosts'))) ?: [] as $h) { if ($h !== '' && preg_match('/^[a-z0-9]([a-z0-9\-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9\-]*[a-z0-9])?)+$/', $h)) { $hosts[] = $h; } } $this->plugin->options->set('aiwend_link_allowlist', array_values(array_unique($hosts))); return 'saved'; } private function saveDemo(): string { $this->plugin->options->set(Plugin::DEMO_OPTION, $this->post('enabled') === '1'); return 'saved'; } /** * Supported recovery for usage that is not confirmed (no automatic invoice matching): * charge the held amount as an unconfirmed estimate, or enter the usage shown in the * provider's own records (0 when the provider confirms no charge). An estimate can be * replaced by entered usage once. */ private function resolveUsage(): string { $rid = $this->post('request_id'); $mode = $this->post('mode'); $units = $this->post('units'); if (!\Aiwend\Support\RequestId::isValid($rid) || !in_array($mode, ['estimate', 'entered'], true)) { return 'invalid'; } if ($mode === 'entered' && !preg_match('/^\d{1,12}$/', $units)) { return 'invalid'; } $ref = mb_substr(sanitize_text_field($this->post('reference')), 0, 191); $site = get_current_blog_id(); $before = $this->plugin->ledger->find($site, $rid); $ok = $this->plugin->ledger->reconcile($site, $rid, $mode === 'entered' ? (int) $units : null, $ref === '' ? null : $ref); // Phase 137: a record of the accounting decision itself. Internal row id, states and units only: no request id, reference text, prompt or provider reply. $this->auditRecovery($site, $rid, $mode === 'entered' ? 'enter_usage' : 'charge_estimate', ($nullsafeVariable1 = $before) ? $nullsafeVariable1->state : null, $ok ? Reservation::SETTLED : null, (($nullsafeVariable2 = $before) ? $nullsafeVariable2->reservedUnits : null) ?? 0, $ok ? 'ok' : 'refused'); return $ok ? 'saved' : 'not_found'; } /** * Phase 137: explicit, administrator-only handling of a stale reservation (see Ledger::reclassifyStale() and releaseStaleReserved() for what is * safe and why). Requires settings.manage and the nonce (checked in handle()) and a ticked confirmation. The age is rechecked inside the * conditional update. Never called from a background process; nothing is charged; the provider is never contacted. */ private function recoverHold(): string { $rid = $this->post('request_id'); $mode = $this->post('mode'); if (!\Aiwend\Support\RequestId::isValid($rid) || !in_array($mode, ['reclassify', 'release_unsent'], true)) { return 'invalid'; } if ($this->post('confirm') !== '1') { return 'recover_confirm'; } $site = get_current_blog_id(); $out = $mode === 'reclassify' ? $this->plugin->ledger->reclassifyStale($site, $rid) : $this->plugin->ledger->releaseStaleReserved($site, $rid); $this->auditRecovery($site, $rid, $mode, $out['from'], $out['result'] === Ledger::RECOVER_DONE ? ($mode === 'reclassify' ? Reservation::UNCERTAIN : Reservation::RELEASED) : null, $out['units'], $out['result'], $out['age'], $out['internal_id']); switch (true) { case $out['result'] === Ledger::RECOVER_DONE: return $mode === 'reclassify' ? 'recover_reclassified' : 'recover_released'; case $out['result'] === Ledger::RECOVER_NOT_STALE: return 'recover_not_stale'; case $out['result'] === Ledger::RECOVER_NOT_FOUND: return 'not_found'; default: return 'recover_conflict'; } } /** * One audit entry per recovery or accounting decision: who (actor), what (action, from and to state, units held before), and the result. The object * is the internal reservation row id, so the request id (which can appear in logs and exports) is not copied into the trail. No free text from the form. */ private function auditRecovery(int $site, string $requestId, string $action, ?string $from, ?string $to, int $units, string $result, ?int $age = null, ?int $internalId = null): void { if ($internalId === null) { $internalId = (int) ($this->plugin->db->selectOne('SELECT id FROM {reservations} WHERE site_id = ? AND request_id = ?', [$site, $requestId])['id'] ?? 0); } $detail = sprintf('action=%s from=%s to=%s units_held=%d age=%s result=%s', $action, $from ?? 'none', $to ?? 'unchanged', $units, $age === null ? 'unknown' : (string) $age, $result); $this->plugin->audit->record($site, $this->plugin->currentPrincipal()->actorRef(), 'usage.recover', 'reservation:' . ($internalId > 0 ? $internalId : 'unknown'), $result === 'done' || $result === 'ok' ? 'ok' : 'refused', null, $detail); } private function toggleModule(): string { try { if ($this->post('enabled') === '1') { if (!$this->plugin->modules->get($this->post('module'))->prerequisitesMet()) { return 'module_prerequisite'; // explained next to the module; nothing is installed or changed } $this->plugin->modules->enable($this->post('module')); } else { $this->plugin->modules->disable($this->post('module')); } return 'saved'; } catch (\Throwable $exception) { return 'module_error'; } } /** What a module that cannot be turned on yet is missing, and what to do about it (Aiwend never installs or activates other plugins). */ private function unavailableText(\Aiwend\Core\Module $m): string { return $m->prerequisiteMessage() . ' ' . __('Aiwend does not install or activate other plugins for you: install and activate it from Plugins, then turn this module on.', 'aiwend'); } // ---------------------------------------------------------------- guided setup (Phase 117) private function setupWizard(): SetupWizard { return new SetupWizard($this->plugin, $this); } /** The evaluated state of the guided setup for the assistant named in the request (local data only). */ private function setupState(): array { $wizard = $this->setupWizard(); return SetupProgress::evaluate($wizard->facts($wizard->focus((int) $this->post('assistant')))); } /** Both modules in one explicit step. The result is the real state afterwards: complete success is reported only when both are on. */ private function setupEnable(): string { $result = SetupProgress::enableModules($this->plugin->modules); if ($result['failed'] === []) { return 'setup_modules_on'; } return $result['on'] === [] ? 'module_error' : 'setup_modules_partial'; } /** The first assistant: a private draft, created once. A second submission (double click, back button) finds it and creates nothing. * @return mixed[]|string */ private function setupAssistant() { if ($this->setupState()['steps']['assistant']['state'] === 'locked') { return 'setup_locked'; } $site = get_current_blog_id(); $existing = $this->plugin->bots->all($site); if ($existing !== []) { return ['notice' => 'setup_assistant_exists', 'args' => ['assistant' => (int) $existing[0]['id']]]; } try { $id = $this->plugin->bots->create($site, [ 'name' => $this->post('name'), 'model' => $this->plugin->ai()->defaultAi() !== null ? BotRepository::MODEL_INHERIT : BotRepository::MODEL_DEMO, 'instruction' => SetupWizard::starterInstruction(), 'is_public' => false, 'status' => 'draft', 'max_output_tokens' => 800, ]); } catch (\InvalidArgumentException $exception) { return 'invalid'; } return ['notice' => 'setup_assistant_created', 'args' => ['assistant' => $id]]; } /** The site limit, as a whole number of the existing units greater than 0. Nothing else about the limits is touched. */ private function setupBudget(): string { if ($this->setupState()['steps']['budget']['state'] === 'locked') { return 'setup_locked'; } $raw = $this->post('site_limit'); if (preg_match('/^[1-9][0-9]{0,11}$/', $raw) !== 1) { return 'setup_budget_invalid'; } $this->plugin->ledger->setLimit(get_current_blog_id(), 'site', (int) $raw); $this->plugin->alerts->clearSite(); return 'saved'; } /** * Publishing from the guided setup: only when every readiness requirement holds at this moment (checked here, not trusted from the * form that was shown earlier) and only with the explicit confirmation. The advanced Publish tab and publishBot() are unchanged. */ private function setupPublish(): string { $wizard = $this->setupWizard(); $requested = (int) $this->post('assistant'); // A state-changing action acts on exactly the assistant that was named, never on a fallback. $bot = $requested > 0 ? $this->plugin->bots->find(get_current_blog_id(), $requested) : null; if ($bot === null) { return 'not_found'; } $state = SetupProgress::evaluate($wizard->facts($bot)); if ($state['steps']['publish']['state'] === 'locked' || $this->blockers($bot) !== []) { return 'setup_locked'; } if ($this->post('confirm') !== '1') { return 'confirm_required'; } return $this->plugin->bots->update(get_current_blog_id(), (int) $bot['id'], ['status' => 'active', 'is_public' => true]) ? 'setup_published' : 'not_found'; } /** New assistants start as Draft and Private; the editor opens next. * @return mixed[]|string */ private function createBot() { try { $id = $this->plugin->bots->create(get_current_blog_id(), [ 'name' => $this->post('name'), 'model' => $this->plugin->ai()->defaultAi() !== null ? BotRepository::MODEL_INHERIT : BotRepository::MODEL_DEMO, 'instruction' => $this->postText('instruction') ?: $this->exampleInstruction(), 'is_public' => $this->post('is_public') === '1', 'status' => in_array($this->post('status'), ['draft', 'active', 'paused'], true) ? $this->post('status') : 'draft', 'max_output_tokens' => (int) $this->post('max_output_tokens', '800'), ]); return ['notice' => 'created', 'page' => 'aiwend-bots', 'args' => ['assistant' => $id, 'tab' => 'knowledge']]; } catch (\InvalidArgumentException $exception) { return ['notice' => 'invalid', 'page' => 'aiwend-bots', 'args' => ['view' => 'new']]; } } private function updateBot(): string { $changes = []; foreach (['name', 'instruction', 'status'] as $field) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce and capability are verified in handle() (check_admin_referer) before any action method runs if (isset($_POST[$field])) { $changes[$field] = $field === 'instruction' ? $this->postText($field) : $this->post($field); } } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce and capability are verified in handle() (check_admin_referer) before any action method runs if (isset($_POST['visibility_set'])) { $changes['is_public'] = $this->post('is_public') === '1'; } try { return $this->plugin->bots->update(get_current_blog_id(), (int) $this->post('id', $this->post('assistant')), $changes) ? 'saved' : 'not_found'; } catch (\InvalidArgumentException $exception) { return 'invalid'; } } private function publishBot(): string { try { return $this->plugin->bots->update(get_current_blog_id(), (int) $this->post('assistant'), [ 'status' => $this->post('status'), 'is_public' => $this->post('is_public') === '1', ]) ? 'saved' : 'not_found'; } catch (\InvalidArgumentException $exception) { return 'invalid'; } } private function saveAppearance(): string { $botId = (int) $this->post('assistant'); $bot = $this->plugin->bots->find(get_current_blog_id(), $botId); if ($bot === null) { return 'not_found'; } // Custom avatar: an image attachment of an allowed raster type. Choosing a new one requires // the media capability; keeping the saved one does not. $avatar = $this->post('avatar', 'mark'); $avatarId = $avatar === 'custom' ? absint($this->post('avatar_id', '0')) : 0; if ($avatar === 'custom') { $saved = BotRepository::appearance($bot); $changed = $saved['avatar'] !== 'custom' || (int) $saved['avatar_id'] !== $avatarId; if (ChatWidget::avatarUrl($avatarId) === null || ($changed && !current_user_can('upload_files'))) { return 'avatar_invalid'; } } // Accent: a preset swatch, or "custom" with a HEX value. Colours: optional light/dark slots, // validated in Palette::normalize() (unknown slots or non-HEX values reject the whole save). $accent = $this->post('accent'); if ($accent === 'custom') { $accent = strtolower(trim($this->post('accent_custom'))); } $colors = []; $rawColors = map_deep(Input::postList('colors'), 'sanitize_text_field'); // every value sanitized; Palette::normalize() validates the shape foreach ($rawColors as $scheme => $slots) { if (!is_array($slots)) { return 'appearance_invalid'; } foreach ($slots as $slot => $value) { $colors[(string) $scheme][(string) $slot] = is_string($value) ? sanitize_text_field($value) : ''; } } try { return $this->plugin->bots->setAppearance(get_current_blog_id(), $botId, [ 'avatar_id' => $avatarId, 'colors' => $colors, 'theme_toggle' => $this->post('theme_toggle') === '1', 'display_name' => $this->postText('display_name'), 'welcome' => $this->postText('welcome'), 'accent' => $accent, 'placement' => $this->post('placement', 'inline'), 'side' => $this->post('side', 'right'), 'launcher_label' => $this->postText('launcher_label', 'Chat with us'), 'desktop_offset' => $this->post('desktop_offset', '24'), 'mobile_offset' => $this->post('mobile_offset', '16'), 'include_pages' => $this->post('include_pages'), 'exclude_pages' => $this->post('exclude_pages'), 'devices' => $this->post('devices', 'all'), 'theme' => $this->post('theme', 'auto'), 'avatar' => $this->post('avatar', 'mark'), 'suggestions' => $this->postText('suggestions'), ]) ? 'saved' : 'not_found'; } catch (\InvalidArgumentException $exception) { return 'appearance_invalid'; } } /** Replaces the full set of sources linked to an assistant (kept for compatibility). */ private function botSources(): string { $site = get_current_blog_id(); $bot = (int) $this->post('id', $this->post('assistant')); if ($this->plugin->bots->find($site, $bot) === null) { return 'not_found'; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce and capability are verified in handle() (check_admin_referer) before any action method runs $wanted = array_map('intval', (array) ($_POST['sources'] ?? [])); foreach ($this->plugin->sources()->list($site) as $s) { if (in_array($s['id'], $wanted, true) && !in_array($s['status'], ['deleting', 'deleted'], true)) { $this->plugin->sources()->attachToBot($site, $bot, $s['id']); } else { $this->plugin->sources()->detachFromBot($site, $bot, $s['id']); } } return 'saved'; } private function attachSources(): string { $site = get_current_blog_id(); $bot = (int) $this->post('assistant'); if ($this->plugin->bots->find($site, $bot) === null) { return 'not_found'; } // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce and capability are verified in handle() (check_admin_referer) before any action method runs $wanted = array_map('intval', (array) ($_POST['sources'] ?? [])); if ($wanted === []) { return 'nothing_selected'; } foreach ($wanted as $id) { $s = $this->plugin->sources()->find($site, $id); if ($s !== null && !in_array($s['status'], ['deleting', 'deleted'], true)) { $this->plugin->sources()->attachToBot($site, $bot, $id); } } return 'attached'; } private function detachSource(): string { $site = get_current_blog_id(); $bot = (int) $this->post('assistant'); if ($this->plugin->bots->find($site, $bot) === null) { return 'not_found'; } $this->plugin->sources()->detachFromBot($site, $bot, (int) $this->post('source')); return 'detached'; } /** * Deletes an assistant (phase 111; administrators only, see AssistantDeletion). The administrator must choose what happens to the * conversations (no default) and type DELETE. An assistant whose deletion is already running is simply continued. * @return mixed[]|string */ private function deleteBot() { $site = get_current_blog_id(); $id = (int) $this->post('assistant'); $bot = $id > 0 ? $this->plugin->bots->findAny($site, $id) : null; if ($bot === null || $bot['status'] === BotRepository::DELETED) { return ['notice' => 'not_found', 'page' => 'aiwend-bots', 'args' => ['assistant' => 0]]; } $again = ['page' => 'aiwend-bots', 'args' => ['assistant' => $id, 'view' => 'delete']]; $running = $bot['status'] === BotRepository::DELETING; $mode = $running ? \Aiwend\Chat\AssistantDeletion::ERASE : $this->post('conversations'); if (!in_array($mode, [\Aiwend\Chat\AssistantDeletion::KEEP, \Aiwend\Chat\AssistantDeletion::ERASE], true)) { return ['notice' => 'delete_choose'] + $again; } if (!$running && $this->post('confirm_text') !== 'DELETE') { return ['notice' => 'delete_confirm'] + $again; } $result = $this->plugin->assistantDeletion()->delete($site, $id, $mode); $actor = $this->plugin->currentPrincipal()->actorRef(); $this->plugin->audit->record($site, $actor, 'bot.delete', "bot:$id", $result['code'], null, sprintf('conversations=%s sources_detached=%d remaining=%d', $mode, $result['detached'], $result['remaining'])); if ($result['code'] === 'not_found') { return ['notice' => 'not_found', 'page' => 'aiwend-bots', 'args' => ['assistant' => 0]]; } if ($result['code'] === 'deleting') { // The rest runs in the background on the existing recurring event (nothing new is scheduled). // One request-started job per assistant per five minutes: repeated submissions cannot pile up jobs (each job is bounded anyway). $queued = ChatModule::queueErase($this->plugin, $site, $id, 'bot-erase:' . $id . ':request:' . (int) floor(time() / 300)); $this->plugin->ensureSchedule('assistant_delete'); return ['notice' => $queued ? 'assistant_deleting' : 'assistant_deleting_waiting', 'page' => 'aiwend-bots', 'args' => ['assistant' => $id, 'view' => 'delete']]; } return ['notice' => 'assistant_deleted', 'page' => 'aiwend-bots', 'args' => ['assistant' => 0]]; } /** A DRAFT page containing the chat block, for testing on the site. Never published here. * @return mixed[]|string */ private function createTestPage() { if (!current_user_can('edit_pages')) { return 'cannot_create_page'; } $bot = $this->plugin->bots->find(get_current_blog_id(), (int) $this->post('assistant')); if ($bot === null) { return 'not_found'; } $page = wp_insert_post([ 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => sprintf(/* translators: %s: assistant name */ __('Chat test: %s', 'aiwend'), $bot['name']), 'post_content' => '', ], true); if (is_wp_error($page)) { return 'invalid'; } return ['notice' => 'page_created', 'args' => ['aiwend_next' => rawurlencode((string) get_edit_post_link((int) $page, 'raw'))]]; } /** Adds one or more published pages/posts; optionally attaches them to an assistant. * @return mixed[]|string */ private function addPostSource() { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce and capability are verified in handle() (check_admin_referer) before any action method runs $ids = array_map('intval', (array) ($_POST['post_ids'] ?? [])); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce and capability are verified in handle() (check_admin_referer) before any action method runs if (isset($_POST['post_id'])) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce and capability are verified in handle() (check_admin_referer) before any action method runs $ids[] = (int) $_POST['post_id']; } $ids = array_values(array_unique(array_filter($ids))); if ($ids === []) { return 'nothing_selected'; } $site = get_current_blog_id(); $visibility = $this->requestedVisibility(); if ($visibility === null) { return 'publish_required'; } $added = []; foreach ($ids as $pid) { $post = get_post($pid); if (!$post instanceof \WP_Post || !KnowledgeModule::isPublic($post)) { return 'not_published'; } $existed = $this->plugin->sources()->originExists($site, 'post', (string) $post->ID); $id = $this->plugin->sources()->upsert($site, 'post', (string) $post->ID, get_the_title($post), (string) get_permalink($post), $visibility, get_current_user_id()); $this->knowledge()->queueIngest($id, KnowledgeModule::postHash($post)); if (!$existed) { $this->auditVisibility($id, 'new', $visibility, 'created'); } $added[] = $id; } return $this->afterAdding($added); } /** * @return mixed[]|string */ private function uploadSource() { // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce and capability are verified in handle() (check_admin_referer) before any action method runs $upload = \Aiwend\Knowledge\UploadReader::read($_FILES['file'] ?? null); if (is_string($upload)) { return $upload; // upload_missing | upload_type | upload_size | upload_binary: the notice codes } $name = $upload['name']; $type = $upload['type']; $content = $upload['content']; $site = get_current_blog_id(); $visibility = $this->requestedVisibility(); if ($visibility === null) { return 'publish_required'; } $id = $this->plugin->sources()->upsert($site, 'upload', $name . ':' . wp_generate_password(12, false), $this->post('title') ?: $name, null, $visibility, get_current_user_id()); $this->knowledge()->queueIngest($id, $this->plugin->sources()->putBlob($site, $id, $type, $content)); $this->auditVisibility($id, 'new', $visibility, 'created'); return $this->afterAdding([$id]); } /** Replace the file of an existing uploaded source: same source and assistants, new version built beside the current one. */ private function replaceSource(): string { $site = get_current_blog_id(); $source = $this->plugin->sources()->find($site, (int) $this->post('id')); if ($source === null || $source['origin'] !== 'upload' || in_array($source['status'], ['deleting', 'deleted'], true)) { return 'not_found'; } // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- nonce and capability are verified in handle() (check_admin_referer) before any action method runs $upload = \Aiwend\Knowledge\UploadReader::read($_FILES['file'] ?? null); if (is_string($upload)) { return $upload; } $name = $upload['name']; $content = $upload['content']; return $this->knowledge()->replaceUpload((int) $source['id'], $name, $upload['type'], $content, null) ? 'replaced' : 'not_found'; } /** Explicit, synthetic sample; it does not publish a page or make any assistant public. * @return mixed[]|string */ private function addSampleKnowledge() { $site = get_current_blog_id(); // Synthetic text, but still a publication: only someone who may publish creates it public; anyone else gets it private. $visibility = $this->can('knowledge.publish') ? 'public' : 'private'; $existed = $this->plugin->sources()->originExists($site, 'upload', 'sample-demo-store-faq.md'); $id = $this->plugin->sources()->upsert($site, 'upload', 'sample-demo-store-faq.md', 'Sample: Demo Store FAQ', null, $visibility, get_current_user_id()); if (!$existed) { $this->auditVisibility($id, 'new', $visibility, 'created'); } $this->knowledge()->queueIngest($id, $this->plugin->sources()->putBlob($site, $id, 'markdown', self::SAMPLE)); $result = $this->afterAdding([$id]); if (is_array($result)) { $result['notice'] = 'sample_added'; } return $result; } /** * After adding knowledge: attach it when the form came from an assistant (and the user may * manage assistants); otherwise point to the next step. * * @param list $sourceIds * @return mixed[]|string */ private function afterAdding(array $sourceIds) { $site = get_current_blog_id(); $bot = (int) $this->post('assistant'); if ($bot > 0 && $this->can('bots.manage') && $this->plugin->bots->find($site, $bot) !== null) { foreach ($sourceIds as $id) { $this->plugin->sources()->attachToBot($site, $bot, $id); } return 'queued'; } $bots = $this->plugin->bots->all($site); if ($bots !== [] && $this->can('bots.manage')) { $target = count($bots) === 1 ? AdminView::url('aiwend-bots', ['assistant' => $bots[0]['id'], 'tab' => 'knowledge']) : AdminView::url('aiwend-bots'); return ['notice' => 'queued', 'args' => ['aiwend_next' => rawurlencode($target)]]; } return 'queued'; } /** Direct visibility changes are only ever TO private. Making a source public has its own confirmed workflow (publishSource). */ private function sourceVisibility(): string { if ($this->post('visibility') !== 'private') { return 'publish_required'; } $site = get_current_blog_id(); $id = (int) $this->post('id'); $before = $this->plugin->sources()->find($site, $id); if ($before === null || !$this->plugin->sources()->setVisibility($site, $id, 'private')) { return 'not_found'; } if ($before['visibility'] !== 'private') { $this->auditVisibility($id, (string) $before['visibility'], 'private', 'changed'); } return 'saved'; } /** * Visibility asked for by a creation form. Private unless the person may publish AND ticked the explicit confirmation; null means * "Public was asked for without the right to publish or without the tick": the action is refused, not silently downgraded. */ private function requestedVisibility(): ?string { if ($this->post('visibility') !== 'public') { return 'private'; } return $this->can('knowledge.publish') && $this->post('confirm_publish') === '1' ? 'public' : null; } /** One audit entry per visibility event: ids and states only, never a title, text or request body. @param list $assistantIds */ private function auditVisibility(int $sourceId, string $from, string $to, string $how, array $assistantIds = []): void { $p = $this->plugin->currentPrincipal(); $this->plugin->audit->record(get_current_blog_id(), $p->actorRef(), 'knowledge.visibility', 'source:' . $sourceId, $to, null, $how . ' ' . \Aiwend\Knowledge\SourceRepository::visibilityDetail($from, $to, $assistantIds)); } /** Applies a confirmed publication. The token, the capability and the nonce are all checked; the source and the affected assistants are re-read inside the transaction. */ private function publishSource(): string { $site = get_current_blog_id(); $id = (int) $this->post('id'); if ($this->post('confirm_publish') !== '1') { return 'confirm_required'; } $token = $this->post('confirmation'); $user = get_current_user_id(); $now = $this->plugin->clock->now(); $secret = wp_salt('auth'); $affected = []; $result = $this->plugin->sources()->publish($site, $id, function (array $row, array $ids) use ($token, $secret, $site, $id, $user, $now, &$affected): bool { $affected = $ids; return \Aiwend\Knowledge\PublishToken::valid($token, $secret, $site, $id, $user, (int) $row['updated_at'], $ids, $now); }); switch (true) { case $result === 'published': $outcome = 'published'; break; case $result === 'already_public': $outcome = 'saved'; break; case $result === 'not_found': $outcome = 'not_found'; break; default: $outcome = 'publish_stale'; break; } $this->auditVisibility($id, 'private', $result === 'published' || $result === 'already_public' ? 'public' : 'private', $result === 'published' ? 'confirmed' : 'refused-' . $result, $affected); return $outcome; } /** * Request-origin and authorization gate of the confirmation screen; returns the source id. The screen issues the short-lived publication * token, so opening it needs the capability and a nonce bound to the source (links are built by AdminView::publishUrl()); the publication * itself is a separate nonce-protected POST. Each condition fails closed on its own. */ public function guardPublishScreen(): int { if (!current_user_can(Capabilities::PUBLISH_KNOWLEDGE)) { wp_die(esc_html__('You do not have permission to publish knowledge.', 'aiwend'), '', ['response' => 403]); } $id = isset($_GET['source']) ? absint($_GET['source']) : 0; $nonce = isset($_GET['_wpnonce']) ? sanitize_text_field(wp_unslash($_GET['_wpnonce'])) : ''; if ($id < 1 || wp_verify_nonce($nonce, AdminView::publishNonceAction($id)) === false) { wp_die(esc_html__('This link has expired or is not valid. Open the knowledge library and choose “Use in answers…” again.', 'aiwend'), '', ['response' => 403, 'back_link' => true]); } return $id; } /** Confirmation screen: names the source, lists the assistants that would start quoting it, and issues the short-lived token. */ public function pagePublish(): void { $site = get_current_blog_id(); $id = $this->guardPublishScreen(); // also run on load (see menu()), before any output; repeated here so the screen can never render without it $source = $this->plugin->sources()->find($site, $id); AdminView::open($this->plugin, 'aiwend-knowledge', __('Use this knowledge in answers?', 'aiwend'), __('Making a source public lets assistants quote it to anyone who can chat with them.', 'aiwend')); AdminView::notice(); echo '

← ' . esc_html__('Back to the knowledge library', 'aiwend') . '

'; if ($source === null || in_array($source['status'], ['deleting', 'deleted'], true) || !current_user_can(Capabilities::PUBLISH_KNOWLEDGE)) { echo '

' . esc_html__('Nothing to publish', 'aiwend') . '

' . esc_html__('This source was not found, or you do not have permission to publish knowledge.', 'aiwend') . '

'; AdminView::close(); return; } if ($source['visibility'] === 'public') { echo wp_kses( '

' . AdminView::badge(__('Already public', 'aiwend'), 'success') . ' ' . esc_html__('This source is already used in answers.', 'aiwend') . '

', AdminView::allowedHtml()); AdminView::close(); return; } $aff = $this->plugin->sources()->affectedAssistants($site, $id); $ids = array_column($aff['active'], 'id'); $token = \Aiwend\Knowledge\PublishToken::issue(wp_salt('auth'), $site, $id, get_current_user_id(), (int) $source['updated_at'], $ids, $this->plugin->clock->now()); echo '

' . esc_html((string) $source['title']) . '

' . esc_html($this->sourceType($source)) . '

'; if ($aff['active'] === []) { echo '

' . esc_html__('No active public assistant has this source attached yet. Once it is public, any assistant you attach it to and publish will quote it.', 'aiwend') . '

'; } else { echo '

' . esc_html(_n('This active public assistant will start quoting it to visitors:', 'These active public assistants will start quoting it to visitors:', count($aff['active']), 'aiwend')) . '

'; } if ($aff['other'] !== []) { /* translators: %d: number of draft or private assistants */ echo '

' . esc_html(sprintf(_n('%d more assistant has it attached but is a draft or private; it will quote it once it is published.', '%d more assistants have it attached but are drafts or private; they will quote it once published.', count($aff['other']), 'aiwend'), count($aff['other']))) . '

'; } echo '

' . esc_html__('Anyone who can chat with those assistants can read passages of this source in answers. Text already shown in conversations cannot be taken back, but turning the source private again stops new answers from using it.', 'aiwend') . '

'; AdminView::form('publish_source', 'aiwend-knowledge', ['id' => (string) $id, 'confirmation' => $token]); echo ''; echo '
' . esc_html__('Cancel', 'aiwend') . '
'; echo '

' . esc_html__('This confirmation expires in 10 minutes and stops working if the source or the list of assistants changes.', 'aiwend') . '

'; AdminView::close(); } private function reindexSource(): string { $s = $this->plugin->sources()->find(get_current_blog_id(), (int) $this->post('id')); if ($s === null || in_array($s['status'], ['deleting', 'deleted'], true)) { return 'not_found'; } $this->knowledge()->queueIngest($s['id'], 'manual', true); return 'queued'; } private function deleteSource(): string { if ($this->post('confirm') !== '1') { return 'confirm_required'; } $s = $this->plugin->sources()->find(get_current_blog_id(), (int) $this->post('id')); if ($s === null || $s['status'] === 'deleted') { return 'not_found'; } $this->knowledge()->deleteSource($s['id']); return 'deleted'; } // ---------------------------------------------------------------- index generations (site administrator; site = the current site only) /** @param array{ok:bool, code:string} $r */ private function indexResult(array $r): string { if ($r['ok'] && in_array($r['code'], ['started', 'resumed', 'sync_queued'], true)) { $this->plugin->ensureSchedule('index_build'); // jobs run on the existing recurring event; nothing new is scheduled } return IndexPanel::noticeFor($r); } private function indexSaveBudget(): string { $units = $this->post('units'); return $this->indexResult(ctype_digit($units) && strlen($units) <= 10 ? $this->plugin->generationAdmin()->setBudget(get_current_blog_id(), (int) $units) : ['ok' => false, 'code' => 'invalid']); } private function indexSaveModel(): string { return $this->indexResult($this->plugin->generationAdmin()->setModel(get_current_blog_id(), $this->post('model'))); } private function indexStart(): string { return $this->indexResult($this->plugin->generationAdmin()->start(get_current_blog_id(), $this->post('confirm') === '1')); } private function indexPause(): string { return $this->indexResult($this->plugin->generationAdmin()->pause(get_current_blog_id())); } private function indexResume(): string { return $this->indexResult($this->plugin->generationAdmin()->resume(get_current_blog_id())); } private function indexCancel(): string { return $this->indexResult($this->post('confirm') === '1' ? $this->plugin->generationAdmin()->cancel(get_current_blog_id()) : ['ok' => false, 'code' => 'confirm_required']); } private function indexUnblock(): string { return $this->indexResult($this->plugin->generationAdmin()->unblock(get_current_blog_id(), max(0, (int) $this->post('generation')), max(0, (int) $this->post('source')))); } private function indexActivate(): string { return $this->indexResult($this->plugin->generationAdmin()->activate(get_current_blog_id(), max(0, (int) $this->post('generation')))); } private function indexRollback(): string { return $this->indexResult($this->plugin->generationAdmin()->rollback(get_current_blog_id())); } /** The uninstall data policy of this site. Keeping data is the default; permanent deletion needs the typed word DELETE. */ private function saveUninstall(): string { if ($this->post('delete') !== '1') { update_option(\Aiwend\Lifecycle\Uninstaller::OPTION, false, false); return 'uninstall_keep'; } if ($this->post('confirm_text') !== 'DELETE') { return 'uninstall_confirm'; } update_option(\Aiwend\Lifecycle\Uninstaller::OPTION, true, false); return 'uninstall_delete'; } private function indexSync(): string { return $this->indexResult($this->plugin->generationAdmin()->syncNow(get_current_blog_id())); } private function indexAuto(): string { $on = $this->post('enabled') === '1'; return $this->indexResult($this->plugin->generationAdmin()->setAutoSync(get_current_blog_id(), $on, $this->post('confirm') === '1')); } private function indexDeactivate(): string { return $this->indexResult($this->plugin->generationAdmin()->deactivate(get_current_blog_id())); } private function knowledge(): KnowledgeModule { $m = $this->plugin->modules->get('knowledge'); assert($m instanceof KnowledgeModule); return $m; } // ---------------------------------------------------------------- state helpers public function exampleInstruction(): string { return __("Answer briefly and politely, using only the information in the knowledge sources.\nIf the sources do not contain the answer, say that you could not find it. Do not guess.", 'aiwend'); } /** @return array{label:string, tone:string, reason:string} */ /** A queued or processing source older than this is reported as waiting longer than expected. */ public const STALE_AFTER = 600; /** * What the administrator should read about one source. "Ready" is shown only when the current text really is the * latest content: a source that keeps serving an older version after a failed update says so. * * @param array $s * @return array{label:string, tone:string, reason:string} */ public static function sourceState(array $s, ?int $now = null): array { $stale = $now !== null && in_array($s['status'], ['queued', 'processing'], true) && $now - (int) ($s['updated_at'] ?? $now) > self::STALE_AFTER; switch (true) { case (string) $s['status'] === 'ready': return !empty($s['updating']) ? ['label' => __('Ready, updating', 'aiwend'), 'tone' => 'info', 'reason' => __('A new version is being prepared. Answers keep using the current version until it is ready.', 'aiwend')] : ((string) ($s['error'] ?? '') !== '' ? ['label' => __('Ready, update failed', 'aiwend'), 'tone' => 'warn', 'reason' => __('Answers still use the previous version of this source. Try Re-index.', 'aiwend') . ' ' . (string) $s['error']] : ($s['visibility'] === 'public' ? ['label' => __('Ready', 'aiwend'), 'tone' => 'success', 'reason' => __('Used in answers.', 'aiwend')] : ['label' => __('Private', 'aiwend'), 'tone' => 'neutral', 'reason' => __('Indexed and stored, but inactive: private sources are never used in answers until an administrator approves them.', 'aiwend')])); case (string) $s['status'] === 'queued': return ['label' => __('Queued', 'aiwend'), 'tone' => $stale ? 'warn' : 'info', 'reason' => $stale ? __('Waiting longer than expected. Press Process now on the Knowledge screen, or see Diagnostics under Settings: background jobs may not be running.', 'aiwend') : __('Waiting for background processing, usually a minute or so. Not used in answers yet.', 'aiwend')]; case (string) $s['status'] === 'processing': return ['label' => __('Processing', 'aiwend'), 'tone' => $stale ? 'warn' : 'info', 'reason' => $stale ? __('Taking longer than expected. It is retried automatically; see Diagnostics under Settings.', 'aiwend') : __('Being prepared for answers. Not used in answers yet.', 'aiwend')]; case (string) $s['status'] === 'failed': return ['label' => __('Failed', 'aiwend'), 'tone' => 'danger', 'reason' => (string) ($s['error'] ?: __('Processing failed. Try Re-index.', 'aiwend'))]; case (string) $s['status'] === 'ocr_required': return ['label' => __('Not readable', 'aiwend'), 'tone' => 'danger', 'reason' => __('This content needs text recognition (OCR), which is not available.', 'aiwend')]; case (string) $s['status'] === 'deleting': return ['label' => __('Deleting', 'aiwend'), 'tone' => 'warn', 'reason' => __('Removed from answers; its index is being deleted.', 'aiwend')]; default: return ['label' => (string) $s['status'], 'tone' => 'neutral', 'reason' => '']; } } /** * Can an assistant actually answer from these attached sources? Counts only what retrieval would use (ready and * public); everything else is explained so "not found" is never a mystery. * * @param list> $sources * @return array{usable:int, total:int, tone:string, text:string} */ public static function knowledgeReadiness(array $sources): array { $total = count($sources); $usable = $waiting = $failed = $private = 0; foreach ($sources as $s) { $status = (string) $s['status']; if ($status === 'ready' && $s['visibility'] === 'public') { $usable++; } elseif ($status === 'ready') { $private++; } elseif ($status === 'queued' || $status === 'processing') { $waiting++; } else { $failed++; } } if ($total === 0) { return ['usable' => 0, 'total' => 0, 'tone' => 'warn', 'text' => __('No knowledge attached. The assistant cannot answer questions about your content.', 'aiwend')]; } $notes = []; if ($waiting > 0) { /* translators: %d: number of knowledge sources still being processed */ $notes[] = sprintf(_n('%d still being processed', '%d still being processed', $waiting, 'aiwend'), $waiting); } if ($failed > 0) { /* translators: %d: number of knowledge sources that failed or are unreadable */ $notes[] = sprintf(_n('%d failed or unreadable', '%d failed or unreadable', $failed, 'aiwend'), $failed); } if ($private > 0) { /* translators: %d: number of attached knowledge sources that are private */ $notes[] = sprintf(_n('%d private (not used in answers)', '%d private (not used in answers)', $private, 'aiwend'), $private); } $detail = $notes === [] ? '' : ' (' . implode(', ', $notes) . ')'; if ($usable === 0) { return ['usable' => 0, 'total' => $total, 'tone' => 'danger', 'text' => __('None of the attached sources can be used in answers yet', 'aiwend') . $detail . __('. Until one is ready, this assistant answers every question with "not found".', 'aiwend')]; } /* translators: 1: number of attached sources used in answers, 2: total number of attached sources */ return ['usable' => $usable, 'total' => $total, 'tone' => $notes === [] ? 'success' : 'warn', 'text' => sprintf(_n('%1$d of %2$d attached source is used in answers', '%1$d of %2$d attached sources are used in answers', $total, 'aiwend'), $usable, $total) . $detail . '.']; } /** The file picker's accept list: PDF only in an edition that contains PDF extraction. */ private static function uploadAccept(): string { return \Aiwend\Knowledge\PdfSupport::available() ? '.txt,.md,.markdown,.pdf,text/plain,text/markdown,application/pdf' : '.txt,.md,.markdown,text/plain,text/markdown'; } private function sourceType(array $s): string { if ($s['origin'] === 'post') { $type = get_post_type((int) $s['origin_ref']); return $type === 'page' ? __('Page', 'aiwend') : __('Post', 'aiwend'); } $ext = strtolower(pathinfo(explode(':', (string) $s['origin_ref'])[0], PATHINFO_EXTENSION)); if ($ext === 'pdf') { return __('PDF file', 'aiwend'); } // Sources added by an edition that has PDF keep their label here; this edition just cannot add new ones. return in_array($ext, ['md', 'markdown'], true) ? __('Markdown file', 'aiwend') : __('Text file', 'aiwend'); } /** @return array>> source id => bots using it */ private function usedBy(int $site): array { $names = []; foreach ($this->plugin->bots->all($site) as $b) { $names[(int) $b['id']] = $b; } $out = []; foreach ($this->plugin->db->select('SELECT bot_id, source_id FROM {bot_sources} WHERE site_id = ?', [$site]) as $r) { if (isset($names[(int) $r['bot_id']])) { $out[(int) $r['source_id']][] = $names[(int) $r['bot_id']]; } } return $out; } /** @return list> live sources attached to an assistant */ public function attached(int $site, int $botId): array { $ids = array_map(static fn($r) => (int) $r['source_id'], $this->plugin->db->select('SELECT source_id FROM {bot_sources} WHERE site_id = ? AND bot_id = ?', [$site, $botId])); return array_values(array_filter($this->plugin->sources()->list($site), static fn($s) => in_array((int) $s['id'], $ids, true))); } private function readyCount(array $sources): int { return count(array_filter($sources, static fn($s) => $s['status'] === 'ready' && $s['visibility'] === 'public')); } public function tested(int $botId): bool { return isset(((array) get_option(ChatController::PREVIEW_OK_OPTION, []))[$botId]); } /** Is an Active, Public assistant placed on a published page or post? */ public function onSite(array $bot): bool { if ($bot['status'] !== 'active' || $bot['is_public'] !== true) { return false; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned custom table; WordPress has no API for it; diagnostic read that must be current $rows = $wpdb->get_col("SELECT post_content FROM {$wpdb->posts} WHERE post_status = 'publish' AND (post_content LIKE '%aiwend_chat%' OR post_content LIKE '%wp:aiwend/chat%') LIMIT 200"); $id = (int) $bot['id']; foreach ($rows as $content) { if (preg_match('/\[aiwend_chat[^\]]*bot=["\']?' . $id . '(?!\d)/', (string) $content) || preg_match('/wp:aiwend\/chat\s+\{[^}]*"botId":\s*' . $id . '(?!\d)/', (string) $content)) { return true; } } return false; } private function lifecycle(array $bot): string { switch (true) { case $bot['status'] === 'active': return AdminView::badge(__('Active', 'aiwend'), 'success'); case $bot['status'] === 'paused': return AdminView::badge(__('Paused', 'aiwend'), 'warn'); case $bot['status'] === BotRepository::DELETING: return AdminView::badge(__('Being deleted', 'aiwend'), 'warn'); default: return AdminView::badge(__('Draft', 'aiwend'), 'neutral'); } } private function visibility(array $bot): string { return $bot['is_public'] ? AdminView::badge(__('Public', 'aiwend'), 'brand') : AdminView::badge(__('Private', 'aiwend'), 'neutral'); } /** Why answers would currently fail for this assistant: list of [text, action url, action label]. */ public function blockers(array $bot): array { $site = get_current_blog_id(); $out = []; if (!$this->plugin->modules->isEnabled('chat')) { $out[] = [__('The Chat module is off.', 'aiwend'), AdminView::url('aiwend-settings'), __('Open settings', 'aiwend')]; } $resolved = $this->plugin->ai()->resolve($bot); if (!$resolved['ready']) { $out[] = [AiSetup::problemText($resolved['problem']), AdminView::url('aiwend-bots', ['assistant' => (int) $bot['id'], 'tab' => 'ai']), __('Review AI & model', 'aiwend')]; } if ($resolved['ready'] && $this->plugin->publicChatRefusal($bot) !== null) { // 0.9.0: not a fault; the safe default for xAI. Private preview and every administrator workflow still work. $out[] = [__('Visitors cannot chat with an assistant that uses xAI: this is off by default because Aiwend cannot check xAI Zero Data Retention or what visitors type. Private preview still works. The site owner can switch it on only in wp-config.php after reviewing the xAI terms that apply to their account.', 'aiwend'), AdminView::url('aiwend-connections'), __('Read the xAI note', 'aiwend')]; } $balance = $this->plugin->ledger->balance($site, 'site'); if ($balance === null || $balance['limit'] <= 0) { $out[] = [__('No site usage limit is set.', 'aiwend'), AdminView::url('aiwend-usage'), __('Set a limit', 'aiwend')]; } elseif ($balance['available'] <= 0) { $out[] = [__('The site usage limit is used up.', 'aiwend'), AdminView::url('aiwend-usage'), __('Review limits', 'aiwend')]; } if ($this->plugin->ledger->isHalted()) { $out[] = [__('New requests are paused.', 'aiwend'), AdminView::url('aiwend-usage'), __('Review limits', 'aiwend')]; } // A conversation limit lower than one answer of this assistant can reserve: every new conversation fails on its first message. $conversation = (int) $this->plugin->options->get(ConversationBudget::OPTION, ConversationBudget::DEFAULT_UNITS); $standing = ConversationBudget::standing($conversation, [(int) ($bot['max_output_tokens'] ?? ConversationBudget::DEFAULT_MAX_ANSWER)]); if ($standing === ConversationBudget::REFUSED || ($standing === ConversationBudget::TOO_LOW_FOR_PROVIDER && ($resolved['mode'] ?? '') !== 'demo')) { $out[] = [__('The limit per conversation is lower than one answer of this assistant can reserve, so new conversations cannot be answered.', 'aiwend'), AdminView::url('aiwend-usage'), __('Review limits', 'aiwend')]; } $sources = $this->attached($site, (int) $bot['id']); if ($sources !== [] && !$this->plugin->modules->isEnabled('knowledge')) { $out[] = [__('This assistant uses knowledge, and the Knowledge module is off.', 'aiwend'), AdminView::url('aiwend-settings'), __('Open settings', 'aiwend')]; } elseif ($sources !== []) { $ready = self::knowledgeReadiness($sources); if ($ready['usable'] === 0) { $out[] = [$ready['text'], AdminView::url('aiwend-bots', ['assistant' => (int) $bot['id'], 'tab' => 'knowledge']), __('Review knowledge', 'aiwend')]; } } return $out; } // ---------------------------------------------------------------- Overview public function pageOverview(): void { $site = get_current_blog_id(); $sources = $this->plugin->sources()->list($site); $wizard = new SetupWizard($this->plugin, $this); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation parameter; no state is changed $bot = $wizard->focus(isset($_GET['assistant']) ? (int) $_GET['assistant'] : 0); AdminView::open($this->plugin, 'aiwend', $bot === null ? __('Build your first assistant', 'aiwend') : __('Overview', 'aiwend'), __('Answer visitors\' questions from content you choose.', 'aiwend')); AdminView::notice(); $this->budgetAlertsSection(); (new UsageReports($this->plugin))->warnings(); // Phase 117: the guided setup comes first, so a new administrator meets it before the technical status. It is derived from real // state and keeps none of its own. $wizard->render(); $this->aiStatusSection(); (new HealthPanel($this->plugin))->overviewSummary(); echo '
'; echo '

' . esc_html__('Knowledge', 'aiwend') . '

'; $ready = count(array_filter($sources, static fn($s) => $s['status'] === 'ready')); echo '

' . esc_html(sprintf(/* translators: 1: sources total, 2: ready */ _n('%1$d source in the library, %2$d ready.', '%1$d sources in the library, %2$d ready.', count($sources), 'aiwend'), count($sources), $ready)) . '

'; echo '

' . esc_html__('Open knowledge library', 'aiwend') . '

'; if ($sources === [] && $this->can('knowledge.manage') && $this->plugin->modules->isEnabled('knowledge')) { AdminView::form('add_sample_knowledge', 'aiwend', $bot ? ['assistant' => (string) $bot['id']] : []); echo '

' . esc_html__('No content yet? Add a short synthetic FAQ (returns, delivery, support hours) to try things out. Nothing is published and no assistant is made public.', 'aiwend') . '

'; } echo '
'; $this->readinessSection(); $this->moduleOverview($sources); AdminView::close(); } /** * Overview module list, built from the real registry and settings (Phase 130). A module is shown as Off, On, or unavailable (a missing * prerequisite); "configured" means the module has what it needs on this site; "ready" is claimed only where Aiwend can check it (an assistant * that was tested privately). Nothing here describes a feature that does not exist, and nothing is claimed as tested by Aiwend that is not. * * @param list> $sources */ /** @return list resolved mode of every assistant of this site */ private function assistantModes(): array { return array_map(fn(array $b): string => (string) $this->plugin->ai()->resolve($b)['mode'], $this->plugin->bots->all(get_current_blog_id())); } /** True when an enabled connection of a real provider (not the simulated type) exists on this site. */ private function hasRealConnection(): bool { return DemoNotice::hasRealConnection($this->plugin); } private function moduleOverview(array $sources): void { $site = get_current_blog_id(); $bots = $this->plugin->bots->all($site); $testedBots = count(array_filter($bots, fn(array $b): bool => $this->tested((int) $b['id']))); $forms = count(array_filter(AiFormsConfig::forms(), static fn(array $f): bool => !empty($f['active']))); $tools = count(McpConfig::enabled(McpConfig::catalog($this->plugin))); $info = [ 'chat' => [count($bots) > 0, sprintf(/* translators: %d: number of assistants */ _n('%d assistant.', '%d assistants.', count($bots), 'aiwend'), count($bots)), 'aiwend-bots', Capabilities::MANAGE_BOTS, $testedBots > 0], 'knowledge' => [$sources !== [], sprintf(/* translators: %d: number of knowledge sources */ _n('%d source in the library.', '%d sources in the library.', count($sources), 'aiwend'), count($sources)), 'aiwend-knowledge', Capabilities::MANAGE_KNOWLEDGE, false], 'content_studio' => [null, __('Writes Post and Page drafts when someone asks. Needs an AI connection.', 'aiwend'), 'aiwend-content-studio', Capabilities::USE_CONTENT_STUDIO, false], 'woocommerce_ai' => [null, __('Proposes product text for simple and variable products and changes a product only after you confirm. Needs WooCommerce and an AI connection.', 'aiwend'), 'aiwend-woocommerce-ai', Capabilities::USE_WOOCOMMERCE_AI, false], 'ai_forms' => [$forms > 0, sprintf(/* translators: %d: number of active forms */ _n('%d active form. Messages stay on this site; AI analysis only when you press Analyze.', '%d active forms. Messages stay on this site; AI analysis only when you press Analyze.', $forms, 'aiwend'), $forms), 'aiwend-forms', Capabilities::MANAGE_FORMS, false], 'mcp' => [$tools > 0, sprintf(/* translators: %d: number of tools offered to connected apps */ _n('%d tool offered to connected apps (read-only).', '%d tools offered to connected apps (read-only).', $tools, 'aiwend'), $tools), 'aiwend-mcp', Capabilities::MANAGE, false], ]; echo '

' . esc_html__('Modules', 'aiwend') . '

'; echo '

' . esc_html__('On means the module is switched on. Configured means it has what it needs on this site. Ready is shown only where Aiwend can check it: an assistant you have tested privately. Aiwend never marks answer quality as tested.', 'aiwend') . '

    '; foreach ($this->plugin->modules->all() as $id => $m) { if (!isset($info[$id])) { continue; } [$configured, $facts, $slug, $cap, $ready] = $info[$id]; $unavailable = !$m->prerequisitesMet(); $on = $this->plugin->modules->isEnabled($id); $badges = $unavailable ? AdminView::badge(__('Unavailable', 'aiwend'), 'warn') : ($on ? AdminView::badge(__('On', 'aiwend'), 'success') : AdminView::badge(__('Off', 'aiwend'), 'neutral')); if ($on && $configured !== null) { $badges .= ' ' . ($configured ? AdminView::badge(__('Configured', 'aiwend'), 'info') : AdminView::badge(__('Not configured yet', 'aiwend'), 'warn')); } if ($on && $ready) { $badges .= ' ' . AdminView::badge(__('Ready: tested privately', 'aiwend'), 'success'); } $detail = $unavailable ? $this->unavailableText($m) : ($on ? $facts : __('Installed, turned off. Nothing of this module runs.', 'aiwend')); echo wp_kses( '
  • ' . esc_html($m->label()) . ' ' . $badges . '
    ' . esc_html($detail) . '
    ', AdminView::allowedHtml()); if ($on && current_user_can($cap)) { echo '' . esc_html__('Open', 'aiwend') . ' ' . esc_html($m->label()) . ''; } echo '
  • '; } echo '

' . esc_html__('Meaning-based search and human handoff are not available.', 'aiwend') . '

'; } /** * Overview AI status: truthful connection state. A saved key is never shown as ready before * an access check, and a check is not presented as proof of ongoing availability or balance. */ /** * Budget exhaustion notices for administrators (Phase 89): site limit reached, visitors who reached their daily limit, and * traffic that cannot be told apart. Shown only to people who can view usage; counts only, never an address. */ private function budgetAlertsSection(): void { if (!$this->can('usage.view')) { return; } $this->breakerNotices(); $alerts = $this->plugin->alerts->current(); if ($alerts === []) { return; } $when = static fn(int $t): string => wp_date(get_option('date_format') . ' ' . get_option('time_format'), $t); $usage = AdminView::url('aiwend-usage'); $items = []; if (isset($alerts['site'])) { /* translators: %s: date and time of the latest refused message */ $items[] = ['error', __('The site usage limit is used up.', 'aiwend'), sprintf(__('Assistants have been refusing questions from every visitor (latest: %s). Raise the site limit on the Usage page, or wait until you are ready to allow more use.', 'aiwend'), $when($alerts['site']['last']))]; } if (isset($alerts['client'])) { /* translators: %d: number of visitors (at most 100 are counted) */ $items[] = ['warning', __('Some visitors reached their daily limit.', 'aiwend'), sprintf(_n('%d visitor was stopped today by the daily limit per visitor. Everyone else is unaffected. This is the protection working; raise the limit only if real visitors need more.', '%d visitors were stopped today by the daily limit per visitor. Everyone else is unaffected. This is the protection working; raise the limit only if real visitors need more.', max(1, $alerts['client']['clients']), 'aiwend'), max(1, $alerts['client']['clients']))]; } if (isset($alerts['shared'])) { $items[] = ['warning', __('Visitors seem to share one address.', 'aiwend'), __('Requests arrived with forwarding headers from a server that is not listed as a trusted proxy, so visitors cannot be told apart and they share one combined daily allowance. If your site is behind a proxy or CDN, ask your developer to declare it with the aiwend_trusted_proxies filter (see the operations notes).', 'aiwend')]; } if (isset($alerts['unknown'])) { $items[] = ['warning', __('Some visitors could not be identified.', 'aiwend'), __('The server did not report an address for some requests, so they share one combined daily allowance. Other visitors are unaffected.', 'aiwend')]; } foreach ($items as [$type, $title, $text]) { printf( '

%3$s %4$s %6$s

', esc_attr($type), $type === 'error' ? 'alert' : 'status', esc_html($title), esc_html($text), esc_url($usage), esc_html__('Review limits', 'aiwend') ); } } /** * Provider circuit breaker (Phase 91a hardening): a connection whose requests keep ending with an unknown outcome is paused for a short * cooldown. Nothing held is released and nothing is deleted; the notice says so and points to the reconciliation list. */ private function breakerNotices(): void { $repo = $this->plugin->connections(); $site = get_current_blog_id(); foreach ((new \Aiwend\Usage\ProviderBreaker($this->plugin->db, $this->plugin->clock))->tripped($site) as $t) { $connection = ($nullsafeVariable3 = $repo) ? $nullsafeVariable3->find($site, $t['connection_id']) : null; $label = $connection !== null ? (string) $connection['label'] : '#' . $t['connection_id']; $when = wp_date(get_option('time_format'), $t['resumes_at']); if ($t['open']) { /* translators: 1: number of recent requests with an unknown outcome, 2: time of day when requests are tried again */ $text = __('%1$d recent requests ended with an unknown outcome (timeouts, dropped connections or provider errors), so visitors are told the assistant is temporarily unavailable instead of using up your budget. Requests are tried again after %2$s. Nothing was released or deleted.', 'aiwend'); } else { /* translators: 1: number of recent requests with an unknown outcome, 2: time of day when the pause ended */ $text = __('%1$d recent requests ended with an unknown outcome. The pause is over (%2$s); one request at a time is being tried. If it also fails the pause starts again. Nothing was released or deleted.', 'aiwend'); } printf( '

%1$s %2$s %4$s

', esc_html(sprintf( /* translators: %s: name of the AI connection */ __('The AI connection "%s" is paused for a few minutes.', 'aiwend'), $label )), esc_html(sprintf($text, $t['failures'], $when)), esc_url(AdminView::url('aiwend-usage')), esc_html__('Review requests that need reconciliation', 'aiwend') ); } } private function aiStatusSection(): void { $site = get_current_blog_id(); $ai = $this->plugin->ai(); $repo = $this->plugin->connections(); $connections = (($nullsafeVariable4 = $repo) ? $nullsafeVariable4->list($site) : null) ?? []; $ai->primeConnections($connections); echo '

' . esc_html__('AI status', 'aiwend') . '

'; if ($connections === []) { echo '

' . esc_html__('Connect your AI service', 'aiwend') . '

'; echo '

' . esc_html__('Aiwend uses an API key from the AI provider you choose (OpenAI, Anthropic, Google Gemini, DeepSeek, xAI, OpenRouter or GroqCloud). Answers and their cost go through your own provider account.', 'aiwend') . '

'; echo ''; if ($this->plugin->mockProviderEnabled()) { echo wp_kses( '

' . AdminView::badge(__('Simulated demo is on', 'aiwend'), 'warn') . ' ' . esc_html__('Assistants set to the simulated demo answer with a built-in stand-in: they contact no AI service and cost nothing.', 'aiwend') . ' ' . esc_html(DemoNotice::scope(false)) . '

', AdminView::allowedHtml()); } elseif ($this->plugin->demoAllowedHere() && $this->can('settings.manage')) { AdminView::form('save_demo', 'aiwend', ['enabled' => '1']); echo '

' . esc_html__('Local sites only. A built-in stand-in assembles answers from your knowledge; it is not an AI model.', 'aiwend') . '

'; } echo '
'; return; } $default = $ai->defaultAi(); echo '
'; foreach ($connections as $c) { $meta = $ai->meta((int) $c['id']); switch (true) { case $c['status'] === 'verified': /* translators: %s: time span since the access check, for example "5 minutes" */ $status = AdminView::badge(sprintf(__('Access checked %s ago', 'aiwend'), $meta['checked_at'] ? human_time_diff((int) $meta['checked_at']) : '?'), 'success'); break; case $c['status'] === 'failed': $status = AdminView::badge(__('Access check failed', 'aiwend'), 'danger'); break; case $c['status'] === 'disabled': $status = AdminView::badge(__('Disabled', 'aiwend'), 'neutral'); break; default: $status = AdminView::badge(__('Saved, not checked', 'aiwend'), 'warn'); break; } echo wp_kses(sprintf('', esc_url(AdminView::url('aiwend-connections', ['view' => 'connection', 'id' => (int) $c['id']])), esc_html($c['label']), esc_html(\Aiwend\Provider\Providers::name($c['provider'])), $default !== null && $default['connection_id'] === (int) $c['id'] ? ' ' . AdminView::badge(__('Site default', 'aiwend'), 'brand') : '', $status, $meta['default_model'] !== null ? '' . esc_html($meta['default_model']) . '' : esc_html__('Not chosen', 'aiwend'), count($ai->dependents((int) $c['id']))), AdminView::allowedHtml()); } echo '
' . esc_html__('Connection', 'aiwend') . '' . esc_html__('Status', 'aiwend') . '' . esc_html__('Connection default model', 'aiwend') . '' . esc_html__('Assistants', 'aiwend') . '
%2$s %3$s%4$s%5$s%6$s%7$d
'; $problems = []; foreach ($this->plugin->bots->all($site) as $b) { $r = $ai->resolve($b); if (!$r['ready']) { $problems[] = '
  • ' . esc_html((string) $b['name']) . ': ' . esc_html(AiSetup::problemText($r['problem'])) . '
  • '; } } if ($problems !== []) { echo wp_kses( '

    ' . esc_html__('Needs attention', 'aiwend') . '

      ' . implode('', $problems) . '
    ', AdminView::allowedHtml()); } echo '

    ' . esc_html__('"Access checked" means the provider accepted the key and listed its models when it was checked. No answer has been generated yet, and it does not prove ongoing availability, provider balance or limits; the first real answer comes from your private test.', 'aiwend') . '

    '; echo ''; } private function readinessSection(): void { $site = get_current_blog_id(); $db = $this->plugin->databaseCompatibility(); $balance = $this->plugin->ledger->balance($site, 'site'); $last = (int) get_option(CronDiagnostics::LAST_RUN_OPTION, 0); $rows = [ [true, __('PHP', 'aiwend'), PHP_VERSION, ''], [!Compatibility::needsAttention($db['status']), __('Database', 'aiwend'), $db['engine'] . ' ' . $db['version'], Compatibility::needsAttention($db['status']) ? __('Older than the minimum Aiwend supports; see Diagnostics.', 'aiwend') : ''], [$this->plugin->modules->isEnabled('chat') && $this->plugin->modules->isEnabled('knowledge'), __('Modules', 'aiwend'), $this->plugin->modules->isEnabled('chat') && $this->plugin->modules->isEnabled('knowledge') ? __('Chat and Knowledge are on', 'aiwend') : __('Chat or Knowledge is off', 'aiwend'), ''], [$balance !== null && $balance['limit'] > 0, __('Usage limit', 'aiwend'), $balance !== null && $balance['limit'] > 0 ? number_format_i18n((int) $balance['limit']) . ' ' . __('units', 'aiwend') : __('Not set — assistants cannot answer', 'aiwend'), ''], /* translators: %s: time span since the last background run, for example "5 minutes" */ [CronDiagnostics::count() === 1 && $last > 0, __('Background jobs', 'aiwend'), (CronDiagnostics::count() === 1 ? __('Scheduled', 'aiwend') : __('Not scheduled', 'aiwend')) . ' · ' . ($last ? sprintf(__('last ran %s ago', 'aiwend'), human_time_diff($last)) : __('has not run yet', 'aiwend')), ''], [$this->plugin->connections() !== null, __('Encryption key', 'aiwend'), $this->plugin->connections() !== null ? __('Configured', 'aiwend') : __('Not configured', 'aiwend'), __('Needed only to store provider keys later. Not needed for the simulated demo.', 'aiwend')], ]; $problems = count(array_filter($rows, static fn($r) => !$r[0])); /* translators: %d: number of items that need review */ echo wp_kses( '
    0 ? '' : '') . '>' . esc_html__('System readiness', 'aiwend') . ' ' . ($problems === 0 ? AdminView::badge(__('All good', 'aiwend'), 'success') : AdminView::badge(sprintf(_n('%d item to review', '%d items to review', $problems, 'aiwend'), $problems), 'warn')) . '', AdminView::allowedHtml()); foreach ($rows as [$ok, $label, $value, $note]) { echo wp_kses(sprintf('', esc_html($label), $ok ? AdminView::badge(__('OK', 'aiwend'), 'success') : AdminView::badge(__('Review', 'aiwend'), 'warn'), esc_html($value), $note !== '' ? '
    ' . esc_html($note) . '' : ''), AdminView::allowedHtml()); } echo '
    %s%s %s%s

    ' . esc_html__('Open diagnostics', 'aiwend') . '

    '; } // ---------------------------------------------------------------- Assistants public function pageAssistants(): void { $site = get_current_blog_id(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation or filter parameter; no state is changed $view = isset($_GET['view']) ? sanitize_key(Input::get('view')) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation or filter parameter; no state is changed $id = isset($_GET['assistant']) ? (int) $_GET['assistant'] : 0; if ($view === 'new') { $this->assistantNew(); return; } if ($view === 'delete' && $id > 0 && $this->assistantDelete($site, $id)) { return; } if ($id > 0) { $bot = $this->plugin->bots->find($site, $id); if ($bot !== null) { $this->assistantEditor($bot); return; } } $bots = $this->plugin->bots->all($site); AdminView::open($this->plugin, 'aiwend-bots', __('Assistants', 'aiwend'), __('Each assistant answers from the knowledge you attach to it.', 'aiwend'), $bots === [] ? '' : '' . esc_html__('New assistant', 'aiwend') . ''); AdminView::notice(); $this->deletingCard($site); MigrationAdmin::entry(); if ($bots === []) { echo '

    ' . esc_html__('Create your first assistant', 'aiwend') . '

    ' . esc_html__('It starts as a private draft: only you can test it until you choose to publish it.', 'aiwend') . '

    ' . esc_html__('Create your first assistant', 'aiwend') . '

    '; AdminView::close(); return; } echo '
    '; foreach ($bots as $b) { $src = $this->attached($site, (int) $b['id']); $ready = $this->readyCount($src); $knowledge = $src === [] ? AdminView::badge(__('No knowledge', 'aiwend'), 'warn') /* translators: 1: number of knowledge sources ready, 2: number of sources attached to the assistant */ : AdminView::badge(sprintf(_n('%d source ready', '%d sources ready', $ready, 'aiwend'), $ready), $ready > 0 ? 'success' : 'warn') . ($ready < count($src) ? ' ' . esc_html(sprintf(__('of %d attached', 'aiwend'), count($src))) . '' : ''); echo wp_kses(sprintf('', esc_url(AdminView::url('aiwend-bots', ['assistant' => $b['id']])), esc_html((string) $b['name']), $this->lifecycle($b), $this->visibility($b), $knowledge, esc_html__('Edit', 'aiwend'), esc_url(AdminView::url('aiwend-bots', ['assistant' => $b['id'], 'tab' => 'publish'])), esc_html__('Test', 'aiwend'), esc_html((string) $b['name'])), AdminView::allowedHtml()); } echo '
    ' . esc_html__('Assistants', 'aiwend') . '
    ' . esc_html__('Assistant', 'aiwend') . '' . esc_html__('Status', 'aiwend') . '' . esc_html__('Knowledge', 'aiwend') . '' . esc_html__('Actions', 'aiwend') . '
    %2$s%3$s %4$s%5$s%6$s %9$s %8$s %9$s
    '; AdminView::close(); } private function assistantNew(): void { AdminView::open($this->plugin, 'aiwend-bots', __('New assistant', 'aiwend'), __('Start with a name and how it should respond. You add knowledge next.', 'aiwend')); AdminView::notice(); echo '

    ← ' . esc_html__('Back to assistants', 'aiwend') . '

    '; echo '
    '; AdminView::form('create_bot', 'aiwend-bots', ['status' => 'draft']); echo '

    '; echo '

    ' . esc_html__('Describe how the assistant should respond. Add facts and documents under Knowledge.', 'aiwend') . '

    '; echo '

    ' . esc_html__('New assistants start as Draft and Private: nobody else can see them until you publish.', 'aiwend') . '

    '; echo '

    '; AdminView::close(); } /** Assistants whose deletion started but whose conversations are not all erased yet (shown to administrators, with a way to continue). */ private function deletingCard(int $site): void { $rows = $this->can('bots.delete') ? array_slice($this->plugin->bots->deleting($site), 0, 20) : []; if ($rows === []) { return; } echo '

    ' . esc_html__('Being deleted', 'aiwend') . '

    ' . esc_html__('These assistants are already out of service. Their conversations are being erased in the background. Nothing is finished until the count reaches zero.', 'aiwend') . '

      '; foreach ($rows as $b) { $left = (int) ($this->plugin->assistantDeletion()->summary($site, (int) $b['id'])['sessions'] ?? 0); echo '
    • ' . esc_html(sprintf( /* translators: 1: the number of the assistant being deleted, 2: number of conversations still to be erased */ _n('Assistant #%1$d: %2$d conversation left', 'Assistant #%1$d: %2$d conversations left', $left, 'aiwend'), (int) $b['id'], $left )) . ' · ' . esc_html__('Progress and Continue', 'aiwend') . '
    • '; } echo '
    '; } /** * The delete screen of one assistant (administrators only): what goes, what stays, a required choice for the conversations and a typed * confirmation. For an assistant whose deletion is already running it shows the progress and a Continue button. Returns false when the * assistant does not exist (the caller then shows the list). */ private function assistantDelete(int $site, int $id): bool { $bot = $this->plugin->bots->findAny($site, $id); if ($bot === null || $bot['status'] === BotRepository::DELETED) { return false; } $days = $this->plugin->retentionDays('aiwend_chat_retention_days'); $summary = $this->plugin->assistantDeletion()->summary($site, $id, $days) ?? ['sessions' => 0, 'messages' => 0, 'sources' => 0, 'past_retention' => 0, 'days_left' => 0]; $running = $bot['status'] === BotRepository::DELETING; $name = $running ? sprintf(/* translators: %d: assistant number */ __('Assistant #%d', 'aiwend'), $id) : (string) $bot['name']; AdminView::open($this->plugin, 'aiwend-bots', sprintf(/* translators: %s: assistant name */ __('Delete “%s”', 'aiwend'), $name), ''); AdminView::notice(); echo '

    ← ' . esc_html__('Back', 'aiwend') . '

    '; if (!$this->can('bots.delete')) { echo '

    ' . esc_html__('Only a site administrator can delete an assistant.', 'aiwend') . '

    '; AdminView::close(); return true; } echo '
    '; if ($running) { echo '

    ' . esc_html(sprintf( /* translators: %d: number of conversations still to be erased */ _n('This assistant is out of service. %d conversation is still to be erased.', 'This assistant is out of service. %d conversations are still to be erased.', $summary['sessions'], 'aiwend'), $summary['sessions'] )) . '

    ' . esc_html__('The deletion is finished only when no conversation is left. Erasing continues in the background (WordPress cron must be running); you can also continue now. Repeating this is safe.', 'aiwend') . '

    '; if (!$this->plugin->modules->isEnabled('chat')) { echo '

    ' . esc_html__('The Chat module is off, so the background erasure is paused. Turn Chat on (Aiwend > Settings), or press Continue erasing until the count reaches zero.', 'aiwend') . '

    '; } AdminView::form('delete_bot', 'aiwend-bots', ['assistant' => $id]); echo '

    '; AdminView::close(); return true; } echo '

    ' . esc_html__('This cannot be undone.', 'aiwend') . ' ' . esc_html__('The assistant stops answering at once; its name, instructions, appearance and links to knowledge are removed.', 'aiwend') . '

    '; echo '

    ' . esc_html__('What stays', 'aiwend') . '

      '; echo '
    • ' . esc_html($summary['sources'] > 0 ? sprintf( /* translators: %d: number of knowledge sources linked to the assistant */ _n('%d knowledge source is unlinked from this assistant. It stays in your Knowledge library and other assistants keep using it.', '%d knowledge sources are unlinked from this assistant. They stay in your Knowledge library and other assistants keep using them.', $summary['sources'], 'aiwend'), $summary['sources'] ) : __('Your knowledge sources stay in your Knowledge library; this assistant has none linked.', 'aiwend')) . '
    • '; echo '
    • ' . esc_html__('Your AI connections and keys are not touched.', 'aiwend') . '
    • '; echo '
    • ' . esc_html__('Usage history stays; on the Usage page this assistant is shown as “Deleted assistant”.', 'aiwend') . '
    • '; echo '
    • ' . esc_html__('Pages or posts that show this assistant stay as they are, and show nothing to visitors.', 'aiwend') . '
    '; AdminView::form('delete_bot', 'aiwend-bots', ['assistant' => $id]); if ($summary['sessions'] === 0) { // Nothing to choose: there are no conversations (a late one is kept, never silently erased). echo '

    ' . esc_html__('This assistant has no conversations.', 'aiwend') . '

    '; } else { echo '
    ' . esc_html(sprintf( /* translators: 1: number of conversations, 2: number of messages */ _n('What should happen to its %1$d conversation (%2$d messages)?', 'What should happen to its %1$d conversations (%2$d messages)?', $summary['sessions'], 'aiwend'), $summary['sessions'], $summary['messages'] )) . ''; echo '


    ' . esc_html__('The messages, feedback and shown sources of these conversations are erased from this site now. This cannot be undone. Export any you need first under Conversations.', 'aiwend') . '

    '; echo '


    ' . esc_html(sprintf( /* translators: 1: retention period in days (used twice) */ __('Visitors can no longer continue them. You can still read, export or delete them under Conversations, where they appear as “Deleted assistant”. They are then removed automatically %1$d days after each conversation started (the retention period under Settings > Privacy), not %1$d days after today.', 'aiwend'), $days )) . ' '; if ($summary['past_retention'] > 0) { echo esc_html(sprintf( /* translators: %d: number of conversations older than the retention period */ _n('%d of them is already past that period and is removed within about a day.', '%d of them are already past that period and are removed within about a day.', $summary['past_retention'], 'aiwend'), $summary['past_retention'] )) . ' '; } echo esc_html(sprintf( /* translators: %d: days until the newest kept conversation is removed */ _n('The last one is removed in about %d day.', 'The last one is removed in about %d days.', max(1, $summary['days_left']), 'aiwend'), max(1, $summary['days_left']) )) . ' ' . esc_html__('The automatic removal runs once a day through WordPress cron.', 'aiwend') . '

    '; } echo '

    ' . esc_html__('Capital letters, nothing else.', 'aiwend') . '

    '; echo '

    '; AdminView::close(); return true; } private function assistantEditor(array $bot): void { $site = get_current_blog_id(); $id = (int) $bot['id']; $tabs = ['instructions' => __('Instructions', 'aiwend'), 'knowledge' => __('Knowledge', 'aiwend'), 'ai' => __('AI & model', 'aiwend'), 'appearance' => __('Appearance', 'aiwend'), 'publish' => __('Test & publish', 'aiwend')]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation or filter parameter; no state is changed $tab = isset($_GET['tab']) ? sanitize_key(Input::get('tab')) : 'instructions'; $tab = isset($tabs[$tab]) ? $tab : 'instructions'; $hasForm = in_array($tab, ['instructions', 'ai', 'appearance', 'publish'], true); $actions = '' . ($hasForm ? '' : ''); AdminView::open($this->plugin, 'aiwend-bots', (string) $bot['name'], '', $actions); echo wp_kses( '

    ← ' . esc_html__('Back to assistants', 'aiwend') . ' ' . $this->lifecycle($bot) . ' ' . $this->visibility($bot) . '

    ', AdminView::allowedHtml()); AdminView::notice(); echo ''; // Configuration dependencies come first, above the form, so they are seen on phones where the preview sits below the fold. $blockers = $this->blockers($bot); if ($blockers !== []) { echo '

    ' . esc_html__('Answers will fail until this is fixed:', 'aiwend') . '

      '; foreach ($blockers as [$text, $url, $label]) { echo '
    • ' . esc_html($text) . ($url !== '' ? ' ' . esc_html($label) . '' : '') . '
    • '; } echo '
    '; } echo '
    '; switch (true) { case $tab === 'knowledge': $this->tabKnowledge($bot); break; case $tab === 'ai': $this->tabAi($bot); break; case $tab === 'appearance': $this->tabAppearance($bot); break; case $tab === 'publish': $this->tabPublish($bot); break; default: $this->tabInstructions($bot); break; } echo '
    '; if ($this->can('bots.delete')) { echo '

    ' . esc_html__('Delete this assistant', 'aiwend') . '

    ' . esc_html__('Removes the assistant and, if you choose, its conversations. You confirm on the next screen.', 'aiwend') . '

    ' . esc_html__('Delete assistant…', 'aiwend') . '

    '; } AdminView::close(); } /** AI & model: site default (inherit), an explicit connection/model, or the simulated demo. */ private function saveAi(): string { $site = get_current_blog_id(); $botId = (int) $this->post('assistant'); $bot = $this->plugin->bots->find($site, $botId); if ($bot === null) { return 'not_found'; } $ai = $this->plugin->ai(); $current = $ai->resolve($bot); // Real-AI choices are disabled (so not submitted) for users without site management: // an absent choice keeps the current mode, and only the answer length changes. $mode = $this->post('ai_mode', $current['mode']); $unchanged = $mode === $current['mode'] && ($mode !== 'explicit' || ((int) $this->post('ai_connection', (string) $bot['connection_id']) === (int) $bot['connection_id'] && $this->post('ai_model', (string) $bot['model']) === (string) $bot['model'])); // Sending data to a provider is a site-level decision. if (in_array($mode, ['inherit', 'explicit'], true) && !$unchanged && !current_user_can(Capabilities::MANAGE)) { return 'invalid'; } try { if ($unchanged) { // Nothing to change about the AI source. } elseif ($mode === 'inherit') { $changing = $current['mode'] !== 'inherit'; if ($changing && $ai->defaultAi() !== null && $this->post('authorize_live') !== '1') { return 'confirm_required'; } $this->plugin->bots->setInherit($site, $botId); } elseif ($mode === 'explicit') { $connectionId = (int) $this->post('ai_connection'); $model = $this->post('ai_model'); if (!$ai->modelKnown($connectionId, $model) || $ai->chatModels($connectionId) === []) { return 'invalid'; } $changing = $current['mode'] !== 'explicit' || (int) $bot['connection_id'] !== $connectionId; if ($changing && $this->post('authorize_live') !== '1') { return 'confirm_required'; } $this->plugin->bots->setProvider($site, $botId, $connectionId, $model); } elseif ($mode === 'demo') { $this->plugin->bots->setDemo($site, $botId); } else { return 'invalid'; } $this->plugin->bots->update($site, $botId, ['max_output_tokens' => (int) $this->post('max_output_tokens', (string) $bot['max_output_tokens'])]); } catch (\InvalidArgumentException $exception) { return 'invalid'; } // A longer maximum answer raises what one answer reserves: tell the administrator when the saved conversation limit no longer fits it (the value is // never changed here; the Usage screen and the assistant's list of blockers report the same figures). $limit = (int) $this->plugin->options->get(ConversationBudget::OPTION, ConversationBudget::DEFAULT_UNITS); $newMax = (int) ($this->plugin->bots->find($site, $botId)['max_output_tokens'] ?? ConversationBudget::DEFAULT_MAX_ANSWER); if (in_array(ConversationBudget::standing($limit, [$newMax]), [ConversationBudget::REFUSED, ConversationBudget::TOO_LOW_FOR_PROVIDER], true)) { return 'ai_saved_limit_low'; } return 'ai_saved'; } private function tabAi(array $bot): void { $site = get_current_blog_id(); $ai = $this->plugin->ai(); $resolved = $ai->resolve($bot); $default = $ai->defaultAi(); $repo = $this->plugin->connections(); $listed = (($nullsafeVariable5 = $repo) ? $nullsafeVariable5->list($site) : null) ?? []; $ai->primeConnections($listed); $usable = array_values(array_filter($listed, fn(array $c): bool => $c['status'] === 'verified' && $ai->chatModels((int) $c['id']) !== [])); $canReal = current_user_can(Capabilities::MANAGE); AdminView::form('save_ai', 'aiwend-bots', ['assistant' => (string) $bot['id'], 'tab' => 'ai'], false, 'id="aw-editor-form" data-aiwend-track'); // Effective AI, resolved now. echo '

    ' . esc_html__('This assistant currently uses', 'aiwend') . '

    '; if ($resolved['mode'] === 'demo') { echo wp_kses( '

    ' . AdminView::badge(__('Simulated demo', 'aiwend'), 'warn') . ' ' . esc_html($resolved['ready'] ? __('Answers are simulated by a built-in stand-in; nothing is sent to an AI provider or charged.', 'aiwend') : AiSetup::problemText($resolved['problem'])) . '

    ', AdminView::allowedHtml()); } else { $c = $resolved['connection']; echo wp_kses( '

    ' . ($resolved['ready'] ? AdminView::badge(__('Real AI', 'aiwend'), 'success') : AdminView::badge(__('Not ready', 'aiwend'), 'danger')) . ' ', AdminView::allowedHtml()); /* translators: 1: provider name, 2: connection label, 3: model name, 4: optional note */ echo esc_html(sprintf(__('%1$s via “%2$s”, model %3$s%4$s', 'aiwend'), $c !== null ? \Aiwend\Provider\Providers::name($c['provider']) : '—', $c['label'] ?? '—', (string) ($resolved['model'] ?? '—'), $resolved['mode'] === 'inherit' ? ' ' . __('(site default)', 'aiwend') : '')) . '

    '; if (!$resolved['ready']) { echo '

    ' . esc_html(AiSetup::problemText($resolved['problem'])) . ' ' . esc_html__('Open AI Connections', 'aiwend') . '

    '; } else { echo '

    ' . esc_html__('Visitor messages, these instructions and selected knowledge excerpts are sent to this provider. Private test conversations in the preview use it too and count toward usage.', 'aiwend') . '

    '; if ($c !== null && (string) ($resolved['model'] ?? '') !== '') { // Advisory only: a model is never refused here, but the owner is told what is and is not known about it. $suit = $ai->suitability((int) $c['id'], (string) $resolved['model']); $note = AiSetup::suitabilityNote($suit); if ($note !== '') { echo '

    ' . esc_html($note) . '

    '; } } } } echo '
    '; echo '

    ' . esc_html__('Choose the AI for this assistant', 'aiwend') . '

    ' . esc_html__('AI source', 'aiwend') . ''; // 1. Inherit $inheritDesc = $default !== null /* translators: 1: connection label, 2: model name */ ? sprintf(__('Currently %1$s, model %2$s. If the site default changes, this assistant follows it.', 'aiwend'), (string) ((($nullsafeVariable6 = $repo) ? $nullsafeVariable6->find($site, $default['connection_id']) : null)['label'] ?? '?'), $default['model']) : __('No site default is set yet; set one under AI Connections. Until then this assistant does not answer.', 'aiwend'); printf('', checked($resolved['mode'], 'inherit', false), $canReal ? '' : ' disabled', esc_html__('Use the site default', 'aiwend'), esc_html($inheritDesc)); // 2. Explicit printf('', checked($resolved['mode'], 'explicit', false), $canReal && $usable !== [] ? '' : ' disabled', esc_html__('A specific connection and model', 'aiwend'), esc_html($usable === [] ? __('No connection has passed an access check yet.', 'aiwend') : __('Independent of the site default.', 'aiwend'))); if ($usable !== []) { echo '
    '; foreach ($usable as $c) { foreach ($ai->chatModels((int) $c['id']) as $m) { $level = \Aiwend\Provider\ModelSuitability::classify($m, $ai->testedModel((int) $c['id']))['level']; $tag = $level === \Aiwend\Provider\ModelSuitability::SPECIALIZED ? ' — ' . __('specialized, probably not for text chat', 'aiwend') : ($level === \Aiwend\Provider\ModelSuitability::UNCONFIRMED ? ' — ' . __('not confirmed for text chat', 'aiwend') : ($level === \Aiwend\Provider\ModelSuitability::TESTED ? ' — ' . __('tested', 'aiwend') : '')); echo ''; } } echo '' . esc_html__('Must be in the chosen connection’s model list from its last access check. Aiwend never substitutes another model.', 'aiwend') . '
    '; } // 3. Demo printf('', checked($resolved['mode'], 'demo', false), esc_html__('Simulated demo', 'aiwend'), esc_html($this->plugin->demoAllowedHere() ? __('Local testing only: a built-in stand-in, not an AI model. Answers only while the demo is switched on.', 'aiwend') : __('Not available on this site: the demo works only on local sites. The assistant would not answer.', 'aiwend'))); echo '
    '; if ($canReal) { echo ''; } else { echo '

    ' . esc_html__('Only site administrators can connect an assistant to a real AI provider.', 'aiwend') . '

    '; } echo '
    '; echo '

    ' . esc_html__('Answer length', 'aiwend') . '

    ' . esc_html__('16–8192; default 800. Models that think before answering count that thinking toward this limit: with a low limit such a model can use all of it before writing any text, the visitor then sees an error and the used tokens are still billed. Raising the limit raises the highest possible cost of one answer; Aiwend never raises it for you and never retries. Visitors cannot change this or choose a provider or model.', 'aiwend') . '
    '; } private function tabInstructions(array $bot): void { AdminView::form('update_bot', 'aiwend-bots', ['assistant' => (string) $bot['id'], 'tab' => 'instructions'], false, 'id="aw-editor-form" data-aiwend-track'); echo '

    ' . esc_html__('For you and your team. Visitors see the display name set under Appearance.', 'aiwend') . '

    '; echo '

    ' . esc_html__('Describe how the assistant should respond. Add facts and documents under Knowledge.', 'aiwend') . ''; echo '

    '; echo '

    '; echo '

    ' . esc_html__('Facts such as prices, opening hours or policies belong under Knowledge, not here: the assistant cites knowledge as sources.', 'aiwend') . '

    '; if ($this->plugin->ai()->resolve($bot)['mode'] === 'demo' && $this->plugin->mockProviderEnabled()) { echo '

    ' . esc_html__('The simulated demo does not follow these instructions; they are stored for a real AI model.', 'aiwend') . '

    '; } echo '
    '; } private function tabKnowledge(array $bot): void { $site = get_current_blog_id(); $id = (int) $bot['id']; if (!$this->plugin->modules->isEnabled('knowledge')) { $this->knowledgeOffCard('aiwend-bots', ['assistant' => (string) $id, 'tab' => 'knowledge']); return; } $attached = $this->attached($site, $id); $this->processNowCallout($attached, 'aiwend-bots', ['assistant' => (string) $id, 'tab' => 'knowledge']); echo '

    ' . esc_html__('Knowledge this assistant answers from', 'aiwend') . '

    '; if ($attached === []) { echo '

    ' . esc_html__('No knowledge attached yet.', 'aiwend') . ' ' . esc_html__('Without knowledge the assistant cannot answer questions about your content.', 'aiwend') . '

    ' . esc_html__('Add knowledge', 'aiwend') . '

    '; } else { echo '
      '; foreach ($attached as $s) { $st = self::sourceState($s, time()); echo wp_kses( '
    • ' . esc_html((string) $s['title']) . ' ' . esc_html($this->sourceType($s)) . '
      ' . AdminView::badge($st['label'], $st['tone']) . ' ' . esc_html($st['reason']) . '
      ', AdminView::allowedHtml()); AdminView::form('detach_source', 'aiwend-bots', ['assistant' => (string) $id, 'tab' => 'knowledge', 'source' => (string) $s['id']]); echo '
    • '; } $ready = self::knowledgeReadiness($attached); echo wp_kses( '

    ' . AdminView::badge($ready['usable'] > 0 ? __('Can answer', 'aiwend') : __('Cannot answer yet', 'aiwend'), $ready['tone']) . ' ' . esc_html($ready['text']) . '

    ', AdminView::allowedHtml()); echo '

    ' . esc_html__('Search method: word matching. It finds passages that share words with the question, so it can miss a differently worded question (for example a synonym). Meaning-based search is not available in this build.', 'aiwend') . '

    '; echo '

    ' . esc_html(sprintf( /* translators: 1: largest file size in MB, 2: most passages per source, 3: most passages searched per question */ __('Limits: a source can be up to %1$s MB and %2$s passages. Each question searches at most the first %3$s passages of the site. Persian, Arabic and Chinese text is matched by letters and word pairs, without word endings, so a differently inflected word can be missed.', 'aiwend'), number_format_i18n(\Aiwend\Knowledge\Extractor::MAX_BYTES / 1_000_000), number_format_i18n(\Aiwend\Knowledge\Chunker::MAX_CHUNKS_PER_SOURCE), number_format_i18n(\Aiwend\Knowledge\Retriever::MAX_SCAN) )) . '

    '; echo '

    ' . esc_html__('Removing a source here only detaches it. It stays in the knowledge library.', 'aiwend') . '

    '; } echo '
    '; echo '

    ' . esc_html__('Add knowledge', 'aiwend') . '

    '; $attachedIds = array_map(static fn($s) => (int) $s['id'], $attached); $available = array_values(array_filter($this->plugin->sources()->list($site), static fn($s) => !in_array((int) $s['id'], $attachedIds, true) && $s['status'] !== 'deleting')); echo '
    ' . esc_html__('Choose from the library', 'aiwend') . ' (' . count($available) . ')'; if ($available === []) { echo '

    ' . esc_html__('Everything in the library is already attached, or the library is empty.', 'aiwend') . '

    '; } else { AdminView::form('attach_sources', 'aiwend-bots', ['assistant' => (string) $id, 'tab' => 'knowledge']); $this->checkList('sources[]', array_map(fn($s) => [(int) $s['id'], (string) $s['title'], $this->sourceType($s) . ' · ' . self::sourceState($s, time())['label']], $available), 'aw-src-filter', __('Filter sources', 'aiwend')); echo '

    '; } echo '
    '; $this->addContentPanels('aiwend-bots', ['assistant' => (string) $id, 'tab' => 'knowledge'], $available === []); echo '
    '; } private function tabAppearance(array $bot): void { $look = BotRepository::appearance($bot); $avatarUrl = $look['avatar'] === 'custom' ? ChatWidget::avatarUrl((int) $look['avatar_id']) : null; $canUpload = current_user_can('upload_files'); AdminView::form('save_appearance', 'aiwend-bots', ['assistant' => (string) $bot['id'], 'tab' => 'appearance'], false, 'id="aw-editor-form" data-aiwend-track data-aiwend-appearance'); $field = static function (string $id, string $label, string $control, string $desc = ''): void { echo wp_kses( '
    ' . $control . ($desc !== '' ? '' . esc_html($desc) . '' : '') . '
    ', AdminView::allowedHtml()); }; $select = static function (string $name, array $values, string $current): string { $out = ''; }; // 1. Identity echo '

    ' . esc_html__('Identity', 'aiwend') . '

    '; $field('aw-display', __('Display name', 'aiwend'), '', __('Shown in the chat header. Plain text, up to 80 characters. Default: the assistant name.', 'aiwend')); echo '
    ' . esc_html__('Avatar', 'aiwend') . '
    '; $choices = [ 'mark' => [__('Default', 'aiwend'), __('First letter of the display name', 'aiwend')], 'initial' => [__('First letter', 'aiwend'), __('Of the display name', 'aiwend')], 'custom' => [__('Custom image', 'aiwend'), __('From the Media Library', 'aiwend')], 'none' => [__('No avatar', 'aiwend'), ''], ]; foreach ($choices as $value => [$label, $hint]) { if ($value === 'custom' && !$canUpload && $look['avatar'] !== 'custom') { continue; // choosing media needs the upload capability } printf('', esc_attr($value), checked($look['avatar'], $value, false), esc_html($label), $hint !== '' ? '' . esc_html($hint) . '' : ''); } echo '
    '; echo '
    '; echo ''; echo '' . ($avatarUrl !== null ? '' : '') . ''; if ($canUpload) { echo ''; } echo '' . esc_html__('JPEG, PNG, WebP or GIF. Shown round, at small sizes. If the image is deleted, the first letter of the display name is shown.', 'aiwend') . '
    '; echo '
    '; // 2. Welcome and suggestions echo '

    ' . esc_html__('Welcome and suggestions', 'aiwend') . '

    '; $field('aw-welcome', __('Welcome message', 'aiwend'), '', __('Shown under “How can we help?” before the first message. Optional, plain text, up to 300 characters.', 'aiwend')); $field('aw-suggestions', __('Suggested questions', 'aiwend'), '', __('Optional. One question per line, up to 4, each up to 120 characters. Only suggest questions your knowledge can answer.', 'aiwend')); echo '
    '; // 3. Colours and theme echo '

    ' . esc_html__('Colors and theme', 'aiwend') . '

    '; $isPreset = isset(BotRepository::ACCENTS[$look['accent']]); echo '
    ' . esc_html__('Brand color', 'aiwend') . '
    '; $names = ['#5746d9' => __('Violet', 'aiwend'), '#4f46e5' => __('Aiwend indigo', 'aiwend'), '#2563eb' => __('Blue', 'aiwend'), '#0f766e' => __('Teal', 'aiwend'), '#b45309' => __('Amber', 'aiwend'), '#be185d' => __('Pink', 'aiwend'), '#b91c1c' => __('Red', 'aiwend'), '#475569' => __('Slate', 'aiwend')]; foreach (BotRepository::ACCENTS as $hex => $name) { printf('', esc_attr($hex), checked($look['accent'], $hex, false), esc_html($names[$hex] ?? $name)); } printf('', checked(!$isPreset, true, false), esc_html__('Custom', 'aiwend')); echo '
    '; echo '
    '; echo '' . esc_html__('Used for the send button, visitor messages, links and the launcher. The presets keep white text readable; for a custom color, check the contrast notes below.', 'aiwend') . ''; echo '
    '; if (!$isPreset && Palette::contrast('#ffffff', $look['accent']) < 4.5) { /* translators: 1: brand colour as a hex code, 2: contrast ratio, for example 3.2 */ echo '

    ' . esc_html(sprintf(__('White text on %1$s has a contrast of %2$s:1 (at least 4.5:1 is recommended). Choose a darker brand color, or set a dark “Primary button text” color under Advanced colors.', 'aiwend'), $look['accent'], number_format_i18n(Palette::contrast('#ffffff', $look['accent']), 2))) . '

    '; } echo '
    '; $field('aw-theme', __('Color mode', 'aiwend'), $select('theme', ['auto' => __('Match the visitor’s device (default)', 'aiwend'), 'light' => __('Always light', 'aiwend'), 'dark' => __('Always dark', 'aiwend')], $look['theme'])); echo ''; // Advanced colours: optional per-slot overrides for each scheme. Empty = built-in colour. $hasCustom = $look['colors']['light'] !== [] || $look['colors']['dark'] !== []; echo wp_kses( '
    ' . esc_html__('Advanced colors', 'aiwend') . '' . ($hasCustom ? ' ' . AdminView::badge(__('Customized', 'aiwend'), 'info') : '') . '', AdminView::allowedHtml()); echo '

    ' . esc_html__('Optional. Leave a field empty to keep the built-in color, which follows the brand color. Light and dark are set separately; “Match the visitor’s device” uses both. Contrast is checked as you type; nothing is changed for you.', 'aiwend') . '

    '; $slotNames = [ 'panel_bg' => __('Panel background', 'aiwend'), 'header_bg' => __('Header background', 'aiwend'), 'header_fg' => __('Header text', 'aiwend'), 'user_bg' => __('Visitor message background', 'aiwend'), 'user_fg' => __('Visitor message text', 'aiwend'), 'bot_bg' => __('Assistant message background', 'aiwend'), 'bot_fg' => __('Assistant message text', 'aiwend'), 'composer_bg' => __('Message box background', 'aiwend'), 'composer_fg' => __('Message box text', 'aiwend'), 'button_bg' => __('Primary button background', 'aiwend'), 'button_fg' => __('Primary button text and icon', 'aiwend'), 'launcher_bg' => __('Launcher button background', 'aiwend'), 'launcher_fg' => __('Launcher logo backing', 'aiwend'), 'link' => __('Links and focus outline', 'aiwend'), ]; echo '
    '; foreach (['light' => __('Light colors', 'aiwend'), 'dark' => __('Dark colors', 'aiwend')] as $scheme => $title) { $defaults = Palette::defaults($scheme, $look['accent']); echo '
    ' . esc_html($title) . '
    '; foreach (Palette::SLOTS as $slot => $_) { $id = 'aw-c-' . $scheme . '-' . $slot; $value = $look['colors'][$scheme][$slot] ?? ''; printf('
    ', /* translators: %s: name of the colour slot the administrator should pick */ esc_attr($id), esc_html($slotNames[$slot]), esc_attr($scheme), esc_attr($slot), esc_attr($defaults[$slot]), esc_attr($value), esc_attr($value !== '' ? $value : $defaults[$slot]), esc_attr(sprintf(__('Pick: %s', 'aiwend'), $slotNames[$slot]))); } echo '
    '; foreach (Palette::warnings($scheme, $look['colors'][$scheme], $look['accent']) as $w) { /* translators: 1: text colour slot, 2: background colour slot, 3: measured contrast ratio, 4: recommended minimum ratio */ echo '

    ' . esc_html(sprintf(__('%1$s on %2$s: %3$s:1, below the recommended %4$s:1.', 'aiwend'), $slotNames[$w['fg']], $slotNames[$w['bg']], number_format_i18n($w['ratio'], 2), number_format_i18n($w['min'], 1))) /* translators: %s: suggested colour as a hex code */ . ($w['suggestion'] !== null ? ' ' . esc_html(sprintf(__('A readable alternative for the text: %s.', 'aiwend'), $w['suggestion'])) : '') . '

    '; } /* translators: %s: the word "light" or "dark" */ echo '

    ' . esc_html__('Clears the fields; saved only when you save.', 'aiwend') . '

    '; } echo '
    '; echo '
    '; // 4. Placement and display rules echo '

    ' . esc_html__('Placement and display rules', 'aiwend') . '

    '; $field('aw-placement', __('Placement', 'aiwend'), $select('placement', ['inline' => __('Inline only (block or shortcode)', 'aiwend'), 'floating' => __('Floating launcher (inline still available)', 'aiwend')], $look['placement'])); echo '
    '; $field('aw-side', __('Launcher side', 'aiwend'), $select('side', ['right' => __('Right (default)', 'aiwend'), 'left' => __('Left', 'aiwend')], $look['side'])); $field('aw-devices', __('Show launcher on', 'aiwend'), $select('devices', ['all' => __('All devices (default)', 'aiwend'), 'desktop' => __('Desktop (over 600px)', 'aiwend'), 'mobile' => __('Mobile (up to 600px)', 'aiwend')], $look['devices'])); $field('aw-desktop_offset', __('Desktop edge spacing (px)', 'aiwend'), '', __('8–160. Default 24.', 'aiwend')); $field('aw-mobile_offset', __('Mobile edge spacing (px)', 'aiwend'), '', __('8–160. Default 16.', 'aiwend')); echo '
    '; $field('aw-launcher_label', __('Launcher label', 'aiwend'), '', __('The launcher’s accessible name and tooltip. Default: “Chat with us”.', 'aiwend')); foreach (['include_pages' => [__('Show only on these pages and posts', 'aiwend'), __('Search by title. Leave empty to show it everywhere.', 'aiwend'), __('Only on page/post IDs', 'aiwend')], 'exclude_pages' => [__('Never show on these pages and posts', 'aiwend'), __('Takes priority over the list above.', 'aiwend'), __('Exclude page/post IDs', 'aiwend')]] as $key => [$label, $help, $idLabel]) { $input = ' ' . esc_html__('Comma separated.', 'aiwend') . ''; echo wp_kses( ContentPicker::field('aw-' . $key, $label, (string) $look[$key], $input, $help), AdminView::allowedHtml()); } echo '

    ' . esc_html__('The floating chat starts closed and appears only while the assistant is Active and Public. Cart and checkout are excluded. If several assistants match a page, the oldest is shown. Check spacing against cookie banners and theme controls on your site.', 'aiwend') . '

    '; echo '
    '; } /** Editor preview: the real widget in a sandboxed sample page, driven by the draft values. */ private function previewPanel(array $bot): void { $look = BotRepository::appearance($bot); $seg = static function (string $name, string $label, array $options, string $current): void { echo '
    '; foreach ($options as $value => $text) { printf('', esc_attr($name), esc_attr($value), checked($current, $value, false), esc_html($text)); } echo '
    '; }; echo '
    '; echo '
    '; $seg('mode', __('Placement', 'aiwend'), ['inline' => __('Inline', 'aiwend'), 'floating' => __('Floating', 'aiwend')], $look['placement'] === 'floating' ? 'floating' : 'inline'); $seg('device', __('Device', 'aiwend'), ['desktop' => __('Desktop', 'aiwend'), 'mobile' => __('Mobile', 'aiwend')], 'desktop'); $seg('theme', __('Color mode', 'aiwend'), ['saved' => __('As set', 'aiwend'), 'light' => __('Light', 'aiwend'), 'dark' => __('Dark', 'aiwend')], 'saved'); $seg('open', __('Floating state', 'aiwend'), ['open' => __('Panel open', 'aiwend'), 'closed' => __('Launcher', 'aiwend')], 'open'); echo '
    '; echo '
    '; echo ''; echo '
    '; } private function tabPublish(array $bot): void { $id = (int) $bot['id']; AdminView::form('publish_bot', 'aiwend-bots', ['assistant' => (string) $id, 'tab' => 'publish'], false, 'id="aw-editor-form" data-aiwend-track'); echo '

    ' . esc_html__('Status', 'aiwend') . '

    ' . esc_html__('Lifecycle', 'aiwend') . ''; foreach ([ 'draft' => [__('Draft', 'aiwend'), __('Being set up. Only people who manage assistants can test it, here in the preview.', 'aiwend')], 'active' => [__('Active', 'aiwend'), __('Answers when its AI (see AI & model) is available. Visitors see it only if it is also Public and placed on a page.', 'aiwend')], 'paused' => [__('Paused', 'aiwend'), __('Temporarily off for visitors. You can still test it here.', 'aiwend')], ] as $v => [$label, $desc]) { printf('', esc_attr($v), checked($bot['status'], $v, false), esc_html($label), esc_html($desc)); } echo '

    ' . esc_html__('Visibility', 'aiwend') . '

    '; printf('', checked($bot['is_public'], true, false), esc_html__('Public: show to visitors', 'aiwend'), esc_html__('Visitors can use it on pages where you add it, while it is Active. Leave unticked to keep it private.', 'aiwend')); echo '

    ' . esc_html__('Testing never publishes the assistant. Changes here take effect when you save.', 'aiwend') . '

    '; echo '

    ' . esc_html__('Add to your website', 'aiwend') . '

    '; echo '

    ' . esc_html__('In the block editor, add the "Aiwend Chat" block and choose this assistant, or paste this shortcode into a page:', 'aiwend') . '

    '; $code = '[aiwend_chat bot="' . $id . '"]'; echo '
    '; if ($bot['status'] !== 'active' || !$bot['is_public']) { echo '

    ' . esc_html__('Visitors will not see it yet: set it to Active and Public first. Until then, editors see a short note where the chat would be.', 'aiwend') . '

    '; } if (current_user_can('edit_pages')) { AdminView::form('create_test_page', 'aiwend-bots', ['assistant' => (string) $id, 'tab' => 'publish']); echo '

    ' . esc_html__('Creates an unpublished page with this assistant, for checking it in your theme.', 'aiwend') . '

    '; } echo '
    '; } // ---------------------------------------------------------------- Knowledge private function knowledgeOffCard(string $back, array $hidden): void { echo '

    ' . esc_html__('Knowledge is turned off', 'aiwend') . '

    ' . esc_html__('Your sources are kept. While Knowledge is off, assistants that use it do not answer.', 'aiwend') . '

    '; if ($this->can('modules.manage')) { AdminView::form('toggle_module', $back, $hidden + ['module' => 'knowledge', 'enabled' => '1']); echo ''; } else { echo '

    ' . esc_html__('Ask a site administrator to turn it on.', 'aiwend') . '

    '; } echo '
    '; } /** @param list $items value, label, detail */ private function checkList(string $name, array $items, string $filterId, string $filterLabel): void { echo '

    '; // Shown by admin.js only; without JavaScript the list works as plain checkboxes. echo ''; echo '
      '; foreach ($items as [$value, $label, $detail]) { echo wp_kses(sprintf('
    • ', esc_attr($name), $value, esc_html($label), esc_html($detail)), AdminView::allowedHtml()); } echo '
    '; } /** Add pages/posts and upload panels, shared by the library and the assistant editor. */ public function addContentPanels(string $back, array $hidden, bool $openFirst): void { $existing = []; foreach ($this->plugin->sources()->list(get_current_blog_id()) as $s) { if ($s['origin'] === 'post') { $existing[(int) $s['origin_ref']] = true; } } $posts = array_values(array_filter(get_posts(['post_type' => ['page', 'post'], 'post_status' => 'publish', 'numberposts' => 200, 'has_password' => false, 'orderby' => 'modified']), static fn($p) => !isset($existing[$p->ID]))); echo '
    ' . esc_html__('Add published pages or posts', 'aiwend') . ''; if ($posts === []) { echo '

    ' . esc_html__('No published pages or posts that are not already in the library.', 'aiwend') . '

    '; } else { AdminView::form('add_post_source', $back, $hidden); $this->checkList('post_ids[]', array_map(static fn($p) => [(int) $p->ID, get_the_title($p) ?: __('(no title)', 'aiwend'), $p->post_type === 'page' ? __('Page', 'aiwend') : __('Post', 'aiwend')], $posts), 'aw-post-filter-' . sanitize_html_class($back), __('Search pages and posts', 'aiwend')); $this->visibilityField('aw-post-vis-' . sanitize_html_class($back)); echo '

    '; } echo '
    ' . esc_html__('Upload a file', 'aiwend') . ''; AdminView::form('upload_source', $back, $hidden, true); $fid = 'aw-file-' . sanitize_html_class($back); echo '

    ' . esc_html(\Aiwend\Knowledge\PdfSupport::available() ? __('TXT, Markdown or text PDF, up to 2 MB. PDF: up to 100 pages; scanned documents require OCR.', 'aiwend') : __('TXT or Markdown, up to 2 MB. PDF files are not supported in this edition; add that content as a post or page, or as text.', 'aiwend')) . '

    '; echo '

    '; $this->visibilityField('aw-up-vis-' . sanitize_html_class($back)); echo '

    '; } private function visibilityField(string $id): void { // Phase 96: Private is the default. Public is offered only to people who may publish, and needs the tick (enforced again on the server). $canPublish = $this->can('knowledge.publish'); echo '
    ' . esc_html__('Use in answers', 'aiwend') . ''; echo ''; if ($canPublish) { echo ''; echo ''; } else { echo '

    ' . esc_html__('An administrator approves a source for answers. Add it as Private now.', 'aiwend') . '

    '; } echo '
    '; } public function pageKnowledge(): void { $site = get_current_blog_id(); $on = $this->plugin->modules->isEnabled('knowledge'); AdminView::open($this->plugin, 'aiwend-knowledge', __('Knowledge library', 'aiwend'), __('Content your assistants can answer from.', 'aiwend'), $on ? '' . esc_html__('Add pages or posts', 'aiwend') . ' ' . esc_html__('Upload file', 'aiwend') . '' : ''); AdminView::notice(); if (!$on) { $this->knowledgeOffCard('aiwend-knowledge', []); AdminView::close(); return; } $passages = $this->plugin->sources()->searchablePassages($site); if ($passages >= \Aiwend\Knowledge\Ingestor::SITE_CHUNK_WARNING) { echo '

    ' . esc_html(sprintf( /* translators: 1: number of searchable passages now, 2: comfortable size, 3: maximum */ __('Your library holds %1$s searchable passages. Every question has to search all of them, so answers get slower as it grows: about %2$s is comfortable, and %3$s is the maximum (a source that would go beyond it is refused). Consider removing sources you no longer need.', 'aiwend'), number_format_i18n($passages), number_format_i18n(\Aiwend\Knowledge\Ingestor::SITE_CHUNK_WARNING), number_format_i18n(\Aiwend\Knowledge\Ingestor::SITE_CHUNK_LIMIT) )) . '

    '; } $list = $this->plugin->sources()->list($site); $this->processNowCallout($list, 'aiwend-knowledge'); $used = $this->usedBy($site); echo '
    '; if ($list === []) { echo '

    ' . esc_html__('No knowledge yet', 'aiwend') . '

    ' . esc_html__('Add published pages or posts, or upload TXT/Markdown files. Assistants answer only from knowledge attached to them.', 'aiwend') . '

    '; } else { echo '

    '; echo ''; foreach ($list as $s) { $id = (int) $s['id']; $st = self::sourceState($s, time()); $users = $used[$id] ?? []; $usedHtml = $users === [] ? '' . esc_html__('Not attached', 'aiwend') . '' : implode(', ', array_map(static fn($b) => '' . esc_html((string) $b['name']) . '', $users)); echo wp_kses( ''; } echo '
    ' . esc_html__('Knowledge sources', 'aiwend') . '
    ' . esc_html__('Source', 'aiwend') . '' . esc_html__('Status', 'aiwend') . '' . esc_html__('Used by', 'aiwend') . '' . esc_html__('Actions', 'aiwend') . '
    ' . esc_html((string) $s['title']) . '
    ' . esc_html($this->sourceType($s)) . '
    ' . AdminView::badge($st['label'], $st['tone']) . '
    ' . esc_html($st['reason']) . '
    ' . $usedHtml . '', AdminView::allowedHtml()); if ($s['status'] !== 'deleting') { $title = (string) $s['title']; // Primary action stays visible; everything else sits in one "More" group, with deletion last and set apart. echo '' . esc_html__('Inspect text', 'aiwend') . ' ' . esc_html($title) . ' '; echo '
    ' . esc_html__('More', 'aiwend') . ' ' . esc_html(sprintf(/* translators: %s: source title */ __('actions for %s', 'aiwend'), $title)) . '
    '; echo '

    ' . esc_html__('Maintenance', 'aiwend') . '

    '; AdminView::form('reindex_source', 'aiwend-knowledge', ['id' => (string) $id]); echo ' ' . esc_html__('Rebuild the searchable passages from the stored text.', 'aiwend') . '
    '; echo '

    ' . esc_html($s['visibility'] === 'public' ? __('Currently: Public, used in answers', 'aiwend') : __('Currently: Private, stored only', 'aiwend')) . '

    '; if ($s['visibility'] === 'public') { AdminView::form('source_visibility', 'aiwend-knowledge', ['id' => (string) $id, 'visibility' => 'private']); echo ' ' . esc_html__('Stops new answers from using it at once.', 'aiwend') . '
    '; } elseif ($this->can('knowledge.publish')) { echo '' . esc_html__('Use in answers…', 'aiwend') . ' ' . esc_html__('Asks you to confirm and shows which assistants would quote it.', 'aiwend') . '
    '; } else { echo '

    ' . esc_html__('Only an administrator can approve a source for answers.', 'aiwend') . '

    '; } if ($s['origin'] === 'upload') { $rid = 'aw-replace-' . $id; echo '

    ' . esc_html__('Replace file', 'aiwend') . '

    '; AdminView::form('replace_source', 'aiwend-knowledge', ['id' => (string) $id], true); echo '

    ' . esc_html__('Keeps this source, its visibility and the assistants it is attached to. Answers keep using the current version until the new one is ready; if it fails, the current version stays in use.', 'aiwend') . '

    '; echo '
    '; } echo '

    ' . esc_html__('Delete', 'aiwend') . '

    '; AdminView::form('delete_source', 'aiwend-knowledge', ['id' => (string) $id]); echo '

    ' . esc_html($s['origin'] === 'post' ? __('Removes Aiwend\'s copy and index only. The page or post itself is not changed.', 'aiwend') : __('Removes the uploaded text and its index from Aiwend. This cannot be undone.', 'aiwend')) . '

    '; echo '
    '; echo '
    '; } echo '
    '; } echo '
    '; echo '

    ' . esc_html__('Add knowledge', 'aiwend') . '

    '; $this->addContentPanels('aiwend-knowledge', [], $list === []); echo '

    ' . esc_html__('Only Public sources attached to an assistant are used in its answers. A Private source stays inactive, even when attached, until an administrator approves it for answers.', 'aiwend') . '

    '; if ($this->can('index.manage')) { if (\Aiwend\Knowledge\EmbeddingConfig::SEMANTIC_ENABLED) { IndexPanel::render($this->plugin, $this->can('index.activate')); } else { // Phase 90: in this beta meaning-based search is switched off for everyone, so the model and budget forms (which could // not do anything) are not shown; the internal machinery is untouched and returns when the switch is turned on. echo '

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

    ' . esc_html__('Not available in this beta. Answers use word matching: a question finds knowledge that shares words with it, so a rephrased question may find nothing. Meaning-based search is planned. It is switched off, and nothing is sent to any embedding service.', 'aiwend') . '

    '; } } AdminView::close(); } // ---------------------------------------------------------------- Usage public function pageUsage(): void { $site = get_current_blog_id(); $balance = $this->plugin->ledger->balance($site, 'site') ?? ['limit' => 0, 'reserved' => 0, 'spent' => 0, 'available' => 0]; $sum = $this->plugin->ledger->usageSummary($site); AdminView::open($this->plugin, 'aiwend-usage', __('Usage & limits', 'aiwend'), __('How much your assistants have used, and the limits that stop them.', 'aiwend')); AdminView::notice(); $this->budgetAlertsSection(); $reports = new UsageReports($this->plugin); $reports->warnings(); if ($this->plugin->mockProviderEnabled()) { echo '

    ' . esc_html__('Simulated usage is recorded separately.', 'aiwend') . ' ' . esc_html__('Demo requests do not contact an AI service. Their unit counts are synthetic, not provider tokens or invoices.', 'aiwend') . ' ' . esc_html(DemoNotice::scope($this->hasRealConnection())) . '

    '; } $n = static fn(int $v): string => number_format_i18n($v); echo '
    '; printf('

    %s

    %s

    %s

    ', esc_html__('Used', 'aiwend'), esc_html($n((int) $balance['spent'])), esc_html__('units counted against the limit', 'aiwend')); printf('

    %s

    %s

    %s

    ', esc_html__('Reserved', 'aiwend'), esc_html($n((int) $balance['reserved'])), esc_html__('units held for answers in progress or awaiting review', 'aiwend')); /* translators: %s: usage limit of the site, a formatted number of units */ printf('

    %s

    %s

    %s

    ', esc_html__('Available', 'aiwend'), esc_html($n((int) $balance['available'])), esc_html(sprintf(__('of a %s unit site limit', 'aiwend'), $n((int) $balance['limit'])))); echo '
    '; echo wp_kses(AdminView::callout('

    ' . esc_html__('A unit is roughly one token of text.', 'aiwend') . ' ' . esc_html__('Units are not money. Estimated costs further down use published list prices or rates you enter, and are estimates, not your provider’s bill. “Used” is Aiwend’s own count; “Reserved” is held for answers in progress; simulated demo units are synthetic and never charged.', 'aiwend') . '

    ', 'info', true), AdminView::allowedHtml()); echo '
    '; if (current_user_can(Capabilities::MANAGE)) { echo '

    ' . esc_html__('Limits', 'aiwend') . '

    '; AdminView::form('save_budget', 'aiwend-usage', [], false, 'data-aiwend-track'); echo '

    ' . esc_html__('Total for all assistants. 0 means assistants stop answering. Units are Aiwend’s own count, not money, and this is not a spending cap: your AI provider bills the real usage, so also set a spending limit in your provider account.', 'aiwend') . '

    '; $this->conversationLimitField(); echo '

    ' . esc_html__('The most one visitor can use in a day (UTC), however many conversations they open. A visitor is a signed-in account or a network address, so people sharing one address (an office, a school, a mobile network) share this amount. Roughly 1,500 to 3,500 units per answer.', 'aiwend') . '

    '; echo '

    ' . esc_html__('A visitor is also stopped at this percentage of the site limit, so a large site limit never gives one visitor a large allowance. Takes effect for each visitor\'s next day. A visitor always gets at least 5,000 units unless the site limit is lower.', 'aiwend') . '

    '; echo ''; echo '

    '; } $this->limitsGuide(); echo '

    ' . esc_html__('How limits work', 'aiwend') . '

    ' . esc_html__('Before each answer, an upper estimate is reserved: about one unit per three characters of question, conversation and knowledge excerpts, plus the longest allowed answer. Afterwards the actual count replaces it.', 'aiwend') . '

    ' . esc_html__('No automatic reset.', 'aiwend') . ' ' . esc_html__('Usage accumulates until you raise the limit. Each new conversation also has its own limit.', 'aiwend') . '

    '; echo '
    '; $reports->limitsInForce(); $reports->costSection(); $reports->series(); $reports->pricingSection(); $this->usageBreakdownSection($site); $reports->glossary(); echo '
    ' . esc_html__('Three different kinds of limits', 'aiwend') . '
      ' . '
    • ' . esc_html__('Aiwend limits', 'aiwend') . ' — ' . esc_html__('the site and conversation limits on this page. Aiwend enforces them before sending anything.', 'aiwend') . '
    • ' . '
    • ' . esc_html__('Provider limits', 'aiwend') . ' — ' . esc_html__('rate limits, spending limits and billing in your AI provider account. Aiwend cannot see them; it shows the provider’s error when one is hit.', 'aiwend') . '
    • ' . '
    • ' . esc_html__('Consumer subscriptions', 'aiwend') . ' — ' . esc_html__('plans such as ChatGPT Plus, Claude Pro or a Gemini app subscription are separate from API billing and do not pay for API usage.', 'aiwend') . '
    '; // Bounded read: 100 listed, one more asked for only to learn that the list is cut (the oldest first; resolving them brings the next into view). $uncertain = $this->plugin->ledger->uncertain($site, 101); $uncertainCut = count($uncertain) > 100; $uncertain = array_slice($uncertain, 0, 100); $estimated = $this->plugin->ledger->estimated($site); $stale = $this->plugin->ledger->staleHolds($site, 101); // bounded; the 101st row only tells the list is cut $review = count($uncertain) + count($estimated) + count($stale); /* translators: %d: number of requests that need review */ echo wp_kses( '
    0 ? ' open' : '') . '>' . esc_html__('Detailed accounting', 'aiwend') . ' ' . ($review > 0 ? AdminView::badge(sprintf(_n('%d request needs review', '%d requests need review', $review, 'aiwend'), $review), 'warn') : '') . '', AdminView::allowedHtml()); echo '

    ' . esc_html__('Estimates and held amounts are kept apart from confirmed usage. Nothing here is matched against provider invoices automatically.', 'aiwend') . '

    '; $labels = [ Reservation::BASIS_REPORTED => __('Usage reported by the provider', 'aiwend'), Reservation::BASIS_ADMIN_ENTERED => __('Usage entered by an administrator from provider records', 'aiwend'), Reservation::BASIS_ESTIMATED => __('Estimated, NOT confirmed (actual usage unknown; held amount charged)', 'aiwend'), Reservation::BASIS_SIMULATED => __('Simulated (demo; no provider, no billing)', 'aiwend'), Reservation::BASIS_NOT_DISPATCHED => __('Failed before sending (nothing billed)', 'aiwend'), Reservation::BASIS_REJECTED => __('Refused by the provider before processing (nothing charged)', 'aiwend'), Reservation::BASIS_LEGACY => __('Recorded before usage sources were tracked', 'aiwend'), 'in_progress' => __('In progress (held)', 'aiwend'), 'uncertain' => __('Outcome unknown, pending reconciliation (held, not counted as usage)', 'aiwend'), 'released' => __('Released before sending (returned to the budget)', 'aiwend'), 'adjusted' => __('Estimates later replaced by entered usage (net change)', 'aiwend'), ]; echo ''; foreach ($labels as $key => $label) { printf('', esc_html($label), esc_html($n((int) $sum[$key]['requests'])), esc_html($n((int) $sum[$key]['units']))); } echo '
    ' . esc_html__('Category', 'aiwend') . '' . esc_html__('Requests', 'aiwend') . '' . esc_html__('Units', 'aiwend') . '
    %s%s%s
    '; $this->staleHoldsSection($stale); if ($uncertain !== [] || $estimated !== []) { $uncertainText = \Aiwend\Support\FailureCatalog::describe(\Aiwend\Support\FailureCatalog::UNCERTAIN_USAGE, \Aiwend\Support\FailureCatalog::BILLING_UNCERTAIN); echo '

    ' . esc_html__('Requests that need reconciliation', 'aiwend') . '

    ' . esc_html($uncertainText['billing_text']) . ' ' . esc_html($uncertainText['auto'] . ' ' . $uncertainText['action']) . '

    ' . esc_html__('These were sent to the provider but their usage is not confirmed. They are never retried. Stopping a request in the browser does not prove that the provider stopped processing or billing. Check the provider\'s own usage records, then either enter the usage shown there (0 if the provider confirms no charge), or charge the held amount as an unconfirmed estimate. An estimate can be replaced by entered usage once.', 'aiwend') . '

    '; echo '

    ' . esc_html__('Until resolved, each hold keeps its units reserved against your site, session and visitor limits.', 'aiwend') . '

    '; if ($uncertainCut) { echo '

    ' . esc_html__('Only the oldest 100 requests are listed; more are held. Resolve these and the next ones appear. Every hold, listed or not, keeps its units reserved.', 'aiwend') . '

    '; } $hadConnection = $this->plugin->ai()->siteHadConnection($site); foreach (array_merge($uncertain, $estimated) as $u) { $isEstimate = $u->state === Reservation::SETTLED; $origin = $this->usageOrigin($u->origin, $u->usageBasis, $hadConnection); $fid = 'aiw-u-' . sanitize_html_class($u->requestId); echo '

    ' . esc_html__('Request', 'aiwend') . ' ' . esc_html($u->requestId) . '

    '; /* translators: %d: estimated usage units (first string) or held usage units (second string) */ echo wp_kses( '

    ' . (str_starts_with($u->requestId, 'emb_') ? AdminView::badge(__('Index build (embeddings)', 'aiwend'), 'info') . ' ' : '') . (str_starts_with($u->requestId, 'emb_') ? '' : AdminView::badge(self::originText($origin), $origin === Reservation::ORIGIN_SIMULATED || $origin === 'likely_simulated' ? 'neutral' : ($origin === 'unknown' ? 'warn' : 'info'))) . ' ' . esc_html($isEstimate ? sprintf(__('Estimated at %d units (unconfirmed)', 'aiwend'), (int) $u->settledUnits) : sprintf(__('%d units held', 'aiwend'), (int) $u->reservedUnits)) . '

    ', AdminView::allowedHtml()); $cause = self::holdCause($u->state === Reservation::UNCERTAIN ? $u->note : null); if ($cause !== '') { echo '

    ' . esc_html($cause) . '

    '; } if (current_user_can(Capabilities::MANAGE)) { AdminView::form('resolve_usage', 'aiwend-usage', ['request_id' => $u->requestId, 'mode' => 'entered'], false, 'class="aw-reconcile__form"'); echo '
    ' . esc_html__('Enter 0 if the provider confirms there was no charge.', 'aiwend') . '
    '; echo '
    ' . esc_html__('For example the provider’s request or invoice line ID.', 'aiwend') . '
    '; echo '
    '; if (!$isEstimate) { // A separate form attached through form="…" keeps the two actions in one row. echo ''; } echo '
    '; if (!$isEstimate) { AdminView::form('resolve_usage', 'aiwend-usage', ['request_id' => $u->requestId, 'mode' => 'estimate'], false, 'id="' . esc_attr($fid) . '-est" class="aw-hidden-form"'); echo ''; } } echo '
    '; } } echo '
    '; AdminView::close(); } /** * Phase 137: reservations still held long after they normally finish, with the two guarded actions an administrator may take. Classification * (outcome unknown) is kept apart from settlement (the existing reconciliation list below). Nothing here asks the provider anything. * * @param list $stale */ private function staleHoldsSection(array $stale): void { if ($stale === []) { return; } $shown = array_slice($stale, 0, 100); $n = static fn(int $v): string => number_format_i18n($v); echo '

    ' . esc_html__('Reservations pending longer than expected', 'aiwend') . '

    '; echo '

    ' . esc_html__('Aiwend does not know what your provider billed for these.', 'aiwend') . ' ' . esc_html__('Each reservation holds usage units against your site and conversation limits, so it lowers the capacity left for new requests until it is released or settled. Aiwend has not asked the provider about any of them and never retrieves an invoice. Check your provider’s own usage page for the time shown.', 'aiwend') . '

    '; if (count($stale) > count($shown)) { echo '

    ' . esc_html(sprintf(/* translators: %d: number of reservations shown */ __('Only the first %d are listed; more exist.', 'aiwend'), count($shown))) . '

    '; } $now = $this->plugin->clock->now(); foreach ($shown as $s) { $fid = 'aiw-s-' . (int) $s['internal_id']; $sent = $s['state'] === Reservation::DISPATCHED; $age = $s['age'] === null ? __('unknown', 'aiwend') : human_time_diff($now - $s['age'], $now); echo '

    ' . esc_html__('Reservation', 'aiwend') . ' ' . esc_html($s['request_id']) . '

    '; echo '

    ' . wp_kses(AdminView::badge($sent ? __('Sent, outcome not recorded', 'aiwend') : __('Reserved, never sent', 'aiwend'), $sent ? 'warn' : 'info'), AdminView::allowedHtml()) . ' ' . esc_html(sprintf(/* translators: 1: age such as "2 hours", 2: usage units held */ __('Pending for %1$s. Holding %2$s units (Aiwend’s own measure, not a charge).', 'aiwend'), $age, $n($s['units']))) . '

    '; echo '

    ' . esc_html($sent ? __('This request was marked as sent but never finished in Aiwend’s records. The provider may have processed it and may have billed it; that is unknown. Listing it as “outcome unknown” only changes how it is shown: the held units stay held, nothing is charged or released, and you can then record the real usage below.', 'aiwend') : __('Aiwend records a reservation before it makes any request and marks it as sent before contacting the provider. This one was never marked as sent, so it did not reach the provider. Releasing it returns its units to your available capacity and charges nothing.', 'aiwend')) . '

    '; if (current_user_can(Capabilities::MANAGE)) { AdminView::form('recover_hold', 'aiwend-usage', ['request_id' => $s['request_id'], 'mode' => $sent ? 'reclassify' : 'release_unsent'], false, 'class="aw-reconcile__form"'); echo '

    '; echo '
    '; } echo '
    '; } } /** * Origin of a usage record from stored evidence only. Records from before 0.7 have no origin: * a simulated basis is proof; otherwise, if no AI connection is recorded on this site at all, * they are labelled "likely simulated" (an inference, shown as such); else "unknown origin". */ private function usageOrigin(?string $origin, ?string $basis, bool $siteHadConnection): string { if ($origin !== null) { return $origin; } if ($basis === Reservation::BASIS_SIMULATED) { return Reservation::ORIGIN_SIMULATED; } return $siteHadConnection ? 'unknown' : 'likely_simulated'; } /** Plain-language cause of an unresolved hold, from the fixed note the ledger recorded (no provider text, no request content). Empty when unknown. */ private static function holdCause(?string $note): string { if ($note === 'user-stopped') { return __('Cause: the visitor stopped the answer while it was streaming. The provider may have kept processing it.', 'aiwend'); } if ($note === 'provider-cancelled') { return __('Cause: the request was cancelled before the provider reported usage (for example the visitor pressed Stop). The provider may still have processed it.', 'aiwend'); } if (is_string($note) && str_starts_with($note, 'provider-')) { return __('Cause: the provider call ended without a usage report.', 'aiwend'); } return ''; } private static function originText(string $origin): string { switch (true) { case $origin === Reservation::ORIGIN_PROVIDER: return __('Real AI (assistant conversations)', 'aiwend'); case $origin === Reservation::ORIGIN_TEST: return __('Real AI (connection test messages)', 'aiwend'); case $origin === Reservation::ORIGIN_CONTENT_STUDIO: return __('Real AI (Content Studio drafts)', 'aiwend'); case $origin === Reservation::ORIGIN_WOOCOMMERCE_AI: return __('Real AI (WooCommerce product content)', 'aiwend'); case $origin === Reservation::ORIGIN_AI_FORMS: return __('Real AI (AI Forms submission analysis)', 'aiwend'); case $origin === Reservation::ORIGIN_SIMULATED: return __('Simulated demo (no provider, no billing)', 'aiwend'); case $origin === 'likely_simulated': return __('Likely simulated (recorded before origins were tracked; no AI connection is recorded on this site)', 'aiwend'); default: return __('Unknown origin (recorded before origins were tracked)', 'aiwend'); } } /** Which column a breakdown row belongs to. Categories with different meaning are never added together. */ private static function usageMeasure(string $state, ?string $basis): ?string { if ($state === Reservation::SETTLED) { switch (true) { case $basis === Reservation::BASIS_REPORTED: return 'reported'; case $basis === Reservation::BASIS_ADMIN_ENTERED: return 'entered'; case $basis === Reservation::BASIS_ESTIMATED: return 'estimated'; case $basis === Reservation::BASIS_SIMULATED: return 'simulated'; case $basis === Reservation::BASIS_NOT_DISPATCHED: case $basis === Reservation::BASIS_REJECTED: return 'none'; default: return 'legacy'; } } return in_array($state, [Reservation::RESERVED, Reservation::DISPATCHED, Reservation::UNCERTAIN], true) ? 'held' : null; } /** Usage grouped by origin and connection, and by assistant, for a time range. Reads stored records only. */ private function usageBreakdownSection(int $site): void { $ranges = ['7' => __('Last 7 days', 'aiwend'), '30' => __('Last 30 days', 'aiwend'), '90' => __('Last 90 days', 'aiwend'), 'all' => __('All time', 'aiwend')]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation or filter parameter; no state is changed $range = isset($_GET['range']) ? sanitize_key(Input::get('range')) : '30'; $range = isset($ranges[$range]) ? $range : '30'; $since = $range === 'all' ? 0 : $this->plugin->clock->now() - (int) $range * DAY_IN_SECONDS; $rows = $this->plugin->ledger->breakdown($site, $since); $hadConnection = $this->plugin->ai()->siteHadConnection($site); $repo = $this->plugin->connections(); $n = static fn(int $v): string => number_format_i18n($v); $cols = [ 'reported' => __('Reported by provider', 'aiwend'), 'entered' => __('Entered by admin', 'aiwend'), 'estimated' => __('Estimated, unconfirmed', 'aiwend'), 'held' => __('Held / unresolved', 'aiwend'), 'simulated' => __('Simulated', 'aiwend'), 'legacy' => __('Older, source not tracked', 'aiwend'), ]; $bySource = []; $byBot = []; foreach ($rows as $r) { $measure = self::usageMeasure($r['state'], $r['basis']); if ($measure === null) { continue; } $origin = $this->usageOrigin($r['origin'], $r['basis'], $hadConnection); $key = $origin . '|' . ($r['connection_id'] ?? 0); $bySource[$key] ??= ['origin' => $origin, 'connection' => $r['connection_id'], 'requests' => 0, 'units' => []]; $bySource[$key]['requests'] += $r['requests']; $bot = $r['bot_id'] ?? 0; $byBot[$bot] ??= ['requests' => 0, 'units' => [], 'with' => 0, 'tin' => null, 'tout' => null]; $byBot[$bot]['requests'] += $r['requests']; if ($r['with_breakdown'] > 0) { $byBot[$bot]['with'] += $r['with_breakdown']; $byBot[$bot]['tin'] = (int) ($byBot[$bot]['tin'] ?? 0) + (int) $r['provider_input']; $byBot[$bot]['tout'] = (int) ($byBot[$bot]['tout'] ?? 0) + (int) $r['provider_output']; } if ($measure !== 'none') { $bySource[$key]['units'][$measure] = ($bySource[$key]['units'][$measure] ?? 0) + $r['units']; $byBot[$bot]['units'][$measure] = ($byBot[$bot]['units'][$measure] ?? 0) + $r['units']; } } $used = array_values(array_filter(array_keys($cols), static function (string $c) use ($bySource): bool { foreach ($bySource as $s) { if (isset($s['units'][$c])) { return true; } } return false; })); $cell = static fn(array $units, string $c): string => isset($units[$c]) ? esc_html($n((int) $units[$c])) : '—'; echo '

    ' . esc_html__('Where usage came from', 'aiwend') . '

    '; echo '
    '; echo '

    ' . esc_html__('Units in different columns mean different things, so they are not added together: provider-reported and admin-entered usage is confirmed; estimates and held amounts are not; simulated units are synthetic. Aiwend does not know what your provider actually charges you or your provider account balance; estimated costs use bundled list prices or rates you enter.', 'aiwend') . '

    '; if ($bySource === []) { echo '

    ' . esc_html__('No usage recorded in this period.', 'aiwend') . '

    '; return; } echo '
    '; foreach ($used as $c) { echo ''; } echo ''; foreach ($bySource as $s) { $conn = $s['connection'] !== null ? ($nullsafeVariable7 = $repo) ? $nullsafeVariable7->find($site, (int) $s['connection']) : null : null; /* translators: %d: number of the removed connection */ $connText = $s['connection'] === null ? '—' : ($conn !== null ? $conn['label'] . ' (' . \Aiwend\Provider\Providers::name($conn['provider']) . ')' : sprintf(__('Removed connection #%d', 'aiwend'), (int) $s['connection'])); echo ''; foreach ($used as $c) { echo wp_kses( '', AdminView::allowedHtml()); } echo ''; } echo '
    ' . esc_html__('Source', 'aiwend') . '' . esc_html__('Connection', 'aiwend') . '' . esc_html__('Attempts', 'aiwend') . '' . esc_html($cols[$c]) . '
    ' . esc_html(self::originText($s['origin'])) . '' . esc_html($connText) . '' . esc_html($n($s['requests'])) . '' . $cell($s['units'], $c) . '
    '; echo '

    ' . esc_html__('By assistant', 'aiwend') . '

    '; foreach ($used as $c) { echo ''; } echo ''; foreach ($byBot as $botId => $b) { $bot = $botId > 0 ? $this->plugin->bots->find($site, (int) $botId) : null; /* translators: %d: number of the deleted assistant */ $name = $botId === 0 ? __('Not recorded (older records and connection tests)', 'aiwend') : ($bot !== null ? (string) $bot['name'] : sprintf(__('Deleted assistant #%d', 'aiwend'), (int) $botId)); echo ''; foreach ($used as $c) { echo wp_kses( '', AdminView::allowedHtml()); } echo wp_kses( '', AdminView::allowedHtml()); } echo '
    ' . esc_html__('Assistant', 'aiwend') . '' . esc_html__('Attempts', 'aiwend') . '' . esc_html($cols[$c]) . '' . esc_html__('Provider tokens (input / output)', 'aiwend') . '
    ' . esc_html($name) . '' . esc_html($n($b['requests'])) . '' . $cell($b['units'], $c) . '' . UsageReports::tokensCell($b['requests'], $b['tin'], $b['tout'], $b['with'], $b['requests']) . '
    '; // The model identifier each request carried (what Aiwend asked for; a provider may serve a newer snapshot under the same name). $models = $this->plugin->ledger->modelBreakdown($site, $since); if ($models !== []) { echo '

    ' . esc_html__('By model requested', 'aiwend') . '

    '; foreach ($models as $m) { echo wp_kses( '', AdminView::allowedHtml()); } echo '
    ' . esc_html__('Model', 'aiwend') . '' . esc_html__('Attempts', 'aiwend') . '' . esc_html__('Usage units', 'aiwend') . '' . esc_html__('Provider tokens (input / output)', 'aiwend') . '
    ' . ($m['model'] === null ? esc_html__('Not recorded', 'aiwend') : '' . esc_html($m['model']) . '') . '' . esc_html($n($m['requests'])) . '' . esc_html($n($m['units'])) . '' . UsageReports::tokensCell($m['requests'], $m['provider_input'], $m['provider_output'], $m['with_breakdown'], $m['requests']) . '

    ' . esc_html__('This is the model name Aiwend sent with the request. Records from before this was kept show “Not recorded”.', 'aiwend') . '

    '; } echo ''; } // ---------------------------------------------------------------- Settings public function pageSettings(): void { $tabs = ['general' => __('General', 'aiwend'), 'privacy' => __('Privacy & data', 'aiwend'), 'diagnostics' => __('Diagnostics', 'aiwend'), 'advanced' => __('Advanced', 'aiwend')]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation or filter parameter; no state is changed $tab = isset($_GET['tab']) ? sanitize_key(Input::get('tab')) : 'general'; $tab = isset($tabs[$tab]) ? $tab : 'general'; AdminView::open($this->plugin, 'aiwend-settings', __('Settings', 'aiwend'), __('How your assistants work on this site.', 'aiwend')); AdminView::notice(); echo ''; switch (true) { case $tab === 'privacy': $this->settingsPrivacy(); break; case $tab === 'diagnostics': $this->diagnostics(); break; case $tab === 'advanced': $this->settingsAdvanced(); break; default: $this->settingsGeneral(); break; } AdminView::close(); } /** Old address of Diagnostics: same content as Settings > Diagnostics. */ public function pageDiagnostics(): void { AdminView::open($this->plugin, 'aiwend-diagnostics', __('Diagnostics', 'aiwend'), __('Health of your assistants and the components they rely on.', 'aiwend')); AdminView::notice(); // the recovery actions return here with their result echo ''; $this->diagnostics(); AdminView::close(); } private function settingsGeneral(): void { echo '

    ' . esc_html__('AI responses', 'aiwend') . '

    '; echo '

    ' . esc_html__('Assistants answer through your own AI provider account (Aiwend > AI connections), or through the local simulated demo below. Provider connections were checked end to end once with real accounts and sample content; they are otherwise tested with recorded responses, so try them on a staging site first.', 'aiwend') . '

    '; $on = (bool) $this->plugin->options->get(Plugin::DEMO_OPTION, false); if ($this->plugin->demoForcedByConstant()) { // Never show "demo on" in the header and an unticked box here at the same time. echo wp_kses( '

    ' . AdminView::badge(__('Simulated demo is on', 'aiwend'), 'warn') . '

    ' . esc_html__('It is switched on by the development setting AIWEND_DEV_MOCK_PROVIDER in wp-config.php, so it cannot be turned off here. Remove that line to control it with this setting.', 'aiwend') . ' ' . esc_html(DemoNotice::scope($this->hasRealConnection())) . '

    ', AdminView::allowedHtml()); $this->settingsModulesAndLinks(); return; } if (!$this->plugin->demoAllowedHere()) { echo '

    ' . esc_html__('This is not a local site, so the simulated demo has no effect here.', 'aiwend') . '

    '; } AdminView::form('save_demo', 'aiwend-settings', [], false, 'data-aiwend-track'); echo ''; echo '

    '; $this->settingsModulesAndLinks(); } private function settingsModulesAndLinks(): void { // P88-4: say plainly who can change what the assistants quote to visitors. echo wp_kses( '
    ' . esc_html__('Who can change what', 'aiwend') . ' ' . AdminView::badge(__('Administrators: everything. Editors: knowledge, private only.', 'aiwend'), 'neutral') . '

    ' . esc_html__('Administrators can manage everything. Editors can manage the knowledge library by default: they can add knowledge, which starts private, and remove it or make it private again. Only administrators can approve a source for answers, because public knowledge is quoted to anonymous visitors in chat answers; that needs the aiwend_publish_knowledge capability, which editors do not have. To withhold the library from editors altogether, remove the aiwend_manage_knowledge capability from the Editor role with a role-management plugin. Other roles have no access.', 'aiwend') . '

    ', AdminView::allowedHtml()); echo '

    ' . esc_html__('Modules', 'aiwend') . '

      '; $consequence = [ 'chat' => __('Turning Chat off removes the chat from your pages and stops answers. Conversations and settings are kept.', 'aiwend'), 'knowledge' => __('Turning Knowledge off keeps all sources. Assistants that use knowledge stop answering until it is back on; they never answer without it.', 'aiwend'), 'content_studio' => __('Content Studio drafts are written only when someone asks. Turning it off removes its screen; drafts already saved stay as ordinary WordPress drafts.', 'aiwend'), 'ai_forms' => __('AI Forms shows a simple inquiry form on your pages. Messages are stored on this site for you to read; nothing is sent to an AI provider unless you press Analyze on one message. Turning it off removes the form from your pages; stored messages are kept until they expire.', 'aiwend'), 'mcp' => __('MCP lets an AI app that you connect read a few public facts about this site. It exposes nothing until you tick a tool on its screen, only reads, and needs an application password of an administrator. Turning it off closes the endpoint.', 'aiwend'), 'woocommerce_ai' => __('WooCommerce AI needs WooCommerce on this site. It only proposes product text and changes a product when you confirm. Turning it off removes its screen; products are never changed or removed.', 'aiwend'), ]; foreach ($this->plugin->modules->all() as $id => $m) { $on = $this->plugin->modules->isEnabled($id); $unavailable = !$on && !$m->prerequisitesMet(); echo wp_kses( '
    • ' . esc_html($m->label()) . ' ' . ($unavailable ? AdminView::badge(__('Unavailable', 'aiwend'), 'warn') : ($on ? AdminView::badge(__('On', 'aiwend'), 'success') : AdminView::badge(__('Off', 'aiwend'), 'neutral'))) . '
      ' . esc_html($consequence[$id] ?? '') . '' . ($unavailable ? '
      ' . esc_html($this->unavailableText($m)) . '' : '') . '
      ', AdminView::allowedHtml()); if ($unavailable) { echo '
    • '; // no switch: the requirement is explained above continue; } AdminView::form('toggle_module', 'aiwend-settings', ['module' => $id, 'enabled' => $on ? '0' : '1']); echo ''; } echo '
    '; echo '

    ' . esc_html__('Links in answers', 'aiwend') . '

    ' . esc_html__('Answers may contain plain links only to this site and to the hosts listed here. A link is not allowed just because it appears in a knowledge source. Images and other embedded content are never shown.', 'aiwend') . '

    '; AdminView::form('save_links', 'aiwend-settings', [], false, 'data-aiwend-track'); $current = implode("\n", (array) $this->plugin->options->get('aiwend_link_allowlist', [])); echo '

    ' . esc_html__('One per line, for example help.example.com.', 'aiwend') . '

    '; echo '

    '; } private function settingsPrivacy(): void { echo '

    ' . esc_html__('Data retention', 'aiwend') . '

    '; AdminView::form('save_retention', 'aiwend-settings', ['tab' => 'privacy'], false, 'data-aiwend-track'); echo '

    ' . esc_html__('Conversations older than this are deleted by a daily background job. Deleted conversations cannot be recovered.', 'aiwend') . '

    '; echo '

    ' . esc_html__('The activity log records actions and results, not message content.', 'aiwend') . '

    '; echo '

    ' . esc_html__('Administrators who may read conversations can review visitors’ questions to provide support and improve the assistant. Counts and recurring unanswered questions on the Conversations screen are calculated from stored conversations when you open it; they are not kept separately and disappear when a conversation is deleted. The chat window tells visitors that their conversation may be reviewed.', 'aiwend') . '

    '; echo '

    ' . esc_html__('The defaults (30 days) are provisional engineering defaults, not legal advice.', 'aiwend') . '

    '; echo '

    '; echo '

    ' . esc_html__('Personal data requests', 'aiwend') . '

    ' . esc_html__('Aiwend takes part in WordPress\'s own export and erase tools for logged-in users: their conversations on this site are included in exports and removed by erasure. Visitors without an account can delete their conversation from the chat window.', 'aiwend') . '

    '; if (current_user_can('export_others_personal_data')) { echo '' . esc_html__('Export personal data', 'aiwend') . ' '; } if (current_user_can('erase_others_personal_data')) { echo '' . esc_html__('Erase personal data', 'aiwend') . ''; } echo '

    ' . esc_html__('These controls delete local data only. External providers and backups may retain separate records. Usage records (anonymous request IDs and unit counts) are kept for spending control and contain no message content.', 'aiwend') . '

    '; echo '

    ' . esc_html__('Irreversible settings', 'aiwend') . '

    '; $this->uninstallCard(); } private function uninstallCard(): void { $delete = (bool) get_option(\Aiwend\Lifecycle\Uninstaller::OPTION, false); echo '

    ' . esc_html__('When the plugin is deleted', 'aiwend') . '

    '; echo '

    ' . esc_html__('Deactivating the plugin never deletes anything. By default, deleting it from the Plugins screen also keeps your data (assistants, knowledge, conversations, usage records and settings), so reinstalling restores everything.', 'aiwend') . '

    '; echo wp_kses( '

    ' . AdminView::badge($delete ? __('Delete everything', 'aiwend') : __('Keep my data', 'aiwend'), $delete ? 'warn' : 'success') . '

    ', AdminView::allowedHtml()); AdminView::form('save_uninstall', 'aiwend-settings'); echo ''; if (!$delete) { echo '

    '; } echo ''; echo '

    ' . esc_html__('Back up first. Deleted data cannot be recovered. On a multisite network each site has its own setting, and data is deleted only for sites that turned it on.', 'aiwend') . '

    '; } private function settingsAdvanced(): void { // Provider keys are added and managed only under AI Connections (one flow, with the // data-destination review and explicit access checks). KeyPanel::render($this->plugin); echo '

    ' . esc_html($this->plugin->connections() === null ? __('Provider keys can be saved after an encryption key exists (see above).', 'aiwend') : __('Provider keys are added and managed under AI Connections; only a fingerprint is shown after saving.', 'aiwend')) . ' ' . esc_html__('Manage AI connections', 'aiwend') . '

    '; } private function diagnostics(): void { (new HealthPanel($this->plugin))->needsAttention(); $site = get_current_blog_id(); $db = $this->plugin->databaseCompatibility(); $health = $this->plugin->queue->health($site); $last = (int) get_option(CronDiagnostics::LAST_RUN_OPTION, 0); $next = wp_next_scheduled(Plugin::CRON_HOOK); $sum = $this->plugin->ledger->usageSummary($site); $failedSources = count(array_filter($this->plugin->sources()->list($site), static fn($s) => in_array($s['status'], ['failed', 'ocr_required'], true))); $items = []; $aiNote = trim(DemoNotice::assistants($this->assistantModes(), $this->plugin->mockProviderEnabled()) . ' ' . DemoNotice::otherFeatures($this->hasRealConnection())); $items[] = $this->plugin->hasAssignedProvider() ? ['warn', $aiNote . ' ' . __('Check each connection; live quality and billing need separate verification.', 'aiwend'), AdminView::url('aiwend-connections'), __('AI connections', 'aiwend')] : ($this->plugin->mockProviderEnabled() ? ['warn', $aiNote, '', ''] : ['warn', __('No AI responses: assign a connection or enable the local demo.', 'aiwend'), AdminView::url('aiwend-connections'), __('AI connections', 'aiwend')]); $schedOk = CronDiagnostics::count() === 1; $recent = $last > 0 && time() - $last < 900; $items[] = [$schedOk && $recent ? 'success' : 'warn', /* translators: %s: time span since the last background run, for example "5 minutes" */ ($schedOk ? __('Background jobs are scheduled', 'aiwend') : __('Background jobs are not scheduled correctly', 'aiwend')) . '; ' . ($last ? sprintf(__('last run %s ago.', 'aiwend'), human_time_diff($last)) : __('no run recorded yet.', 'aiwend')) . ($recent ? '' : ' ' . __('On low-traffic sites jobs run when pages are visited; a server cron job makes them punctual.', 'aiwend')), '', '']; if ((int) ($health['counts']['dead'] ?? 0) > 0) { /* translators: %d: number of background jobs that failed */ $items[] = ['danger', sprintf(_n('%d background job failed and needs attention.', '%d background jobs failed and need attention.', (int) $health['counts']['dead'], 'aiwend'), (int) $health['counts']['dead']), '', '']; } if ($failedSources > 0) { /* translators: %d: number of knowledge sources that could not be processed */ $items[] = ['danger', sprintf(_n('%d knowledge source could not be processed.', '%d knowledge sources could not be processed.', $failedSources, 'aiwend'), $failedSources), AdminView::url('aiwend-knowledge'), __('Open knowledge', 'aiwend')]; } if ($sum['uncertain']['requests'] + $sum['estimated_unconfirmed']['requests'] > 0) { $items[] = ['warn', __('Some usage needs review.', 'aiwend'), AdminView::url('aiwend-usage'), __('Open usage', 'aiwend')]; } if (Compatibility::needsAttention($db['status'])) { /* translators: 1: database engine name, 2: database version, 3: minimum MySQL version, 4: minimum MariaDB version */ $items[] = ['warn', sprintf(__('Database %1$s %2$s is older than the minimum Aiwend supports (MySQL %3$s, MariaDB %4$s). Some features may not work.', 'aiwend'), $db['engine'], $db['version'], Compatibility::MIN_MYSQL, Compatibility::MIN_MARIADB), '', '']; } $problemsN = count(array_filter($items, static fn(array $i): bool => $i[0] === 'danger')); $notesN = count(array_filter($items, static fn(array $i): bool => $i[0] === 'warn')); echo '

    ' . esc_html__('Health summary', 'aiwend') . '

    '; if ($problemsN > 0) { /* translators: %d: number of problems found */ $healthBadge = AdminView::badge(sprintf(_n('%d problem needs attention', '%d problems need attention', $problemsN, 'aiwend'), $problemsN), 'danger'); } elseif ($notesN > 0) { /* translators: %d: number of notes */ $healthBadge = AdminView::badge(sprintf(_n('No problems, %d note', 'No problems, %d notes', $notesN, 'aiwend'), $notesN), 'warn'); } else { $healthBadge = AdminView::badge(__('Everything looks fine', 'aiwend'), 'success'); } echo wp_kses( '

    ' . $healthBadge . ' ' . esc_html__('Details are in the sections below.', 'aiwend') . '

      ', AdminView::allowedHtml()); $labels = ['success' => __('OK', 'aiwend'), 'warn' => __('Note', 'aiwend'), 'danger' => __('Problem', 'aiwend')]; foreach ($items as [$tone, $text, $url, $label]) { echo wp_kses( '
    • ' . AdminView::badge($labels[$tone], $tone) . ' ' . esc_html($text) . ($url !== '' ? ' ' . esc_html($label) . '' : '') . '
    • ', AdminView::allowedHtml()); } echo '
    '; switch (true) { case $db['status'] === 'verified': $dbStatusText = __('verified series', 'aiwend'); break; case $db['status'] === 'unverified': $dbStatusText = __('meets the minimum; this release series was not individually verified and no problem is known', 'aiwend'); break; case $db['status'] === 'unsupported': $dbStatusText = __('older than the minimum Aiwend supports', 'aiwend'); break; default: $dbStatusText = __('version could not be read', 'aiwend'); break; } $env = [ __('Aiwend version', 'aiwend') => AIWEND_VERSION, __('Database schema', 'aiwend') => (string) \Aiwend\Db\Schema::VERSION, __('PHP', 'aiwend') => PHP_VERSION . (version_compare(PHP_VERSION, Compatibility::MIN_PHP, '>=') ? '' : ' ' . __('(below minimum)', 'aiwend')), __('WordPress', 'aiwend') => get_bloginfo('version'), __('Database', 'aiwend') => $db['engine'] . ' ' . $db['version'] . ' — ' . $dbStatusText, ]; $this->detailsTable(__('Environment', 'aiwend'), $env); $demoRow = $this->plugin->mockProviderEnabled() ? [__('Simulated demo', 'aiwend') => DemoNotice::scope($this->hasRealConnection()) . ' ' . DemoNotice::brief()] : []; $this->detailsTable(__('Answers and retrieval', 'aiwend'), $demoRow + [ __('Answer generation', 'aiwend') => $this->plugin->hasAssignedProvider() ? __('External AI assigned; per-connection status applies', 'aiwend') : ($this->plugin->mockProviderEnabled() ? __('Simulated stand-in (no AI model)', 'aiwend') : __('None available', 'aiwend')), /* translators: %s: name of the word-matching retrieval model */ __('Retrieval', 'aiwend') => sprintf(__('Experimental lexical retrieval (word overlap, %s); not a semantic embedding model', 'aiwend'), $this->plugin->embedder()->model()), __('Citations', 'aiwend') => __('Each source shown was given to the generator. Whether it supports the sentence is not evaluated.', 'aiwend'), ]); $this->detailsTable(__('Background jobs', 'aiwend'), [ /* translators: %d: number of scheduled background-job events found */ __('Schedule', 'aiwend') => CronDiagnostics::count() === 1 ? __('Scheduled', 'aiwend') : sprintf(__('%d events (expected 1)', 'aiwend'), CronDiagnostics::count()), __('Next scheduled run', 'aiwend') => $next ? wp_date('Y-m-d H:i', (int) $next) : __('None', 'aiwend'), __('Last run', 'aiwend') => $last ? wp_date('Y-m-d H:i', $last) : __('Never', 'aiwend'), __('Queued jobs', 'aiwend') => (string) ($health['counts']['queued'] ?? 0), __('Failed jobs', 'aiwend') => (string) ($health['counts']['dead'] ?? 0), __('Diagnostic records suppressed by rate limit', 'aiwend') => (string) (int) get_option(CronDiagnostics::SUPPRESSED_OPTION, 0), ]); echo '
    ' . esc_html__('Recent activity', 'aiwend') . '

    ' . esc_html__('Actions and results only; no message content or keys. For a refused AI request, Detail shows the provider answer type and its HTTP number (for example 404 means the model is not available to this account).', 'aiwend') . '

    '; foreach ($this->plugin->audit->recent($site, 30) as $a) { printf('', esc_html(wp_date('Y-m-d H:i', (int) $a['created_at'])), esc_html((string) $a['actor']), esc_html((string) $a['action']), esc_html((string) $a['outcome']), esc_html((string) ($a['detail'] ?? '')), esc_html((string) ($a['request_id'] ?? ''))); } echo '
    ' . esc_html__('When', 'aiwend') . '' . esc_html__('Who', 'aiwend') . '' . esc_html__('What', 'aiwend') . '' . esc_html__('Result', 'aiwend') . '' . esc_html__('Detail', 'aiwend') . '' . esc_html__('Request ID', 'aiwend') . '
    %s%s%s%s%s%s
    '; } /** @param array $rows */ private function detailsTable(string $title, array $rows): void { echo '
    ' . esc_html($title) . ''; foreach ($rows as $k => $v) { echo ''; } echo '
    ' . esc_html($k) . '' . esc_html($v) . '
    '; } }