Add event hook to ai_ckeditor to allow context injection
>>> [!note] Migrated issue <!-- Drupal.org comment --> <!-- Migrated from issue #3581952. --> Reported by: [jessehs](https://www.drupal.org/user/620440) Related to !1469 !1425 >>> <h3 id="summary-problem-motivation">Problem/Motivation</h3> <p>The <code>ai_ckeditor</code> module provides AI-powered CKEditor 5 actions (Tone, Translate, Summarize, etc.) but has <strong>zero entity awareness</strong> &mdash; it doesn't know what content type, bundle, or entity ID the editor is attached to. The <code>ai_context</code> module provides a sophisticated scope-based context selection system that can inject bundle-specific writing guidelines into AI prompts.</p> <p>These two modules cannot communicate because:</p> <ol> <li><code>ai_ckeditor</code> does not pass entity information (type, bundle, ID) through its request chain &mdash; from the CKEditor 5 JS dialog, through the Drupal AJAX form, to the streaming controller.</li> <li><code>ai_ckeditor</code> does not dispatch any events before calling the AI provider, so there is no extension point for other modules to modify the prompt or system prompt.</li> <li><code>ai_context</code>'s existing event subscriber only hooks into the <code>ai_agents</code> pipeline (<code>BuildSystemPromptEvent</code>), which <code>ai_ckeditor</code> bypasses entirely by calling the AI provider directly in its own controller.</li> </ol> <p>This means site builders cannot use <code>ai_context</code> items (brand voice guidelines, content-type-specific writing rules, editorial tone instructions, etc.) to influence AI CKEditor actions &mdash; even though both modules are installed and configured.</p> <h4 id="summary-steps-reproduce">Steps to reproduce (required for bugs, but not feature requests)</h4> <p>This is a feature request / architectural gap, not a bug.</p> <h3 id="summary-proposed-resolution">Proposed resolution</h3> <p>This solution is delivered as <strong>three patches applied in sequence</strong>. Patch 1 is a prerequisite. Patches 2 and 3 are the new work in this issue.</p> <h4>Patch 1 (prerequisite): <code>3549657-ai_ckeditor-use-prompt-entities.patch</code></h4> <p><strong>Applied to:</strong> <code>drupal/ai</code> module</p> <p>Prerequisite patch from <a href="https://www.drupal.org/project/ai/issues/3549657">#3549657</a>. Converts all inline text prompts in <code>ai_ckeditor</code> to AI Prompt config entities and changes form elements from <code>textarea</code> to <code>ai_prompt</code> type. All plugin <code>ajaxGenerate()</code> methods now use <code>strtr()</code> for variable replacement and load prompts from config entities via <code>$this-&gt;getConfigFactory()-&gt;get('ai.ai_prompt.' . $promptId)</code>.</p> <h4>Patch 2: <code>ai_ckeditor-entity-context-event.patch</code></h4> <p><strong>Applied to:</strong> <code>drupal/ai</code> module (on top of Patch 1)</p> <p>This is the core of the issue. It threads entity context through the entire <code>ai_ckeditor</code> request chain and dispatches a new event before the AI provider call. Here is what each layer does:</p> <ul> <li><strong>New file &mdash; <code>src/Event/AiCKEditorRequestEvent.php</code>:</strong> A Symfony event class with <code>EVENT_NAME = 'ai_ckeditor.pre_request'</code>. It carries two mutable properties &mdash; <code>prompt</code> (get/set) and <code>systemPrompt</code> (get/set) &mdash; that subscribers can modify before the AI call. It also carries read-only properties: <code>editorId</code> (the text format ID), <code>pluginId</code> (e.g., <code>ai_ckeditor_tone</code>), <code>entityType</code> (e.g., <code>node</code>), <code>entityBundle</code> (e.g., <code>blog</code>), <code>entityId</code> (e.g., <code>123</code>, empty on add forms), and the <code>EditorInterface</code> config entity.</li> <li><strong>JS layer &mdash; <code>AiDrupalDialog.js</code>:</strong> A new <code>getEntityContext(editor)</code> helper function extracts entity info from the current page. It parses <code>drupalSettings.path.currentPath</code> for patterns like <code>node/123/edit</code> (returning <code>entityType: 'node'</code>, <code>entityId: '123'</code>) and <code>node/add/blog</code> (returning <code>entityType: 'node'</code>, <code>entityBundle: 'blog'</code>, <code>entityId: ''</code>). As a fallback, it parses the closest form element's <code>data-drupal-selector</code> attribute (e.g., <code>node-blog-edit-form</code>). The resulting <code>entity_type</code>, <code>entity_bundle</code>, and <code>entity_id</code> are included in the dialog payload alongside the existing <code>selected_text</code>, <code>editor_id</code>, and <code>plugin_id</code> fields. The webpack build output (<code>js/build/aickeditor.js</code>) is rebuilt.</li> <li><strong>Form layer &mdash; <code>AiCKEditorDialogForm.php</code>:</strong> The <code>buildForm()</code> method now extracts <code>entity_type</code>, <code>entity_bundle</code>, and <code>entity_id</code> from the AJAX payload. These are passed into <code>buildCkEditorModalForm()</code> via the <code>$settings</code> array and rendered as hidden form fields alongside the existing <code>editor_id</code>, <code>plugin_id</code>, and <code>selected_text</code> hidden fields. This ensures entity context survives the AJAX round-trip from the dialog form to the <code>ajaxGenerate()</code> callback.</li> <li><strong>AJAX command &mdash; <code>AiRequestCommand.php</code>:</strong> The constructor gains three new parameters: <code>$entity_type = ''</code>, <code>$entity_bundle = ''</code>, <code>$entity_id = ''</code> &mdash; all with empty-string defaults for full backward compatibility. The <code>render()</code> method includes these fields in the JSON command output, which <code>AiWriter.js</code> sends to the streaming controller via <code>JSON.stringify(request_parameters)</code>.</li> <li><strong>Plugin base &mdash; <code>AiCKEditorPluginBase.php</code>:</strong> A new protected helper method <code>createAiRequestCommand(string $prompt, FormStateInterface $form_state): AiRequestCommand</code> centralizes command construction. It reads <code>editor_id</code>, <code>entity_type</code>, <code>entity_bundle</code>, and <code>entity_id</code> from <code>$form_state-&gt;getValues()</code> and uses <code>$this-&gt;pluginDefinition['id']</code> for the plugin ID.</li> <li><strong>All 7 plugins updated:</strong> <code>Tone.php</code>, <code>Completion.php</code>, <code>Summarize.php</code>, <code>Translate.php</code>, <code>SpellFix.php</code>, <code>ReformatHtml.php</code>, and <code>ModifyPrompt.php</code> &mdash; each <code>ajaxGenerate()</code> method is updated from multi-line manual <code>AiRequestCommand</code> construction to a single-line <code>$this-&gt;createAiRequestCommand($promptText, $form_state)</code> call. Unused <code>use Drupal\ai_ckeditor\Command\AiRequestCommand;</code> imports are removed from each plugin.</li> <li><strong>Controller &mdash; <code>AiRequest.php</code>:</strong> The constructor now injects <code>EventDispatcherInterface</code> (added to both <code>__construct()</code> and <code>create()</code>). In <code>doRequest()</code>, entity fields are extracted from the decoded JSON body. Request attributes <code>ai_context_entity_type</code> and <code>ai_context_entity_id</code> are set on the request object for downstream services. After building the HTML-formatting prompt prefix and the system prompt, the controller dispatches <code>AiCKEditorRequestEvent</code>. The <code>ChatInput</code> is then built using <code>$event-&gt;getPrompt()</code> and <code>$event-&gt;getSystemPrompt()</code> instead of the original local variables, so any modifications made by subscribers take effect.</li> </ul> <h4>Patch 3: <code>ai_context-implement-event-subscriber-to-inject-context-to-ckeditor.patch</code></h4> <p><strong>Applied to:</strong> <code>drupal/ai_context</code> module</p> <p>This patch makes <code>ai_context</code> subscribe to the new event from Patch 2, inject scope-matched context items into the CKEditor AI system prompt, and fixes two bugs in the Site Section scope plugin that prevented path-pattern-based context items from matching on entity edit forms.</p> <ul> <li><strong>New file &mdash; <code>src/EventSubscriber/AiContextCKEditorSubscriber.php</code>:</strong> Implements <code>EventSubscriberInterface</code>. Uses a <code>class_exists('Drupal\ai_ckeditor\Event\AiCKEditorRequestEvent')</code> guard in <code>getSubscribedEvents()</code> so <code>ai_context</code> does not hard-depend on <code>ai_ckeditor</code>. Listens to <code>ai_ckeditor.pre_request</code>. The handler extracts <code>entityType</code> and <code>entityId</code> from the event, builds a context request using <code>AiContextRequestFactory::fromParameters()</code> with <code>SELECTION_MODE_MATCH_ALL</code>, runs <code>AiContextSelector::select()</code> to find matching context items, and appends the rendered context text to the system prompt via <code>$event-&gt;setSystemPrompt()</code>. The append uses the same prefix/separator format as the existing <code>AiContextSystemPromptSubscriber</code>. Finally, it records usage via <code>AiContextUsageTracker::recordUsage()</code>.</li> <li><strong>Modified file &mdash; <code>ai_context.services.yml</code>:</strong> Registers the new subscriber as <code>ai_context.event_subscriber.ckeditor</code> with dependencies on <code>@ai_context.selector</code>, <code>@ai_context.request_factory</code>, <code>@ai_context.usage_tracker</code>, and <code>@config.factory</code>. Tagged with <code>event_subscriber</code>.</li> <li><strong>Modified file &mdash; <code>src/Plugin/AiContextScope/AiContextScopeSiteSection.php</code>:</strong> Fixes two bugs that prevented Site Section scope items with custom path patterns from matching on entity edit forms: <ol> <li><strong>URL alias resolution for edit forms:</strong> When a user edits a node at <code>/node/123/edit</code>, the HTTP request path is <code>/node/123/edit</code> &mdash; not the front-end URL alias (e.g., <code>/blogs/my-blog-post-title</code>). A context item scoped with the custom path pattern <code>/blogs/*</code> would never match because the request path never starts with <code>/blogs/</code>. This patch adds a <code>getCurrentPaths()</code> method that builds an array of candidate paths by: (1) using the current HTTP request path, (2) resolving the entity's canonical internal path from the <code>ai_context_entity_type</code> and <code>ai_context_entity_id</code> request attributes (set by the <code>AiRequest</code> controller in Patch 2), and (3) resolving the URL alias for that canonical path via <code>AliasManagerInterface::getAliasByPath()</code>. For example, when editing node 123, the candidate paths would be <code>['/node/123/edit', '/node/123', '/blogs/my-blog-post-title']</code>, and the pattern <code>/blogs/*</code> successfully matches the alias.</li> <li><strong>Custom pattern matching in <code>matchesCurrentContext()</code>:</strong> The base class <code>matchesCurrentContext()</code> calls <code>getCurrentValue()</code>, which only returns pre-defined section IDs (e.g., <code>marketing</code>) &mdash; it cannot evaluate custom patterns (stored as <code>custom:/blogs/*</code>). This patch overrides <code>matchesCurrentContext()</code> to use the existing <code>matchesCurrentPath()</code> method, which correctly handles both custom wildcard patterns and pre-defined section ID lookups against all candidate paths.</li> </ol> <p> New dependencies injected: <code>AliasManagerInterface</code> (optional, guarded by <code>$container-&gt;has('path_alias.manager')</code>) and <code>EntityTypeManagerInterface</code>. </p></li> </ul> <p><strong>Selection mode note:</strong> The CKEditor subscriber uses <code>AiContextRequestFactory::fromParameters()</code> with <code>SELECTION_MODE_MATCH_ALL</code> rather than <code>fromAgent()</code>. This is intentional: <code>fromAgent()</code> hardcodes <code>SELECTION_MODE_MINIMAL</code>, which requires explicit scope subscriptions in the agent's <code>ai_context.agents</code> config entry to select non-global items. Since CKEditor is not an agent and typically has no such config entry, <code>MINIMAL</code> mode would silently drop all non-global scoped items (like a blog context item scoped to <code>/blogs/*</code>) even after they pass the hard context filter. <code>MATCH_ALL</code> mode includes all items that pass hard context filtering, which is the correct behavior for a non-agent consumer like CKEditor. Site admins can still optionally add an <code>ai_ckeditor</code> consumer entry in <code>ai_context.agents</code> config to further narrow selection with specific scope subscriptions.</p> <h3 id="summary-remaining-tasks">Remaining tasks</h3> <ol> <li>Review and feedback on the approach.</li> <li>Confirm backward compatibility &mdash; all new <code>AiRequestCommand</code> params default to empty strings, existing custom plugins continue to work without changes.</li> <li>Consider adding kernel/functional tests for the event dispatch, entity context extraction, and Site Section URL alias resolution.</li> <li>Coordinate with <a href="https://www.drupal.org/project/ai/issues/3549657">#3549657</a> (prompt entities patch) since Patch 2 is applied on top of Patch 1.</li> <li>Review whether the Site Section scope plugin's <code>matchesCurrentContext()</code> override and <code>getCurrentPaths()</code> URL alias resolution should be upstreamed as a standalone fix to <code>ai_context</code> (it fixes a general limitation where edit forms can never match front-end path patterns, independent of the CKEditor integration).</li> </ol> <h3>Testing instructions</h3> <h4>Prerequisites</h4> <ul> <li>Drupal 10.3+ or 11.x</li> <li>Modules enabled: <code>ai</code>, <code>ai_ckeditor</code>, <code>ai_context</code>, <code>taxonomy</code></li> <li>A working AI chat provider configured (e.g., OpenAI, Anthropic) &mdash; confirm via <em>Admin &gt; Configuration &gt; AI &gt; Settings</em> that a default Chat model is set</li> </ul> <h4>Step 1: Apply all three patches in order</h4> <ol> <li>Apply <code>patches/3549657-ai_ckeditor-use-prompt-entities.patch</code> to the <code>drupal/ai</code> module</li> <li>Apply <code>patches/ai_ckeditor-entity-context-event.patch</code> to the <code>drupal/ai</code> module</li> <li>Apply <code>patches/ai_context-implement-event-subscriber-to-inject-context-to-ckeditor.patch</code> to the <code>drupal/ai_context</code> module</li> <li>Run <code>drush cr</code> to rebuild caches</li> </ol> <h4>Step 2: Create a Tone taxonomy vocabulary and terms</h4> <ol> <li>Go to <em>Admin &gt; Structure &gt; Taxonomy</em> and click <strong>"Add vocabulary"</strong></li> <li>Name it <strong>"Tone"</strong> (machine name: <code>tone</code>) and save</li> <li>Click <strong>"Add term"</strong> and create a term called <strong>"Formal"</strong></li> <li>Optionally add additional terms like <strong>"Casual"</strong> or <strong>"Professional"</strong></li> </ol> <h4>Step 3: Configure the CKEditor text format with AI plugins</h4> <ol> <li>Go to <em>Admin &gt; Configuration &gt; Content authoring &gt; Text formats and editors</em></li> <li>Edit a text format that uses CKEditor 5 (e.g., "Full HTML")</li> <li>In the CKEditor 5 toolbar configuration, drag the <strong>"AI Tools"</strong> button into the active toolbar</li> <li>Scroll down to the <strong>"AI CKEditor"</strong> plugin settings section</li> <li>Enable the <strong>Tone</strong> plugin: <ul> <li>Check the <strong>"Enabled"</strong> checkbox next to Tone</li> <li>Select the <strong>"Tone"</strong> vocabulary from the dropdown</li> <li>Select an AI provider/model, or leave as <strong>"Default from AI module (chat)"</strong></li> <li>Configure the tone prompt (a default prompt entity should be available)</li> </ul> </li> <li>Click <strong>"Save configuration"</strong></li> </ol> <h4>Step 4: Create an ai_context item with an easily verifiable instruction</h4> <ol> <li>Go to <em>Admin &gt; Configuration &gt; AI &gt; AI Context</em></li> <li>Click <strong>"Add context item"</strong></li> <li>Set the <strong>Label</strong> to: <code>Company Brand Voice</code></li> <li>Set the <strong>Scope</strong> to: <strong>Global</strong> (so it applies regardless of content type)</li> <li>In the <strong>Content</strong> field, enter the following text exactly:<br> <br><br><br> <code>IMPORTANT: You must insert the following sentence on its own line at the very beginning of your response: "Modified by the Company Brand Voice context control center item." Then continue with the rest of your response as normal.</code><br> <br><br><br> <em>Note: This deliberately obvious instruction makes it trivially easy to verify that ai_context content is being injected. In production you would use real brand voice guidelines, writing style rules, etc.</em> </li> <li>Set the status to <strong>Published</strong></li> <li>Click <strong>"Save"</strong></li> </ol> <h4>Step 5: Trigger an AI CKEditor action</h4> <ol> <li>Create or edit a <strong>node</strong> (e.g., a Basic Page) that uses the text format configured in Step 3</li> <li>In the CKEditor body field, type some sample text, e.g.: <code>Our company provides excellent services to all of our valued customers around the world.</code></li> <li><strong>Select the text</strong> you just typed</li> <li>Click the <strong>AI Tools</strong> button in the CKEditor toolbar</li> <li>Choose <strong>"Tone"</strong> from the dropdown</li> <li>In the modal dialog that opens, select <strong>"Formal"</strong> from the tone dropdown</li> <li>Click <strong>"Change the tone"</strong></li> <li>Wait for the AI response to stream into the response textarea</li> </ol> <h4>Step 6: Verify the results</h4> <p><strong>Primary verification &mdash; context injection works:</strong></p> <p>The AI response in the modal textarea should include the sentence: <code>"Modified by the Company Brand Voice context control center item."</code> This confirms that <code>ai_context</code> successfully injected its content into the system prompt via the new <code>ai_ckeditor.pre_request</code> event, and that the AI provider received and acted on it.</p> <p><strong>Secondary verification &mdash; entity context is being sent:</strong></p> <ol> <li>Open browser DevTools &gt; <strong>Network</strong> tab</li> <li>Repeat Step 5 (trigger a tone change)</li> <li>Find the POST request to <code>/api/ai-ckeditor/request/{editor}/{plugin}</code></li> <li>Inspect the <strong>Request Body</strong> (JSON). You should see: <ul> <li><code>"entity_type": "node"</code></li> <li><code>"entity_bundle": "page"</code> (or whatever content type you used)</li> <li><code>"entity_id": "123"</code> (the actual node ID, or empty string on <code>node/add/*</code> forms)</li> </ul> </li> </ol> <p><strong>Tertiary verification &mdash; usage tracking:</strong></p> <ol> <li>Go to <em>Admin &gt; Configuration &gt; AI &gt; AI Context &gt; Usage</em></li> <li>Verify that entries appear for the <code>ai_ckeditor</code> consumer, confirming the usage tracker recorded the context injection</li> </ol> <p><strong>Optional verification &mdash; Site Section path-pattern scoping:</strong></p> <p>This test verifies that <code>ai_context</code> items scoped to a URL path pattern (e.g., <code>/blogs/*</code>) are correctly selected when editing a node whose URL alias matches that pattern, even though the edit form's request path is <code>/node/123/edit</code>.</p> <ol> <li>Ensure the <strong>path_alias</strong> module is enabled and that your "Blog" (or similar) content type has URL alias patterns configured (e.g., <code>/blogs/[node:title]</code>)</li> <li>Create a Blog node &mdash; for example, titled "My Test Post" &mdash; and confirm it has a URL alias like <code>/blogs/my-test-post</code></li> <li>Go to <em>Admin &gt; Configuration &gt; AI &gt; AI Context &gt; Settings &gt; Scope: Site Sections</em> and optionally add a pre-defined section. (This step is optional &mdash; the custom pattern field on the context item works without any pre-defined sections.)</li> <li>Create a second <code>ai_context</code> item: <ul> <li>Set the <strong>Label</strong> to: <code>Blog Writing Guidelines</code></li> <li>In the <strong>Site Sections</strong> scope, enter the custom path pattern: <code>/blogs/*</code></li> <li>In the <strong>Content</strong> field, enter: <code>IMPORTANT: Insert the sentence "This is blog-specific context from the /blogs/* path pattern." at the beginning of your response.</code></li> <li>Set the status to <strong>Published</strong> and save</li> </ul> </li> <li>Edit the Blog node you created in step 2 (navigate to <code>/node/123/edit</code>) and trigger a tone change via AI Tools</li> <li>Verify the AI response contains <strong>both</strong> the global brand voice sentence <strong>and</strong> the blog-specific sentence &mdash; this confirms that the Site Section scope resolved the node's URL alias (<code>/blogs/my-test-post</code>) from the edit form path (<code>/node/123/edit</code>) and matched it against the <code>/blogs/*</code> pattern</li> <li>Edit a <strong>Basic Page</strong> node (whose alias does <strong>not</strong> match <code>/blogs/*</code>) and trigger the same tone change &mdash; the response should contain <strong>only</strong> the global brand voice sentence (the blog-specific one should be absent)</li> </ol> <p><strong>Regression check &mdash; other plugins still work:</strong></p> <ol> <li>Test the other AI CKEditor plugins (Summarize, Translate, SpellFix, Completion, Reformat HTML, Modify with a prompt) &mdash; all should continue functioning normally</li> <li>Each should also receive the injected context from <code>ai_context</code></li> </ol> <h3>API changes</h3> <h4>Patch 2 (ai_ckeditor):</h4> <ul> <li><strong>New event:</strong> <code>Drupal\ai_ckeditor\Event\AiCKEditorRequestEvent</code> dispatched as <code>ai_ckeditor.pre_request</code> before every AI CKEditor streaming request. Subscribers can call <code>$event-&gt;setPrompt()</code> and <code>$event-&gt;setSystemPrompt()</code> to modify the AI input. Read-only getters: <code>getEditorId()</code>, <code>getPluginId()</code>, <code>getEntityType()</code>, <code>getEntityBundle()</code>, <code>getEntityId()</code>, <code>getEditor()</code>.</li> <li><strong><code>AiRequestCommand</code> constructor:</strong> Three new optional string params &mdash; <code>$entity_type = ''</code>, <code>$entity_bundle = ''</code>, <code>$entity_id = ''</code>. Fully backward compatible; existing code that only passes 4 args continues to work.</li> <li><strong><code>AiCKEditorPluginBase</code>:</strong> New protected method <code>createAiRequestCommand(string $prompt, FormStateInterface $form_state): AiRequestCommand</code>. Custom plugin subclasses are not required to use it.</li> <li><strong>JS dialog payload:</strong> Now includes <code>entity_type</code>, <code>entity_bundle</code>, and <code>entity_id</code> fields extracted from the current page context.</li> </ul> <h4>Patch 3 (ai_context):</h4> <ul> <li><strong><code>AiContextScopeSiteSection</code>:</strong> New protected method <code>getCurrentPaths(): string[]</code> resolves the current entity's canonical path and URL alias from request attributes. Overridden <code>matchesCurrentContext()</code> now checks all candidate paths (request path, canonical path, URL alias) against both custom patterns and pre-defined section patterns. New dependencies: <code>AliasManagerInterface</code> (optional), <code>EntityTypeManagerInterface</code>.</li> <li><strong><code>AiContextCKEditorSubscriber</code>:</strong> New event subscriber using <code>SELECTION_MODE_MATCH_ALL</code> via <code>fromParameters()</code> for correct non-agent consumer behavior.</li> </ul> <h3 id="summary-ai-usage">AI usage (if applicable)</h3> <p>[x] AI Generated Code<br> <br>This code was mainly generated by an AI with human guidance, and reviewed, tested, and refined by a human.</p> > Related issue: [Issue #3549657](https://www.drupal.org/node/3549657)
issue