Link assistant: internal-link suggestions for a post, and a chat about them. * * The suggestions come from LinkPlanner (computed, no AI). The chat model only talks about * them: it gets the suggestions as data, may refer to them as [#id], and the server removes any * URL it writes, so it cannot send the editor to a page that is not a real suggestion. */ final class LinkAssistant { public const SLUG = 'aipcf-link-assistant'; public const TASK = 'links'; private const NONCE = 'aipcf_links'; private const MAX_TURNS = 20; /** The page's hook suffix, from add_submenu_page(). */ private static string $hook = ''; private const MAX_MESSAGE = 2000; /** The whole conversation sent to the model, in characters. */ private const MAX_TRANSCRIPT = 12000; /** Chat messages per user per minute. */ private const PER_MINUTE = 6; public static function boot(): void { add_action('aipcf_embed_tick_done', [self::class, 'afterTick'], 20, 2); if (!is_admin()) { return; } add_action('admin_menu', [self::class, 'registerMenu'], 20); // after the plugin's own menu add_action('admin_enqueue_scripts', [self::class, 'enqueue']); add_filter('post_row_actions', [self::class, 'rowAction'], 10, 2); add_filter('page_row_actions', [self::class, 'rowAction'], 10, 2); foreach (['find', 'plan', 'orphans', 'chat'] as $action) { add_action('wp_ajax_aipcf_links_' . $action, [self::class, 'ajax' . ucfirst($action)]); } } public static function enabled(): bool { return !empty(Settings::get('links_enabled')) && EmbeddingSettings::enabled(); } /** * After an embeddings run: record terms and links of what was embedded. * * @param array $out */ public static function afterTick(array $out, float $deadline): void { if (self::enabled() && !empty($out['ran']) && microtime(true) < $deadline) { try { LinkIndex::refresh($deadline); } catch (\Throwable $e) { unset($e); // must not stop the other features' work after the run } } } public static function registerMenu(): void { // Under the plugin's own menu (Cloudflare AI), next to its settings. Administrators always // see it (when it is off, the page says what to switch on); editors only when it is on. if (!self::enabled() && !current_user_can('manage_options')) { return; } // 2.0: administrators open it from its card on Features; editors, who see nothing // else of the plugin, keep it in their menu. self::$hook = (string) add_submenu_page( current_user_can('manage_options') ? '' : SettingsPage::PAGE_SLUG, __('Link assistant', 'aipcf-ai-provider-for-cloudflare'), __('Link assistant', 'aipcf-ai-provider-for-cloudflare'), self::enabled() ? 'edit_posts' : 'manage_options', self::SLUG, [self::class, 'render'] ); } public static function pageUrl(int $postId = 0): string { return add_query_arg(array_filter(['page' => self::SLUG, 'post' => $postId ?: null]), admin_url('admin.php')); } /** * @param array $actions * @return array */ public static function rowAction(array $actions, \WP_Post $post): array { if ( self::enabled() && $post->post_status === 'publish' && current_user_can('edit_post', $post->ID) && in_array($post->post_type, (array) (get_plugin_settings()['embed_post_types'] ?? []), true) ) { $actions['aipcf_links'] = sprintf('%s', esc_url(self::pageUrl($post->ID)), esc_html__('Suggest links', 'aipcf-ai-provider-for-cloudflare')); } return $actions; } public static function enqueue(string $hook): void { if (self::$hook === '' || $hook !== self::$hook || !self::enabled()) { return; } $base = plugin_dir_url(AIPCF_FILE) . 'assets/admin/'; wp_enqueue_style('aipcf-admin', $base . 'settings.css', [], AIPCF_VERSION); // shared page background wp_enqueue_style('aipcf-links', $base . 'links.css', ['aipcf-admin'], AIPCF_VERSION); wp_enqueue_script('aipcf-links', $base . 'links.js', [], AIPCF_VERSION, true); $post = isset($_GET['post']) ? absint($_GET['post']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- preselects a post; nothing is changed wp_localize_script('aipcf-links', 'aipcfLinks', [ 'ajaxUrl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce(self::NONCE), 'post' => $post > 0 && current_user_can('edit_post', $post) ? ['id' => $post, 'title' => html_entity_decode(get_the_title($post), ENT_QUOTES, 'UTF-8')] : null, 'i18n' => [ 'noPosts' => __('No published post you can edit matches.', 'aipcf-ai-provider-for-cloudflare'), 'use' => __('Suggest links', 'aipcf-ai-provider-for-cloudflare'), 'working' => __('Finding suggestions...', 'aipcf-ai-provider-for-cloudflare'), 'none' => __('No post is close enough to suggest a link. Try again when there is more content on this topic.', 'aipcf-ai-provider-for-cloudflare'), 'already' => __('Already linked', 'aipcf-ai-provider-for-cloudflare'), 'noSentence' => __('No sentence uses a shared word. Add a sentence that mentions it.', 'aipcf-ai-provider-for-cloudflare'), 'copy' => __('Copy link', 'aipcf-ai-provider-for-cloudflare'), 'copied' => __('Copied', 'aipcf-ai-provider-for-cloudflare'), 'copyFailed' => __('Copy failed. Select the link and copy it.', 'aipcf-ai-provider-for-cloudflare'), 'copyField' => __('Text to copy', 'aipcf-ai-provider-for-cloudflare'), 'edit' => __('Edit that post', 'aipcf-ai-provider-for-cloudflare'), 'editThis' => __('Edit this post', 'aipcf-ai-provider-for-cloudflare'), 'orphansNone' => __('Every embedded post has at least one link to it.', 'aipcf-ai-provider-for-cloudflare'), 'orphansHead' => __('Posts with no links to them', 'aipcf-ai-provider-for-cloudflare'), 'thinking' => __('Thinking...', 'aipcf-ai-provider-for-cloudflare'), 'you' => __('You', 'aipcf-ai-provider-for-cloudflare'), 'assistant' => __('Assistant', 'aipcf-ai-provider-for-cloudflare'), 'pickFirst' => __('Pick a post first.', 'aipcf-ai-provider-for-cloudflare'), 'error' => __('Something went wrong. Try again.', 'aipcf-ai-provider-for-cloudflare'), 'suggestionsFor' => __('Suggestions for', 'aipcf-ai-provider-for-cloudflare'), 'viewPost' => __('View post', 'aipcf-ai-provider-for-cloudflare'), // Singular and plural of each count; links.js picks one (n === 1 ? one : many). 'related' => [ /* translators: %d: number of posts */ _n('%d related post', '%d related posts', 1, 'aipcf-ai-provider-for-cloudflare'), /* translators: %d: number of posts */ _n('%d related post', '%d related posts', 2, 'aipcf-ai-provider-for-cloudflare'), ], 'toAdd' => [ /* translators: %d: number of links */ _n('%d link to add', '%d links to add', 1, 'aipcf-ai-provider-for-cloudflare'), /* translators: %d: number of links */ _n('%d link to add', '%d links to add', 2, 'aipcf-ai-provider-for-cloudflare'), ], 'toGetBack' => [ /* translators: %d: number of links */ _n('%d link back to ask for', '%d links back to ask for', 1, 'aipcf-ai-provider-for-cloudflare'), /* translators: %d: number of links */ _n('%d link back to ask for', '%d links back to ask for', 2, 'aipcf-ai-provider-for-cloudflare'), ], 'mentioned' => [ /* translators: %d: number of posts */ _n('Showing the %d post the assistant mentioned.', 'Showing the %d posts the assistant mentioned.', 1, 'aipcf-ai-provider-for-cloudflare'), /* translators: %d: number of posts */ _n('Showing the %d post the assistant mentioned.', 'Showing the %d posts the assistant mentioned.', 2, 'aipcf-ai-provider-for-cloudflare'), ], 'linksOut' => [ /* translators: %d: number of links */ _n('%d link out', '%d links out', 1, 'aipcf-ai-provider-for-cloudflare'), /* translators: %d: number of links */ _n('%d link out', '%d links out', 2, 'aipcf-ai-provider-for-cloudflare'), ], 'filterAll' => __('All', 'aipcf-ai-provider-for-cloudflare'), 'filterOut' => __('To link from here', 'aipcf-ai-provider-for-cloudflare'), 'filterBack' => __('To link back', 'aipcf-ai-provider-for-cloudflare'), 'filterDone' => __('Already linked', 'aipcf-ai-provider-for-cloudflare'), 'filterLabel' => __('Show', 'aipcf-ai-provider-for-cloudflare'), /* translators: %d: percentage */ 'match' => __('%d%% match', 'aipcf-ai-provider-for-cloudflare'), 'fromHere' => __('From this post', 'aipcf-ai-provider-for-cloudflare'), 'backThere' => __('Back from that post', 'aipcf-ai-provider-for-cloudflare'), 'linkedBack' => __('Links here already', 'aipcf-ai-provider-for-cloudflare'), 'copyHtml' => __('Copy as HTML', 'aipcf-ai-provider-for-cloudflare'), 'showAll' => __('Show all', 'aipcf-ai-provider-for-cloudflare'), 'nothingHere' => __('Nothing in this view.', 'aipcf-ai-provider-for-cloudflare'), 'orphansText' => __('None of these embedded posts has a link to it from another post. Pick one to find posts that should link to it.', 'aipcf-ai-provider-for-cloudflare'), /* translators: 1: number of posts shown, 2: number of posts in all */ 'showing' => __('Showing %1$d of %2$d.', 'aipcf-ai-provider-for-cloudflare'), /* translators: 1: a count like "6 related posts", 2: a count like "1 link to add" */ 'planStatus' => __('%1$s, %2$s.', 'aipcf-ai-provider-for-cloudflare'), ], ]); } public static function render(): void { if (!current_user_can('edit_posts')) { wp_die(esc_html__('You do not have permission to do this.', 'aipcf-ai-provider-for-cloudflare'), 403); } $admin = current_user_can('manage_options'); $settings = FeaturesPage::url('links') . '#aipcf-links-enabled'; $sub = __('Internal links your posts should have, with the exact words and sentence for each. Nothing changes until you edit a post.', 'aipcf-ai-provider-for-cloudflare'); $side = $admin ? sprintf('%s', esc_url($settings), esc_html__('Settings', 'aipcf-ai-provider-for-cloudflare')) : ''; if (!self::enabled()) { // Switched on but Embeddings are off: say what is actually missing. $needsEmbeddings = !empty(Settings::get('links_enabled')); if ($needsEmbeddings) { $pill = ['is-warn', __('Needs you', 'aipcf-ai-provider-for-cloudflare')]; $title = __('Link assistant needs the content index', 'aipcf-ai-provider-for-cloudflare'); $text = __('Switch the content index on in AIPCF > Settings > Content index.', 'aipcf-ai-provider-for-cloudflare'); $button = $admin ? sprintf('%s', esc_url(SettingsPage::tabUrl('embeddings')), esc_html__('Open Content index', 'aipcf-ai-provider-for-cloudflare')) : ''; } else { $pill = ['is-off', __('Off', 'aipcf-ai-provider-for-cloudflare')]; /* translators: %s: feature name, like "Link assistant" */ $title = sprintf(__('%s is off', 'aipcf-ai-provider-for-cloudflare'), __('Link assistant', 'aipcf-ai-provider-for-cloudflare')); $text = __('Switch it on in AIPCF > Features > Link assistant. It needs the content index.', 'aipcf-ai-provider-for-cloudflare'); $button = $admin ? sprintf('%s', esc_url($settings), esc_html__('Open Link assistant', 'aipcf-ai-provider-for-cloudflare')) : ''; } echo '', esc_html($title), esc_html($text), $button // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- built above from escaped parts ); return; } $left = Budget::remaining(self::TASK); $pill = $left === null ? ['is-ok', __('Chat ready', 'aipcf-ai-provider-for-cloudflare')] /* translators: %s: number of neurons */ : [$left <= 0 ? 'is-warn' : 'is-ok', sprintf(__('%s chat neurons left today', 'aipcf-ai-provider-for-cloudflare'), number_format_i18n($left))]; ?> __('You do not have permission to do this.', 'aipcf-ai-provider-for-cloudflare')], 403); } if (!self::enabled()) { $message = empty(Settings::get('links_enabled')) ? __('Link assistant is off. Switch it on in AIPCF > Features > Link assistant. It needs the content index.', 'aipcf-ai-provider-for-cloudflare') : __('Link assistant needs the content index. Switch it on in AIPCF > Settings > Content index.', 'aipcf-ai-provider-for-cloudflare'); wp_send_json_error(['message' => $message]); } } /** * Published posts of the embedded types, matching a title search, that this user may edit. */ public static function ajaxFind(): void { self::check(); $q = isset($_POST['q']) ? sanitize_text_field(wp_unslash((string) $_POST['q'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified in check() $types = (array) (get_plugin_settings()['embed_post_types'] ?? ['post']); $posts = get_posts(['s' => $q, 'post_type' => $types, 'post_status' => 'publish', 'posts_per_page' => 20, 'orderby' => 'relevance', 'suppress_filters' => false]); // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.SuppressFilters_suppress_filters -- let multilingual plugins and search tweaks apply $out = []; foreach ($posts as $p) { if (current_user_can('edit_post', $p->ID)) { $out[] = ['id' => $p->ID, 'title' => html_entity_decode(get_the_title($p), ENT_QUOTES, 'UTF-8'), 'date' => get_the_date('Y-m-d', $p)]; } } wp_send_json_success(array_slice($out, 0, 10)); } public static function ajaxPlan(): void { self::check(); $postId = isset($_POST['post']) ? absint($_POST['post']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified in check() if (!current_user_can('edit_post', $postId)) { wp_send_json_error(['message' => __('You cannot edit that post.', 'aipcf-ai-provider-for-cloudflare')], 403); } $plan = self::cachedPlan($postId); if (is_wp_error($plan)) { wp_send_json_error(['message' => $plan->get_error_code() === 'aipcf_links_post' || current_user_can('manage_options') ? $plan->get_error_message() : __('Suggestions are not available just now. Try again later.', 'aipcf-ai-provider-for-cloudflare')]); } // Edit links depend on who is looking, so they are added here, not cached with the plan. $plan['post']['edit'] = (string) get_edit_post_link($postId, 'raw'); foreach ($plan['candidates'] as $i => $c) { $plan['candidates'][$i]['edit'] = current_user_can('edit_post', (int) $c['id']) ? (string) get_edit_post_link((int) $c['id'], 'raw') : ''; } wp_send_json_success($plan); } public static function ajaxOrphans(): void { self::check(); LinkIndex::install(); wp_send_json_success(LinkIndex::orphans(20)); } public static function ajaxChat(): void { self::check(); $postId = isset($_POST['post']) ? absint($_POST['post']) : 0; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified in check() if (!current_user_can('edit_post', $postId)) { wp_send_json_error(['message' => __('You cannot edit that post.', 'aipcf-ai-provider-for-cloudflare')], 403); } $raw = isset($_POST['messages']) ? json_decode(wp_unslash((string) $_POST['messages']), true) : []; // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- decoded, then every field is sanitised in messages() $messages = self::messages(is_array($raw) ? $raw : []); if ($messages === [] || end($messages)['role'] !== 'user') { wp_send_json_error(['message' => __('Type a question first.', 'aipcf-ai-provider-for-cloudflare')]); } $allowed = Budget::allows(self::TASK); if (is_wp_error($allowed)) { wp_send_json_error(['message' => $allowed->get_error_message()]); } // Per user, so one account cannot use up everyone's daily chat allowance. $rate = 'aipcf_links_rate_' . get_current_user_id(); $count = (int) get_transient($rate); if ($count >= self::PER_MINUTE) { wp_send_json_error(['message' => __('Too many messages in a minute. Wait a moment and try again.', 'aipcf-ai-provider-for-cloudflare')]); } set_transient($rate, $count + 1, MINUTE_IN_SECONDS); $plan = self::cachedPlan($postId); if (is_wp_error($plan)) { wp_send_json_error(['message' => $plan->get_error_message()]); } $reply = self::ask($plan, $messages); if (is_wp_error($reply)) { // Provider errors can name models, gateways or accounts: only administrators see them. wp_send_json_error(['message' => current_user_can('manage_options') ? $reply->get_error_message() : __('The assistant could not answer just now. Try again later.', 'aipcf-ai-provider-for-cloudflare')]); } wp_send_json_success($reply); } /** * The conversation as sent by the browser, cut down to what is safe to pass on: user and * assistant turns only (no injected system turn), plain text, the last 20. * * @param array $raw * @return list */ public static function messages(array $raw): array { $out = []; foreach ($raw as $m) { if (!is_array($m) || !in_array($m['role'] ?? '', ['user', 'assistant'], true)) { continue; } $text = trim(sanitize_textarea_field((string) ($m['content'] ?? ''))); if ($text !== '') { $out[] = ['role' => (string) $m['role'], 'content' => mb_substr($text, 0, self::MAX_MESSAGE)]; } } $out = array_slice($out, -self::MAX_TURNS); // Keep the latest turns within the total size. while (count($out) > 1 && array_sum(array_map(static fn(array $m): int => mb_strlen($m['content']), $out)) > self::MAX_TRANSCRIPT) { array_shift($out); } return $out; } /** * @return array|\WP_Error */ private static function cachedPlan(int $postId) { $key = 'aipcf_links_plan_' . $postId; $plan = get_transient($key); if (is_array($plan)) { // A candidate may have been unpublished or protected since the plan was cached. $plan['candidates'] = array_values(array_filter((array) $plan['candidates'], static fn(array $c): bool => \WordPress\CloudflareWorkersAiProvider\Features\Related::showable(get_post((int) $c['id'])))); return $plan; } $plan = LinkPlanner::plan($postId); if (!is_wp_error($plan)) { set_transient($key, $plan, 10 * MINUTE_IN_SECONDS); } return $plan; } /** * One reply from the chat model about the plan. * * @param array $plan * @param list $messages * @return array{reply: string, show: list}|\WP_Error */ public static function ask(array $plan, array $messages) { if (!class_exists('\\WordPress\\AiClient\\AiClient')) { return new \WP_Error('aipcf_env', __('The WordPress AI Client is not available.', 'aipcf-ai-provider-for-cloudflare')); } $ids = array_map(static fn(array $c): int => (int) $c['id'], $plan['candidates']); $data = array_map(static fn(array $c): array => [ 'id' => $c['id'], 'title' => $c['title'], 'date' => $c['date'], 'similarity' => $c['score'], 'shared' => $c['shared'], 'linked' => $c['linked'], 'linked_back' => $c['linked_back'], 'outbound' => $c['outbound'], 'inbound' => $c['inbound'], ], $plan['candidates']); $system = sprintf( "You help an editor of the website \"%s\" add internal links to the post \"%s\".\n" . "CANDIDATES is a JSON list of the site's closest posts. For each: similarity (0 to 1), shared words, " . "linked (this post already links to it), linked_back (it already links to this post), outbound (an anchor " . "word in this post and the sentence it is in) and inbound (an anchor in that post, for a link back).\n" . "Rules: talk only about these candidates. Refer to one by writing its id like [#%d]. Never write a URL. " . "Quote anchors and sentences exactly as given, never invent one. If the candidates cannot answer the question, say so. " . "Be brief: a few short sentences or a short list.", get_bloginfo('name'), $plan['post']['title'], $ids[0] ?? 123 ); $transcript = ''; foreach ($messages as $m) { $transcript .= ($m['role'] === 'user' ? 'Editor: ' : 'Assistant: ') . $m['content'] . "\n"; } $prompt = "CANDIDATES:\n" . wp_json_encode($data) . "\n\nCONVERSATION:\n" . $transcript . 'Assistant:'; $settings = get_plugin_settings(); $modelId = trim((string) ($settings['light_model'] ?? '')) ?: get_default_model_id(); try { TaskContext::pin(self::TASK); $model = \WordPress\AiClient\AiClient::defaultRegistry()->getProviderModel('cloudflare-workers-ai', $modelId); $result = \WordPress\AiClient\AiClient::prompt($prompt) ->usingModel($model) ->usingSystemInstruction($system) ->usingMaxTokens(400) ->generateTextResult(); $text = trim($result->toText()); } catch (\Throwable $e) { return new \WP_Error('aipcf_links_chat', $e->getMessage()); } finally { TaskContext::clear(); } return self::clean($text, $ids); } /** * The reply as shown: no URLs, and only references to real candidates. * * @param list $ids * @return array{reply: string, show: list} */ public static function clean(string $text, array $ids): array { $text = (string) preg_replace('~\b(?:https?://|www\.)\S+~i', '', $text); $text = trim(wp_strip_all_tags($text)); $show = []; $text = (string) preg_replace_callback('/\[#(\d+)\]/', static function (array $m) use ($ids, &$show): string { $id = (int) $m[1]; if (!in_array($id, $ids, true)) { return ''; } $show[$id] = true; return '[#' . $id . ']'; }, $text); return ['reply' => $text, 'show' => array_keys($show)]; } }