| @@ -59,186 +59,186 @@ | ||
| 59 | 59 | */ |
| 60 | 60 | function openstation_ai_get_admin_page_catalog() { |
| 61 | 61 | $catalog = array( |
| 62 | 62 | array( |
| 63 | - 'title' => 'Dashboard', | |
| 63 | + 'title' => __( 'Dashboard', 'desktop-mode' ), | |
| 64 | 64 | 'url' => admin_url( 'index.php' ), |
| 65 | 65 | 'icon' => 'dashicons-dashboard', |
| 66 | - 'description' => 'The main admin dashboard — activity, drafts, site overview.', | |
| 66 | + 'description' => __( 'The main admin dashboard: activity, drafts, site overview.', 'desktop-mode' ), | |
| 67 | 67 | ), |
| 68 | 68 | array( |
| 69 | - 'title' => 'All Posts', | |
| 69 | + 'title' => __( 'All Posts', 'desktop-mode' ), | |
| 70 | 70 | 'url' => admin_url( 'edit.php' ), |
| 71 | 71 | 'icon' => 'dashicons-admin-post', |
| 72 | - 'description' => 'List, edit, bulk-manage blog posts.', | |
| 72 | + 'description' => __( 'List, edit, bulk-manage blog posts.', 'desktop-mode' ), | |
| 73 | 73 | ), |
| 74 | 74 | array( |
| 75 | - 'title' => 'Add New Post', | |
| 75 | + 'title' => __( 'Add New Post', 'desktop-mode' ), | |
| 76 | 76 | 'url' => admin_url( 'post-new.php' ), |
| 77 | 77 | 'icon' => 'dashicons-plus', |
| 78 | - 'description' => 'Create a new blog post.', | |
| 78 | + 'description' => __( 'Create a new blog post.', 'desktop-mode' ), | |
| 79 | 79 | ), |
| 80 | 80 | array( |
| 81 | - 'title' => 'Categories', | |
| 81 | + 'title' => __( 'Categories', 'desktop-mode' ), | |
| 82 | 82 | 'url' => admin_url( 'edit-tags.php?taxonomy=category' ), |
| 83 | 83 | 'icon' => 'dashicons-category', |
| 84 | - 'description' => 'Manage post categories — add, rename, merge.', | |
| 84 | + 'description' => __( 'Manage post categories: add, rename, merge.', 'desktop-mode' ), | |
| 85 | 85 | ), |
| 86 | 86 | array( |
| 87 | - 'title' => 'Tags', | |
| 87 | + 'title' => __( 'Tags', 'desktop-mode' ), | |
| 88 | 88 | 'url' => admin_url( 'edit-tags.php?taxonomy=post_tag' ), |
| 89 | 89 | 'icon' => 'dashicons-tag', |
| 90 | - 'description' => 'Manage post tags.', | |
| 90 | + 'description' => __( 'Manage post tags.', 'desktop-mode' ), | |
| 91 | 91 | ), |
| 92 | 92 | array( |
| 93 | - 'title' => 'All Pages', | |
| 93 | + 'title' => __( 'All Pages', 'desktop-mode' ), | |
| 94 | 94 | 'url' => admin_url( 'edit.php?post_type=page' ), |
| 95 | 95 | 'icon' => 'dashicons-admin-page', |
| 96 | - 'description' => 'List and edit static pages (About, Contact, etc.).', | |
| 96 | + 'description' => __( 'List and edit static pages (About, Contact, etc.).', 'desktop-mode' ), | |
| 97 | 97 | ), |
| 98 | 98 | array( |
| 99 | - 'title' => 'Add New Page', | |
| 99 | + 'title' => __( 'Add New Page', 'desktop-mode' ), | |
| 100 | 100 | 'url' => admin_url( 'post-new.php?post_type=page' ), |
| 101 | 101 | 'icon' => 'dashicons-plus', |
| 102 | - 'description' => 'Create a new page.', | |
| 102 | + 'description' => __( 'Create a new page.', 'desktop-mode' ), | |
| 103 | 103 | ), |
| 104 | 104 | array( |
| 105 | - 'title' => 'Media Library', | |
| 105 | + 'title' => __( 'Media Library', 'desktop-mode' ), | |
| 106 | 106 | 'url' => admin_url( 'upload.php' ), |
| 107 | 107 | 'icon' => 'dashicons-admin-media', |
| 108 | - 'description' => 'Browse, upload, and manage images, files, videos.', | |
| 108 | + 'description' => __( 'Browse, upload, and manage images, files, videos.', 'desktop-mode' ), | |
| 109 | 109 | ), |
| 110 | 110 | array( |
| 111 | - 'title' => 'Comments', | |
| 111 | + 'title' => __( 'Comments', 'desktop-mode' ), | |
| 112 | 112 | 'url' => admin_url( 'edit-comments.php' ), |
| 113 | 113 | 'icon' => 'dashicons-admin-comments', |
| 114 | - 'description' => 'Moderate and reply to comments on posts and pages.', | |
| 114 | + 'description' => __( 'Moderate and reply to comments on posts and pages.', 'desktop-mode' ), | |
| 115 | 115 | ), |
| 116 | 116 | array( |
| 117 | - 'title' => 'Themes', | |
| 117 | + 'title' => __( 'Themes', 'desktop-mode' ), | |
| 118 | 118 | 'url' => admin_url( 'themes.php' ), |
| 119 | 119 | 'icon' => 'dashicons-admin-appearance', |
| 120 | - 'description' => 'Change, install, or customize the active theme.', | |
| 120 | + 'description' => __( 'Change, install, or customize the active theme.', 'desktop-mode' ), | |
| 121 | 121 | ), |
| 122 | 122 | array( |
| 123 | - 'title' => 'Customize', | |
| 123 | + 'title' => __( 'Customize', 'desktop-mode' ), | |
| 124 | 124 | 'url' => admin_url( 'customize.php' ), |
| 125 | 125 | 'icon' => 'dashicons-admin-customizer', |
| 126 | - 'description' => 'Live-preview theme customisation — colors, fonts, layout.', | |
| 126 | + 'description' => __( 'Live-preview theme customisation: colors, fonts, layout.', 'desktop-mode' ), | |
| 127 | 127 | ), |
| 128 | 128 | array( |
| 129 | - 'title' => 'Widgets', | |
| 129 | + 'title' => __( 'Widgets', 'desktop-mode' ), | |
| 130 | 130 | 'url' => admin_url( 'widgets.php' ), |
| 131 | 131 | 'icon' => 'dashicons-screenoptions', |
| 132 | - 'description' => 'Manage sidebar and footer widgets.', | |
| 132 | + 'description' => __( 'Manage sidebar and footer widgets.', 'desktop-mode' ), | |
| 133 | 133 | ), |
| 134 | 134 | array( |
| 135 | - 'title' => 'Menus', | |
| 135 | + 'title' => __( 'Menus', 'desktop-mode' ), | |
| 136 | 136 | 'url' => admin_url( 'nav-menus.php' ), |
| 137 | 137 | 'icon' => 'dashicons-menu', |
| 138 | - 'description' => 'Create and edit navigation menus.', | |
| 138 | + 'description' => __( 'Create and edit navigation menus.', 'desktop-mode' ), | |
| 139 | 139 | ), |
| 140 | 140 | array( |
| 141 | - 'title' => 'Plugins', | |
| 141 | + 'title' => __( 'Plugins', 'desktop-mode' ), | |
| 142 | 142 | 'url' => admin_url( 'plugins.php' ), |
| 143 | 143 | 'icon' => 'dashicons-admin-plugins', |
| 144 | - 'description' => 'Activate, deactivate, update or delete plugins.', | |
| 144 | + 'description' => __( 'Activate, deactivate, update or delete plugins.', 'desktop-mode' ), | |
| 145 | 145 | ), |
| 146 | 146 | array( |
| 147 | - 'title' => 'Add New Plugin', | |
| 147 | + 'title' => __( 'Add New Plugin', 'desktop-mode' ), | |
| 148 | 148 | 'url' => admin_url( 'plugin-install.php' ), |
| 149 | 149 | 'icon' => 'dashicons-plus', |
| 150 | - 'description' => 'Search and install new plugins from the directory.', | |
| 150 | + 'description' => __( 'Search and install new plugins from the directory.', 'desktop-mode' ), | |
| 151 | 151 | ), |
| 152 | 152 | array( |
| 153 | - 'title' => 'Users', | |
| 153 | + 'title' => __( 'Users', 'desktop-mode' ), | |
| 154 | 154 | 'url' => admin_url( 'users.php' ), |
| 155 | 155 | 'icon' => 'dashicons-admin-users', |
| 156 | - 'description' => 'Manage user accounts and roles.', | |
| 156 | + 'description' => __( 'Manage user accounts and roles.', 'desktop-mode' ), | |
| 157 | 157 | ), |
| 158 | 158 | array( |
| 159 | - 'title' => 'Add New User', | |
| 159 | + 'title' => __( 'Add New User', 'desktop-mode' ), | |
| 160 | 160 | 'url' => admin_url( 'user-new.php' ), |
| 161 | 161 | 'icon' => 'dashicons-plus', |
| 162 | - 'description' => 'Create a new user account.', | |
| 162 | + 'description' => __( 'Create a new user account.', 'desktop-mode' ), | |
| 163 | 163 | ), |
| 164 | 164 | array( |
| 165 | - 'title' => 'Your Profile', | |
| 165 | + 'title' => __( 'Your Profile', 'desktop-mode' ), | |
| 166 | 166 | 'url' => admin_url( 'profile.php' ), |
| 167 | 167 | 'icon' => 'dashicons-id', |
| 168 | - 'description' => 'Edit your own profile, password, admin colour scheme.', | |
| 168 | + 'description' => __( 'Edit your own profile, password, admin colour scheme.', 'desktop-mode' ), | |
| 169 | 169 | ), |
| 170 | 170 | array( |
| 171 | - 'title' => 'General Settings', | |
| 171 | + 'title' => __( 'General Settings', 'desktop-mode' ), | |
| 172 | 172 | 'url' => admin_url( 'options-general.php' ), |
| 173 | 173 | 'icon' => 'dashicons-admin-settings', |
| 174 | - 'description' => 'Site title, tagline, URL, timezone, language.', | |
| 174 | + 'description' => __( 'Site title, tagline, URL, timezone, language.', 'desktop-mode' ), | |
| 175 | 175 | ), |
| 176 | 176 | array( |
| 177 | - 'title' => 'Writing Settings', | |
| 177 | + 'title' => __( 'Writing Settings', 'desktop-mode' ), | |
| 178 | 178 | 'url' => admin_url( 'options-writing.php' ), |
| 179 | 179 | 'icon' => 'dashicons-edit', |
| 180 | - 'description' => 'Default post category, post format, remote publishing.', | |
| 180 | + 'description' => __( 'Default post category, post format, remote publishing.', 'desktop-mode' ), | |
| 181 | 181 | ), |
| 182 | 182 | array( |
| 183 | - 'title' => 'Reading Settings', | |
| 183 | + 'title' => __( 'Reading Settings', 'desktop-mode' ), | |
| 184 | 184 | 'url' => admin_url( 'options-reading.php' ), |
| 185 | 185 | 'icon' => 'dashicons-book', |
| 186 | - 'description' => 'Homepage, blog posts per page, search-engine visibility.', | |
| 186 | + 'description' => __( 'Homepage, blog posts per page, search-engine visibility.', 'desktop-mode' ), | |
| 187 | 187 | ), |
| 188 | 188 | array( |
| 189 | - 'title' => 'Discussion Settings', | |
| 189 | + 'title' => __( 'Discussion Settings', 'desktop-mode' ), | |
| 190 | 190 | 'url' => admin_url( 'options-discussion.php' ), |
| 191 | 191 | 'icon' => 'dashicons-format-chat', |
| 192 | - 'description' => 'Comment moderation, avatars, email notifications.', | |
| 192 | + 'description' => __( 'Comment moderation, avatars, email notifications.', 'desktop-mode' ), | |
| 193 | 193 | ), |
| 194 | 194 | array( |
| 195 | - 'title' => 'Media Settings', | |
| 195 | + 'title' => __( 'Media Settings', 'desktop-mode' ), | |
| 196 | 196 | 'url' => admin_url( 'options-media.php' ), |
| 197 | 197 | 'icon' => 'dashicons-format-image', |
| 198 | - 'description' => 'Image size settings for thumbnail / medium / large.', | |
| 198 | + 'description' => __( 'Image size settings for thumbnail / medium / large.', 'desktop-mode' ), | |
| 199 | 199 | ), |
| 200 | 200 | array( |
| 201 | - 'title' => 'Permalinks', | |
| 201 | + 'title' => __( 'Permalinks', 'desktop-mode' ), | |
| 202 | 202 | 'url' => admin_url( 'options-permalink.php' ), |
| 203 | 203 | 'icon' => 'dashicons-admin-links', |
| 204 | - 'description' => 'URL structure for posts, pages, categories, tags.', | |
| 204 | + 'description' => __( 'URL structure for posts, pages, categories, tags.', 'desktop-mode' ), | |
| 205 | 205 | ), |
| 206 | 206 | array( |
| 207 | - 'title' => 'Privacy', | |
| 207 | + 'title' => __( 'Privacy', 'desktop-mode' ), | |
| 208 | 208 | 'url' => admin_url( 'options-privacy.php' ), |
| 209 | 209 | 'icon' => 'dashicons-privacy', |
| 210 | - 'description' => 'Privacy policy page selection and preview.', | |
| 210 | + 'description' => __( 'Privacy policy page selection and preview.', 'desktop-mode' ), | |
| 211 | 211 | ), |
| 212 | 212 | array( |
| 213 | - 'title' => 'Tools', | |
| 213 | + 'title' => __( 'Tools', 'desktop-mode' ), | |
| 214 | 214 | 'url' => admin_url( 'tools.php' ), |
| 215 | 215 | 'icon' => 'dashicons-admin-tools', |
| 216 | - 'description' => 'Built-in site tools.', | |
| 216 | + 'description' => __( 'Built-in site tools.', 'desktop-mode' ), | |
| 217 | 217 | ), |
| 218 | 218 | array( |
| 219 | - 'title' => 'Import', | |
| 219 | + 'title' => __( 'Import', 'desktop-mode' ), | |
| 220 | 220 | 'url' => admin_url( 'import.php' ), |
| 221 | 221 | 'icon' => 'dashicons-download', |
| 222 | - 'description' => 'Import content from other platforms (WP, Tumblr, RSS, etc.).', | |
| 222 | + 'description' => __( 'Import content from other platforms (WP, Tumblr, RSS, etc.).', 'desktop-mode' ), | |
| 223 | 223 | ), |
| 224 | 224 | array( |
| 225 | - 'title' => 'Export', | |
| 225 | + 'title' => __( 'Export', 'desktop-mode' ), | |
| 226 | 226 | 'url' => admin_url( 'export.php' ), |
| 227 | 227 | 'icon' => 'dashicons-upload', |
| 228 | - 'description' => 'Export all site content as XML.', | |
| 228 | + 'description' => __( 'Export all site content as XML.', 'desktop-mode' ), | |
| 229 | 229 | ), |
| 230 | 230 | array( |
| 231 | - 'title' => 'Site Health', | |
| 231 | + 'title' => __( 'Site Health', 'desktop-mode' ), | |
| 232 | 232 | 'url' => admin_url( 'site-health.php' ), |
| 233 | 233 | 'icon' => 'dashicons-heart', |
| 234 | - 'description' => 'Performance and security recommendations for the site.', | |
| 234 | + 'description' => __( 'Performance and security recommendations for the site.', 'desktop-mode' ), | |
| 235 | 235 | ), |
| 236 | 236 | array( |
| 237 | - 'title' => 'Updates', | |
| 237 | + 'title' => __( 'Updates', 'desktop-mode' ), | |
| 238 | 238 | 'url' => admin_url( 'update-core.php' ), |
| 239 | 239 | 'icon' => 'dashicons-update', |
| 240 | - 'description' => 'WordPress, theme, and plugin updates.', | |
| 240 | + 'description' => __( 'WordPress, theme, and plugin updates.', 'desktop-mode' ), | |
| 241 | 241 | ), |
| 242 | 242 | ); |
| 243 | 243 | |
| 244 | 244 | /** |
| @@ -381,10 +381,16 @@ | ||
| 381 | 381 | * Keyword-searches published posts or pages with WordPress's native search |
| 382 | 382 | * (`WP_Query` `s=`), returning data rich enough for the agent to compare |
| 383 | 383 | * AND for the UI to render links. |
| 384 | 384 | * |
| 385 | - * No AI analysis is required — every published post/page is searchable. | |
| 385 | + * No AI analysis is required — every published, non-password-protected post/page is searchable. | |
| 386 | 386 | * |
| 387 | + * Password-protected posts are excluded (`has_password => false`): `publish` | |
| 388 | + * is also the status of a password-protected post, and this tool emits the | |
| 389 | + * stored body as an excerpt without ever passing through `post_password_required()`. | |
| 390 | + * Filtering at the query level keeps them out of both `items` and `found_posts`, | |
| 391 | + * so the `total` counter cannot become an oracle for their contents either. | |
| 392 | + * | |
| 387 | 393 | * @param string $post_type 'post' | 'page'. |
| 388 | 394 | * @param string $query Keyword search terms (may be empty to list newest). |
| 389 | 395 | * @param int $offset |
| 390 | 396 | * @return array |
| @@ -393,8 +399,9 @@ | ||
| 393 | 399 | $wp_query = new WP_Query( |
| 394 | 400 | array( |
| 395 | 401 | 'post_type' => $post_type, |
| 396 | 402 | 'post_status' => 'publish', |
| 403 | + 'has_password' => false, | |
| 397 | 404 | 's' => (string) $query, |
| 398 | 405 | 'posts_per_page' => OPENSTATION_AI_SEARCH_BATCH_SIZE, |
| 399 | 406 | 'offset' => $offset, |
| 400 | 407 | 'no_found_rows' => false, |
| @@ -446,8 +453,82 @@ | ||
| 446 | 453 | return (string) mb_substr( $text, 0, 300 ); |
| 447 | 454 | } |
| 448 | 455 | |
| 449 | 456 | /** |
| 457 | + * Whether the current user may read a post the comment tools are about to | |
| 458 | + * surface. | |
| 459 | + * | |
| 460 | + * A comment being `approved` is a moderation decision — it says nothing about | |
| 461 | + * who may see the discussion. An approved comment can hang on a private, | |
| 462 | + * draft, or password-protected post the caller cannot reach, so the comment | |
| 463 | + * search tools must gate on the PARENT POST's visibility before returning the | |
| 464 | + * comment text or the parent title. Mirrors Core's | |
| 465 | + * `WP_REST_Comments_Controller::check_read_post_permission()`: | |
| 466 | + * | |
| 467 | + * - a password-protected parent needs the password satisfied or `edit_post`. | |
| 468 | + * `post_password_required()` honours the `wp-postpass` cookie Core's | |
| 469 | + * password form sets, and that is deliberate Core parity, not a gap: the | |
| 470 | + * cookie only exists because the caller already entered the correct | |
| 471 | + * password, and Core's comments controller reads the same cookie. The | |
| 472 | + * ability itself has no password input, so a caller who never unlocked | |
| 473 | + * the post front-end is refused; | |
| 474 | + * - a publicly viewable parent (public status AND viewable post type) is | |
| 475 | + * readable by anyone the ability admits; | |
| 476 | + * - a parent whose post TYPE is not viewable (an internal/admin-only CPT) | |
| 477 | + * needs `edit_post` — `read_post` cannot stand in, because a public status | |
| 478 | + * resolves it to plain `read` whatever the type's visibility, which is how | |
| 479 | + * Core's REST layer needs its own post-type gate too; | |
| 480 | + * - any other parent (private, draft, pending, …) needs `read_post`. | |
| 481 | + * | |
| 482 | + * @param int|WP_Post $post Post ID or object. | |
| 483 | + * @return bool | |
| 484 | + */ | |
| 485 | +function openstation_ai_can_read_post( $post ) { | |
| 486 | + // An id of 0 must stay unreadable: get_post( 0 ) falls back to the global | |
| 487 | + // $post, which would judge an orphaned comment against an unrelated post. | |
| 488 | + if ( is_numeric( $post ) && (int) $post <= 0 ) { | |
| 489 | + return false; | |
| 490 | + } | |
| 491 | + | |
| 492 | + $post = get_post( $post ); | |
| 493 | + if ( ! $post instanceof WP_Post ) { | |
| 494 | + return false; | |
| 495 | + } | |
| 496 | + | |
| 497 | + if ( post_password_required( $post ) && ! current_user_can( 'edit_post', $post->ID ) ) { | |
| 498 | + return false; | |
| 499 | + } | |
| 500 | + | |
| 501 | + if ( is_post_publicly_viewable( $post ) ) { | |
| 502 | + return true; | |
| 503 | + } | |
| 504 | + | |
| 505 | + $post_type = get_post_type_object( $post->post_type ); | |
| 506 | + if ( ! $post_type || ! is_post_type_viewable( $post_type ) ) { | |
| 507 | + return current_user_can( 'edit_post', $post->ID ); | |
| 508 | + } | |
| 509 | + | |
| 510 | + return current_user_can( 'read_post', $post->ID ); | |
| 511 | +} | |
| 512 | + | |
| 513 | +/** | |
| 514 | + * Whether the current user may read the post a comment is attached to. | |
| 515 | + * | |
| 516 | + * Used to drop comments on posts the caller cannot see from the comment | |
| 517 | + * search results. See {@see openstation_ai_can_read_post()}. | |
| 518 | + * | |
| 519 | + * @param int|WP_Comment $comment Comment ID or object. | |
| 520 | + * @return bool | |
| 521 | + */ | |
| 522 | +function openstation_ai_can_read_comment_parent( $comment ) { | |
| 523 | + $comment = get_comment( $comment ); | |
| 524 | + if ( ! $comment instanceof WP_Comment ) { | |
| 525 | + return false; | |
| 526 | + } | |
| 527 | + return openstation_ai_can_read_post( (int) $comment->comment_post_ID ); | |
| 528 | +} | |
| 529 | + | |
| 530 | +/** | |
| 450 | 531 | * Keyword-searches approved comments across all posts with WordPress's |
| 451 | 532 | * native comment search (`get_comments` `search=`). |
| 452 | 533 | * |
| 453 | 534 | * No AI analysis is required — every approved comment is searchable. |
| @@ -489,12 +570,28 @@ | ||
| 489 | 570 | if ( $parent_ids ) { |
| 490 | 571 | _prime_post_caches( $parent_ids, false, false ); |
| 491 | 572 | } |
| 492 | 573 | |
| 574 | + // "Approved" is a moderation decision, not a visibility one: drop comments | |
| 575 | + // whose parent post the caller cannot read (private / draft / password / | |
| 576 | + // internal CPT), so the comment text and the parent title never leak. See | |
| 577 | + // openstation_ai_can_read_comment_parent(). | |
| 578 | + // | |
| 579 | + // This runs per row, after the batch, and that is the price of gating on | |
| 580 | + // per-caller readability: an Administrator reads comments on private | |
| 581 | + // posts and a reader who entered a post password reads that post's | |
| 582 | + // discussion, neither of which a single `post_status` or `has_password` | |
| 583 | + // query var can express. `total` therefore counts rows this caller does | |
| 584 | + // not get, and a batch can come back short. The alternative — a blanket | |
| 585 | + // publish-only, no-password query — would be exact and would also hide | |
| 586 | + // those discussions from the people entitled to them. | |
| 587 | + $comments = array_values( array_filter( $comments, 'openstation_ai_can_read_comment_parent' ) ); | |
| 588 | + | |
| 493 | 589 | $items = array(); |
| 494 | 590 | foreach ( $comments as $comment ) { |
| 591 | + // Readable, per the filter above. | |
| 495 | 592 | $parent_post = get_post( $comment->comment_post_ID ); |
| 496 | - $parent_title = $parent_post ? wp_strip_all_tags( $parent_post->post_title ) : ''; | |
| 593 | + $parent_title = wp_strip_all_tags( $parent_post->post_title ); | |
| 497 | 594 | |
| 498 | 595 | $items[] = array( |
| 499 | 596 | 'id' => (int) $comment->comment_ID, |
| 500 | 597 | 'type' => 'comment', |
| @@ -504,9 +601,9 @@ | ||
| 504 | 601 | // Links. |
| 505 | 602 | 'url' => (string) get_comment_link( $comment ), |
| 506 | 603 | 'edit_url' => admin_url( 'comment.php?action=editcomment&c=' . (int) $comment->comment_ID ), |
| 507 | 604 | 'post_id' => (int) $comment->comment_post_ID, |
| 508 | - 'post_url' => $parent_post ? (string) get_permalink( $parent_post ) : '', | |
| 605 | + 'post_url' => (string) get_permalink( $parent_post ), | |
| 509 | 606 | ); |
| 510 | 607 | } |
| 511 | 608 | |
| 512 | 609 | return array( |
| @@ -553,8 +650,25 @@ | ||
| 553 | 650 | 'error' => 'post_id must be a positive integer.', |
| 554 | 651 | ); |
| 555 | 652 | } |
| 556 | 653 | |
| 654 | + // The model picks the post id, so it is untrusted the same way an entity | |
| 655 | + // id is. Comments inherit their parent's reach: a thread on a private, | |
| 656 | + // draft, password-protected or internal-CPT post is not this user's to | |
| 657 | + // read, and the envelope below would otherwise echo its title back. | |
| 658 | + if ( ! openstation_ai_can_read_post( $post_id ) ) { | |
| 659 | + return array( | |
| 660 | + 'tool' => 'search_comments_by_post', | |
| 661 | + 'post_id' => $post_id, | |
| 662 | + 'offset' => $offset, | |
| 663 | + 'items' => array(), | |
| 664 | + 'count' => 0, | |
| 665 | + 'total' => 0, | |
| 666 | + 'has_more' => false, | |
| 667 | + 'error' => 'Post not found or not readable.', | |
| 668 | + ); | |
| 669 | + } | |
| 670 | + | |
| 557 | 671 | $base_args = array( |
| 558 | 672 | 'post_id' => $post_id, |
| 559 | 673 | 'status' => 'approve', |
| 560 | 674 | 'type' => 'comment', |
| @@ -573,10 +687,11 @@ | ||
| 573 | 687 | ); |
| 574 | 688 | |
| 575 | 689 | $total = (int) get_comments( array_merge( $base_args, array( 'count' => true ) ) ); |
| 576 | 690 | |
| 691 | + // Readable, per the gate above. | |
| 577 | 692 | $parent_post = get_post( $post_id ); |
| 578 | - $parent_title = $parent_post ? wp_strip_all_tags( $parent_post->post_title ) : ''; | |
| 693 | + $parent_title = wp_strip_all_tags( $parent_post->post_title ); | |
| 579 | 694 | |
| 580 | 695 | $items = array(); |
| 581 | 696 | foreach ( $comments as $comment ) { |
| 582 | 697 | $items[] = array( |
| @@ -616,8 +731,22 @@ | ||
| 616 | 731 | * required. Comments opportunistically surface the `spam` / `harmful` |
| 617 | 732 | * verdict when the comment-moderation analysis happens to have run, but |
| 618 | 733 | * its absence never blocks the entity from being returned. |
| 619 | 734 | * |
| 735 | + * The id arrives from the MODEL's final answer, and model output is | |
| 736 | + * untrusted — a search turn can be driven by attacker-controlled content, so | |
| 737 | + * an injected instruction could name an entity the search tools never | |
| 738 | + * surfaced. Hydration therefore re-checks readability itself instead of | |
| 739 | + * trusting that the id came out of a filtered tool result: posts/pages go | |
| 740 | + * through {@see openstation_ai_can_read_post()}, and so does a comment's | |
| 741 | + * PARENT, because the comment record carries that post's title and permalink | |
| 742 | + * — approval is a moderation decision, not a visibility one, and an approved | |
| 743 | + * comment outlives its post being switched to private or back to draft. | |
| 744 | + * Reading an unapproved comment needs `edit_comment`, mirroring Core's | |
| 745 | + * `WP_REST_Comments_Controller::check_read_permission()`; the AI moderation | |
| 746 | + * verdicts and the wp-admin edit link are narrower still. Unreadable ids | |
| 747 | + * resolve to null, indistinguishable from nonexistent ones. | |
| 748 | + * | |
| 620 | 749 | * @param string $entity_type 'post' | 'page' | 'comment'. |
| 621 | 750 | * @param int $entity_id |
| 622 | 751 | * @return array|null |
| 623 | 752 | */ |
| @@ -625,11 +754,22 @@ | ||
| 625 | 754 | $entity_id = (int) $entity_id; |
| 626 | 755 | |
| 627 | 756 | if ( in_array( $entity_type, array( 'post', 'page' ), true ) ) { |
| 628 | 757 | $post = get_post( $entity_id ); |
| 629 | - if ( ! $post instanceof WP_Post ) { | |
| 758 | + | |
| 759 | + // The id must resolve to an actual post or page. The gate below answers | |
| 760 | + // type visibility on its own, so this is the contract rather than the | |
| 761 | + // lock: the record's `type` is what the client renders the card from, | |
| 762 | + // and post/page is what the search tools surface. A viewable CPT row | |
| 763 | + // would pass the gate and still have no card to land in. | |
| 764 | + if ( ! $post instanceof WP_Post || ! in_array( $post->post_type, array( 'post', 'page' ), true ) ) { | |
| 630 | 765 | return null; |
| 631 | 766 | } |
| 767 | + | |
| 768 | + if ( ! openstation_ai_can_read_post( $post ) ) { | |
| 769 | + return null; | |
| 770 | + } | |
| 771 | + | |
| 632 | 772 | return array( |
| 633 | 773 | 'id' => $entity_id, |
| 634 | 774 | 'type' => $post->post_type, |
| 635 | 775 | 'title' => wp_strip_all_tags( $post->post_title ), |
| @@ -642,25 +782,49 @@ | ||
| 642 | 782 | } |
| 643 | 783 | |
| 644 | 784 | if ( 'comment' === $entity_type ) { |
| 645 | 785 | $comment = get_comment( $entity_id ); |
| 646 | - if ( ! $comment instanceof WP_Comment ) { | |
| 786 | + | |
| 787 | + // The parent's reach bounds the comment's: approval is a moderation | |
| 788 | + // decision, not a visibility one, and this record carries the parent's | |
| 789 | + // title and permalink — so without this check, naming a comment id | |
| 790 | + // would walk straight around the post branch's gate above. | |
| 791 | + if ( ! $comment instanceof WP_Comment || ! openstation_ai_can_read_comment_parent( $comment ) ) { | |
| 647 | 792 | return null; |
| 648 | 793 | } |
| 649 | - $meta = openstation_ai_get_meta( 'comment', $entity_id ); | |
| 650 | - $parent_post = get_post( $comment->comment_post_ID ); | |
| 651 | - return array( | |
| 794 | + | |
| 795 | + // Reading an unapproved comment is an editor's business, per Core's | |
| 796 | + // WP_REST_Comments_Controller::check_read_permission(). | |
| 797 | + if ( '1' !== (string) $comment->comment_approved && ! current_user_can( 'edit_comment', $entity_id ) ) { | |
| 798 | + return null; | |
| 799 | + } | |
| 800 | + | |
| 801 | + // The AI verdicts are the moderation queue's data, so they follow the | |
| 802 | + // moderation capability rather than the per-comment edit one. | |
| 803 | + $can_moderate = current_user_can( 'moderate_comments' ); | |
| 804 | + $parent_post = get_post( (int) $comment->comment_post_ID ); | |
| 805 | + | |
| 806 | + $meta = $can_moderate ? openstation_ai_get_meta( 'comment', $entity_id ) : null; | |
| 807 | + $entity = array( | |
| 652 | 808 | 'id' => $entity_id, |
| 653 | 809 | 'type' => 'comment', |
| 654 | 810 | 'excerpt' => openstation_ai_search_excerpt( $comment->comment_content ), |
| 655 | 811 | 'post_id' => (int) $comment->comment_post_ID, |
| 656 | - 'post_title' => $parent_post ? wp_strip_all_tags( $parent_post->post_title ) : '', | |
| 657 | - 'post_url' => $parent_post ? (string) get_permalink( $parent_post ) : '', | |
| 812 | + 'post_title' => wp_strip_all_tags( $parent_post->post_title ), | |
| 813 | + 'post_url' => (string) get_permalink( $parent_post ), | |
| 658 | 814 | 'url' => (string) get_comment_link( $comment ), |
| 659 | - 'edit_url' => admin_url( 'comment.php?action=editcomment&c=' . $entity_id ), | |
| 660 | - 'harmful' => $meta ? (bool) ( $meta['harmful'] ?? false ) : false, | |
| 661 | - 'spam' => $meta ? (bool) ( $meta['spam'] ?? false ) : false, | |
| 815 | + 'edit_url' => current_user_can( 'edit_comment', $entity_id ) | |
| 816 | + ? admin_url( 'comment.php?action=editcomment&c=' . $entity_id ) | |
| 817 | + : '', | |
| 662 | 818 | ); |
| 819 | + | |
| 820 | + // Moderation verdicts are for moderators only. | |
| 821 | + if ( $can_moderate ) { | |
| 822 | + $entity['harmful'] = $meta ? (bool) ( $meta['harmful'] ?? false ) : false; | |
| 823 | + $entity['spam'] = $meta ? (bool) ( $meta['spam'] ?? false ) : false; | |
| 824 | + } | |
| 825 | + | |
| 826 | + return $entity; | |
| 663 | 827 | } |
| 664 | 828 | |
| 665 | 829 | return null; |
| 666 | 830 | } |
| @@ -669,41 +833,37 @@ | ||
| 669 | 833 | // Agentic search loop |
| 670 | 834 | // --------------------------------------------------------------------------- |
| 671 | 835 | |
| 672 | 836 | /** |
| 673 | - * Returns a friendly progress message for a tool name — surfaced to the | |
| 674 | - * client via SSE so the user sees "Looking through your posts…" rather | |
| 675 | - * than the raw tool call. | |
| 837 | + * Returns the label for the "keep looking" button on an exhausted search. | |
| 676 | 838 | * |
| 677 | - * @param string $tool_name | |
| 839 | + * One full sentence per resumable tool: a noun interpolated into a shared | |
| 840 | + * template cannot be translated. | |
| 841 | + * | |
| 842 | + * @param string $resume_tool Tool the client would resume from. | |
| 843 | + * @param int $from_item 1-based index of the next item to search. | |
| 678 | 844 | * @return string |
| 679 | 845 | */ |
| 680 | -function openstation_ai_progress_message( $tool_name ) { | |
| 681 | - switch ( $tool_name ) { | |
| 682 | - case 'search_posts': | |
| 683 | - return 'Looking through your posts…'; | |
| 846 | +function openstation_ai_continue_label( $resume_tool, $from_item ) { | |
| 847 | + switch ( $resume_tool ) { | |
| 684 | 848 | case 'search_pages': |
| 685 | - return 'Checking your pages…'; | |
| 849 | + /* translators: %d: 1-based index of the next page to search. */ | |
| 850 | + return sprintf( __( 'Continue searching in pages (from item %d)', 'desktop-mode' ), $from_item ); | |
| 686 | 851 | case 'search_comments': |
| 687 | - return 'Reading through comments…'; | |
| 688 | - case 'search_comments_by_post': | |
| 689 | - return 'Scanning comments on that post…'; | |
| 690 | - case 'list_admin_pages': | |
| 691 | - return 'Finding the right admin page…'; | |
| 692 | - case 'search_wporg_plugins': | |
| 693 | - return 'Searching the WordPress.org plugin directory…'; | |
| 694 | - case 'get_php_error_log': | |
| 695 | - return 'Tailing the PHP error log…'; | |
| 852 | + /* translators: %d: 1-based index of the next comment to search. */ | |
| 853 | + return sprintf( __( 'Continue searching in comments (from item %d)', 'desktop-mode' ), $from_item ); | |
| 854 | + default: | |
| 855 | + /* translators: %d: 1-based index of the next post to search. */ | |
| 856 | + return sprintf( __( 'Continue searching in posts (from item %d)', 'desktop-mode' ), $from_item ); | |
| 696 | 857 | } |
| 697 | - return 'Thinking…'; | |
| 698 | 858 | } |
| 699 | 859 | |
| 700 | 860 | /** |
| 701 | 861 | * Returns the tools a client may resume an exhausted search from. |
| 702 | 862 | * |
| 703 | - * Single source of truth for every `resume_tool` allowlist — the REST | |
| 704 | - * arg sanitizer, the SSE handler, and the `$initial_tool` validation | |
| 705 | - * inside `openstation_ai_run_search()`. `search_comments_by_post` is | |
| 863 | + * Single source of truth for every `resume_tool` allowlist: the REST | |
| 864 | + * arg sanitizer and the `$initial_tool` validation inside | |
| 865 | + * `openstation_ai_run_search()`. `search_comments_by_post` is | |
| 706 | 866 | * deliberately absent: the `continue` payload carries no `post_id`, so |
| 707 | 867 | * it cannot truly resume — exhausted runs map it to `search_comments` |
| 708 | 868 | * when building the `continue` object. |
| 709 | 869 | * |
| @@ -851,26 +1011,15 @@ | ||
| 851 | 1011 | * For continuation runs ($initial_tool + $start_offset > 0), the system |
| 852 | 1012 | * message primes the agent to resume from the last searched position with |
| 853 | 1013 | * the same keywords. |
| 854 | 1014 | * |
| 855 | - * @param string $query User's natural-language search. | |
| 856 | - * @param string|null $initial_tool Tool name to resume from, or null for fresh search. | |
| 857 | - * @param int $start_offset Offset to resume from (0 for fresh). | |
| 858 | - * @param callable|null $on_progress Optional progress emitter for SSE ticks. | |
| 859 | - * @param array $extra Extensibility context (command tools, prompt overrides, …). | |
| 1015 | + * @param string $query User's natural-language search. | |
| 1016 | + * @param string|null $initial_tool Tool name to resume from, or null for fresh search. | |
| 1017 | + * @param int $start_offset Offset to resume from (0 for fresh). | |
| 1018 | + * @param array $extra Extensibility context (command tools, prompt overrides, …). | |
| 860 | 1019 | * @return array|WP_Error |
| 861 | 1020 | */ |
| 862 | -function openstation_ai_run_search( $query, $initial_tool = null, $start_offset = 0, $on_progress = null, array $extra = array() ) { | |
| 863 | - /** | |
| 864 | - * Progress emitter — sends a tick to the caller if they provided a | |
| 865 | - * callable; no-op otherwise. Callers use this to render real-time | |
| 866 | - * status to the user via SSE. | |
| 867 | - */ | |
| 868 | - $emit = static function ( array $event ) use ( $on_progress ) { | |
| 869 | - if ( is_callable( $on_progress ) ) { | |
| 870 | - $on_progress( $event ); | |
| 871 | - } | |
| 872 | - }; | |
| 1021 | +function openstation_ai_run_search( $query, $initial_tool = null, $start_offset = 0, array $extra = array() ) { | |
| 873 | 1022 | $start_offset = max( 0, (int) $start_offset ); |
| 874 | 1023 | $search_tools = array( 'search_posts', 'search_pages', 'search_comments', 'search_comments_by_post' ); |
| 875 | 1024 | $valid_tools = array_merge( |
| 876 | 1025 | $search_tools, |
| @@ -1151,15 +1300,8 @@ | ||
| 1151 | 1300 | } |
| 1152 | 1301 | |
| 1153 | 1302 | $answer_schema = openstation_ai_search_answer_schema(); |
| 1154 | 1303 | |
| 1155 | - $emit( | |
| 1156 | - array( | |
| 1157 | - 'phase' => 'start', | |
| 1158 | - 'message' => 'Thinking about your question…', | |
| 1159 | - ) | |
| 1160 | - ); | |
| 1161 | - | |
| 1162 | 1304 | // ----------------------------------------------------------------------- |
| 1163 | 1305 | // First call — user query as the sole message, instructions as system |
| 1164 | 1306 | // guidance. Generation routes through the WordPress AI Client; the tools |
| 1165 | 1307 | // are advertised as function declarations and dispatched by this loop. |
| @@ -1166,10 +1308,15 @@ | ||
| 1166 | 1308 | // The full ordered conversation is rebuilt and re-sent each turn. |
| 1167 | 1309 | // ----------------------------------------------------------------------- |
| 1168 | 1310 | $messages = array( openstation_ai_user_text_message( $query ) ); |
| 1169 | 1311 | |
| 1170 | - $turn = openstation_ai_client_generate( $user_id, $messages, $tools, $answer_schema, $instructions ); | |
| 1312 | + $generation_context = array( | |
| 1313 | + 'source' => 'ai-copilot/search', | |
| 1314 | + 'request_id' => $request_id, | |
| 1315 | + ); | |
| 1171 | 1316 | |
| 1317 | + $turn = openstation_ai_client_generate( $user_id, $messages, $tools, $answer_schema, $instructions, $generation_context ); | |
| 1318 | + | |
| 1172 | 1319 | if ( is_wp_error( $turn ) ) { |
| 1173 | 1320 | return $turn; |
| 1174 | 1321 | } |
| 1175 | 1322 | |
| @@ -1210,14 +1357,8 @@ | ||
| 1210 | 1357 | $function_calls = is_array( $turn['function_calls'] ?? null ) ? $turn['function_calls'] : array(); |
| 1211 | 1358 | |
| 1212 | 1359 | // No tool calls in this response → final answer. |
| 1213 | 1360 | if ( empty( $function_calls ) ) { |
| 1214 | - $emit( | |
| 1215 | - array( | |
| 1216 | - 'phase' => 'composing', | |
| 1217 | - 'message' => 'Putting together your answer…', | |
| 1218 | - ) | |
| 1219 | - ); | |
| 1220 | 1361 | // A toolless turn with no extractable text never reaches here: |
| 1221 | 1362 | // openstation_ai_client_generate() returns |
| 1222 | 1363 | // `openstation_ai_empty_answer` for that case, handled with the |
| 1223 | 1364 | // other generation errors above. |
| @@ -1224,9 +1365,9 @@ | ||
| 1224 | 1365 | $text = (string) ( $turn['text'] ?? '' ); |
| 1225 | 1366 | |
| 1226 | 1367 | $answer = json_decode( $text, true ); |
| 1227 | 1368 | if ( ! is_array( $answer ) ) { |
| 1228 | - return new WP_Error( 'openstation_ai_result_parse', 'Could not parse structured search answer.' ); | |
| 1369 | + return new WP_Error( 'openstation_ai_result_parse', __( 'Could not parse structured search answer.', 'desktop-mode' ) ); | |
| 1229 | 1370 | } |
| 1230 | 1371 | |
| 1231 | 1372 | $answer_type = isset( $answer['answer_type'] ) && in_array( $answer['answer_type'], array( 'entity', 'navigation', 'chat' ), true ) |
| 1232 | 1373 | ? (string) $answer['answer_type'] |
| @@ -1380,16 +1521,8 @@ | ||
| 1380 | 1521 | $raw = json_decode( $fc['arguments'] ?? '{}', true ); |
| 1381 | 1522 | $args = is_array( $raw ) ? $raw : array(); |
| 1382 | 1523 | $offset = max( 0, (int) ( $args['offset'] ?? 0 ) ); |
| 1383 | 1524 | |
| 1384 | - $emit( | |
| 1385 | - array( | |
| 1386 | - 'phase' => 'tool_call', | |
| 1387 | - 'tool' => $tool_name, | |
| 1388 | - 'message' => openstation_ai_progress_message( $tool_name ), | |
| 1389 | - ) | |
| 1390 | - ); | |
| 1391 | - | |
| 1392 | 1525 | do_action( |
| 1393 | 1526 | 'openstation_ai_tool_called', |
| 1394 | 1527 | array( |
| 1395 | 1528 | 'tool_name' => $tool_name, |
| @@ -1472,9 +1605,9 @@ | ||
| 1472 | 1605 | // results to the conversation, then regenerate with the full history. |
| 1473 | 1606 | $messages[] = $turn['message']; |
| 1474 | 1607 | $messages[] = openstation_ai_tool_result_message( $tool_outputs ); |
| 1475 | 1608 | |
| 1476 | - $turn = openstation_ai_client_generate( $user_id, $messages, $tools, $answer_schema, $instructions ); | |
| 1609 | + $turn = openstation_ai_client_generate( $user_id, $messages, $tools, $answer_schema, $instructions, $generation_context ); | |
| 1477 | 1610 | |
| 1478 | 1611 | if ( is_wp_error( $turn ) ) { |
| 1479 | 1612 | return $turn; |
| 1480 | 1613 | } |
| @@ -1490,20 +1623,19 @@ | ||
| 1490 | 1623 | // `search_comments_by_post` cannot resume — the continue payload |
| 1491 | 1624 | // carries no post_id — so fall back to plain comment search, |
| 1492 | 1625 | // keeping `tool` inside openstation_ai_search_resumable_tools(). |
| 1493 | 1626 | $resume_tool = 'search_comments_by_post' === $last_tool ? 'search_comments' : $last_tool; |
| 1494 | - $type_label = str_replace( 'search_', '', $resume_tool ) . 's'; | |
| 1495 | 1627 | $continue = array( |
| 1496 | 1628 | 'tool' => $resume_tool, |
| 1497 | 1629 | 'entity_type' => rtrim( str_replace( 'search_', '', $resume_tool ), 's' ), |
| 1498 | 1630 | 'offset' => $next_offset, |
| 1499 | - 'label' => sprintf( 'Continue searching in %s (from item %d)', $type_label, $next_offset + 1 ), | |
| 1631 | + 'label' => openstation_ai_continue_label( $resume_tool, $next_offset + 1 ), | |
| 1500 | 1632 | ); |
| 1501 | 1633 | } |
| 1502 | 1634 | |
| 1503 | 1635 | $final = array( |
| 1504 | 1636 | 'answer_type' => 'chat', |
| 1505 | - 'message' => 'I searched 100 items without finding a clear match. Want me to keep looking further?', | |
| 1637 | + 'message' => __( 'I searched 100 items without finding a clear match. Want me to keep looking further?', 'desktop-mode' ), | |
| 1506 | 1638 | 'entity' => null, |
| 1507 | 1639 | 'admin_links' => null, |
| 1508 | 1640 | 'iterations' => OPENSTATION_AI_SEARCH_MAX_ITERATIONS, |
| 1509 | 1641 | 'exhausted' => ! $last_has_more, |
| @@ -1760,9 +1892,13 @@ | ||
| 1760 | 1892 | $user_id, |
| 1761 | 1893 | array( openstation_ai_user_text_message( $user_message ) ), |
| 1762 | 1894 | array(), // no tools — we want a plain reply |
| 1763 | 1895 | null, // no JSON schema — free-form text |
| 1764 | - $instructions | |
| 1896 | + $instructions, | |
| 1897 | + array( | |
| 1898 | + 'source' => 'ai-copilot/followup', | |
| 1899 | + 'request_id' => $request_id, | |
| 1900 | + ) | |
| 1765 | 1901 | ); |
| 1766 | 1902 | |
| 1767 | 1903 | // `openstation_ai_empty_answer` is the one generation error this path |
| 1768 | 1904 | // deliberately absorbs: the command DID run, so a text-less summary turn |
| @@ -1945,9 +2081,9 @@ | ||
| 1945 | 2081 | function openstation_rest_ai_search_permission() { |
| 1946 | 2082 | if ( ! is_user_logged_in() || ! current_user_can( 'read' ) ) { |
| 1947 | 2083 | return new WP_Error( |
| 1948 | 2084 | 'openstation_ai_forbidden', |
| 1949 | - 'You must be logged in to use the AI assistant.', | |
| 2085 | + __( 'You must be logged in to use the AI assistant.', 'desktop-mode' ), | |
| 1950 | 2086 | array( 'status' => 403 ) |
| 1951 | 2087 | ); |
| 1952 | 2088 | } |
| 1953 | 2089 | if ( ! openstation_ai_is_available() ) { |
| @@ -1952,17 +2088,26 @@ | ||
| 1952 | 2088 | } |
| 1953 | 2089 | if ( ! openstation_ai_is_available() ) { |
| 1954 | 2090 | return new WP_Error( |
| 1955 | 2091 | 'openstation_ai_unavailable', |
| 1956 | - 'The AI assistant is unavailable on this site.', | |
| 2092 | + __( 'The AI assistant is unavailable on this site.', 'desktop-mode' ), | |
| 1957 | 2093 | array( 'status' => 503 ) |
| 1958 | 2094 | ); |
| 1959 | 2095 | } |
| 1960 | 2096 | if ( ! openstation_ai_is_enabled( get_current_user_id() ) ) { |
| 2097 | + // The message names the tab for consumers that only get text (a REST | |
| 2098 | + // client, `wp.os.ai.ask()`). `settings_tab` names it again as data, | |
| 2099 | + // so the overlay can offer a one-click link without parsing prose. | |
| 1961 | 2100 | return new WP_Error( |
| 1962 | 2101 | 'openstation_ai_disabled', |
| 1963 | - 'The AI assistant is turned off. Enable it in OpenStation Preferences → Features.', | |
| 1964 | - array( 'status' => 403 ) | |
| 2102 | + __( | |
| 2103 | + 'The AI assistant is turned off. Enable it in OpenStation Preferences → Features.', | |
| 2104 | + 'desktop-mode' | |
| 2105 | + ), | |
| 2106 | + array( | |
| 2107 | + 'status' => 403, | |
| 2108 | + 'settings_tab' => 'features', | |
| 2109 | + ) | |
| 1965 | 2110 | ); |
| 1966 | 2111 | } |
| 1967 | 2112 | return true; |
| 1968 | 2113 | } |
| @@ -1973,8 +2118,14 @@ | ||
| 1973 | 2118 | * @param WP_REST_Request $request |
| 1974 | 2119 | * @return WP_REST_Response|WP_Error |
| 1975 | 2120 | */ |
| 1976 | 2121 | function openstation_rest_ai_search( WP_REST_Request $request ) { |
| 2122 | + // The agent loop runs up to OPENSTATION_AI_SEARCH_MAX_ITERATIONS model | |
| 2123 | + // round-trips, each with a tool call, which overruns a default 30s | |
| 2124 | + // max_execution_time. The streaming endpoint used to raise this; it is | |
| 2125 | + // the only path now, so it carries the limit. | |
| 2126 | + @set_time_limit( 120 ); // phpcs:ignore | |
| 2127 | + | |
| 1977 | 2128 | $user_id = get_current_user_id(); |
| 1978 | 2129 | $query = $request->get_param( 'query' ); |
| 1979 | 2130 | $resume_tool = $request->get_param( 'resume_tool' ); |
| 1980 | 2131 | $start_offset = $request->get_param( 'start_offset' ); |
| @@ -2018,9 +2169,9 @@ | ||
| 2018 | 2169 | ? ( is_array( $follow_up['result'] ) ? $follow_up['result'] : array( 'value' => $follow_up['result'] ) ) |
| 2019 | 2170 | : array(); |
| 2020 | 2171 | $result = openstation_ai_run_followup( $query, $tool, $outcome, $extra ); |
| 2021 | 2172 | } else { |
| 2022 | - $result = openstation_ai_run_search( $query, $resume_tool, $start_offset, null, $extra ); | |
| 2173 | + $result = openstation_ai_run_search( $query, $resume_tool, $start_offset, $extra ); | |
| 2023 | 2174 | } |
| 2024 | 2175 | |
| 2025 | 2176 | if ( is_wp_error( $result ) ) { |
| 2026 | 2177 | $request_id = isset( $extra['request_id'] ) ? (string) $extra['request_id'] : ''; |
| @@ -2320,120 +2471,4 @@ | ||
| 2320 | 2471 | } |
| 2321 | 2472 | |
| 2322 | 2473 | return array_slice( $all, -1 * ( $lines + 1 ) ); |
| 2323 | 2474 | } |
| 2324 | - | |
| 2325 | -// --------------------------------------------------------------------------- | |
| 2326 | -// Streaming endpoint (Server-Sent Events) | |
| 2327 | -// | |
| 2328 | -// EventSource can't send POST or custom headers, so we ride admin-ajax.php | |
| 2329 | -// which handles cookie-based auth natively. The nonce goes in the URL. | |
| 2330 | -// Output buffering is forcibly disabled and every emit is flushed so the | |
| 2331 | -// browser receives progress ticks in real time. | |
| 2332 | -// --------------------------------------------------------------------------- | |
| 2333 | - | |
| 2334 | -/** | |
| 2335 | - * Admin-ajax handler for the streaming search endpoint. | |
| 2336 | - * | |
| 2337 | - * URL: /wp-admin/admin-ajax.php?action=openstation_ai_search_stream | |
| 2338 | - * &nonce=<rest_nonce> | |
| 2339 | - * &query=<user question> | |
| 2340 | - * &resume_tool=<search_posts|…> (optional) | |
| 2341 | - * &start_offset=<int> (optional) | |
| 2342 | - * | |
| 2343 | - * Emits SSE events: | |
| 2344 | - * data: { "event": "progress", "phase": "tool_call", "message": "…" } | |
| 2345 | - * data: { "event": "done", "result": { … } } | |
| 2346 | - * data: { "event": "error", "message": "…" } | |
| 2347 | - */ | |
| 2348 | -function openstation_ai_ajax_search_stream() { | |
| 2349 | - $nonce = isset( $_GET['nonce'] ) ? sanitize_text_field( wp_unslash( $_GET['nonce'] ) ) : ''; | |
| 2350 | - if ( ! wp_verify_nonce( $nonce, 'wp_rest' ) ) { | |
| 2351 | - status_header( 403 ); | |
| 2352 | - exit; | |
| 2353 | - } | |
| 2354 | - if ( ! is_user_logged_in() || ! current_user_can( 'read' ) ) { | |
| 2355 | - status_header( 403 ); | |
| 2356 | - exit; | |
| 2357 | - } | |
| 2358 | - $user_id = get_current_user_id(); | |
| 2359 | - if ( ! openstation_ai_is_available() ) { | |
| 2360 | - status_header( 503 ); | |
| 2361 | - exit; | |
| 2362 | - } | |
| 2363 | - if ( ! openstation_ai_is_enabled( $user_id ) ) { | |
| 2364 | - status_header( 403 ); | |
| 2365 | - exit; | |
| 2366 | - } | |
| 2367 | - | |
| 2368 | - $query = isset( $_GET['query'] ) ? sanitize_text_field( wp_unslash( $_GET['query'] ) ) : ''; // phpcs:ignore WordPress.Security | |
| 2369 | - if ( trim( $query ) === '' ) { | |
| 2370 | - status_header( 400 ); | |
| 2371 | - exit; | |
| 2372 | - } | |
| 2373 | - | |
| 2374 | - $resume_tool = isset( $_GET['resume_tool'] ) ? sanitize_key( wp_unslash( $_GET['resume_tool'] ) ) : null; // phpcs:ignore WordPress.Security | |
| 2375 | - $start_offset = isset( $_GET['start_offset'] ) ? absint( $_GET['start_offset'] ) : 0; // phpcs:ignore WordPress.Security | |
| 2376 | - if ( null !== $resume_tool && ! in_array( $resume_tool, openstation_ai_search_resumable_tools(), true ) ) { | |
| 2377 | - $resume_tool = null; | |
| 2378 | - } | |
| 2379 | - | |
| 2380 | - // SSE headers — tell nginx to stop buffering, tell the browser this is | |
| 2381 | - // a persistent event stream. | |
| 2382 | - header( 'Content-Type: text/event-stream; charset=utf-8' ); | |
| 2383 | - header( 'Cache-Control: no-cache, no-store, must-revalidate' ); | |
| 2384 | - header( 'X-Accel-Buffering: no' ); | |
| 2385 | - header( 'Connection: keep-alive' ); | |
| 2386 | - | |
| 2387 | - // Let other requests from this user proceed (release session lock). | |
| 2388 | - if ( session_status() === PHP_SESSION_ACTIVE ) { | |
| 2389 | - session_write_close(); | |
| 2390 | - } | |
| 2391 | - | |
| 2392 | - // Kill any output buffers PHP set up, otherwise nothing flushes until | |
| 2393 | - // the request ends — which defeats the whole point of streaming. | |
| 2394 | - while ( ob_get_level() > 0 ) { | |
| 2395 | - @ob_end_flush(); // phpcs:ignore | |
| 2396 | - } | |
| 2397 | - @ini_set( 'output_buffering', 'off' ); // phpcs:ignore | |
| 2398 | - @ini_set( 'zlib.output_compression', 'off' ); // phpcs:ignore | |
| 2399 | - @set_time_limit( 120 ); // phpcs:ignore | |
| 2400 | - | |
| 2401 | - $emit = static function ( array $payload ) { | |
| 2402 | - echo 'data: ' . wp_json_encode( $payload ) . "\n\n"; | |
| 2403 | - @ob_flush(); // phpcs:ignore | |
| 2404 | - flush(); | |
| 2405 | - }; | |
| 2406 | - | |
| 2407 | - // Initial tick so the EventSource opens immediately and the JS can | |
| 2408 | - // start showing "Thinking…" without waiting for the first model call. | |
| 2409 | - $emit( array( 'event' => 'open' ) ); | |
| 2410 | - | |
| 2411 | - $result = openstation_ai_run_search( | |
| 2412 | - $query, | |
| 2413 | - $resume_tool, | |
| 2414 | - $start_offset, | |
| 2415 | - function ( $progress ) use ( $emit ) { | |
| 2416 | - $emit( array_merge( array( 'event' => 'progress' ), $progress ) ); | |
| 2417 | - } | |
| 2418 | - ); | |
| 2419 | - | |
| 2420 | - if ( is_wp_error( $result ) ) { | |
| 2421 | - $emit( | |
| 2422 | - array( | |
| 2423 | - 'event' => 'error', | |
| 2424 | - 'message' => $result->get_error_message(), | |
| 2425 | - 'code' => $result->get_error_code(), | |
| 2426 | - ) | |
| 2427 | - ); | |
| 2428 | - } else { | |
| 2429 | - $emit( | |
| 2430 | - array( | |
| 2431 | - 'event' => 'done', | |
| 2432 | - 'result' => $result, | |
| 2433 | - ) | |
| 2434 | - ); | |
| 2435 | - } | |
| 2436 | - | |
| 2437 | - exit; | |
| 2438 | -} | |
| 2439 | -add_action( 'wp_ajax_openstation_ai_search_stream', 'openstation_ai_ajax_search_stream' ); | |